Unverified Commit 83863449 authored by Renzo Olivares's avatar Renzo Olivares Committed by GitHub

Hide toolbar when selection is out of view (#98152)

* Hide toolbar when selection is out of view

* properly dispose of toolbar visibility listener

* Add test

* rename toolbarvisibility

* Make visibility for toolbar nullable

* Properly dispose of toolbar visibility listener

* Merge visibility methods into one

* properly dispose of start selection view listener

* Add some docs

* remove unnecessary null check

* more docs

* Update dispose order
Co-authored-by: 's avatarRenzo Olivares <roliv@google.com>
parent a454da02
......@@ -268,9 +268,9 @@ class TextSelectionOverlay {
assert(handlesVisible != null),
_handlesVisible = handlesVisible,
_value = value {
renderObject.selectionStartInViewport.addListener(_updateHandleVisibilities);
renderObject.selectionEndInViewport.addListener(_updateHandleVisibilities);
_updateHandleVisibilities();
renderObject.selectionStartInViewport.addListener(_updateTextSelectionOverlayVisibilities);
renderObject.selectionEndInViewport.addListener(_updateTextSelectionOverlayVisibilities);
_updateTextSelectionOverlayVisibilities();
_selectionOverlay = SelectionOverlay(
context: context,
debugRequiredFor: debugRequiredFor,
......@@ -285,6 +285,7 @@ class TextSelectionOverlay {
lineHeightAtEnd: 0.0,
onEndHandleDragStart: _handleSelectionEndHandleDragStart,
onEndHandleDragUpdate: _handleSelectionEndHandleDragUpdate,
toolbarVisible: _effectiveToolbarVisibility,
selectionEndPoints: const <TextSelectionPoint>[],
selectionControls: selectionControls,
selectionDelegate: selectionDelegate,
......@@ -321,9 +322,11 @@ class TextSelectionOverlay {
final ValueNotifier<bool> _effectiveStartHandleVisibility = ValueNotifier<bool>(false);
final ValueNotifier<bool> _effectiveEndHandleVisibility = ValueNotifier<bool>(false);
void _updateHandleVisibilities() {
final ValueNotifier<bool> _effectiveToolbarVisibility = ValueNotifier<bool>(false);
void _updateTextSelectionOverlayVisibilities() {
_effectiveStartHandleVisibility.value = _handlesVisible && renderObject.selectionStartInViewport.value;
_effectiveEndHandleVisibility.value = _handlesVisible && renderObject.selectionEndInViewport.value;
_effectiveToolbarVisibility.value = renderObject.selectionStartInViewport.value || renderObject.selectionEndInViewport.value;
}
/// Whether selection handles are visible.
......@@ -339,7 +342,7 @@ class TextSelectionOverlay {
if (_handlesVisible == visible)
return;
_handlesVisible = visible;
_updateHandleVisibilities();
_updateTextSelectionOverlayVisibilities();
}
/// {@macro flutter.widgets.SelectionOverlay.showHandles}
......@@ -413,9 +416,12 @@ class TextSelectionOverlay {
/// {@macro flutter.widgets.SelectionOverlay.dispose}
void dispose() {
renderObject.selectionStartInViewport.removeListener(_updateHandleVisibilities);
renderObject.selectionEndInViewport.removeListener(_updateHandleVisibilities);
_selectionOverlay.dispose();
renderObject.selectionStartInViewport.removeListener(_updateTextSelectionOverlayVisibilities);
renderObject.selectionEndInViewport.removeListener(_updateTextSelectionOverlayVisibilities);
_effectiveToolbarVisibility.dispose();
_effectiveStartHandleVisibility.dispose();
_effectiveEndHandleVisibility.dispose();
}
double _getStartGlyphHeight() {
......@@ -562,6 +568,7 @@ class SelectionOverlay {
this.onEndHandleDragStart,
this.onEndHandleDragUpdate,
this.onEndHandleDragEnd,
this.toolbarVisible,
required List<TextSelectionPoint> selectionEndPoints,
required this.selectionControls,
required this.selectionDelegate,
......@@ -585,7 +592,6 @@ class SelectionOverlay {
'Usually the Navigator created by WidgetsApp provides the overlay. Perhaps your '
'app content was created above the Navigator with the WidgetsApp builder parameter.',
);
_toolbarController = AnimationController(duration: fadeDuration, vsync: overlay!);
}
/// The context in which the selection handles should appear.
......@@ -682,6 +688,14 @@ class SelectionOverlay {
/// handles.
final ValueChanged<DragEndDetails>? onEndHandleDragEnd;
/// Whether the toolbar is visible.
///
/// If the value changes, the toolbar uses [FadeTransition] to transition
/// itself on and off the screen.
///
/// If this is null the toolbar will always be visible.
final ValueListenable<bool>? toolbarVisible;
/// The text selection positions of selection start and end.
List<TextSelectionPoint> get selectionEndPoints => _selectionEndPoints;
List<TextSelectionPoint> _selectionEndPoints;
......@@ -780,9 +794,6 @@ class SelectionOverlay {
/// Controls the fade-in and fade-out animations for the toolbar and handles.
static const Duration fadeDuration = Duration(milliseconds: 150);
late final AnimationController _toolbarController;
Animation<double> get _toolbarOpacity => _toolbarController.view;
/// A pair of handles. If this is non-null, there are always 2, though the
/// second is hidden when the selection is collapsed.
List<OverlayEntry>? _handles;
......@@ -826,7 +837,6 @@ class SelectionOverlay {
}
_toolbar = OverlayEntry(builder: _buildToolbar);
Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor)!.insert(_toolbar!);
_toolbarController.forward(from: 0.0);
}
bool _buildScheduled = false;
......@@ -878,7 +888,6 @@ class SelectionOverlay {
void hideToolbar() {
if (_toolbar == null)
return;
_toolbarController.stop();
_toolbar?.remove();
_toolbar = null;
}
......@@ -888,7 +897,6 @@ class SelectionOverlay {
/// {@endtemplate}
void dispose() {
hide();
_toolbarController.dispose();
}
Widget _buildStartHandle(BuildContext context) {
......@@ -967,26 +975,115 @@ class SelectionOverlay {
return Directionality(
textDirection: Directionality.of(this.context),
child: FadeTransition(
opacity: _toolbarOpacity,
child: CompositedTransformFollower(
link: toolbarLayerLink,
showWhenUnlinked: false,
offset: -editingRegion.topLeft,
child: Builder(
builder: (BuildContext context) {
return selectionControls!.buildToolbar(
context,
editingRegion,
lineHeightAtStart,
midpoint,
selectionEndPoints,
selectionDelegate,
clipboardStatus!,
toolbarLocation,
);
},
),
child: _SelectionToolbarOverlay(
preferredLineHeight: lineHeightAtStart,
toolbarLocation: toolbarLocation,
layerLink: toolbarLayerLink,
editingRegion: editingRegion,
selectionControls: selectionControls,
midpoint: midpoint,
selectionEndpoints: selectionEndPoints,
visibility: toolbarVisible,
selectionDelegate: selectionDelegate,
clipboardStatus: clipboardStatus,
),
);
}
}
/// This widget represents a selection toolbar.
class _SelectionToolbarOverlay extends StatefulWidget {
/// Creates a toolbar overlay.
const _SelectionToolbarOverlay({
Key? key,
required this.preferredLineHeight,
required this.toolbarLocation,
required this.layerLink,
required this.editingRegion,
required this.selectionControls,
this.visibility,
required this.midpoint,
required this.selectionEndpoints,
required this.selectionDelegate,
required this.clipboardStatus,
}) : super(key: key);
final double preferredLineHeight;
final Offset? toolbarLocation;
final LayerLink layerLink;
final Rect editingRegion;
final TextSelectionControls? selectionControls;
final ValueListenable<bool>? visibility;
final Offset midpoint;
final List<TextSelectionPoint> selectionEndpoints;
final TextSelectionDelegate? selectionDelegate;
final ClipboardStatusNotifier? clipboardStatus;
@override
_SelectionToolbarOverlayState createState() => _SelectionToolbarOverlayState();
}
class _SelectionToolbarOverlayState extends State<_SelectionToolbarOverlay> with SingleTickerProviderStateMixin {
late AnimationController _controller;
Animation<double> get _opacity => _controller.view;
@override
void initState() {
super.initState();
_controller = AnimationController(duration: SelectionOverlay.fadeDuration, vsync: this);
_toolbarVisibilityChanged();
widget.visibility?.addListener(_toolbarVisibilityChanged);
}
@override
void didUpdateWidget(_SelectionToolbarOverlay oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.visibility == widget.visibility) {
return;
}
oldWidget.visibility?.removeListener(_toolbarVisibilityChanged);
_toolbarVisibilityChanged();
widget.visibility?.addListener(_toolbarVisibilityChanged);
}
@override
void dispose() {
widget.visibility?.removeListener(_toolbarVisibilityChanged);
_controller.dispose();
super.dispose();
}
void _toolbarVisibilityChanged() {
if (widget.visibility?.value != false) {
_controller.forward();
} else {
_controller.reverse();
}
}
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: _opacity,
child: CompositedTransformFollower(
link: widget.layerLink,
showWhenUnlinked: false,
offset: -widget.editingRegion.topLeft,
child: Builder(
builder: (BuildContext context) {
return widget.selectionControls!.buildToolbar(
context,
widget.editingRegion,
widget.preferredLineHeight,
widget.midpoint,
widget.selectionEndpoints,
widget.selectionDelegate!,
widget.clipboardStatus!,
widget.toolbarLocation,
);
},
),
),
);
......
......@@ -4697,6 +4697,75 @@ void main() {
expect(renderEditable.text!.style!.decoration, isNull);
});
testWidgets('text selection toolbar visibility', (WidgetTester tester) async {
const String testText = 'hello \n world \n this \n is \n text';
final TextEditingController controller = TextEditingController(text: testText);
await tester.pumpWidget(MaterialApp(
home: Align(
alignment: Alignment.topLeft,
child: Container(
height: 50,
color: Colors.white,
child: EditableText(
showSelectionHandles: true,
controller: controller,
focusNode: FocusNode(),
style: Typography.material2018().black.subtitle1!,
cursorColor: Colors.blue,
backgroundCursorColor: Colors.grey,
selectionControls: materialTextSelectionControls,
keyboardType: TextInputType.text,
selectionColor: Colors.lightBlueAccent,
maxLines: 3,
),
),
),
));
final EditableTextState state =
tester.state<EditableTextState>(find.byType(EditableText));
final RenderEditable renderEditable = state.renderEditable;
final Scrollable scrollable = tester.widget<Scrollable>(find.byType(Scrollable));
// Select the first word. And show the toolbar.
await tester.tapAt(const Offset(20, 10));
renderEditable.selectWord(cause: SelectionChangedCause.longPress);
expect(state.showToolbar(), true);
await tester.pumpAndSettle();
// Find the toolbar fade transition while the toolbar is still visible.
final List<FadeTransition> transitionsBefore = find.descendant(
of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_SelectionToolbarOverlay'),
matching: find.byType(FadeTransition),
).evaluate().map((Element e) => e.widget).cast<FadeTransition>().toList();
expect(transitionsBefore.length, 1);
final FadeTransition toolbarBefore = transitionsBefore[0];
expect(toolbarBefore.opacity.value, 1.0);
// Scroll until the selection is no longer within view.
scrollable.controller!.jumpTo(50.0);
await tester.pumpAndSettle();
// Find the toolbar fade transition after the toolbar has been hidden.
final List<FadeTransition> transitionsAfter = find.descendant(
of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_SelectionToolbarOverlay'),
matching: find.byType(FadeTransition),
).evaluate().map((Element e) => e.widget).cast<FadeTransition>().toList();
expect(transitionsAfter.length, 1);
final FadeTransition toolbarAfter = transitionsAfter[0];
expect(toolbarAfter.opacity.value, 0.0);
// On web, we don't show the Flutter toolbar and instead rely on the browser
// toolbar. Until we change that, this test should remain skipped.
}, skip: kIsWeb); // [intended]
testWidgets('text selection handle visibility', (WidgetTester tester) async {
// Text with two separate words to select.
const String testText = 'XXXXX XXXXX';
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment