Skip to content

Commit

Permalink
Add iOS tap in empty space to show toolbar, Android long-press in emp…
Browse files Browse the repository at this point in the history
…ty space to show toolbar. (Resolves superlistapp#1472) (superlistapp#1476)
  • Loading branch information
raulmabe-labhouse committed Oct 10, 2023
1 parent ff36084 commit f282641
Show file tree
Hide file tree
Showing 4 changed files with 109 additions and 43 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ class _MobileSuperTextFieldDemoState extends State<MobileSuperTextFieldDemo> {

_textController = ImeAttributedTextEditingController(
controller: AttributedTextEditingController(
text: widget.initialText,
text: widget.initialText.copyText(0),
),
);
}
Expand All @@ -62,15 +62,23 @@ class _MobileSuperTextFieldDemoState extends State<MobileSuperTextFieldDemo> {
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(
Expand Down Expand Up @@ -115,11 +123,14 @@ class _MobileSuperTextFieldDemoState extends State<MobileSuperTextFieldDemo> {
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),
Expand All @@ -133,6 +144,10 @@ class _MobileSuperTextFieldDemoState extends State<MobileSuperTextFieldDemo> {
icon: Icon(Icons.wrap_text_rounded),
label: 'Tall',
),
BottomNavigationBarItem(
icon: Icon(Icons.short_text),
label: 'Empty',
),
],
onTap: (int newIndex) {
setState(() {
Expand All @@ -142,6 +157,8 @@ class _MobileSuperTextFieldDemoState extends State<MobileSuperTextFieldDemo> {
_sizeMode = _TextFieldSizeMode.short;
} else if (newIndex == 2) {
_sizeMode = _TextFieldSizeMode.tall;
} else if (newIndex == 3) {
_sizeMode = _TextFieldSizeMode.empty;
}
});
},
Expand Down Expand Up @@ -178,6 +195,7 @@ enum _TextFieldSizeMode {
singleLine,
short,
tall,
empty,
}

class MobileTextFieldDemoConfig {
Expand Down
10 changes: 10 additions & 0 deletions super_editor/lib/src/infrastructure/flutter/text_selection.dart
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -209,6 +211,16 @@ class AndroidTextFieldTouchInteractorState extends State<AndroidTextFieldTouchIn
widget.editingOverlayController.showHandles();
}

void _onLongPress() {
if (!widget.textController.selection.isValid) {
// There's no user selection. Don't show the toolbar when there's
// nothing to apply it to.
return;
}

widget.editingOverlayController.showToolbar();
}

void _onDoubleTapDown(TapDownDetails details) {
_log.fine("User double-tapped down");
widget.focusNode.requestFocus();
Expand Down Expand Up @@ -428,6 +440,14 @@ class AndroidTextFieldTouchInteractorState extends State<AndroidTextFieldTouchIn
..gestureSettings = gestureSettings;
},
),
LongPressGestureRecognizer: GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
() => LongPressGestureRecognizer(),
(LongPressGestureRecognizer recognizer) {
recognizer
..onLongPress = _onLongPress
..gestureSettings = gestureSettings;
},
),
PanGestureRecognizer: GestureRecognizerFactoryWithHandlers<PanGestureRecognizer>(
() => PanGestureRecognizer(),
(PanGestureRecognizer recognizer) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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.
Expand Down Expand Up @@ -100,6 +102,8 @@ class IOSTextFieldTouchInteractorState extends State<IOSTextFieldTouchInteractor
Offset? _globalDragOffset;
Offset? _dragOffset;

TextSelection? _selectionBeforeTap;

@override
void initState() {
super.initState();
Expand Down Expand Up @@ -132,11 +136,7 @@ class IOSTextFieldTouchInteractorState extends State<IOSTextFieldTouchInteractor
return;
}

// When the user drags, the toolbar should not be visible.
// A drag can begin with a tap down, so we hide the toolbar
// preemptively.
widget.editingOverlayController.hideToolbar();

_selectionBeforeTap = widget.textController.selection;
_selectAtOffset(details.localPosition);
}

Expand All @@ -155,40 +155,41 @@ class IOSTextFieldTouchInteractorState extends State<IOSTextFieldTouchInteractor
widget.focusNode.requestFocus();
}

// If the user tapped on a collapsed caret, or tapped on an
// expanded selection, toggle the toolbar appearance.
final tapTextPosition = _getTextPositionAtOffset(details.localPosition);
if (tapTextPosition == null) {
// Place the caret based on the tap offset. In this case, the caret will
// be placed at the end of text because the user tapped in empty space.
_selectAtOffset(details.localPosition);
return;
}
final exactTapTextPosition = _getTextPositionAtOffset(details.localPosition);
final didTapOnExistingSelection = exactTapTextPosition != null &&
_selectionBeforeTap != null &&
(_selectionBeforeTap!.isCollapsed
? exactTapTextPosition.offset == _selectionBeforeTap!.extent.offset
: exactTapTextPosition.offset >= _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;
Expand All @@ -207,7 +208,7 @@ class IOSTextFieldTouchInteractorState extends State<IOSTextFieldTouchInteractor
// again.
widget.editingOverlayController.hideToolbar();

final tapTextPosition = _getTextPositionAtOffset(details.localPosition);
final tapTextPosition = _getTextPositionNearestToOffset(details.localPosition);
if (tapTextPosition != null) {
setState(() {
final wordSelection = _getWordSelectionAt(tapTextPosition);
Expand All @@ -225,16 +226,18 @@ class IOSTextFieldTouchInteractorState extends State<IOSTextFieldTouchInteractor
final textLayout = _textLayout;
final tapTextPosition = textLayout.getPositionAtOffset(details.localPosition)!;

widget.textController.selection =
textLayout.expandSelection(tapTextPosition, paragraphExpansionFilter, TextAffinity.downstream);
widget.textController.selection = textLayout.expandSelection(tapTextPosition, paragraphExpansionFilter, TextAffinity.downstream);
}

void _onTextPanStart(DragStartDetails details) {
_log.fine('_onTextPanStart()');
void _onPanStart(DragStartDetails details) {
_log.fine('_onPanStart()');
setState(() {
_isDraggingCaret = true;
_globalDragOffset = details.globalPosition;
_dragOffset = details.localPosition;

// When the user drags, the toolbar should not be visible.
widget.editingOverlayController.hideToolbar();
});
}

Expand Down Expand Up @@ -317,9 +320,9 @@ class IOSTextFieldTouchInteractorState extends State<IOSTextFieldTouchInteractor
);
}

/// Returns the [TextPosition] sitting at the given [localOffset] within
/// Returns the [TextPosition] that's nearest to the given [localOffset] within
/// this [IOSTextFieldInteractor].
TextPosition? _getTextPositionAtOffset(Offset localOffset) {
TextPosition? _getTextPositionNearestToOffset(Offset localOffset) {
// We show placeholder text when there is no text content. We don't want
// to place the caret in the placeholder text, so when _currentText is
// empty, explicitly set the text position to an offset of -1.
Expand All @@ -328,11 +331,26 @@ class IOSTextFieldTouchInteractorState extends State<IOSTextFieldTouchInteractor
}

final globalOffset = (context.findRenderObject() as RenderBox).localToGlobal(localOffset);
final textOffset =
(widget.selectableTextKey.currentContext!.findRenderObject() as RenderBox).globalToLocal(globalOffset);
final textOffset = (widget.selectableTextKey.currentContext!.findRenderObject() as RenderBox).globalToLocal(globalOffset);
return _textLayout.getPositionNearestToOffset(textOffset);
}

/// Returns the [TextPosition] that's at the given [localOffset] within
/// this [IOSTextFieldInteractor], or `null` if no text exists at the given
/// offset.
TextPosition? _getTextPositionAtOffset(Offset localOffset) {
// We show placeholder text when there is no text content. We don't want
// to place the caret in the placeholder text, so when _currentText is
// empty, explicitly set the text position to an offset of -1.
if (widget.textController.text.text.isEmpty) {
return const TextPosition(offset: -1);
}

final globalOffset = (context.findRenderObject() as RenderBox).localToGlobal(localOffset);
final textOffset = (widget.selectableTextKey.currentContext!.findRenderObject() as RenderBox).globalToLocal(globalOffset);
return _textLayout.getPositionAtOffset(textOffset);
}

/// Returns a [TextSelection] that selects the word surrounding the given
/// [position].
TextSelection _getWordSelectionAt(TextPosition position) {
Expand All @@ -347,7 +365,7 @@ class IOSTextFieldTouchInteractorState extends State<IOSTextFieldTouchInteractor
onTap: () {
_log.fine('Intercepting single tap');
// This GestureDetector is here to prevent taps from going further
// up the tree. There must an issue with the custom gesture detector
// up the tree. There must be an issue with the custom gesture detector
// used below that's allowing taps to bubble up even if handled.
//
// If this GestureDetector is placed any further down in this tree,
Expand Down Expand Up @@ -401,7 +419,7 @@ class IOSTextFieldTouchInteractorState extends State<IOSTextFieldTouchInteractor
() => 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
Expand Down

0 comments on commit f282641

Please sign in to comment.