diff --git a/packages/flutter/lib/src/cupertino/text_field.dart b/packages/flutter/lib/src/cupertino/text_field.dart index a7c4f07c882..6aa1ad8d819 100644 --- a/packages/flutter/lib/src/cupertino/text_field.dart +++ b/packages/flutter/lib/src/cupertino/text_field.dart @@ -1,7 +1,9 @@ // 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'; @@ -418,6 +420,13 @@ class _CupertinoTextFieldState extends State 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(); @@ -447,6 +456,7 @@ class _CupertinoTextFieldState extends State with AutomaticK void dispose() { _focusNode?.dispose(); _controller?.removeListener(updateKeepAlive); + _doubleTapTimer?.cancel(); super.dispose(); } @@ -456,17 +466,54 @@ class _CupertinoTextFieldState extends State 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 _handleTap() { - _renderEditable.handleTap(); - _requestKeyboard(); + void _handleTapUp(TapUpDetails details) { + if (!_isDoubleTap) { + _renderEditable.selectWordEdge(cause: SelectionChangedCause.tap); + _lastTapOffset = details.globalPosition; + _doubleTapTimer = Timer(kDoubleTapTimeout, _doubleTapTimeout); + _requestKeyboard(); + } + _isDoubleTap = false; } 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 @@ -648,7 +695,7 @@ class _CupertinoTextFieldState extends State with AutomaticK child: GestureDetector( behavior: HitTestBehavior.translucent, onTapDown: _handleTapDown, - onTap: _handleTap, + onTapUp: _handleTapUp, onLongPress: _handleLongPress, excludeFromSemantics: true, child: _addTextDependentAttachments(paddedEditable), diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart index 663d3d5efe3..603f02890ac 100644 --- a/packages/flutter/lib/src/rendering/editable.dart +++ b/packages/flutter/lib/src/rendering/editable.dart @@ -32,6 +32,10 @@ enum SelectionChangedCause { /// of the cursor) to change. 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 /// location of the cursor) to change. longPress, @@ -190,7 +194,7 @@ class RenderEditable extends RenderBox { /// If true [handleEvent] does nothing and it's assumed that this /// renderer will be notified of input gestures via [handleTapDown], - /// [handleTap], and [handleLongPress]. + /// [handleTap], [handleDoubleTap], and [handleLongPress]. /// /// The default value of this property is false. bool ignorePointer; @@ -1081,18 +1085,23 @@ class RenderEditable extends RenderBox { /// When [ignorePointer] is true, an ancestor widget must respond to tap /// events by calling this method. void handleTap() { - _layoutText(constraints.maxWidth); - assert(_lastTapDownPosition != null); - if (onSelectionChanged != null) { - final TextPosition position = _textPainter.getPositionForOffset(globalToLocal(_lastTapDownPosition)); - onSelectionChanged(TextSelection.fromPosition(position), this, SelectionChangedCause.tap); - } + selectPosition(cause: SelectionChangedCause.tap); } void _handleTap() { assert(!ignorePointer); 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 /// the internal gesture recognizer's [LongPressRecognizer.onLongPress] /// callback. @@ -1100,18 +1109,59 @@ class RenderEditable extends RenderBox { /// When [ignorePointer] is true, an ancestor widget must respond to long /// press events by calling this method. void handleLongPress() { - _layoutText(constraints.maxWidth); - assert(_lastTapDownPosition != null); - if (onSelectionChanged != null) { - final TextPosition position = _textPainter.getPositionForOffset(globalToLocal(_lastTapDownPosition)); - onSelectionChanged(_selectWordAtOffset(position), this, SelectionChangedCause.longPress); - } + selectWord(cause: SelectionChangedCause.longPress); } void _handleLongPress() { assert(!ignorePointer); 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) { assert(_textLayoutLastWidth == constraints.maxWidth); final TextRange word = _textPainter.getWordBoundary(position); diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index df7e67c337e..e4ed2b0d17b 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -740,7 +740,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien final bool longPress = cause == SelectionChangedCause.longPress; if (cause != SelectionChangedCause.keyboard && (_value.text.isNotEmpty || longPress)) _selectionOverlay.showHandles(); - if (longPress) + if (longPress || cause == SelectionChangedCause.doubleTap) _selectionOverlay.showToolbar(); if (widget.onSelectionChanged != null) widget.onSelectionChanged(selection, cause); diff --git a/packages/flutter/test/cupertino/text_field_test.dart b/packages/flutter/test/cupertino/text_field_test.dart index ddfff509ec0..f66040694a0 100644 --- a/packages/flutter/test/cupertino/text_field_test.dart +++ b/packages/flutter/test/cupertino/text_field_test.dart @@ -685,4 +685,417 @@ void main() { expect(find.text("j'aime la poutine"), findsOneWidget); 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)); + }, + ); }