feat: improve android selection gesture (#647)

* feat: improve android touch gesture

* feat: long press to select a word on Android
This commit is contained in:
Lucas.Xu 2024-01-05 13:23:18 +07:00 • committed by GitHub
commit 07afd7a839
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 756 additions and 402 deletions

View file

@ -156,6 +156,7 @@ class _CustomizeThemeForEditorState extends State<CustomizeThemeForEditor> {
? const EdgeInsets.only(left: 200, right: 200) ? const EdgeInsets.only(left: 200, right: 200)
: const EdgeInsets.symmetric(horizontal: 20), : const EdgeInsets.symmetric(horizontal: 20),
cursorColor: Colors.green, cursorColor: Colors.green,
dragHandleColor: Colors.green,
selectionColor: Colors.green.withOpacity(0.5), selectionColor: Colors.green.withOpacity(0.5),
textStyleConfiguration: TextStyleConfiguration( textStyleConfiguration: TextStyleConfiguration(
text: GoogleFonts.poppins( text: GoogleFonts.poppins(

View file

@ -112,6 +112,7 @@ class _MobileEditorState extends State<MobileEditor> {
EditorStyle _buildMobileEditorStyle() { EditorStyle _buildMobileEditorStyle() {
return EditorStyle.mobile( return EditorStyle.mobile(
cursorColor: const Color.fromARGB(255, 134, 46, 247), cursorColor: const Color.fromARGB(255, 134, 46, 247),
dragHandleColor: const Color.fromARGB(255, 134, 46, 247),
selectionColor: const Color.fromARGB(50, 134, 46, 247), selectionColor: const Color.fromARGB(50, 134, 46, 247),
textStyleConfiguration: TextStyleConfiguration( textStyleConfiguration: TextStyleConfiguration(
text: GoogleFonts.poppins( text: GoogleFonts.poppins(

View file

@ -1,6 +1,7 @@
import 'dart:math' as math; import 'dart:math' as math;
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/editor/editor_component/service/selection/mobile_selection_service.dart';
import 'package:appflowy_editor/src/flutter/overlay.dart'; import 'package:appflowy_editor/src/flutter/overlay.dart';
import 'package:appflowy_editor/src/service/selection/selection_gesture.dart'; import 'package:appflowy_editor/src/service/selection/selection_gesture.dart';
import 'package:flutter/material.dart' hide Overlay, OverlayEntry; import 'package:flutter/material.dart' hide Overlay, OverlayEntry;
@ -191,6 +192,30 @@ class _DesktopSelectionServiceWidgetState
return selectable.getPositionInOffset(offset); return selectable.getPositionInOffset(offset);
} }
@override
Selection? onPanStart(
DragStartDetails details,
MobileSelectionDragMode mode,
) {
throw UnimplementedError();
}
@override
Selection? onPanUpdate(
DragUpdateDetails details,
MobileSelectionDragMode mode,
) {
throw UnimplementedError();
}
@override
void onPanEnd(
DragEndDetails details,
MobileSelectionDragMode mode,
) {
throw UnimplementedError();
}
void _onTapDown(TapDownDetails details) { void _onTapDown(TapDownDetails details) {
_clearContextMenu(); _clearContextMenu();

View file

@ -1,11 +1,12 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/editor/editor_component/service/selection/mobile_magnifier.dart'; import 'package:appflowy_editor/src/editor/editor_component/service/selection/mobile_magnifier.dart';
import 'package:appflowy_editor/src/flutter/overlay.dart'; import 'package:appflowy_editor/src/render/selection/mobile_selection_handle.dart';
import 'package:appflowy_editor/src/render/selection/mobile_selection_widget.dart';
import 'package:appflowy_editor/src/service/selection/mobile_selection_gesture.dart'; import 'package:appflowy_editor/src/service/selection/mobile_selection_gesture.dart';
import 'package:flutter/material.dart' hide Overlay, OverlayEntry; import 'package:flutter/material.dart' hide Overlay, OverlayEntry;
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
/// only used in mobile /// only used in mobile
@ -62,8 +63,6 @@ class _MobileSelectionServiceWidgetState
implements AppFlowySelectionService { implements AppFlowySelectionService {
@override @override
final List<Rect> selectionRects = []; final List<Rect> selectionRects = [];
final List<OverlayEntry> _selectionAreas = [];
final List<OverlayEntry> _cursorAreas = [];
@override @override
ValueNotifier<Selection?> currentSelection = ValueNotifier(null); ValueNotifier<Selection?> currentSelection = ValueNotifier(null);
@ -81,6 +80,8 @@ class _MobileSelectionServiceWidgetState
MobileSelectionDragMode dragMode = MobileSelectionDragMode.none; MobileSelectionDragMode dragMode = MobileSelectionDragMode.none;
bool updateSelectionByTapUp = false;
late EditorState editorState = Provider.of<EditorState>( late EditorState editorState = Provider.of<EditorState>(
context, context,
listen: false, listen: false,
@ -106,19 +107,23 @@ class _MobileSelectionServiceWidgetState
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MobileSelectionGestureDetector( return MobileSelectionGestureDetector(
onPanStart: _onPanStart,
onPanUpdate: _onPanUpdate,
onPanEnd: _onPanEnd,
onTapUp: _onTapUp, onTapUp: _onTapUp,
onDoubleTapUp: _onDoubleTapUp, onDoubleTapUp: _onDoubleTapUp,
onTripleTapUp: _onTripleTapUp, onTripleTapUp: _onTripleTapUp,
onLongPressStart: _onLongPressStart, onLongPressStart: _onLongPressStart,
onLongPressMoveUpdate: _onLongPressMoveUpdate, // onLongPressMoveUpdate: _onLongPressMoveUpdate,
onLongPressEnd: _onLongPressEnd, // onLongPressEnd: _onLongPressEnd,
child: Stack( child: Stack(
children: [ children: [
widget.child, widget.child,
// magnifier for zoom in the text.
if (widget.showMagnifier) _buildMagnifier(), if (widget.showMagnifier) _buildMagnifier(),
// the handles for expanding the selection area.
_buildLeftHandle(),
_buildRightHandle(),
_buildCollapsedHandle(),
], ],
), ),
); );
@ -141,6 +146,100 @@ class _MobileSelectionServiceWidgetState
); );
} }
Widget _buildCollapsedHandle() {
if (Platform.isIOS) {
return const SizedBox.shrink();
}
return ValueListenableBuilder(
valueListenable: editorState.selectionNotifier,
builder: (context, selection, _) {
if (selection == null ||
!selection.isCollapsed ||
editorState.selectionUpdateReason !=
SelectionUpdateReason.uiEvent) {
return const SizedBox.shrink();
}
selection = selection.normalized;
final node = editorState.getNodeAtPath(selection.start.path);
final selectable = node?.selectable;
var rect = selectable?.getCursorRectInPosition(
selection.start,
shiftWithBaseOffset: true,
);
if (node == null || rect == null) {
return const SizedBox.shrink();
}
final editorStyle = editorState.editorStyle;
return AndroidCollapsedHandle(
layerLink: node.layerLink,
rect: rect,
handleColor: editorStyle.dragHandleColor,
handleWidth: editorStyle.mobileDragHandleWidth,
handleBallWidth: editorStyle.mobileDragHandleBallSize.width,
enableHapticFeedbackOnAndroid:
editorStyle.enableHapticFeedbackOnAndroid,
);
},
);
}
Widget _buildLeftHandle() {
return _buildHandle(HandleType.left);
}
Widget _buildRightHandle() {
return _buildHandle(HandleType.right);
}
Widget _buildHandle(HandleType handleType) {
if (![HandleType.left, HandleType.right].contains(handleType)) {
throw ArgumentError('showLeftHandle and showRightHandle cannot be same.');
}
return ValueListenableBuilder(
valueListenable: editorState.selectionNotifier,
builder: (context, selection, _) {
if (selection == null || selection.isCollapsed) {
return const SizedBox.shrink();
}
selection = selection.normalized;
final node = editorState.getNodeAtPath(
handleType == HandleType.left
? selection.start.path
: selection.end.path,
);
final selectable = node?.selectable;
final rects = selectable?.getRectsInSelection(
selection,
shiftWithBaseOffset: true,
);
if (node == null || rects == null || rects.isEmpty) {
return const SizedBox.shrink();
}
final editorStyle = editorState.editorStyle;
return MobileSelectionArea(
layerLink: node.layerLink,
rect: handleType == HandleType.left ? rects.first : rects.last,
handleType: handleType,
handleColor: editorStyle.dragHandleColor,
handleWidth: editorStyle.mobileDragHandleWidth,
handleBallWidth: editorStyle.mobileDragHandleBallSize.width,
enableHapticFeedbackOnAndroid:
editorStyle.enableHapticFeedbackOnAndroid,
);
},
);
}
@override @override
void updateSelection(Selection? selection) { void updateSelection(Selection? selection) {
if (currentSelection.value == selection) { if (currentSelection.value == selection) {
@ -175,23 +274,13 @@ class _MobileSelectionServiceWidgetState
_clearSelection(); _clearSelection();
} }
void _clearSelection() {
clearCursor();
// clear selection areas
_selectionAreas
..forEach((overlay) => overlay.remove())
..clear();
// clear cursor areas
_selectionAreas.clear();
selectionRects.clear();
}
@override @override
void clearCursor() { void clearCursor() {
// clear cursor areas _clearSelection();
_cursorAreas }
..forEach((overlay) => overlay.remove())
..clear(); void _clearSelection() {
selectionRects.clear();
} }
@override @override
@ -290,6 +379,7 @@ class _MobileSelectionServiceWidgetState
editorState.updateSelectionWithReason( editorState.updateSelectionWithReason(
Selection.collapsed(position), Selection.collapsed(position),
reason: SelectionUpdateReason.uiEvent,
extraInfo: null, extraInfo: null,
); );
} }
@ -311,10 +401,6 @@ class _MobileSelectionServiceWidgetState
void _onTripleTapUp(TapUpDetails details) { void _onTripleTapUp(TapUpDetails details) {
final offset = details.globalPosition; final offset = details.globalPosition;
// if (_isClickOnSelectionArea(offset)) {
// appFlowyEditorOnTapSelectionArea.add(0);
// return;
// }
final node = getNodeInOffset(offset); final node = getNodeInOffset(offset);
final selectable = node?.selectable; final selectable = node?.selectable;
if (selectable == null) { if (selectable == null) {
@ -328,45 +414,35 @@ class _MobileSelectionServiceWidgetState
updateSelection(selection); updateSelection(selection);
} }
void _onPanStart(DragStartDetails details) { @override
Selection? onPanStart(
DragStartDetails details,
MobileSelectionDragMode mode,
) {
_panStartOffset = details.globalPosition.translate(-3.0, 0); _panStartOffset = details.globalPosition.translate(-3.0, 0);
_panStartScrollDy = editorState.service.scrollService?.dy; _panStartScrollDy = editorState.service.scrollService?.dy;
final position = details.globalPosition;
final selection = editorState.selection; final selection = editorState.selection;
_panStartSelection = selection; _panStartSelection = selection;
if (selection == null) {
dragMode = MobileSelectionDragMode.none; dragMode = mode;
} else if (selection.isCollapsed &&
_isOverlayOnHandler( return selection;
position,
MobileSelectionHandlerType.cursorHandler,
)) {
dragMode = MobileSelectionDragMode.cursor;
} else if (_isOverlayOnHandler(
position,
MobileSelectionHandlerType.leftHandler,
)) {
dragMode = MobileSelectionDragMode.leftSelectionHandler;
} else if (_isOverlayOnHandler(
position,
MobileSelectionHandlerType.rightHandler,
)) {
dragMode = MobileSelectionDragMode.rightSelectionHandler;
} else {
dragMode = MobileSelectionDragMode.none;
}
} }
void _onPanUpdate(DragUpdateDetails details) { @override
Selection? onPanUpdate(
DragUpdateDetails details,
MobileSelectionDragMode mode,
) {
if (_panStartOffset == null || _panStartScrollDy == null) { if (_panStartOffset == null || _panStartScrollDy == null) {
return; return null;
} }
// only support selection mode now. // only support selection mode now.
final selection = editorState.selection; final selection = editorState.selection;
if (selection == null || dragMode == MobileSelectionDragMode.none) { if (selection == null || dragMode == MobileSelectionDragMode.none) {
return; return null;
} }
final panEndOffset = details.globalPosition; final panEndOffset = details.globalPosition;
@ -380,32 +456,34 @@ class _MobileSelectionServiceWidgetState
?.getSelectionInRange(panStartOffset, panEndOffset) ?.getSelectionInRange(panStartOffset, panEndOffset)
.end; .end;
Selection? newSelection;
if (end != null) { if (end != null) {
if (dragMode == MobileSelectionDragMode.leftSelectionHandler) { if (dragMode == MobileSelectionDragMode.leftSelectionHandler) {
updateSelection( newSelection = Selection(
Selection( start: _panStartSelection!.normalized.end,
start: _panStartSelection!.normalized.end, end: end,
end: end, ).normalized;
).normalized,
);
} else if (dragMode == MobileSelectionDragMode.rightSelectionHandler) { } else if (dragMode == MobileSelectionDragMode.rightSelectionHandler) {
updateSelection( newSelection = Selection(
Selection( start: _panStartSelection!.normalized.start,
start: _panStartSelection!.normalized.start, end: end,
end: end, ).normalized;
).normalized,
);
} else if (dragMode == MobileSelectionDragMode.cursor) { } else if (dragMode == MobileSelectionDragMode.cursor) {
updateSelection( newSelection = Selection.collapsed(end);
Selection.collapsed(end),
);
} }
_lastPanOffset.value = panEndOffset; _lastPanOffset.value = panEndOffset;
} }
if (newSelection != null) {
updateSelection(newSelection);
}
return newSelection;
} }
void _onPanEnd(DragEndDetails details) { @override
void onPanEnd(DragEndDetails details, MobileSelectionDragMode mode) {
// do nothing // do nothing
_lastPanOffset.value = null; _lastPanOffset.value = null;
@ -413,55 +491,37 @@ class _MobileSelectionServiceWidgetState
dragMode = MobileSelectionDragMode.none; dragMode = MobileSelectionDragMode.none;
editorState.updateSelectionWithReason( editorState.updateSelectionWithReason(
editorState.selection, editorState.selection,
reason: SelectionUpdateReason.uiEvent,
extraInfo: null, extraInfo: null,
); );
} }
void _onLongPressStart(LongPressStartDetails details) { void _onLongPressStart(LongPressStartDetails details) {
clearSelection(); if (!Platform.isAndroid) {
// clear old state.
_panStartOffset = null;
final position = getPositionInOffset(details.globalPosition);
if (position == null) {
return; return;
} }
_lastPanOffset.value = details.globalPosition; // on Android, long press to select a word.
final offset = details.globalPosition;
dragMode = MobileSelectionDragMode.cursor; if (_isClickOnSelectionArea(offset)) {
updateSelection( appFlowyEditorOnTapSelectionArea.add(0);
Selection.collapsed(position), return;
); }
} final node = getNodeInOffset(offset);
final selection = node?.selectable?.getWordBoundaryInOffset(offset);
void _onLongPressMoveUpdate(LongPressMoveUpdateDetails details) { if (selection == null) {
final panEndOffset = details.globalPosition; clearSelection();
final position = getNodeInOffset(panEndOffset)
?.selectable
?.getPositionInOffset(panEndOffset);
if (position == null) {
return; return;
} }
_lastPanOffset.value = panEndOffset; if (editorState.editorStyle.enableHapticFeedbackOnAndroid) {
updateSelection( HapticFeedback.mediumImpact();
Selection.collapsed(position), }
);
} updateSelection(selection);
void _onLongPressEnd(LongPressEndDetails details) {
_lastPanOffset.value = null;
dragMode = MobileSelectionDragMode.none;
editorState.updateSelectionWithReason(
editorState.selection,
extraInfo: null,
);
} }
// delete this function in the future.
void _updateSelectionAreas(Selection selection) { void _updateSelectionAreas(Selection selection) {
final nodes = editorState.getNodesInSelection(selection); final nodes = editorState.getNodesInSelection(selection);
@ -509,39 +569,14 @@ class _MobileSelectionServiceWidgetState
newSelection, newSelection,
shiftWithBaseOffset: true, shiftWithBaseOffset: true,
); );
for (var (j, rect) in rects.indexed) { for (final rect in rects) {
final selectionRect = selectable.transformRectToGlobal( final selectionRect = selectable.transformRectToGlobal(
rect, rect,
shiftWithBaseOffset: true, shiftWithBaseOffset: true,
); );
selectionRects.add(selectionRect); selectionRects.add(selectionRect);
final showLeftHandler = i == 0 && j == 0;
final showRightHandler =
i == backwardNodes.length - 1 && j == rects.length - 1;
if (rect.width <= 0) {
rect = Rect.fromLTWH(rect.left, rect.top, 8.0, rect.height);
}
final overlay = OverlayEntry(
builder: (context) => MobileSelectionWidget(
color: Colors.transparent,
layerLink: node.layerLink,
rect: rect,
showLeftHandler: showLeftHandler,
showRightHandler: showRightHandler,
handlerColor: editorState.editorStyle.cursorColor,
handlerWidth: editorState.editorStyle.mobileDragHandleWidth,
handlerBallWidth:
editorState.editorStyle.mobileDragHandleBallSize.width,
),
);
_selectionAreas.add(overlay);
} }
} }
final overlay = Overlay.of(context);
overlay?.insertAll(
_selectionAreas,
);
} }
Node? _getNodeInOffset( Node? _getNodeInOffset(
@ -578,60 +613,6 @@ class _MobileSelectionServiceWidgetState
return node; return node;
} }
bool _isOverlayOnHandler(Offset point, MobileSelectionHandlerType type) {
final selection = editorState.selection;
if (selection == null) {
return false;
}
SelectableMixin? selectable;
Rect? rect;
switch (type) {
case MobileSelectionHandlerType.leftHandler:
case MobileSelectionHandlerType.cursorHandler:
selectable =
editorState.getNodeAtPath(selection.start.path)?.selectable;
if (selectable == null) {
return false;
}
rect = selectable.getCursorRectInPosition(
selection.start,
shiftWithBaseOffset: true,
);
if (rect == null) {
return false;
}
break;
case MobileSelectionHandlerType.rightHandler:
selectable = editorState.getNodeAtPath(selection.end.path)?.selectable;
if (selectable == null) {
return false;
}
rect = selectable.getCursorRectInPosition(
selection.end,
shiftWithBaseOffset: true,
);
if (rect == null) {
return false;
}
break;
}
const extend = 20.0;
final handlerRect = selectable.transformRectToGlobal(
Rect.fromLTWH(
rect.left - extend,
rect.top - extend,
extend * 2,
rect.height + 2 * extend,
),
shiftWithBaseOffset: true,
);
return handlerRect.contains(point);
}
bool _isClickOnSelectionArea(Offset point) { bool _isClickOnSelectionArea(Offset point) {
for (final rect in selectionRects) { for (final rect in selectionRects) {
if (rect.contains(point)) { if (rect.contains(point)) {

View file

@ -1,6 +1,7 @@
import 'package:appflowy_editor/src/core/document/node.dart'; import 'package:appflowy_editor/src/core/document/node.dart';
import 'package:appflowy_editor/src/core/location/position.dart'; import 'package:appflowy_editor/src/core/location/position.dart';
import 'package:appflowy_editor/src/core/location/selection.dart'; import 'package:appflowy_editor/src/core/location/selection.dart';
import 'package:appflowy_editor/src/editor/editor_component/service/selection/mobile_selection_service.dart';
import 'package:flutter/material.dart' hide Overlay, OverlayEntry; import 'package:flutter/material.dart' hide Overlay, OverlayEntry;
/// [AppFlowySelectionService] is responsible for processing /// [AppFlowySelectionService] is responsible for processing
@ -66,6 +67,20 @@ abstract class AppFlowySelectionService {
void registerGestureInterceptor(SelectionGestureInterceptor interceptor); void registerGestureInterceptor(SelectionGestureInterceptor interceptor);
void unregisterGestureInterceptor(String key); void unregisterGestureInterceptor(String key);
/// The functions below are only for mobile.
Selection? onPanStart(
DragStartDetails details,
MobileSelectionDragMode mode,
);
Selection? onPanUpdate(
DragUpdateDetails details,
MobileSelectionDragMode mode,
);
void onPanEnd(
DragEndDetails details,
MobileSelectionDragMode mode,
);
} }
class SelectionGestureInterceptor { class SelectionGestureInterceptor {

View file

@ -93,4 +93,25 @@ class _SelectionServiceWidgetState extends State<SelectionServiceWidget>
@override @override
void updateSelection(Selection? selection) => void updateSelection(Selection? selection) =>
forward.updateSelection(selection); forward.updateSelection(selection);
@override
Selection? onPanStart(
DragStartDetails details,
MobileSelectionDragMode mode,
) =>
forward.onPanStart(details, mode);
@override
Selection? onPanUpdate(
DragUpdateDetails details,
MobileSelectionDragMode mode,
) =>
forward.onPanUpdate(details, mode);
@override
void onPanEnd(
DragEndDetails details,
MobileSelectionDragMode mode,
) =>
forward.onPanEnd(details, mode);
} }

View file

@ -10,6 +10,7 @@ class EditorStyle {
const EditorStyle({ const EditorStyle({
required this.padding, required this.padding,
required this.cursorColor, required this.cursorColor,
required this.dragHandleColor,
required this.selectionColor, required this.selectionColor,
required this.textStyleConfiguration, required this.textStyleConfiguration,
required this.textSpanDecorator, required this.textSpanDecorator,
@ -17,44 +18,54 @@ class EditorStyle {
this.mobileDragHandleBallSize = const Size(8, 8), this.mobileDragHandleBallSize = const Size(8, 8),
this.mobileDragHandleWidth = 2.0, this.mobileDragHandleWidth = 2.0,
this.defaultTextDirection, this.defaultTextDirection,
this.enableHapticFeedbackOnAndroid = true,
}); });
/// The padding of the editor. // The padding of the editor.
final EdgeInsets padding; final EdgeInsets padding;
/// The cursor color // The cursor color
final Color cursorColor; final Color cursorColor;
/// The selection color // The drag handle color
// only works on mobile
// the drag handle color will be ignored on Android.
final Color dragHandleColor;
// The selection color
final Color selectionColor; final Color selectionColor;
/// Customize the text style of the editor. // Customize the text style of the editor.
/// //
/// All the text-based components will use this configuration to build their // All the text-based components will use this configuration to build their
/// text style. // text style.
/// //
/// Notes, this configuration is only for the common config of text style and // Notes, this configuration is only for the common config of text style and
/// it maybe override if the text block has its own [BlockComponentConfiguration]. // it maybe override if the text block has its own [BlockComponentConfiguration].
final TextStyleConfiguration textStyleConfiguration; final TextStyleConfiguration textStyleConfiguration;
/// Customize the built-in or custom text span. // Customize the built-in or custom text span.
/// //
/// For example, you can add a custom text span for the mention text // For example, you can add a custom text span for the mention text
/// or override the built-in text span. // or override the built-in text span.
final TextSpanDecoratorForAttribute? textSpanDecorator; final TextSpanDecoratorForAttribute? textSpanDecorator;
final String? defaultTextDirection; final String? defaultTextDirection;
/// The size of the magnifier. // The size of the magnifier.
/// Only works on mobile. // Only works on mobile.
final Size magnifierSize; final Size magnifierSize;
/// mobile drag handler size. // mobile drag handler size.
/// /// Only works on mobile. // Only works on mobile.
final Size mobileDragHandleBallSize; final Size mobileDragHandleBallSize;
final double mobileDragHandleWidth; final double mobileDragHandleWidth;
// only works on android
// enable haptic feedback when updating selection by dragging the drag handler
final bool enableHapticFeedbackOnAndroid;
const EditorStyle.desktop({ const EditorStyle.desktop({
EdgeInsets? padding, EdgeInsets? padding,
Color? cursorColor, Color? cursorColor,
@ -74,11 +85,14 @@ class EditorStyle {
textSpanDecorator ?? defaultTextSpanDecoratorForAttribute, textSpanDecorator ?? defaultTextSpanDecoratorForAttribute,
magnifierSize = Size.zero, magnifierSize = Size.zero,
mobileDragHandleBallSize = Size.zero, mobileDragHandleBallSize = Size.zero,
mobileDragHandleWidth = 0.0; mobileDragHandleWidth = 0.0,
enableHapticFeedbackOnAndroid = false,
dragHandleColor = Colors.transparent;
const EditorStyle.mobile({ const EditorStyle.mobile({
EdgeInsets? padding, EdgeInsets? padding,
Color? cursorColor, Color? cursorColor,
Color? dragHandleColor,
Color? selectionColor, Color? selectionColor,
TextStyleConfiguration? textStyleConfiguration, TextStyleConfiguration? textStyleConfiguration,
TextSpanDecoratorForAttribute? textSpanDecorator, TextSpanDecoratorForAttribute? textSpanDecorator,
@ -86,8 +100,10 @@ class EditorStyle {
this.magnifierSize = const Size(72, 48), this.magnifierSize = const Size(72, 48),
this.mobileDragHandleBallSize = const Size(8, 8), this.mobileDragHandleBallSize = const Size(8, 8),
this.mobileDragHandleWidth = 2.0, this.mobileDragHandleWidth = 2.0,
this.enableHapticFeedbackOnAndroid = true,
}) : padding = padding ?? const EdgeInsets.symmetric(horizontal: 20), }) : padding = padding ?? const EdgeInsets.symmetric(horizontal: 20),
cursorColor = cursorColor ?? const Color(0xFF00BCF0), cursorColor = cursorColor ?? const Color(0xFF00BCF0),
dragHandleColor = dragHandleColor ?? const Color(0xFF00BCF0),
selectionColor = selectionColor =
selectionColor ?? const Color.fromARGB(53, 111, 201, 231), selectionColor ?? const Color.fromARGB(53, 111, 201, 231),
textStyleConfiguration = textStyleConfiguration ?? textStyleConfiguration = textStyleConfiguration ??
@ -100,19 +116,32 @@ class EditorStyle {
EditorStyle copyWith({ EditorStyle copyWith({
EdgeInsets? padding, EdgeInsets? padding,
Color? cursorColor, Color? cursorColor,
Color? dragHandleColor,
Color? selectionColor, Color? selectionColor,
TextStyleConfiguration? textStyleConfiguration, TextStyleConfiguration? textStyleConfiguration,
TextSpanDecoratorForAttribute? textSpanDecorator, TextSpanDecoratorForAttribute? textSpanDecorator,
String? defaultTextDirection, String? defaultTextDirection,
Size? magnifierSize,
Size? mobileDragHandleBallSize,
double? mobileDragHandleWidth,
bool? enableHapticFeedbackOnAndroid,
}) { }) {
return EditorStyle( return EditorStyle(
padding: padding ?? this.padding, padding: padding ?? this.padding,
cursorColor: cursorColor ?? this.cursorColor, cursorColor: cursorColor ?? this.cursorColor,
dragHandleColor: dragHandleColor ?? this.dragHandleColor,
selectionColor: selectionColor ?? this.selectionColor, selectionColor: selectionColor ?? this.selectionColor,
textStyleConfiguration: textStyleConfiguration:
textStyleConfiguration ?? this.textStyleConfiguration, textStyleConfiguration ?? this.textStyleConfiguration,
textSpanDecorator: textSpanDecorator ?? this.textSpanDecorator, textSpanDecorator: textSpanDecorator ?? this.textSpanDecorator,
defaultTextDirection: defaultTextDirection, defaultTextDirection: defaultTextDirection,
magnifierSize: magnifierSize ?? this.magnifierSize,
mobileDragHandleBallSize:
mobileDragHandleBallSize ?? this.mobileDragHandleBallSize,
mobileDragHandleWidth:
mobileDragHandleWidth ?? this.mobileDragHandleWidth,
enableHapticFeedbackOnAndroid:
enableHapticFeedbackOnAndroid ?? this.enableHapticFeedbackOnAndroid,
); );
} }
} }

View file

@ -300,10 +300,8 @@ class EditorState {
selectionExtraInfo = transaction.selectionExtraInfo; selectionExtraInfo = transaction.selectionExtraInfo;
} }
selection = transaction.afterSelection; selection = transaction.afterSelection;
_selectionUpdateReason = SelectionUpdateReason.uiEvent;
} }
// TODO: execute this line after the UI has been updated.
completer.complete(); completer.complete();
return completer.future; return completer.future;

View file

@ -0,0 +1,475 @@
import 'dart:io';
import 'dart:math';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/editor/editor_component/service/selection/mobile_selection_service.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
GlobalKey _mobileLeftHandleKey = GlobalKey();
GlobalKey _mobileRightHandleKey = GlobalKey();
GlobalKey _mobileCollapsedHandleKey = GlobalKey();
enum HandleType {
none,
left,
right,
collapsed;
GlobalKey get key {
switch (this) {
case HandleType.none:
throw UnsupportedError('Unsupported handle type');
case HandleType.left:
return _mobileLeftHandleKey;
case HandleType.right:
return _mobileRightHandleKey;
case HandleType.collapsed:
return _mobileCollapsedHandleKey;
}
}
MobileSelectionDragMode get dragMode {
switch (this) {
case HandleType.none:
throw UnsupportedError('Unsupported handle type');
case HandleType.left:
return MobileSelectionDragMode.leftSelectionHandler;
case HandleType.right:
return MobileSelectionDragMode.rightSelectionHandler;
case HandleType.collapsed:
return MobileSelectionDragMode.cursor;
}
}
CrossAxisAlignment get crossAxisAlignment {
switch (this) {
case HandleType.none:
throw UnsupportedError('Unsupported handle type');
case HandleType.left:
return CrossAxisAlignment.end;
case HandleType.right:
return CrossAxisAlignment.start;
case HandleType.collapsed:
return CrossAxisAlignment.center;
}
}
}
// only used on Android
class AndroidCollapsedHandle extends StatelessWidget {
const AndroidCollapsedHandle({
super.key,
required this.layerLink,
required this.rect,
this.handleColor = Colors.black,
this.handleBallWidth = 6.0,
this.handleWidth = 2.0,
this.enableHapticFeedbackOnAndroid = true,
});
final Rect rect;
final LayerLink layerLink;
final Color handleColor;
final double handleWidth;
final double handleBallWidth;
final bool enableHapticFeedbackOnAndroid;
@override
Widget build(BuildContext context) {
// Extend the click area to make it easier to click.
final adjustedRect = Rect.fromLTWH(
rect.left - 2 * (handleBallWidth),
rect.top,
rect.width + 4 * (handleBallWidth),
// Enable clicking in the handle area outside the stack.
// https://github.com/flutter/flutter/issues/75747
rect.height + 2 * handleBallWidth,
);
return Positioned.fromRect(
rect: adjustedRect,
child: CompositedTransformFollower(
link: layerLink,
offset: adjustedRect.topLeft,
showWhenUnlinked: false,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned(
top: 4.0,
child: _AndroidDragHandle(
handleHeight: adjustedRect.height,
handleType: HandleType.collapsed,
handleColor: handleColor,
handleWidth: adjustedRect.width,
handleBallWidth: handleBallWidth,
),
),
],
),
),
);
}
}
class MobileSelectionArea extends StatelessWidget {
const MobileSelectionArea({
super.key,
required this.layerLink,
required this.rect,
this.handleType = HandleType.none,
this.handleColor = Colors.black,
this.handleBallWidth = 6.0,
this.handleWidth = 2.0,
this.enableHapticFeedbackOnAndroid = true,
});
final Rect rect;
final LayerLink layerLink;
final HandleType handleType;
final Color handleColor;
final double handleWidth;
final double handleBallWidth;
final bool enableHapticFeedbackOnAndroid;
@override
Widget build(BuildContext context) {
assert(handleType != HandleType.collapsed);
var adjustedRect = rect;
if (handleType != HandleType.none) {
if (Platform.isIOS) {
// on iOS, the cursor will still be visible if the selection is not collapsed.
// So, adding a threshold padding to avoid row overflow.
const threshold = 0.25;
adjustedRect = Rect.fromLTWH(
rect.left - 2 * (handleWidth + threshold),
rect.top - handleBallWidth,
rect.width + 4 * (handleWidth + threshold),
rect.height + 2 * handleBallWidth,
);
} else if (Platform.isAndroid) {
// on Android, normally the cursor will be hidden if the selection is not collapsed.
// Extend the click area to make it easier to click.
adjustedRect = Rect.fromLTWH(
rect.left - 2 * (handleBallWidth),
rect.top,
rect.width + 4 * (handleBallWidth),
// Enable clicking in the handle area outside the stack.
// https://github.com/flutter/flutter/issues/75747
rect.height + 2 * handleBallWidth,
);
}
}
return Positioned.fromRect(
rect: adjustedRect,
child: CompositedTransformFollower(
link: layerLink,
offset: adjustedRect.topLeft,
showWhenUnlinked: false,
child: _MobileSelectionWithHandles(
handleType: handleType,
handleColor: handleColor,
handleHeight: adjustedRect.height,
handleWidth: handleWidth,
handleBallWidth: handleBallWidth,
),
),
);
}
}
class _MobileSelectionWithHandles extends StatelessWidget {
const _MobileSelectionWithHandles({
required this.handleType,
this.handleColor = Colors.black,
this.handleWidth = 2.0,
required this.handleHeight,
required this.handleBallWidth,
});
final HandleType handleType;
final Color handleColor;
final double handleWidth;
final double handleHeight;
final double handleBallWidth;
@override
Widget build(BuildContext context) {
assert(handleType != HandleType.collapsed);
final Widget child;
if (handleType != HandleType.none) {
final offset = Platform.isIOS ? -handleWidth : 0.0;
child = Stack(
clipBehavior: Clip.none,
children: [
if (handleType == HandleType.left)
Positioned(
left: offset,
child: _DragHandle(
handleColor: handleColor,
handleWidth: handleWidth,
handleBallWidth: handleBallWidth,
handleHeight: handleHeight,
handleType: HandleType.left,
),
),
if (handleType == HandleType.right)
Positioned(
right: offset,
child: _DragHandle(
handleColor: handleColor,
handleWidth: handleWidth,
handleBallWidth: handleBallWidth,
handleHeight: handleHeight,
handleType: HandleType.right,
),
),
],
);
} else {
child = const SizedBox.shrink();
}
return child;
}
}
abstract class _IDragHandle extends StatelessWidget {
const _IDragHandle({
required this.handleHeight,
this.handleColor = Colors.black,
this.handleWidth = 2.0,
this.handleBallWidth = 6.0,
required this.handleType,
});
final Color handleColor;
final double handleWidth;
final double handleHeight;
final double handleBallWidth;
final HandleType handleType;
}
class _DragHandle extends _IDragHandle {
const _DragHandle({
required super.handleHeight,
super.handleColor,
super.handleWidth,
super.handleBallWidth,
required super.handleType,
});
@override
Widget build(BuildContext context) {
if (Platform.isIOS) {
return _IOSDragHandle(
handleHeight: handleHeight,
handleColor: handleColor,
handleWidth: handleWidth,
handleBallWidth: handleBallWidth,
handleType: handleType,
);
} else if (Platform.isAndroid) {
return _AndroidDragHandle(
handleHeight: handleHeight,
handleColor: handleColor,
handleWidth: handleWidth,
handleBallWidth: handleBallWidth,
handleType: handleType,
);
}
throw UnsupportedError('Unsupported platform');
}
}
class _IOSDragHandle extends _IDragHandle {
const _IOSDragHandle({
required super.handleHeight,
super.handleColor,
super.handleWidth,
super.handleBallWidth,
required super.handleType,
});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.max,
children: [
if (handleType == HandleType.left)
Container(
width: handleBallWidth,
height: handleBallWidth,
decoration: BoxDecoration(
color: handleColor,
shape: BoxShape.circle,
),
),
if (handleType == HandleType.right)
SizedBox(
width: handleBallWidth,
height: handleBallWidth,
),
Container(
width: handleWidth,
color: handleColor,
height: handleHeight - 2.0 * handleBallWidth,
),
if (handleType == HandleType.right)
Container(
width: handleBallWidth,
height: handleBallWidth,
decoration: BoxDecoration(
color: handleColor,
shape: BoxShape.circle,
),
),
if (handleType == HandleType.left)
SizedBox(
width: handleBallWidth,
height: handleBallWidth,
),
],
);
}
}
// ignore: must_be_immutable
class _AndroidDragHandle extends _IDragHandle {
_AndroidDragHandle({
required super.handleHeight,
super.handleColor,
super.handleWidth,
super.handleBallWidth,
required super.handleType,
});
Selection? selection;
@override
Widget build(BuildContext context) {
final editorState = context.read<EditorState>();
Widget child = SizedBox(
width: handleWidth,
height: handleHeight - 2.0 * handleBallWidth,
);
if (handleType == HandleType.none) {
return child;
}
final ballWidth = handleBallWidth * 2.0;
child = GestureDetector(
key: handleType.key,
behavior: HitTestBehavior.opaque,
dragStartBehavior: DragStartBehavior.down,
onPanStart: (details) {
selection = editorState.service.selectionService.onPanStart(
details.translate(0, -ballWidth),
handleType.dragMode,
);
},
onPanUpdate: (details) {
final selection = editorState.service.selectionService.onPanUpdate(
details.translate(0, -ballWidth),
handleType.dragMode,
);
if (this.selection != selection) {
HapticFeedback.selectionClick();
}
this.selection = selection;
},
onPanEnd: (details) {
editorState.service.selectionService.onPanEnd(
details,
handleType.dragMode,
);
},
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: handleType.crossAxisAlignment,
children: [
child,
if (handleType == HandleType.collapsed)
Transform.rotate(
angle: pi / 4.0,
child: Container(
width: ballWidth,
height: ballWidth,
decoration: BoxDecoration(
color: handleColor,
borderRadius: BorderRadius.only(
topRight: Radius.circular(handleBallWidth),
bottomLeft: Radius.circular(handleBallWidth),
bottomRight: Radius.circular(handleBallWidth),
),
),
),
),
if (handleType == HandleType.left)
Container(
width: ballWidth,
height: ballWidth,
decoration: BoxDecoration(
color: handleColor,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(handleBallWidth),
bottomLeft: Radius.circular(handleBallWidth),
bottomRight: Radius.circular(handleBallWidth),
),
),
),
if (handleType == HandleType.right)
Container(
width: ballWidth,
height: ballWidth,
decoration: BoxDecoration(
color: handleColor,
borderRadius: BorderRadius.only(
topRight: Radius.circular(handleBallWidth),
bottomLeft: Radius.circular(handleBallWidth),
bottomRight: Radius.circular(handleBallWidth),
),
),
),
],
),
);
// use it to debug the handle area.
// if (kDebugMode) {
// child = ColoredBox(
// color: Colors.red.withOpacity(0.5),
// child: child,
// );
// }
return child;
}
}
extension on DragStartDetails {
DragStartDetails translate(double dx, double dy) {
return DragStartDetails(
sourceTimeStamp: sourceTimeStamp,
globalPosition: Offset(globalPosition.dx + dx, globalPosition.dy + dy),
localPosition: Offset(localPosition.dx + dx, localPosition.dy + dy),
);
}
}
extension on DragUpdateDetails {
DragUpdateDetails translate(double dx, double dy) {
return DragUpdateDetails(
sourceTimeStamp: sourceTimeStamp,
globalPosition: Offset(globalPosition.dx + dx, globalPosition.dy + dy),
localPosition: Offset(localPosition.dx + dx, localPosition.dy + dy),
delta: Offset(delta.dx + dx, delta.dy + dy),
primaryDelta: primaryDelta,
);
}
}

View file

@ -1,189 +0,0 @@
import 'package:flutter/material.dart';
class MobileSelectionWidget extends StatelessWidget {
const MobileSelectionWidget({
super.key,
required this.layerLink,
required this.rect,
required this.color,
this.decoration,
this.showLeftHandler = false,
this.showRightHandler = false,
this.handlerColor = Colors.black,
this.handlerBallWidth = 6.0,
this.handlerWidth = 2.0,
});
final Color color;
final Rect rect;
final LayerLink layerLink;
final BoxDecoration? decoration;
final bool showLeftHandler;
final bool showRightHandler;
final Color handlerColor;
final double handlerWidth;
final double handlerBallWidth;
@override
Widget build(BuildContext context) {
// to avoid row overflow
const threshold = 0.25;
// left and right add 2px to avoid the selection area from being too narrow
var adjustedRect = rect;
if (showLeftHandler || showRightHandler) {
adjustedRect = Rect.fromLTWH(
rect.left - 2 * (handlerWidth + threshold),
rect.top - handlerBallWidth,
rect.width + 4 * (handlerWidth + threshold),
rect.height + 2 * handlerBallWidth,
);
}
return Positioned.fromRect(
rect: adjustedRect,
child: CompositedTransformFollower(
link: layerLink,
offset: adjustedRect.topLeft,
showWhenUnlinked: false,
// Ignore the gestures in selection overlays
// to solve the problem that selection areas cannot overlap.
child: IgnorePointer(
child: MobileSelectionWithHandler(
color: color,
decoration: decoration,
showLeftHandler: showLeftHandler,
showRightHandler: showRightHandler,
handlerColor: handlerColor,
handlerHeight: adjustedRect.height,
handlerWidth: handlerWidth,
handlerBallWidth: handlerBallWidth,
),
),
),
);
}
}
class MobileSelectionWithHandler extends StatelessWidget {
const MobileSelectionWithHandler({
super.key,
required this.color,
this.showLeftHandler = false,
this.showRightHandler = false,
this.handlerColor = Colors.black,
this.decoration,
this.handlerWidth = 2.0,
required this.handlerHeight,
required this.handlerBallWidth,
});
final Color color;
final BoxDecoration? decoration;
final bool showLeftHandler;
final bool showRightHandler;
final Color handlerColor;
final double handlerWidth;
final double handlerHeight;
final double handlerBallWidth;
@override
Widget build(BuildContext context) {
Widget child = Container(
color: decoration == null ? color : null,
decoration: decoration,
);
if (showLeftHandler || showRightHandler) {
child = Stack(
clipBehavior: Clip.none,
children: [
if (showLeftHandler)
Positioned(
left: -handlerWidth,
child: _DragHandler(
handlerColor: handlerColor,
handlerWidth: handlerWidth,
handlerBallWidth: handlerBallWidth,
handlerHeight: handlerHeight,
showLeftHandler: true,
showRightHandler: false,
),
),
child,
if (showRightHandler)
Positioned(
right: -handlerWidth,
child: _DragHandler(
handlerColor: handlerColor,
handlerWidth: handlerWidth,
handlerBallWidth: handlerBallWidth,
handlerHeight: handlerHeight,
showRightHandler: true,
showLeftHandler: false,
),
),
],
);
}
return child;
}
}
class _DragHandler extends StatelessWidget {
const _DragHandler({
required this.handlerHeight,
this.handlerColor = Colors.black,
this.handlerWidth = 2.0,
this.handlerBallWidth = 6.0,
this.showLeftHandler = false,
this.showRightHandler = false,
});
final Color handlerColor;
final double handlerWidth;
final double handlerHeight;
final double handlerBallWidth;
final bool showLeftHandler;
final bool showRightHandler;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.max,
children: [
if (showLeftHandler)
Container(
width: handlerBallWidth,
height: handlerBallWidth,
decoration: BoxDecoration(
color: handlerColor,
shape: BoxShape.circle,
),
),
if (showRightHandler)
SizedBox(
width: handlerBallWidth,
height: handlerBallWidth,
),
Container(
width: handlerWidth,
color: handlerColor,
height: handlerHeight - 2.0 * handlerBallWidth,
),
if (showRightHandler)
Container(
width: handlerBallWidth,
height: handlerBallWidth,
decoration: BoxDecoration(
color: handlerColor,
shape: BoxShape.circle,
),
),
if (showLeftHandler)
SizedBox(
width: handlerBallWidth,
height: handlerBallWidth,
),
],
);
}
}

View file

@ -1,5 +1,5 @@
import 'package:appflowy_editor/src/core/document/node.dart'; import 'package:appflowy_editor/src/core/document/node.dart';
import 'package:appflowy_editor/src/render/selection/mobile_selection_widget.dart'; import 'package:appflowy_editor/src/render/selection/mobile_selection_handle.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@ -13,19 +13,16 @@ void main() {
await tester.buildAndPump( await tester.buildAndPump(
Stack( Stack(
children: [ children: [
MobileSelectionWidget( MobileSelectionArea(
showLeftHandler: true,
showRightHandler: true,
layerLink: node.layerLink, layerLink: node.layerLink,
rect: const Rect.fromLTWH(0, 0, 100, 100), rect: const Rect.fromLTWH(0, 0, 100, 100),
color: Colors.red,
), ),
], ],
), ),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byType(MobileSelectionWidget), findsOneWidget); expect(find.byType(MobileSelectionArea), findsOneWidget);
}); });
}); });
} }