Unverified Commit 4ec4c249 authored by Markus Aksli's avatar Markus Aksli Committed by GitHub

Hide text selection toolbar when dragging handles on mobile (#104274)

parent dffddf00
...@@ -29,6 +29,11 @@ export 'package:flutter/services.dart' show TextSelectionDelegate; ...@@ -29,6 +29,11 @@ export 'package:flutter/services.dart' show TextSelectionDelegate;
/// called. /// called.
const Duration _kDragSelectionUpdateThrottle = Duration(milliseconds: 50); 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. /// 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 /// The first argument [startDetails] contains the details of the event that
...@@ -782,6 +787,8 @@ class SelectionOverlay { ...@@ -782,6 +787,8 @@ class SelectionOverlay {
/// Controls the fade-in and fade-out animations for the toolbar and handles. /// Controls the fade-in and fade-out animations for the toolbar and handles.
static const Duration fadeDuration = Duration(milliseconds: 150); static const Duration fadeDuration = Duration(milliseconds: 150);
Timer? _androidPostDragShowTimer;
/// A pair of handles. If this is non-null, there are always 2, though the /// A pair of handles. If this is non-null, there are always 2, though the
/// second is hidden when the selection is collapsed. /// second is hidden when the selection is collapsed.
List<OverlayEntry>? _handles; List<OverlayEntry>? _handles;
...@@ -821,7 +828,7 @@ class SelectionOverlay { ...@@ -821,7 +828,7 @@ class SelectionOverlay {
/// Shows the toolbar by inserting it into the [context]'s overlay. /// Shows the toolbar by inserting it into the [context]'s overlay.
/// {@endtemplate} /// {@endtemplate}
void showToolbar() { void showToolbar() {
if (_toolbar != null) { if (_toolbar != null || (_hideToolbarWhenDraggingHandles && _isDraggingHandles)) {
return; return;
} }
_toolbar = OverlayEntry(builder: _buildToolbar); _toolbar = OverlayEntry(builder: _buildToolbar);
...@@ -888,9 +895,62 @@ class SelectionOverlay { ...@@ -888,9 +895,62 @@ class SelectionOverlay {
/// Disposes this object and release resources. /// Disposes this object and release resources.
/// {@endtemplate} /// {@endtemplate}
void dispose() { void dispose() {
_androidPostDragShowTimer?.cancel();
_androidPostDragShowTimer = null;
hide(); hide();
} }
final bool _hideToolbarWhenDraggingHandles =
<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.android }.contains(defaultTargetPlatform);
bool _isDraggingStartHandle = false;
bool _isDraggingEndHandle = false;
bool get _isDraggingHandles => _isDraggingStartHandle || _isDraggingEndHandle;
Future<void> _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) { Widget _buildStartHandle(BuildContext context) {
final Widget handle; final Widget handle;
final TextSelectionControls? selectionControls = this.selectionControls; final TextSelectionControls? selectionControls = this.selectionControls;
...@@ -901,9 +961,9 @@ class SelectionOverlay { ...@@ -901,9 +961,9 @@ class SelectionOverlay {
type: _startHandleType, type: _startHandleType,
handleLayerLink: startHandleLayerLink, handleLayerLink: startHandleLayerLink,
onSelectionHandleTapped: onSelectionHandleTapped, onSelectionHandleTapped: onSelectionHandleTapped,
onSelectionHandleDragStart: onStartHandleDragStart, onSelectionHandleDragStart: _onStartHandleDragStart,
onSelectionHandleDragUpdate: onStartHandleDragUpdate, onSelectionHandleDragUpdate: onStartHandleDragUpdate,
onSelectionHandleDragEnd: onStartHandleDragEnd, onSelectionHandleDragEnd: _onStartHandleDragEnd,
selectionControls: selectionControls, selectionControls: selectionControls,
visibility: startHandlesVisible, visibility: startHandlesVisible,
preferredLineHeight: _lineHeightAtStart, preferredLineHeight: _lineHeightAtStart,
...@@ -926,9 +986,9 @@ class SelectionOverlay { ...@@ -926,9 +986,9 @@ class SelectionOverlay {
type: _endHandleType, type: _endHandleType,
handleLayerLink: endHandleLayerLink, handleLayerLink: endHandleLayerLink,
onSelectionHandleTapped: onSelectionHandleTapped, onSelectionHandleTapped: onSelectionHandleTapped,
onSelectionHandleDragStart: onEndHandleDragStart, onSelectionHandleDragStart: _onEndHandleDragStart,
onSelectionHandleDragUpdate: onEndHandleDragUpdate, onSelectionHandleDragUpdate: onEndHandleDragUpdate,
onSelectionHandleDragEnd: onEndHandleDragEnd, onSelectionHandleDragEnd: _onEndHandleDragEnd,
selectionControls: selectionControls, selectionControls: selectionControls,
visibility: endHandlesVisible, visibility: endHandlesVisible,
preferredLineHeight: _lineHeightAtEnd, preferredLineHeight: _lineHeightAtEnd,
......
...@@ -3179,6 +3179,11 @@ void main() { ...@@ -3179,6 +3179,11 @@ void main() {
expect(controller.selection.extentOffset, 50); expect(controller.selection.extentOffset, 50);
if (!isContextMenuProvidedByPlatform) { 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.tap(find.text('Cut'));
await tester.pump(); await tester.pump();
expect(controller.selection.isCollapsed, true); expect(controller.selection.isCollapsed, true);
......
...@@ -1553,6 +1553,69 @@ void main() { ...@@ -1553,6 +1553,69 @@ void main() {
// toolbar. Until we change that, this test should remain skipped. // toolbar. Until we change that, this test should remain skipped.
}, skip: kIsWeb, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.android })); // [intended] }, skip: kIsWeb, variant: const TargetPlatformVariant(<TargetPlatform>{ 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<EditableTextState>(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<TextSelectionPoint> 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>{ TargetPlatform.iOS, TargetPlatform.android }),
);
testWidgets('Paste is shown only when there is something to paste', (WidgetTester tester) async { testWidgets('Paste is shown only when there is something to paste', (WidgetTester tester) async {
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
......
...@@ -1132,7 +1132,12 @@ void main() { ...@@ -1132,7 +1132,12 @@ void main() {
await gesture.moveTo(newHandlePos); await gesture.moveTo(newHandlePos);
await tester.pump(); await tester.pump();
await gesture.up(); 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.baseOffset, 5);
expect(controller.selection.extentOffset, 50); expect(controller.selection.extentOffset, 50);
......
...@@ -1246,6 +1246,8 @@ void main() { ...@@ -1246,6 +1246,8 @@ void main() {
await gesture2.up(); await gesture2.up();
await tester.pump(const Duration(milliseconds: 20)); await tester.pump(const Duration(milliseconds: 20));
expect(endDragEndDetails, isNotNull); expect(endDragEndDetails, isNotNull);
selectionOverlay.dispose();
}); });
}); });
......
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