iOS tap handling on CupertinoTextField (#24034)
This commit is contained in:
parent
0155ee7157
commit
988bfc166d
@ -1,7 +1,9 @@
|
|||||||
// 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';
|
||||||
@ -418,6 +420,13 @@ 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();
|
||||||
@ -447,6 +456,7 @@ 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -456,17 +466,54 @@ 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 _handleTap() {
|
void _handleTapUp(TapUpDetails details) {
|
||||||
_renderEditable.handleTap();
|
if (!_isDoubleTap) {
|
||||||
_requestKeyboard();
|
_renderEditable.selectWordEdge(cause: SelectionChangedCause.tap);
|
||||||
|
_lastTapOffset = details.globalPosition;
|
||||||
|
_doubleTapTimer = Timer(kDoubleTapTimeout, _doubleTapTimeout);
|
||||||
|
_requestKeyboard();
|
||||||
|
}
|
||||||
|
_isDoubleTap = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleLongPress() {
|
void _handleLongPress() {
|
||||||
_renderEditable.handleLongPress();
|
if (!_isDoubleTap) {
|
||||||
|
_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;
|
||||||
|
return difference.distance <= kDoubleTapSlop;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -648,7 +695,7 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with AutomaticK
|
|||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
behavior: HitTestBehavior.translucent,
|
behavior: HitTestBehavior.translucent,
|
||||||
onTapDown: _handleTapDown,
|
onTapDown: _handleTapDown,
|
||||||
onTap: _handleTap,
|
onTapUp: _handleTapUp,
|
||||||
onLongPress: _handleLongPress,
|
onLongPress: _handleLongPress,
|
||||||
excludeFromSemantics: true,
|
excludeFromSemantics: true,
|
||||||
child: _addTextDependentAttachments(paddedEditable),
|
child: _addTextDependentAttachments(paddedEditable),
|
||||||
|
@ -32,6 +32,10 @@ enum SelectionChangedCause {
|
|||||||
/// of the cursor) to change.
|
/// of the cursor) to change.
|
||||||
tap,
|
tap,
|
||||||
|
|
||||||
|
/// The user tapped twice in quick succession on the text and that caused
|
||||||
|
/// the selection (or the location of the cursor) to change.
|
||||||
|
doubleTap,
|
||||||
|
|
||||||
/// The user long-pressed the text and that caused the selection (or the
|
/// The user long-pressed the text and that caused the selection (or the
|
||||||
/// location of the cursor) to change.
|
/// location of the cursor) to change.
|
||||||
longPress,
|
longPress,
|
||||||
@ -190,7 +194,7 @@ class RenderEditable extends RenderBox {
|
|||||||
|
|
||||||
/// If true [handleEvent] does nothing and it's assumed that this
|
/// If true [handleEvent] does nothing and it's assumed that this
|
||||||
/// renderer will be notified of input gestures via [handleTapDown],
|
/// renderer will be notified of input gestures via [handleTapDown],
|
||||||
/// [handleTap], and [handleLongPress].
|
/// [handleTap], [handleDoubleTap], and [handleLongPress].
|
||||||
///
|
///
|
||||||
/// The default value of this property is false.
|
/// The default value of this property is false.
|
||||||
bool ignorePointer;
|
bool ignorePointer;
|
||||||
@ -1081,18 +1085,23 @@ class RenderEditable extends RenderBox {
|
|||||||
/// When [ignorePointer] is true, an ancestor widget must respond to tap
|
/// When [ignorePointer] is true, an ancestor widget must respond to tap
|
||||||
/// events by calling this method.
|
/// events by calling this method.
|
||||||
void handleTap() {
|
void handleTap() {
|
||||||
_layoutText(constraints.maxWidth);
|
selectPosition(cause: SelectionChangedCause.tap);
|
||||||
assert(_lastTapDownPosition != null);
|
|
||||||
if (onSelectionChanged != null) {
|
|
||||||
final TextPosition position = _textPainter.getPositionForOffset(globalToLocal(_lastTapDownPosition));
|
|
||||||
onSelectionChanged(TextSelection.fromPosition(position), this, SelectionChangedCause.tap);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
void _handleTap() {
|
void _handleTap() {
|
||||||
assert(!ignorePointer);
|
assert(!ignorePointer);
|
||||||
handleTap();
|
handleTap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// If [ignorePointer] is false (the default) then this method is called by
|
||||||
|
/// the internal gesture recognizer's [DoubleTapGestureRecognizer.onDoubleTap]
|
||||||
|
/// callback.
|
||||||
|
///
|
||||||
|
/// When [ignorePointer] is true, an ancestor widget must respond to double
|
||||||
|
/// tap events by calling this method.
|
||||||
|
void handleDoubleTap() {
|
||||||
|
selectWord(cause: SelectionChangedCause.doubleTap);
|
||||||
|
}
|
||||||
|
|
||||||
/// If [ignorePointer] is false (the default) then this method is called by
|
/// If [ignorePointer] is false (the default) then this method is called by
|
||||||
/// the internal gesture recognizer's [LongPressRecognizer.onLongPress]
|
/// the internal gesture recognizer's [LongPressRecognizer.onLongPress]
|
||||||
/// callback.
|
/// callback.
|
||||||
@ -1100,18 +1109,59 @@ class RenderEditable extends RenderBox {
|
|||||||
/// When [ignorePointer] is true, an ancestor widget must respond to long
|
/// When [ignorePointer] is true, an ancestor widget must respond to long
|
||||||
/// press events by calling this method.
|
/// press events by calling this method.
|
||||||
void handleLongPress() {
|
void handleLongPress() {
|
||||||
_layoutText(constraints.maxWidth);
|
selectWord(cause: SelectionChangedCause.longPress);
|
||||||
assert(_lastTapDownPosition != null);
|
|
||||||
if (onSelectionChanged != null) {
|
|
||||||
final TextPosition position = _textPainter.getPositionForOffset(globalToLocal(_lastTapDownPosition));
|
|
||||||
onSelectionChanged(_selectWordAtOffset(position), this, SelectionChangedCause.longPress);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
void _handleLongPress() {
|
void _handleLongPress() {
|
||||||
assert(!ignorePointer);
|
assert(!ignorePointer);
|
||||||
handleLongPress();
|
handleLongPress();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Move selection to the location of the last tap down.
|
||||||
|
void selectPosition({@required SelectionChangedCause cause}) {
|
||||||
|
assert(cause != null);
|
||||||
|
_layoutText(constraints.maxWidth);
|
||||||
|
assert(_lastTapDownPosition != null);
|
||||||
|
if (onSelectionChanged != null) {
|
||||||
|
final TextPosition position = _textPainter.getPositionForOffset(globalToLocal(_lastTapDownPosition));
|
||||||
|
onSelectionChanged(TextSelection.fromPosition(position), this, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Select a word around the location of the last tap down.
|
||||||
|
void selectWord({@required SelectionChangedCause cause}) {
|
||||||
|
assert(cause != null);
|
||||||
|
_layoutText(constraints.maxWidth);
|
||||||
|
assert(_lastTapDownPosition != null);
|
||||||
|
if (onSelectionChanged != null) {
|
||||||
|
final TextPosition position = _textPainter.getPositionForOffset(globalToLocal(_lastTapDownPosition));
|
||||||
|
onSelectionChanged(_selectWordAtOffset(position), this, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move the selection to the beginning or end of a word.
|
||||||
|
void selectWordEdge({@required SelectionChangedCause cause}) {
|
||||||
|
assert(cause != null);
|
||||||
|
_layoutText(constraints.maxWidth);
|
||||||
|
assert(_lastTapDownPosition != null);
|
||||||
|
if (onSelectionChanged != null) {
|
||||||
|
final TextPosition position = _textPainter.getPositionForOffset(globalToLocal(_lastTapDownPosition));
|
||||||
|
final TextRange word = _textPainter.getWordBoundary(position);
|
||||||
|
if (position.offset - word.start <= 1) {
|
||||||
|
onSelectionChanged(
|
||||||
|
TextSelection.collapsed(offset: word.start, affinity: TextAffinity.downstream),
|
||||||
|
this,
|
||||||
|
cause,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
onSelectionChanged(
|
||||||
|
TextSelection.collapsed(offset: word.end, affinity: TextAffinity.upstream),
|
||||||
|
this,
|
||||||
|
cause,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
TextSelection _selectWordAtOffset(TextPosition position) {
|
TextSelection _selectWordAtOffset(TextPosition position) {
|
||||||
assert(_textLayoutLastWidth == constraints.maxWidth);
|
assert(_textLayoutLastWidth == constraints.maxWidth);
|
||||||
final TextRange word = _textPainter.getWordBoundary(position);
|
final TextRange word = _textPainter.getWordBoundary(position);
|
||||||
|
@ -740,7 +740,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
|||||||
final bool longPress = cause == SelectionChangedCause.longPress;
|
final bool longPress = cause == SelectionChangedCause.longPress;
|
||||||
if (cause != SelectionChangedCause.keyboard && (_value.text.isNotEmpty || longPress))
|
if (cause != SelectionChangedCause.keyboard && (_value.text.isNotEmpty || longPress))
|
||||||
_selectionOverlay.showHandles();
|
_selectionOverlay.showHandles();
|
||||||
if (longPress)
|
if (longPress || cause == SelectionChangedCause.doubleTap)
|
||||||
_selectionOverlay.showToolbar();
|
_selectionOverlay.showToolbar();
|
||||||
if (widget.onSelectionChanged != null)
|
if (widget.onSelectionChanged != null)
|
||||||
widget.onSelectionChanged(selection, cause);
|
widget.onSelectionChanged(selection, cause);
|
||||||
|
@ -685,4 +685,417 @@ void main() {
|
|||||||
expect(find.text("j'aime la poutine"), findsOneWidget);
|
expect(find.text("j'aime la poutine"), findsOneWidget);
|
||||||
expect(find.text('field 2'), findsNothing);
|
expect(find.text('field 2'), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'tap moves cursor to the edge of the word it tapped on',
|
||||||
|
(WidgetTester tester) async {
|
||||||
|
final TextEditingController controller = TextEditingController(
|
||||||
|
text: 'Atwater Peel Sherbrooke Bonaventure',
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
CupertinoApp(
|
||||||
|
home: Center(
|
||||||
|
child: CupertinoTextField(
|
||||||
|
controller: controller,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final Offset textfieldStart = tester.getTopLeft(find.byType(CupertinoTextField));
|
||||||
|
|
||||||
|
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(
|
||||||
|
'slow double tap does not trigger double tap',
|
||||||
|
(WidgetTester tester) async {
|
||||||
|
final TextEditingController controller = TextEditingController(
|
||||||
|
text: 'Atwater Peel Sherbrooke Bonaventure',
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
CupertinoApp(
|
||||||
|
home: Center(
|
||||||
|
child: CupertinoTextField(
|
||||||
|
controller: controller,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final Offset textfieldStart = tester.getTopLeft(find.byType(CupertinoTextField));
|
||||||
|
|
||||||
|
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',
|
||||||
|
(WidgetTester tester) async {
|
||||||
|
final TextEditingController controller = TextEditingController(
|
||||||
|
text: 'Atwater Peel Sherbrooke Bonaventure',
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
CupertinoApp(
|
||||||
|
home: Center(
|
||||||
|
child: CupertinoTextField(
|
||||||
|
controller: controller,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final Offset textfieldStart = tester.getTopLeft(find.byType(CupertinoTextField));
|
||||||
|
|
||||||
|
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 hold selects word',
|
||||||
|
(WidgetTester tester) async {
|
||||||
|
final TextEditingController controller = TextEditingController(
|
||||||
|
text: 'Atwater Peel Sherbrooke Bonaventure',
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
CupertinoApp(
|
||||||
|
home: Center(
|
||||||
|
child: CupertinoTextField(
|
||||||
|
controller: controller,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final Offset textfieldStart = tester.getTopLeft(find.byType(CupertinoTextField));
|
||||||
|
|
||||||
|
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',
|
||||||
|
(WidgetTester tester) async {
|
||||||
|
final TextEditingController controller = TextEditingController(
|
||||||
|
text: 'Atwater Peel Sherbrooke Bonaventure',
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
CupertinoApp(
|
||||||
|
home: Center(
|
||||||
|
child: CupertinoTextField(
|
||||||
|
controller: controller,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final Offset textfieldStart = tester.getTopLeft(find.byType(CupertinoTextField));
|
||||||
|
|
||||||
|
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',
|
||||||
|
(WidgetTester tester) async {
|
||||||
|
final TextEditingController controller = TextEditingController(
|
||||||
|
text: 'Atwater Peel Sherbrooke Bonaventure',
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
CupertinoApp(
|
||||||
|
home: Center(
|
||||||
|
child: CupertinoTextField(
|
||||||
|
controller: controller,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final Offset textfieldStart = tester.getTopLeft(find.byType(CupertinoTextField));
|
||||||
|
|
||||||
|
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, affinity: TextAffinity.upstream),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Collapsed toolbar shows 2 buttons.
|
||||||
|
expect(find.byType(CupertinoButton), findsNWidgets(2));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'long press tap is not a double tap',
|
||||||
|
(WidgetTester tester) async {
|
||||||
|
final TextEditingController controller = TextEditingController(
|
||||||
|
text: 'Atwater Peel Sherbrooke Bonaventure',
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
CupertinoApp(
|
||||||
|
home: Center(
|
||||||
|
child: CupertinoTextField(
|
||||||
|
controller: controller,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final Offset textfieldStart = tester.getTopLeft(find.byType(CupertinoTextField));
|
||||||
|
|
||||||
|
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',
|
||||||
|
(WidgetTester tester) async {
|
||||||
|
final TextEditingController controller = TextEditingController(
|
||||||
|
text: 'Atwater Peel Sherbrooke Bonaventure',
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
CupertinoApp(
|
||||||
|
home: Center(
|
||||||
|
child: CupertinoTextField(
|
||||||
|
controller: controller,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final Offset textfieldStart = tester.getTopLeft(find.byType(CupertinoTextField));
|
||||||
|
|
||||||
|
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, affinity: TextAffinity.upstream),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Long press toolbar.
|
||||||
|
expect(find.byType(CupertinoButton), findsNWidgets(2));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'double tap after a long tap is not affected',
|
||||||
|
(WidgetTester tester) async {
|
||||||
|
final TextEditingController controller = TextEditingController(
|
||||||
|
text: 'Atwater Peel Sherbrooke Bonaventure',
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
CupertinoApp(
|
||||||
|
home: Center(
|
||||||
|
child: CupertinoTextField(
|
||||||
|
controller: controller,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final Offset textfieldStart = tester.getTopLeft(find.byType(CupertinoTextField));
|
||||||
|
|
||||||
|
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',
|
||||||
|
(WidgetTester tester) async {
|
||||||
|
final TextEditingController controller = TextEditingController(
|
||||||
|
text: 'Atwater Peel Sherbrooke Bonaventure',
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
CupertinoApp(
|
||||||
|
home: Center(
|
||||||
|
child: CupertinoTextField(
|
||||||
|
controller: controller,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final Offset textfieldStart = tester.getTopLeft(find.byType(CupertinoTextField));
|
||||||
|
|
||||||
|
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));
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user