From 8386344962140577d4a1acfcbde7d24146a25466 Mon Sep 17 00:00:00 2001 From: Renzo Olivares Date: Tue, 15 Feb 2022 13:48:13 -0800 Subject: [PATCH] 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: Renzo Olivares --- .../lib/src/widgets/text_selection.dart | 165 ++++++++++++++---- .../test/widgets/editable_text_test.dart | 69 ++++++++ 2 files changed, 200 insertions(+), 34 deletions(-) diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart index cabd678836..5d67f31de9 100644 --- a/packages/flutter/lib/src/widgets/text_selection.dart +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -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 [], selectionControls: selectionControls, selectionDelegate: selectionDelegate, @@ -321,9 +322,11 @@ class TextSelectionOverlay { final ValueNotifier _effectiveStartHandleVisibility = ValueNotifier(false); final ValueNotifier _effectiveEndHandleVisibility = ValueNotifier(false); - void _updateHandleVisibilities() { + final ValueNotifier _effectiveToolbarVisibility = ValueNotifier(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 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? 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? toolbarVisible; + /// The text selection positions of selection start and end. List get selectionEndPoints => _selectionEndPoints; List _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 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? _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? visibility; + final Offset midpoint; + final List selectionEndpoints; + final TextSelectionDelegate? selectionDelegate; + final ClipboardStatusNotifier? clipboardStatus; + + @override + _SelectionToolbarOverlayState createState() => _SelectionToolbarOverlayState(); +} + +class _SelectionToolbarOverlayState extends State<_SelectionToolbarOverlay> with SingleTickerProviderStateMixin { + late AnimationController _controller; + Animation 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, + ); + }, ), ), ); diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 4a714e284c..8ba96c63fe 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -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(find.byType(EditableText)); + final RenderEditable renderEditable = state.renderEditable; + final Scrollable scrollable = tester.widget(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 transitionsBefore = find.descendant( + of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_SelectionToolbarOverlay'), + matching: find.byType(FadeTransition), + ).evaluate().map((Element e) => e.widget).cast().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 transitionsAfter = find.descendant( + of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_SelectionToolbarOverlay'), + matching: find.byType(FadeTransition), + ).evaluate().map((Element e) => e.widget).cast().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';