From f282641fa6afeb0b093c3ac83253363c7b7d92e0 Mon Sep 17 00:00:00 2001 From: Raul Date: Tue, 10 Oct 2023 14:52:09 +0200 Subject: [PATCH] Add iOS tap in empty space to show toolbar, Android long-press in empty space to show toolbar. (Resolves #1472) (#1476) --- .../_mobile_textfield_demo.dart | 22 +++- .../flutter/text_selection.dart | 10 ++ .../android/_user_interaction.dart | 20 ++++ .../ios/_user_interaction.dart | 100 +++++++++++------- 4 files changed, 109 insertions(+), 43 deletions(-) create mode 100644 super_editor/lib/src/infrastructure/flutter/text_selection.dart diff --git a/super_editor/example/lib/demos/supertextfield/_mobile_textfield_demo.dart b/super_editor/example/lib/demos/supertextfield/_mobile_textfield_demo.dart index 2fa6368c9f..6183dac249 100644 --- a/super_editor/example/lib/demos/supertextfield/_mobile_textfield_demo.dart +++ b/super_editor/example/lib/demos/supertextfield/_mobile_textfield_demo.dart @@ -40,7 +40,7 @@ class _MobileSuperTextFieldDemoState extends State { _textController = ImeAttributedTextEditingController( controller: AttributedTextEditingController( - text: widget.initialText, + text: widget.initialText.copyText(0), ), ); } @@ -62,15 +62,23 @@ class _MobileSuperTextFieldDemoState extends State { int? maxLines; switch (_sizeMode) { case _TextFieldSizeMode.singleLine: + _textController.text = widget.initialText.copyText(0); minLines = 1; maxLines = 1; break; case _TextFieldSizeMode.short: + _textController.text = widget.initialText.copyText(0); maxLines = 5; break; case _TextFieldSizeMode.tall: + _textController.text = widget.initialText.copyText(0); // no-op break; + case _TextFieldSizeMode.empty: + _textController.text = AttributedText(); + minLines = 1; + maxLines = 1; + break; } return MobileTextFieldDemoConfig( @@ -115,11 +123,14 @@ class _MobileSuperTextFieldDemoState extends State { child: const Icon(Icons.bug_report), ), bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.fixed, currentIndex: _sizeMode == _TextFieldSizeMode.singleLine ? 0 : _sizeMode == _TextFieldSizeMode.short ? 1 - : 2, + : _sizeMode == _TextFieldSizeMode.tall + ? 2 + : 3, items: const [ BottomNavigationBarItem( icon: Icon(Icons.short_text), @@ -133,6 +144,10 @@ class _MobileSuperTextFieldDemoState extends State { icon: Icon(Icons.wrap_text_rounded), label: 'Tall', ), + BottomNavigationBarItem( + icon: Icon(Icons.short_text), + label: 'Empty', + ), ], onTap: (int newIndex) { setState(() { @@ -142,6 +157,8 @@ class _MobileSuperTextFieldDemoState extends State { _sizeMode = _TextFieldSizeMode.short; } else if (newIndex == 2) { _sizeMode = _TextFieldSizeMode.tall; + } else if (newIndex == 3) { + _sizeMode = _TextFieldSizeMode.empty; } }); }, @@ -178,6 +195,7 @@ enum _TextFieldSizeMode { singleLine, short, tall, + empty, } class MobileTextFieldDemoConfig { diff --git a/super_editor/lib/src/infrastructure/flutter/text_selection.dart b/super_editor/lib/src/infrastructure/flutter/text_selection.dart new file mode 100644 index 0000000000..93a51d4990 --- /dev/null +++ b/super_editor/lib/src/infrastructure/flutter/text_selection.dart @@ -0,0 +1,10 @@ +import 'package:flutter/painting.dart'; + +extension Bounds on TextSelection { + /// Returns `true` if this [TextSelection] has the same bounds as the + /// [other] [TextSelection], regardless of selection direction, i.e., + /// affinity. + bool hasSameBoundsAs(TextSelection other) { + return start == other.start && end == other.end; + } +} diff --git a/super_editor/lib/src/infrastructure/super_textfield/android/_user_interaction.dart b/super_editor/lib/src/infrastructure/super_textfield/android/_user_interaction.dart index c6fa4c8e76..0719caa4a6 100644 --- a/super_editor/lib/src/infrastructure/super_textfield/android/_user_interaction.dart +++ b/super_editor/lib/src/infrastructure/super_textfield/android/_user_interaction.dart @@ -18,6 +18,8 @@ final _log = androidTextFieldLog; /// /// * Tap: Place a collapsed text selection at the tapped location /// in text. +/// * Long-Press (over text): select surrounding word. +/// * Long-Press (in empty space with a selection): show the toolbar. /// * Double-Tap: Select the word surrounding the tapped location /// * Triple-Tap: Select the paragraph surrounding the tapped location /// * Drag: Move a collapsed selection wherever the user drags, while @@ -209,6 +211,16 @@ class AndroidTextFieldTouchInteractorState extends State( + () => LongPressGestureRecognizer(), + (LongPressGestureRecognizer recognizer) { + recognizer + ..onLongPress = _onLongPress + ..gestureSettings = gestureSettings; + }, + ), PanGestureRecognizer: GestureRecognizerFactoryWithHandlers( () => PanGestureRecognizer(), (PanGestureRecognizer recognizer) { diff --git a/super_editor/lib/src/infrastructure/super_textfield/ios/_user_interaction.dart b/super_editor/lib/src/infrastructure/super_textfield/ios/_user_interaction.dart index 5c139809a3..fd25c69bc0 100644 --- a/super_editor/lib/src/infrastructure/super_textfield/ios/_user_interaction.dart +++ b/super_editor/lib/src/infrastructure/super_textfield/ios/_user_interaction.dart @@ -1,6 +1,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/infrastructure/flutter/text_selection.dart'; import 'package:super_editor/src/infrastructure/multi_tap_gesture.dart'; import 'package:super_editor/src/infrastructure/super_textfield/super_textfield.dart'; import 'package:super_text_layout/super_text_layout.dart'; @@ -16,9 +17,10 @@ final _log = iosTextFieldLog; /// /// This widget recognizes and acts upon various user interactions: /// -/// * Tap: Place a collapsed text selection at the tapped location -/// in text. -/// * Double-Tap: Select the word surrounding the tapped location +/// * Tap: Place a collapsed text selection at the text location that's +/// nearest to the tap offset. +/// * Tap (in a location that doesn't move the caret): Toggle the toolbar. +/// * Double-Tap: Select the word surrounding the tapped location. /// * Triple-Tap: Select the paragraph surrounding the tapped location /// * Drag: Move a collapsed selection wherever the user drags, while /// displaying a magnifying glass. @@ -100,6 +102,8 @@ class IOSTextFieldTouchInteractorState extends State= _selectionBeforeTap!.start && exactTapTextPosition.offset <= _selectionBeforeTap!.end); - final previousSelection = widget.textController.selection; - final didTapOnExistingSelection = previousSelection.isCollapsed - ? tapTextPosition == previousSelection.extent - : tapTextPosition.offset >= previousSelection.start && tapTextPosition.offset <= previousSelection.end; + // Select the text that's nearest to where the user tapped. + _selectAtOffset(details.localPosition); - if (didTapOnExistingSelection) { - // Toggle the toolbar display when the user taps on the collapsed caret, - // or on top of an existing selection. + final didCaretStayInSamePlace = _selectionBeforeTap != null && + _selectionBeforeTap?.hasSameBoundsAs(widget.textController.selection) == true && + _selectionBeforeTap!.isCollapsed; + if (didCaretStayInSamePlace || didTapOnExistingSelection) { + // The user either tapped directly on the caret, or on an expanded selection, + // or the user tapped in empty space but didn't move the caret, for example + // the user tapped in empty space after the text and the caret was already + // at the end of the text. + // + // Toggle the toolbar. widget.editingOverlayController.toggleToolbar(); - } else { + } else if (!didCaretStayInSamePlace && !didTapOnExistingSelection) { // The user tapped somewhere in the text outside any existing selection. // Hide the toolbar. widget.editingOverlayController.hideToolbar(); - - // Place the caret based on the tap offset. - _selectAtOffset(details.localPosition); } + + _selectionBeforeTap = null; } /// Places the caret in the field's text based on the given [localOffset], /// and displays the drag handle. void _selectAtOffset(Offset localOffset) { - final tapTextPosition = _getTextPositionAtOffset(localOffset); - if (tapTextPosition == null) { + final tapTextPosition = _getTextPositionNearestToOffset(localOffset); + if (tapTextPosition == null || tapTextPosition.offset < 0) { // This situation indicates the user tapped in empty space widget.textController.selection = TextSelection.collapsed(offset: widget.textController.text.text.length); return; @@ -207,7 +208,7 @@ class IOSTextFieldTouchInteractorState extends State PanGestureRecognizer(), (PanGestureRecognizer recognizer) { recognizer - ..onStart = widget.focusNode.hasFocus ? _onTextPanStart : null + ..onStart = widget.focusNode.hasFocus ? _onPanStart : null ..onUpdate = widget.focusNode.hasFocus ? _onPanUpdate : null ..onEnd = widget.focusNode.hasFocus || _isDraggingCaret ? _onPanEnd : null ..onCancel = widget.focusNode.hasFocus || _isDraggingCaret ? _onPanCancel : null