diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart index 3075c511ca58..f27b1cf831e1 100644 --- a/packages/flutter/lib/src/widgets/text_selection.dart +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -29,6 +29,11 @@ export 'package:flutter/services.dart' show TextSelectionDelegate; /// called. const Duration _kDragSelectionUpdateThrottle = Duration(milliseconds: 50); +/// A duration that determines the delay before showing the text selection +/// toolbar again after dragging either selection handle on Android. Eyeballed +/// on a Pixel 5 Android API 31 emulator. +const Duration _kAndroidPostDragShowDelay = Duration(milliseconds: 300); + /// Signature for when a pointer that's dragging to select text has moved again. /// /// The first argument [startDetails] contains the details of the event that @@ -782,6 +787,8 @@ class SelectionOverlay { /// Controls the fade-in and fade-out animations for the toolbar and handles. static const Duration fadeDuration = Duration(milliseconds: 150); + Timer? _androidPostDragShowTimer; + /// 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; @@ -821,7 +828,7 @@ class SelectionOverlay { /// Shows the toolbar by inserting it into the [context]'s overlay. /// {@endtemplate} void showToolbar() { - if (_toolbar != null) { + if (_toolbar != null || (_hideToolbarWhenDraggingHandles && _isDraggingHandles)) { return; } _toolbar = OverlayEntry(builder: _buildToolbar); @@ -888,9 +895,62 @@ class SelectionOverlay { /// Disposes this object and release resources. /// {@endtemplate} void dispose() { + _androidPostDragShowTimer?.cancel(); + _androidPostDragShowTimer = null; hide(); } + final bool _hideToolbarWhenDraggingHandles = + { TargetPlatform.iOS, TargetPlatform.android }.contains(defaultTargetPlatform); + + bool _isDraggingStartHandle = false; + bool _isDraggingEndHandle = false; + bool get _isDraggingHandles => _isDraggingStartHandle || _isDraggingEndHandle; + + Future _handleDragging() async { + // Hide toolbar while dragging either handle on Android and iOS. + if (!_hideToolbarWhenDraggingHandles) + return; + + if (_isDraggingHandles) { + if (defaultTargetPlatform == TargetPlatform.android) { + _androidPostDragShowTimer?.cancel(); + _androidPostDragShowTimer = null; + } + hideToolbar(); + } else { + if (defaultTargetPlatform == TargetPlatform.android) { + _androidPostDragShowTimer = Timer(_kAndroidPostDragShowDelay, showToolbar); + } else { + showToolbar(); + } + } + } + + void _onStartHandleDragStart(DragStartDetails details) { + _isDraggingStartHandle = true; + _handleDragging(); + onStartHandleDragStart?.call(details); + } + + void _onStartHandleDragEnd(DragEndDetails details) { + _isDraggingStartHandle = false; + _handleDragging(); + onStartHandleDragEnd?.call(details); + } + + void _onEndHandleDragStart(DragStartDetails details) { + _isDraggingEndHandle = true; + _handleDragging(); + onEndHandleDragStart?.call(details); + } + + void _onEndHandleDragEnd(DragEndDetails details) { + _isDraggingEndHandle = false; + _handleDragging(); + onEndHandleDragEnd?.call(details); + } + Widget _buildStartHandle(BuildContext context) { final Widget handle; final TextSelectionControls? selectionControls = this.selectionControls; @@ -901,9 +961,9 @@ class SelectionOverlay { type: _startHandleType, handleLayerLink: startHandleLayerLink, onSelectionHandleTapped: onSelectionHandleTapped, - onSelectionHandleDragStart: onStartHandleDragStart, + onSelectionHandleDragStart: _onStartHandleDragStart, onSelectionHandleDragUpdate: onStartHandleDragUpdate, - onSelectionHandleDragEnd: onStartHandleDragEnd, + onSelectionHandleDragEnd: _onStartHandleDragEnd, selectionControls: selectionControls, visibility: startHandlesVisible, preferredLineHeight: _lineHeightAtStart, @@ -926,9 +986,9 @@ class SelectionOverlay { type: _endHandleType, handleLayerLink: endHandleLayerLink, onSelectionHandleTapped: onSelectionHandleTapped, - onSelectionHandleDragStart: onEndHandleDragStart, + onSelectionHandleDragStart: _onEndHandleDragStart, onSelectionHandleDragUpdate: onEndHandleDragUpdate, - onSelectionHandleDragEnd: onEndHandleDragEnd, + onSelectionHandleDragEnd: _onEndHandleDragEnd, selectionControls: selectionControls, visibility: endHandlesVisible, preferredLineHeight: _lineHeightAtEnd, diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index 5aee23098ab5..c818af486925 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -3179,6 +3179,11 @@ void main() { expect(controller.selection.extentOffset, 50); if (!isContextMenuProvidedByPlatform) { + if (defaultTargetPlatform == TargetPlatform.android) { + // There should be a delay before the toolbar is shown again on Android. + expect(find.text('Cut'), findsNothing); + await tester.pump(const Duration(milliseconds: 300)); + } await tester.tap(find.text('Cut')); await tester.pump(); expect(controller.selection.isCollapsed, true); diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 3e46034def0b..38b1247dc281 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -1553,6 +1553,69 @@ void main() { // toolbar. Until we change that, this test should remain skipped. }, skip: kIsWeb, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.android })); // [intended] + testWidgets('dragging handles hides toolbar on mobile', (WidgetTester tester) async { + controller.text = 'blah blah blah'; + + await tester.pumpWidget( + MaterialApp( + home: EditableText( + backgroundCursorColor: Colors.grey, + controller: controller, + focusNode: focusNode, + style: textStyle, + cursorColor: cursorColor, + selectionControls: materialTextSelectionControls, + ), + ), + ); + + final EditableTextState state = + tester.state(find.byType(EditableText)); + + // Show the toolbar + state.renderEditable.selectWordsInRange( + from: Offset.zero, + cause: SelectionChangedCause.tap, + ); + await tester.pump(); + + expect(state.showToolbar(), true); + await tester.pumpAndSettle(); + expect(find.text('Paste'), findsOneWidget); + + final List endpoints = globalize( + state.renderEditable.getEndpointsForSelection(state.textEditingValue.selection), + state.renderEditable, + ); + expect(endpoints.length, 2); + + // We use a small offset because the endpoint is on the very corner of the + // handle. + final Offset endHandlePosition = endpoints[1].point + const Offset(1.0, 1.0); + + // Select 2 more characters by dragging end handle. + final TestGesture gesture = await tester.startGesture(endHandlePosition); + await gesture.moveTo(textOffsetToPosition(tester, 6)); + await tester.pumpAndSettle(); + expect(find.text('Paste'), findsNothing); + + // End drag gesture and expect toolbar to show again. + await gesture.up(); + if (defaultTargetPlatform == TargetPlatform.android) { + // There should be a delay before the toolbar is shown again on Android. + expect(find.text('Paste'), findsNothing); + await tester.pump(const Duration(milliseconds: 300)); + } + await tester.pumpAndSettle(); + expect(find.text('Paste'), findsOneWidget); + + // 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] + variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.android }), + ); + testWidgets('Paste is shown only when there is something to paste', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( diff --git a/packages/flutter/test/widgets/selectable_text_test.dart b/packages/flutter/test/widgets/selectable_text_test.dart index 66bee58dc5b3..206bde93e9db 100644 --- a/packages/flutter/test/widgets/selectable_text_test.dart +++ b/packages/flutter/test/widgets/selectable_text_test.dart @@ -1132,7 +1132,12 @@ void main() { await gesture.moveTo(newHandlePos); await tester.pump(); await gesture.up(); - await tester.pump(); + if (defaultTargetPlatform == TargetPlatform.android) { + // There should be a delay before the toolbar is shown again on Android. + expect(find.text('Cut'), findsNothing); + await tester.pump(const Duration(milliseconds: 300)); + } + await tester.pumpAndSettle(); expect(controller.selection.baseOffset, 5); expect(controller.selection.extentOffset, 50); diff --git a/packages/flutter/test/widgets/text_selection_test.dart b/packages/flutter/test/widgets/text_selection_test.dart index bf04f41bd3c2..a5c9cf1c92f1 100644 --- a/packages/flutter/test/widgets/text_selection_test.dart +++ b/packages/flutter/test/widgets/text_selection_test.dart @@ -1246,6 +1246,8 @@ void main() { await gesture2.up(); await tester.pump(const Duration(milliseconds: 20)); expect(endDragEndDetails, isNotNull); + + selectionOverlay.dispose(); }); });