Unverified Commit b3b764c9 authored by xster's avatar xster Committed by GitHub

Revise Android and iOS gestures on Material TextField (#24457)

parent eb7a59b6
// Copyright 2018 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'package:flutter/gestures.dart' show kDoubleTapTimeout, kDoubleTapSlop;
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
......@@ -412,13 +409,6 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with AutomaticK
FocusNode _focusNode;
FocusNode get _effectiveFocusNode => widget.focusNode ?? (_focusNode ??= FocusNode());
// Is shortly after a previous single tap when not null.
Timer _doubleTapTimer;
Offset _lastTapOffset;
// True if second tap down of a double tap is detected. Used to discard
// subsequent tap up / tap hold of the same tap.
bool _isDoubleTap = false;
@override
void initState() {
super.initState();
......@@ -448,7 +438,6 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with AutomaticK
void dispose() {
_focusNode?.dispose();
_controller?.removeListener(updateKeepAlive);
_doubleTapTimer?.cancel();
super.dispose();
}
......@@ -458,54 +447,21 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with AutomaticK
RenderEditable get _renderEditable => _editableTextKey.currentState.renderEditable;
// The down handler is force-run on success of a single tap and optimistically
// run before a long press success.
void _handleTapDown(TapDownDetails details) {
_renderEditable.handleTapDown(details);
// This isn't detected as a double tap gesture in the gesture recognizer
// because it's 2 single taps, each of which may do different things depending
// on whether it's a single tap, the first tap of a double tap, the second
// tap held down, a clean double tap etc.
if (_doubleTapTimer != null && _isWithinDoubleTapTolerance(details.globalPosition)) {
// If there was already a previous tap, the second down hold/tap is a
// double tap.
_renderEditable.selectWord(cause: SelectionChangedCause.doubleTap);
_doubleTapTimer.cancel();
_doubleTapTimeout();
_isDoubleTap = true;
}
}
void _handleTapUp(TapUpDetails details) {
if (!_isDoubleTap) {
_renderEditable.selectWordEdge(cause: SelectionChangedCause.tap);
_lastTapOffset = details.globalPosition;
_doubleTapTimer = Timer(kDoubleTapTimeout, _doubleTapTimeout);
_requestKeyboard();
}
_isDoubleTap = false;
void _handleSingleTapUp(TapUpDetails details) {
_renderEditable.selectWordEdge(cause: SelectionChangedCause.tap);
_requestKeyboard();
}
void _handleLongPress() {
if (!_isDoubleTap) {
_renderEditable.selectPosition(cause: SelectionChangedCause.longPress);
}
_isDoubleTap = false;
void _handleSingleLongTapDown() {
_renderEditable.selectPosition(cause: SelectionChangedCause.longPress);
}
void _doubleTapTimeout() {
_doubleTapTimer = null;
_lastTapOffset = null;
}
bool _isWithinDoubleTapTolerance(Offset secondTapOffset) {
assert(secondTapOffset != null);
if (_lastTapOffset == null) {
return false;
}
final Offset difference = secondTapOffset - _lastTapOffset;
return difference.distance <= kDoubleTapSlop;
void _handleDoubleTapDown(TapDownDetails details) {
_renderEditable.selectWord(cause: SelectionChangedCause.doubleTap);
}
@override
......@@ -690,12 +646,12 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with AutomaticK
: CupertinoTheme.of(context).brightness == Brightness.light
? _kDisabledBackground
: CupertinoColors.darkBackgroundGray,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
child: TextSelectionGestureDetector(
onTapDown: _handleTapDown,
onTapUp: _handleTapUp,
onLongPress: _handleLongPress,
excludeFromSemantics: true,
onSingleTapUp: _handleSingleTapUp,
onSingleLongTapDown: _handleSingleLongTapDown,
onDoubleTapDown: _handleDoubleTapDown,
behavior: HitTestBehavior.translucent,
child: _addTextDependentAttachments(paddedEditable, textStyle),
),
),
......
......@@ -411,8 +411,9 @@ class _TextFieldState extends State<TextField> with AutomaticKeepAliveClientMixi
InputDecoration _getEffectiveDecoration() {
final MaterialLocalizations localizations = MaterialLocalizations.of(context);
final ThemeData themeData = Theme.of(context);
final InputDecoration effectiveDecoration = (widget.decoration ?? const InputDecoration())
.applyDefaults(Theme.of(context).inputDecorationTheme)
.applyDefaults(themeData.inputDecorationTheme)
.copyWith(
enabled: widget.enabled,
hintMaxLines: widget.decoration?.hintMaxLines ?? widget.maxLines
......@@ -434,7 +435,6 @@ class _TextFieldState extends State<TextField> with AutomaticKeepAliveClientMixi
// Handle length exceeds maxLength
if (_effectiveController.value.text.runes.length > widget.maxLength) {
final ThemeData themeData = Theme.of(context);
return effectiveDecoration.copyWith(
errorText: effectiveDecoration.errorText ?? '',
counterStyle: effectiveDecoration.errorStyle
......@@ -489,10 +489,11 @@ class _TextFieldState extends State<TextField> with AutomaticKeepAliveClientMixi
InteractiveInkFeature _createInkFeature(TapDownDetails details) {
final MaterialInkController inkController = Material.of(context);
final ThemeData themeData = Theme.of(context);
final BuildContext editableContext = _editableTextKey.currentContext;
final RenderBox referenceBox = InputDecorator.containerOf(editableContext) ?? editableContext.findRenderObject();
final Offset position = referenceBox.globalToLocal(details.globalPosition);
final Color color = Theme.of(context).splashColor;
final Color color = themeData.splashColor;
InteractiveInkFeature splash;
void handleRemoved() {
......@@ -505,7 +506,7 @@ class _TextFieldState extends State<TextField> with AutomaticKeepAliveClientMixi
} // else we're probably in deactivate()
}
splash = Theme.of(context).splashFactory.create(
splash = themeData.splashFactory.create(
controller: inkController,
referenceBox: referenceBox,
position: position,
......@@ -527,25 +528,47 @@ class _TextFieldState extends State<TextField> with AutomaticKeepAliveClientMixi
_startSplash(details);
}
void _handleTap() {
if (widget.selectionEnabled)
_renderEditable.handleTap();
void _handleSingleTapUp(TapUpDetails details) {
if (widget.selectionEnabled) {
switch (Theme.of(context).platform) {
case TargetPlatform.iOS:
_renderEditable.selectWordEdge(cause: SelectionChangedCause.tap);
break;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
_renderEditable.selectPosition(cause: SelectionChangedCause.tap);
break;
}
}
_requestKeyboard();
_confirmCurrentSplash();
if (widget.onTap != null)
widget.onTap();
}
void _handleTapCancel() {
void _handleSingleTapCancel() {
_cancelCurrentSplash();
}
void _handleLongPress() {
if (widget.selectionEnabled)
_renderEditable.handleLongPress();
void _handleSingleLongTapDown() {
if (widget.selectionEnabled) {
switch (Theme.of(context).platform) {
case TargetPlatform.iOS:
_renderEditable.selectPosition(cause: SelectionChangedCause.longPress);
break;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
_renderEditable.selectWord(cause: SelectionChangedCause.longPress);
break;
}
}
_confirmCurrentSplash();
}
void _handleDoubleTapDown(TapDownDetails details) {
_renderEditable.selectWord(cause: SelectionChangedCause.doubleTap);
}
void _startSplash(TapDownDetails details) {
if (_effectiveFocusNode.hasFocus)
return;
......@@ -632,7 +655,7 @@ class _TextFieldState extends State<TextField> with AutomaticKeepAliveClientMixi
rendererIgnoresPointer: true,
cursorWidth: widget.cursorWidth,
cursorRadius: widget.cursorRadius,
cursorColor: widget.cursorColor ?? Theme.of(context).cursorColor,
cursorColor: widget.cursorColor ?? themeData.cursorColor,
backgroundCursorColor: CupertinoColors.inactiveGray,
scrollPadding: widget.scrollPadding,
keyboardAppearance: keyboardAppearance,
......@@ -665,13 +688,13 @@ class _TextFieldState extends State<TextField> with AutomaticKeepAliveClientMixi
},
child: IgnorePointer(
ignoring: !(widget.enabled ?? widget.decoration?.enabled ?? true),
child: GestureDetector(
behavior: HitTestBehavior.translucent,
child: TextSelectionGestureDetector(
onTapDown: _handleTapDown,
onTap: _handleTap,
onTapCancel: _handleTapCancel,
onLongPress: _handleLongPress,
excludeFromSemantics: true,
onSingleTapUp: _handleSingleTapUp,
onSingleTapCancel: _handleSingleTapCancel,
onSingleLongTapDown: _handleSingleLongTapDown,
onDoubleTapDown: _handleDoubleTapDown,
behavior: HitTestBehavior.translucent,
child: child,
),
),
......
......@@ -4,6 +4,7 @@
import 'dart:async';
import 'package:flutter/gestures.dart' show kDoubleTapTimeout, kDoubleTapSlop;
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter/scheduler.dart';
......@@ -568,3 +569,159 @@ class _TextSelectionHandleOverlayState extends State<_TextSelectionHandleOverlay
return null;
}
}
/// A gesture detector to respond to non-exclusive event chains for a text field.
///
/// An ordinary [GestureDetector] configured to handle events like tap and
/// double tap will only recognize one or the other. This widget detects both:
/// first the tap and then, if another tap down occurs within a time limit, the
/// double tap.
///
/// See also:
///
/// * [TextField], a Material text field which uses this gesture detector.
/// * [CupertinoTextField], a Cupertino text field which uses this gesture
/// detector.
class TextSelectionGestureDetector extends StatefulWidget {
/// Create a [TextSelectionGestureDetector].
///
/// Multiple callbacks can be called for one sequence of input gesture.
/// The [child] parameter must not be null.
const TextSelectionGestureDetector({
Key key,
this.onTapDown,
this.onSingleTapUp,
this.onSingleTapCancel,
this.onSingleLongTapDown,
this.onDoubleTapDown,
this.behavior,
@required this.child,
}) : assert(child != null),
super(key: key);
/// Called for every tap down including every tap down that's part of a
/// double click or a long press, except touches that include enough movement
/// to not qualify as taps (e.g. pans and flings).
final GestureTapDownCallback onTapDown;
/// Called for each distinct tap except for every second tap of a double tap.
/// For example, if the detector was configured [onSingleTapDown] and
/// [onDoubleTapDown], three quick taps would be recognized as a single tap
/// down, followed by a double tap down, followed by a single tap down.
final GestureTapUpCallback onSingleTapUp;
/// Called for each touch that becomes recognized as a gesture that is not a
/// short tap, such as a long tap or drag. It is called at the moment when
/// another gesture from the touch is recognized.
final GestureTapCancelCallback onSingleTapCancel;
/// Called for a single long tap that's sustained for longer than
/// [kLongPressTimeout] but not necessarily lifted. Not called for a
/// double-tap-hold, which calls [onDoubleTapDown] instead.
final GestureLongPressCallback onSingleLongTapDown;
/// Called after a momentary hold or a short tap that is close in space and
/// time (within [kDoubleTapTimeout]) to a previous short tap.
final GestureTapDownCallback onDoubleTapDown;
/// How this gesture detector should behave during hit testing.
///
/// This defaults to [HitTestBehavior.deferToChild].
final HitTestBehavior behavior;
/// Child below this widget.
final Widget child;
@override
State<StatefulWidget> createState() => _TextSelectionGestureDetectorState();
}
class _TextSelectionGestureDetectorState extends State<TextSelectionGestureDetector> {
// Counts down for a short duration after a previous tap. Null otherwise.
Timer _doubleTapTimer;
Offset _lastTapOffset;
// True if a second tap down of a double tap is detected. Used to discard
// subsequent tap up / tap hold of the same tap.
bool _isDoubleTap = false;
@override
void dispose() {
_doubleTapTimer?.cancel();
super.dispose();
}
// The down handler is force-run on success of a single tap and optimistically
// run before a long press success.
void _handleTapDown(TapDownDetails details) {
if (widget.onTapDown != null) {
widget.onTapDown(details);
}
// This isn't detected as a double tap gesture in the gesture recognizer
// because it's 2 single taps, each of which may do different things depending
// on whether it's a single tap, the first tap of a double tap, the second
// tap held down, a clean double tap etc.
if (_doubleTapTimer != null && _isWithinDoubleTapTolerance(details.globalPosition)) {
// If there was already a previous tap, the second down hold/tap is a
// double tap down.
if (widget.onDoubleTapDown != null) {
widget.onDoubleTapDown(details);
}
_doubleTapTimer.cancel();
_doubleTapTimeout();
_isDoubleTap = true;
}
}
void _handleTapUp(TapUpDetails details) {
if (!_isDoubleTap) {
if (widget.onSingleTapUp != null) {
widget.onSingleTapUp(details);
}
_lastTapOffset = details.globalPosition;
_doubleTapTimer = Timer(kDoubleTapTimeout, _doubleTapTimeout);
}
_isDoubleTap = false;
}
void _handleTapCancel() {
if (widget.onSingleTapCancel != null) {
widget.onSingleTapCancel();
}
}
void _handleLongPress() {
if (!_isDoubleTap && widget.onSingleLongTapDown != null) {
widget.onSingleLongTapDown();
}
_isDoubleTap = false;
}
void _doubleTapTimeout() {
_doubleTapTimer = null;
_lastTapOffset = null;
}
bool _isWithinDoubleTapTolerance(Offset secondTapOffset) {
assert(secondTapOffset != null);
if (_lastTapOffset == null) {
return false;
}
final Offset difference = secondTapOffset - _lastTapOffset;
return difference.distance <= kDoubleTapSlop;
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: _handleTapDown,
onTapUp: _handleTapUp,
onTapCancel: _handleTapCancel,
onLongPress: _handleLongPress,
excludeFromSemantics: true,
behavior: widget.behavior,
child: widget.child,
);
}
}
......@@ -7,6 +7,7 @@ import 'dart:io' show Platform;
import 'dart:math' as math;
import 'dart:ui' as ui show window;
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/rendering.dart';
......@@ -3455,8 +3456,12 @@ void main() {
expect(tapCount, 0);
await tester.tap(find.byType(TextField));
// Wait a bit so they're all single taps and not double taps.
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.byType(TextField));
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.byType(TextField));
await tester.pump(const Duration(milliseconds: 300));
expect(tapCount, 3);
});
......@@ -3560,4 +3565,562 @@ void main() {
)));
expect(tester.takeException(), isNotNull);
});
testWidgets(
'tap moves cursor to the edge of the word it tapped on (iOS)',
(WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure',
);
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(platform: TargetPlatform.iOS),
home: Material(
child: Center(
child: TextField(
controller: controller,
),
),
),
),
);
final Offset textfieldStart = tester.getTopLeft(find.byType(TextField));
await tester.tapAt(textfieldStart + const Offset(50.0, 5.0));
await tester.pump();
// We moved the cursor.
expect(
controller.selection,
const TextSelection.collapsed(offset: 7, affinity: TextAffinity.upstream),
);
// But don't trigger the toolbar.
expect(find.byType(CupertinoButton), findsNothing);
},
);
testWidgets(
'tap moves cursor to the position tapped (Android)',
(WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure',
);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Center(
child: TextField(
controller: controller,
),
),
),
),
);
final Offset textfieldStart = tester.getTopLeft(find.byType(TextField));
await tester.tapAt(textfieldStart + const Offset(50.0, 5.0));
await tester.pump();
// We moved the cursor.
expect(
controller.selection,
const TextSelection.collapsed(offset: 3),
);
// But don't trigger the toolbar.
expect(find.byType(FlatButton), findsNothing);
},
);
testWidgets(
'two slow taps do not trigger a word selection (iOS)',
(WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure',
);
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(platform: TargetPlatform.iOS),
home: Material(
child: Center(
child: TextField(
controller: controller,
),
),
),
),
);
final Offset textfieldStart = tester.getTopLeft(find.byType(TextField));
await tester.tapAt(textfieldStart + const Offset(50.0, 5.0));
await tester.pump(const Duration(milliseconds: 500));
await tester.tapAt(textfieldStart + const Offset(50.0, 5.0));
await tester.pump();
// Plain collapsed selection.
expect(
controller.selection,
const TextSelection.collapsed(offset: 7, affinity: TextAffinity.upstream),
);
// No toolbar.
expect(find.byType(CupertinoButton), findsNothing);
},
);
testWidgets(
'double tap selects word and first tap of double tap moves cursor (iOS)',
(WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure',
);
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(platform: TargetPlatform.iOS),
home: Material(
child: Center(
child: TextField(
controller: controller,
),
),
),
),
);
final Offset textfieldStart = tester.getTopLeft(find.byType(TextField));
// This tap just puts the cursor somewhere different than where the double
// tap will occur to test that the double tap moves the existing cursor first.
await tester.tapAt(textfieldStart + const Offset(50.0, 5.0));
await tester.pump(const Duration(milliseconds: 500));
await tester.tapAt(textfieldStart + const Offset(150.0, 5.0));
await tester.pump(const Duration(milliseconds: 50));
// First tap moved the cursor.
expect(
controller.selection,
const TextSelection.collapsed(offset: 8, affinity: TextAffinity.downstream),
);
await tester.tapAt(textfieldStart + const Offset(150.0, 5.0));
await tester.pump();
// Second tap selects the word around the cursor.
expect(
controller.selection,
const TextSelection(baseOffset: 8, extentOffset: 12),
);
// Selected text shows 3 toolbar buttons.
expect(find.byType(CupertinoButton), findsNWidgets(3));
},
);
testWidgets(
'double tap selects word and first tap of double tap moves cursor and shows toolbar (Android)',
(WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure',
);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Center(
child: TextField(
controller: controller,
),
),
),
),
);
final Offset textfieldStart = tester.getTopLeft(find.byType(TextField));
// This tap just puts the cursor somewhere different than where the double
// tap will occur to test that the double tap moves the existing cursor first.
await tester.tapAt(textfieldStart + const Offset(50.0, 5.0));
await tester.pump(const Duration(milliseconds: 500));
await tester.tapAt(textfieldStart + const Offset(150.0, 5.0));
await tester.pump(const Duration(milliseconds: 50));
// First tap moved the cursor.
expect(
controller.selection,
const TextSelection.collapsed(offset: 9),
);
await tester.tapAt(textfieldStart + const Offset(150.0, 5.0));
await tester.pump();
// Second tap selects the word around the cursor.
expect(
controller.selection,
const TextSelection(baseOffset: 8, extentOffset: 12),
);
// Selected text shows 3 toolbar buttons.
expect(find.byType(FlatButton), findsNWidgets(3));
},
);
testWidgets(
'double tap hold selects word (iOS)',
(WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure',
);
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(platform: TargetPlatform.iOS),
home: Material(
child: Center(
child: TextField(
controller: controller,
),
),
),
),
);
final Offset textfieldStart = tester.getTopLeft(find.byType(TextField));
await tester.tapAt(textfieldStart + const Offset(150.0, 5.0));
await tester.pump(const Duration(milliseconds: 50));
final TestGesture gesture =
await tester.startGesture(textfieldStart + const Offset(150.0, 5.0));
// Hold the press.
await tester.pump(const Duration(milliseconds: 500));
expect(
controller.selection,
const TextSelection(baseOffset: 8, extentOffset: 12),
);
// Selected text shows 3 toolbar buttons.
expect(find.byType(CupertinoButton), findsNWidgets(3));
await gesture.up();
await tester.pump();
// Still selected.
expect(
controller.selection,
const TextSelection(baseOffset: 8, extentOffset: 12),
);
expect(find.byType(CupertinoButton), findsNWidgets(3));
},
);
testWidgets(
'tap after a double tap select is not affected (iOS)',
(WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure',
);
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(platform: TargetPlatform.iOS),
home: Material(
child: Center(
child: TextField(
controller: controller,
),
),
),
),
);
final Offset textfieldStart = tester.getTopLeft(find.byType(TextField));
await tester.tapAt(textfieldStart + const Offset(150.0, 5.0));
await tester.pump(const Duration(milliseconds: 50));
// First tap moved the cursor.
expect(
controller.selection,
const TextSelection.collapsed(offset: 8, affinity: TextAffinity.downstream),
);
await tester.tapAt(textfieldStart + const Offset(150.0, 5.0));
await tester.pump(const Duration(milliseconds: 500));
await tester.tapAt(textfieldStart + const Offset(100.0, 5.0));
await tester.pump();
// Plain collapsed selection at the edge of first word. In iOS 12, the
// the first tap after a double tap ends up putting the cursor at where
// you tapped instead of the edge like every other single tap. This is
// likely a bug in iOS 12 and not present in other versions.
expect(
controller.selection,
const TextSelection.collapsed(offset: 7, affinity: TextAffinity.upstream),
);
// No toolbar.
expect(find.byType(CupertinoButton), findsNothing);
},
);
testWidgets(
'long press moves cursor to the exact long press position and shows toolbar (iOS)',
(WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure',
);
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(platform: TargetPlatform.iOS),
home: Material(
child: Center(
child: TextField(
controller: controller,
),
),
),
),
);
final Offset textfieldStart = tester.getTopLeft(find.byType(TextField));
await tester.longPressAt(textfieldStart + const Offset(50.0, 5.0));
await tester.pump();
// Collapsed cursor for iOS long press.
expect(
controller.selection,
const TextSelection.collapsed(offset: 3),
);
// Collapsed toolbar shows 2 buttons.
expect(find.byType(CupertinoButton), findsNWidgets(2));
},
);
testWidgets(
'long press selects word and shows toolbar (Android)',
(WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure',
);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Center(
child: TextField(
controller: controller,
),
),
),
),
);
final Offset textfieldStart = tester.getTopLeft(find.byType(TextField));
await tester.longPressAt(textfieldStart + const Offset(50.0, 5.0));
await tester.pump();
expect(
controller.selection,
const TextSelection(baseOffset: 0, extentOffset: 7),
);
// Collapsed toolbar shows 3 buttons.
expect(find.byType(FlatButton), findsNWidgets(3));
},
);
testWidgets(
'long press tap is not a double tap (iOS)',
(WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure',
);
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(platform: TargetPlatform.iOS),
home: Material(
child: Center(
child: TextField(
controller: controller,
),
),
),
),
);
final Offset textfieldStart = tester.getTopLeft(find.byType(TextField));
await tester.longPressAt(textfieldStart + const Offset(50.0, 5.0));
await tester.pump(const Duration(milliseconds: 50));
await tester.tapAt(textfieldStart + const Offset(50.0, 5.0));
await tester.pump();
// We ended up moving the cursor to the edge of the same word and dismissed
// the toolbar.
expect(
controller.selection,
const TextSelection.collapsed(offset: 7, affinity: TextAffinity.upstream),
);
// Collapsed toolbar shows 2 buttons.
expect(find.byType(CupertinoButton), findsNothing);
},
);
testWidgets(
'long tap after a double tap select is not affected (iOS)',
(WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure',
);
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(platform: TargetPlatform.iOS),
home: Material(
child: Center(
child: TextField(
controller: controller,
),
),
),
),
);
final Offset textfieldStart = tester.getTopLeft(find.byType(TextField));
await tester.tapAt(textfieldStart + const Offset(150.0, 5.0));
await tester.pump(const Duration(milliseconds: 50));
// First tap moved the cursor to the beginning of the second word.
expect(
controller.selection,
const TextSelection.collapsed(offset: 8, affinity: TextAffinity.downstream),
);
await tester.tapAt(textfieldStart + const Offset(150.0, 5.0));
await tester.pump(const Duration(milliseconds: 500));
await tester.longPressAt(textfieldStart + const Offset(100.0, 5.0));
await tester.pump();
// Plain collapsed selection at the exact tap position.
expect(
controller.selection,
const TextSelection.collapsed(offset: 6),
);
// Long press toolbar.
expect(find.byType(CupertinoButton), findsNWidgets(2));
},
);
testWidgets(
'double tap after a long tap is not affected (iOS)',
(WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure',
);
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(platform: TargetPlatform.iOS),
home: Material(
child: Center(
child: TextField(
controller: controller,
),
),
),
),
);
final Offset textfieldStart = tester.getTopLeft(find.byType(TextField));
await tester.longPressAt(textfieldStart + const Offset(50.0, 5.0));
await tester.pump(const Duration(milliseconds: 50));
await tester.tapAt(textfieldStart + const Offset(150.0, 5.0));
await tester.pump(const Duration(milliseconds: 50));
// First tap moved the cursor.
expect(
controller.selection,
const TextSelection.collapsed(offset: 8, affinity: TextAffinity.downstream),
);
await tester.tapAt(textfieldStart + const Offset(150.0, 5.0));
await tester.pump();
// Double tap selection.
expect(
controller.selection,
const TextSelection(baseOffset: 8, extentOffset: 12),
);
expect(find.byType(CupertinoButton), findsNWidgets(3));
},
);
testWidgets(
'double tap chains work (iOS)',
(WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure',
);
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(platform: TargetPlatform.iOS),
home: Material(
child: Center(
child: TextField(
controller: controller,
),
),
),
),
);
final Offset textfieldStart = tester.getTopLeft(find.byType(TextField));
await tester.tapAt(textfieldStart + const Offset(50.0, 5.0));
await tester.pump(const Duration(milliseconds: 50));
expect(
controller.selection,
const TextSelection.collapsed(offset: 7, affinity: TextAffinity.upstream),
);
await tester.tapAt(textfieldStart + const Offset(50.0, 5.0));
await tester.pump(const Duration(milliseconds: 50));
expect(
controller.selection,
const TextSelection(baseOffset: 0, extentOffset: 7),
);
expect(find.byType(CupertinoButton), findsNWidgets(3));
// Double tap selecting the same word somewhere else is fine.
await tester.tapAt(textfieldStart + const Offset(100.0, 5.0));
await tester.pump(const Duration(milliseconds: 50));
// First tap moved the cursor.
expect(
controller.selection,
const TextSelection.collapsed(offset: 7, affinity: TextAffinity.upstream),
);
await tester.tapAt(textfieldStart + const Offset(100.0, 5.0));
await tester.pump(const Duration(milliseconds: 50));
expect(
controller.selection,
const TextSelection(baseOffset: 0, extentOffset: 7),
);
expect(find.byType(CupertinoButton), findsNWidgets(3));
await tester.tapAt(textfieldStart + const Offset(150.0, 5.0));
await tester.pump(const Duration(milliseconds: 50));
// First tap moved the cursor.
expect(
controller.selection,
const TextSelection.collapsed(offset: 8, affinity: TextAffinity.downstream),
);
await tester.tapAt(textfieldStart + const Offset(150.0, 5.0));
await tester.pump(const Duration(milliseconds: 50));
expect(
controller.selection,
const TextSelection(baseOffset: 8, extentOffset: 12),
);
expect(find.byType(CupertinoButton), findsNWidgets(3));
},
);
}
// Copyright 2018 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/widgets.dart';
void main() {
int tapCount;
int singleTapUpCount;
int singleTapCancelCount;
int singleLongTapDownCount;
int doubleTapDownCount;
void _handleTapDown(TapDownDetails details) { tapCount++; }
void _handleSingleTapUp(TapUpDetails details) { singleTapUpCount++; }
void _handleSingleTapCancel() { singleTapCancelCount++; }
void _handleSingleLongTapDown() { singleLongTapDownCount++; }
void _handleDoubleTapDown(TapDownDetails details) { doubleTapDownCount++; }
setUp(() {
tapCount = 0;
singleTapUpCount = 0;
singleTapCancelCount = 0;
singleLongTapDownCount = 0;
doubleTapDownCount = 0;
});
Future<void> pumpGestureDetector(WidgetTester tester) async {
await tester.pumpWidget(
TextSelectionGestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: _handleTapDown,
onSingleTapUp: _handleSingleTapUp,
onSingleTapCancel: _handleSingleTapCancel,
onSingleLongTapDown: _handleSingleLongTapDown,
onDoubleTapDown: _handleDoubleTapDown,
child: Container(),
),
);
}
testWidgets('a series of taps all call onTaps', (WidgetTester tester) async {
await pumpGestureDetector(tester);
await tester.tapAt(const Offset(200, 200));
await tester.pump(const Duration(milliseconds: 150));
await tester.tapAt(const Offset(200, 200));
await tester.pump(const Duration(milliseconds: 150));
await tester.tapAt(const Offset(200, 200));
await tester.pump(const Duration(milliseconds: 150));
await tester.tapAt(const Offset(200, 200));
await tester.pump(const Duration(milliseconds: 150));
await tester.tapAt(const Offset(200, 200));
await tester.pump(const Duration(milliseconds: 150));
await tester.tapAt(const Offset(200, 200));
expect(tapCount, 6);
});
testWidgets('in a series of rapid taps, onTapDown and onDoubleTapDown alternate', (WidgetTester tester) async {
await pumpGestureDetector(tester);
await tester.tapAt(const Offset(200, 200));
await tester.pump(const Duration(milliseconds: 50));
expect(singleTapUpCount, 1);
await tester.tapAt(const Offset(200, 200));
await tester.pump(const Duration(milliseconds: 50));
expect(singleTapUpCount, 1);
expect(doubleTapDownCount, 1);
await tester.tapAt(const Offset(200, 200));
await tester.pump(const Duration(milliseconds: 50));
expect(singleTapUpCount, 2);
expect(doubleTapDownCount, 1);
await tester.tapAt(const Offset(200, 200));
await tester.pump(const Duration(milliseconds: 50));
expect(singleTapUpCount, 2);
expect(doubleTapDownCount, 2);
await tester.tapAt(const Offset(200, 200));
await tester.pump(const Duration(milliseconds: 50));
expect(singleTapUpCount, 3);
expect(doubleTapDownCount, 2);
await tester.tapAt(const Offset(200, 200));
expect(singleTapUpCount, 3);
expect(doubleTapDownCount, 3);
expect(tapCount, 6);
});
testWidgets('quick tap-tap-hold is a double tap down', (WidgetTester tester) async {
await pumpGestureDetector(tester);
await tester.tapAt(const Offset(200, 200));
await tester.pump(const Duration(milliseconds: 50));
expect(singleTapUpCount, 1);
final TestGesture gesture = await tester.startGesture(const Offset(200, 200));
await tester.pump(const Duration(milliseconds: 200));
expect(singleTapUpCount, 1);
// Every down is counted.
expect(tapCount, 2);
// No cancels because the second tap of the double tap is a second successful
// single tap behind the scene.
expect(singleTapCancelCount, 0);
expect(doubleTapDownCount, 1);
// The double tap down hold supersedes the single tap down.
expect(singleLongTapDownCount, 0);
await gesture.up();
// Nothing else happens on up.
expect(singleTapUpCount, 1);
expect(tapCount, 2);
expect(singleTapCancelCount, 0);
expect(doubleTapDownCount, 1);
expect(singleLongTapDownCount, 0);
});
testWidgets('a very quick swipe is just a canceled tap', (WidgetTester tester) async {
await pumpGestureDetector(tester);
final TestGesture gesture = await tester.startGesture(const Offset(200, 200));
await tester.pump(const Duration(milliseconds: 20));
await gesture.moveBy(const Offset(100, 100));
await tester.pump();
expect(singleTapUpCount, 0);
expect(tapCount, 0);
expect(singleTapCancelCount, 1);
expect(doubleTapDownCount, 0);
expect(singleLongTapDownCount, 0);
await gesture.up();
// Nothing else happens on up.
expect(singleTapUpCount, 0);
expect(tapCount, 0);
expect(singleTapCancelCount, 1);
expect(doubleTapDownCount, 0);
expect(singleLongTapDownCount, 0);
});
testWidgets('a slower swipe has a tap down and a canceled tap', (WidgetTester tester) async {
await pumpGestureDetector(tester);
final TestGesture gesture = await tester.startGesture(const Offset(200, 200));
await tester.pump(const Duration(milliseconds: 120));
await gesture.moveBy(const Offset(100, 100));
await tester.pump();
expect(singleTapUpCount, 0);
expect(tapCount, 1);
expect(singleTapCancelCount, 1);
expect(doubleTapDownCount, 0);
expect(singleLongTapDownCount, 0);
});
}
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