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';