Skip to content

Commit

Permalink
Hide text selection toolbar when dragging handles on mobile (#104274)
Browse files Browse the repository at this point in the history
  • Loading branch information
markusaksli-nc authored Jun 2, 2022
1 parent dffddf0 commit 4ec4c24
Show file tree
Hide file tree
Showing 5 changed files with 141 additions and 6 deletions.
70 changes: 65 additions & 5 deletions packages/flutter/lib/src/widgets/text_selection.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions packages/flutter/test/material/text_field_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
63 changes: 63 additions & 0 deletions packages/flutter/test/widgets/editable_text_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
7 changes: 6 additions & 1 deletion packages/flutter/test/widgets/selectable_text_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions packages/flutter/test/widgets/text_selection_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1246,6 +1246,8 @@ void main() {
await gesture2.up();
await tester.pump(const Duration(milliseconds: 20));
expect(endDragEndDetails, isNotNull);

selectionOverlay.dispose();
});
});

Expand Down

0 comments on commit 4ec4c24

Please sign in to comment.