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;
/// 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<OverlayEntry>? _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>{ 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) {
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,
......
......@@ -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);
......
......@@ -1553,6 +1553,69 @@ void main() {
// toolbar. Until we change that, this test should remain skipped.
}, 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 {
await tester.pumpWidget(
MaterialApp(
......
......@@ -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);
......
......@@ -1246,6 +1246,8 @@ void main() {
await gesture2.up();
await tester.pump(const Duration(milliseconds: 20));
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