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 <roliv@google.com>
diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart
index cabd678..5d67f31 100644
--- a/packages/flutter/lib/src/widgets/text_selection.dart
+++ b/packages/flutter/lib/src/widgets/text_selection.dart
@@ -268,9 +268,9 @@
        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 @@
       lineHeightAtEnd: 0.0,
       onEndHandleDragStart: _handleSelectionEndHandleDragStart,
       onEndHandleDragUpdate: _handleSelectionEndHandleDragUpdate,
+      toolbarVisible: _effectiveToolbarVisibility,
       selectionEndPoints: const <TextSelectionPoint>[],
       selectionControls: selectionControls,
       selectionDelegate: selectionDelegate,
@@ -321,9 +322,11 @@
 
   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 @@
     if (_handlesVisible == visible)
       return;
     _handlesVisible = visible;
-    _updateHandleVisibilities();
+    _updateTextSelectionOverlayVisibilities();
   }
 
   /// {@macro flutter.widgets.SelectionOverlay.showHandles}
@@ -413,9 +416,12 @@
 
   /// {@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 @@
     this.onEndHandleDragStart,
     this.onEndHandleDragUpdate,
     this.onEndHandleDragEnd,
+    this.toolbarVisible,
     required List<TextSelectionPoint> selectionEndPoints,
     required this.selectionControls,
     required this.selectionDelegate,
@@ -585,7 +592,6 @@
       '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 @@
   /// 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 @@
   /// 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 @@
     }
     _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 @@
   void hideToolbar() {
     if (_toolbar == null)
       return;
-    _toolbarController.stop();
     _toolbar?.remove();
     _toolbar = null;
   }
@@ -888,7 +897,6 @@
   /// {@endtemplate}
   void dispose() {
     hide();
-    _toolbarController.dispose();
   }
 
   Widget _buildStartHandle(BuildContext context) {
@@ -967,26 +975,115 @@
 
     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,
+            );
+          },
         ),
       ),
     );
diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart
index 4a714e2..8ba96c6 100644
--- a/packages/flutter/test/widgets/editable_text_test.dart
+++ b/packages/flutter/test/widgets/editable_text_test.dart
@@ -4697,6 +4697,75 @@
     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';