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. // Copyright 2018 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:async';
import 'package:flutter/gestures.dart' show kDoubleTapTimeout, kDoubleTapSlop;
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
...@@ -412,13 +409,6 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with AutomaticK ...@@ -412,13 +409,6 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with AutomaticK
FocusNode _focusNode; FocusNode _focusNode;
FocusNode get _effectiveFocusNode => widget.focusNode ?? (_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 @override
void initState() { void initState() {
super.initState(); super.initState();
...@@ -448,7 +438,6 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with AutomaticK ...@@ -448,7 +438,6 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with AutomaticK
void dispose() { void dispose() {
_focusNode?.dispose(); _focusNode?.dispose();
_controller?.removeListener(updateKeepAlive); _controller?.removeListener(updateKeepAlive);
_doubleTapTimer?.cancel();
super.dispose(); super.dispose();
} }
...@@ -458,54 +447,21 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with AutomaticK ...@@ -458,54 +447,21 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with AutomaticK
RenderEditable get _renderEditable => _editableTextKey.currentState.renderEditable; 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) { void _handleTapDown(TapDownDetails details) {
_renderEditable.handleTapDown(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) { void _handleSingleTapUp(TapUpDetails details) {
if (!_isDoubleTap) {
_renderEditable.selectWordEdge(cause: SelectionChangedCause.tap); _renderEditable.selectWordEdge(cause: SelectionChangedCause.tap);
_lastTapOffset = details.globalPosition;
_doubleTapTimer = Timer(kDoubleTapTimeout, _doubleTapTimeout);
_requestKeyboard(); _requestKeyboard();
} }
_isDoubleTap = false;
}
void _handleLongPress() { void _handleSingleLongTapDown() {
if (!_isDoubleTap) {
_renderEditable.selectPosition(cause: SelectionChangedCause.longPress); _renderEditable.selectPosition(cause: SelectionChangedCause.longPress);
} }
_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; void _handleDoubleTapDown(TapDownDetails details) {
return difference.distance <= kDoubleTapSlop; _renderEditable.selectWord(cause: SelectionChangedCause.doubleTap);
} }
@override @override
...@@ -690,12 +646,12 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with AutomaticK ...@@ -690,12 +646,12 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with AutomaticK
: CupertinoTheme.of(context).brightness == Brightness.light : CupertinoTheme.of(context).brightness == Brightness.light
? _kDisabledBackground ? _kDisabledBackground
: CupertinoColors.darkBackgroundGray, : CupertinoColors.darkBackgroundGray,
child: GestureDetector( child: TextSelectionGestureDetector(
behavior: HitTestBehavior.translucent,
onTapDown: _handleTapDown, onTapDown: _handleTapDown,
onTapUp: _handleTapUp, onSingleTapUp: _handleSingleTapUp,
onLongPress: _handleLongPress, onSingleLongTapDown: _handleSingleLongTapDown,
excludeFromSemantics: true, onDoubleTapDown: _handleDoubleTapDown,
behavior: HitTestBehavior.translucent,
child: _addTextDependentAttachments(paddedEditable, textStyle), child: _addTextDependentAttachments(paddedEditable, textStyle),
), ),
), ),
......
...@@ -411,8 +411,9 @@ class _TextFieldState extends State<TextField> with AutomaticKeepAliveClientMixi ...@@ -411,8 +411,9 @@ class _TextFieldState extends State<TextField> with AutomaticKeepAliveClientMixi
InputDecoration _getEffectiveDecoration() { InputDecoration _getEffectiveDecoration() {
final MaterialLocalizations localizations = MaterialLocalizations.of(context); final MaterialLocalizations localizations = MaterialLocalizations.of(context);
final ThemeData themeData = Theme.of(context);
final InputDecoration effectiveDecoration = (widget.decoration ?? const InputDecoration()) final InputDecoration effectiveDecoration = (widget.decoration ?? const InputDecoration())
.applyDefaults(Theme.of(context).inputDecorationTheme) .applyDefaults(themeData.inputDecorationTheme)
.copyWith( .copyWith(
enabled: widget.enabled, enabled: widget.enabled,
hintMaxLines: widget.decoration?.hintMaxLines ?? widget.maxLines hintMaxLines: widget.decoration?.hintMaxLines ?? widget.maxLines
...@@ -434,7 +435,6 @@ class _TextFieldState extends State<TextField> with AutomaticKeepAliveClientMixi ...@@ -434,7 +435,6 @@ class _TextFieldState extends State<TextField> with AutomaticKeepAliveClientMixi
// Handle length exceeds maxLength // Handle length exceeds maxLength
if (_effectiveController.value.text.runes.length > widget.maxLength) { if (_effectiveController.value.text.runes.length > widget.maxLength) {
final ThemeData themeData = Theme.of(context);
return effectiveDecoration.copyWith( return effectiveDecoration.copyWith(
errorText: effectiveDecoration.errorText ?? '', errorText: effectiveDecoration.errorText ?? '',
counterStyle: effectiveDecoration.errorStyle counterStyle: effectiveDecoration.errorStyle
...@@ -489,10 +489,11 @@ class _TextFieldState extends State<TextField> with AutomaticKeepAliveClientMixi ...@@ -489,10 +489,11 @@ class _TextFieldState extends State<TextField> with AutomaticKeepAliveClientMixi
InteractiveInkFeature _createInkFeature(TapDownDetails details) { InteractiveInkFeature _createInkFeature(TapDownDetails details) {
final MaterialInkController inkController = Material.of(context); final MaterialInkController inkController = Material.of(context);
final ThemeData themeData = Theme.of(context);
final BuildContext editableContext = _editableTextKey.currentContext; final BuildContext editableContext = _editableTextKey.currentContext;
final RenderBox referenceBox = InputDecorator.containerOf(editableContext) ?? editableContext.findRenderObject(); final RenderBox referenceBox = InputDecorator.containerOf(editableContext) ?? editableContext.findRenderObject();
final Offset position = referenceBox.globalToLocal(details.globalPosition); final Offset position = referenceBox.globalToLocal(details.globalPosition);
final Color color = Theme.of(context).splashColor; final Color color = themeData.splashColor;
InteractiveInkFeature splash; InteractiveInkFeature splash;
void handleRemoved() { void handleRemoved() {
...@@ -505,7 +506,7 @@ class _TextFieldState extends State<TextField> with AutomaticKeepAliveClientMixi ...@@ -505,7 +506,7 @@ class _TextFieldState extends State<TextField> with AutomaticKeepAliveClientMixi
} // else we're probably in deactivate() } // else we're probably in deactivate()
} }
splash = Theme.of(context).splashFactory.create( splash = themeData.splashFactory.create(
controller: inkController, controller: inkController,
referenceBox: referenceBox, referenceBox: referenceBox,
position: position, position: position,
...@@ -527,25 +528,47 @@ class _TextFieldState extends State<TextField> with AutomaticKeepAliveClientMixi ...@@ -527,25 +528,47 @@ class _TextFieldState extends State<TextField> with AutomaticKeepAliveClientMixi
_startSplash(details); _startSplash(details);
} }
void _handleTap() { void _handleSingleTapUp(TapUpDetails details) {
if (widget.selectionEnabled) if (widget.selectionEnabled) {
_renderEditable.handleTap(); 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(); _requestKeyboard();
_confirmCurrentSplash(); _confirmCurrentSplash();
if (widget.onTap != null) if (widget.onTap != null)
widget.onTap(); widget.onTap();
} }
void _handleTapCancel() { void _handleSingleTapCancel() {
_cancelCurrentSplash(); _cancelCurrentSplash();
} }
void _handleLongPress() { void _handleSingleLongTapDown() {
if (widget.selectionEnabled) if (widget.selectionEnabled) {
_renderEditable.handleLongPress(); 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(); _confirmCurrentSplash();
} }
void _handleDoubleTapDown(TapDownDetails details) {
_renderEditable.selectWord(cause: SelectionChangedCause.doubleTap);
}
void _startSplash(TapDownDetails details) { void _startSplash(TapDownDetails details) {
if (_effectiveFocusNode.hasFocus) if (_effectiveFocusNode.hasFocus)
return; return;
...@@ -632,7 +655,7 @@ class _TextFieldState extends State<TextField> with AutomaticKeepAliveClientMixi ...@@ -632,7 +655,7 @@ class _TextFieldState extends State<TextField> with AutomaticKeepAliveClientMixi
rendererIgnoresPointer: true, rendererIgnoresPointer: true,
cursorWidth: widget.cursorWidth, cursorWidth: widget.cursorWidth,
cursorRadius: widget.cursorRadius, cursorRadius: widget.cursorRadius,
cursorColor: widget.cursorColor ?? Theme.of(context).cursorColor, cursorColor: widget.cursorColor ?? themeData.cursorColor,
backgroundCursorColor: CupertinoColors.inactiveGray, backgroundCursorColor: CupertinoColors.inactiveGray,
scrollPadding: widget.scrollPadding, scrollPadding: widget.scrollPadding,
keyboardAppearance: keyboardAppearance, keyboardAppearance: keyboardAppearance,
...@@ -665,13 +688,13 @@ class _TextFieldState extends State<TextField> with AutomaticKeepAliveClientMixi ...@@ -665,13 +688,13 @@ class _TextFieldState extends State<TextField> with AutomaticKeepAliveClientMixi
}, },
child: IgnorePointer( child: IgnorePointer(
ignoring: !(widget.enabled ?? widget.decoration?.enabled ?? true), ignoring: !(widget.enabled ?? widget.decoration?.enabled ?? true),
child: GestureDetector( child: TextSelectionGestureDetector(
behavior: HitTestBehavior.translucent,
onTapDown: _handleTapDown, onTapDown: _handleTapDown,
onTap: _handleTap, onSingleTapUp: _handleSingleTapUp,
onTapCancel: _handleTapCancel, onSingleTapCancel: _handleSingleTapCancel,
onLongPress: _handleLongPress, onSingleLongTapDown: _handleSingleLongTapDown,
excludeFromSemantics: true, onDoubleTapDown: _handleDoubleTapDown,
behavior: HitTestBehavior.translucent,
child: child, child: child,
), ),
), ),
......
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/gestures.dart' show kDoubleTapTimeout, kDoubleTapSlop;
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
...@@ -568,3 +569,159 @@ class _TextSelectionHandleOverlayState extends State<_TextSelectionHandleOverlay ...@@ -568,3 +569,159 @@ class _TextSelectionHandleOverlayState extends State<_TextSelectionHandleOverlay
return null; 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,
);
}
}
// 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