feat: implement autoscrolling on edge touch

This commit is contained in:
Lucas.Xu 2022-08-05 10:59:51 +08:00
parent 90fa1312f2
commit e5787090d2
8 changed files with 403 additions and 184 deletions

View File

@ -241,6 +241,62 @@
"subtype": "number-list",
"number": 3
}
},
{
"type": "text",
"delta": [
{
"insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowys five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow."
}
]
},
{
"type": "text",
"delta": [
{
"insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowys five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow."
}
]
},
{
"type": "text",
"delta": [
{
"insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowys five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow."
}
]
},
{
"type": "text",
"delta": [
{
"insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowys five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow."
}
]
},
{
"type": "text",
"delta": [
{
"insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowys five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow."
}
]
},
{
"type": "text",
"delta": [
{
"insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowys five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow."
}
]
},
{
"type": "text",
"delta": [
{
"insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowys five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow."
}
]
}
]
}

View File

@ -1,7 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flowy_editor/document/node.dart';
import 'package:flowy_editor/editor_state.dart';
import 'package:flowy_editor/service/render_plugin_service.dart';
import 'package:flutter/material.dart';
class EditorEntryWidgetBuilder extends NodeWidgetBuilder<Node> {
@override
@ -31,28 +32,26 @@ class EditorNodeWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: node.children
.map(
(child) =>
editorState.service.renderPluginService.buildPluginWidget(
child is TextNode
? NodeWidgetContext<TextNode>(
context: context,
node: child,
editorState: editorState,
)
: NodeWidgetContext<Node>(
context: context,
node: child,
editorState: editorState,
),
),
)
.toList(),
),
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: node.children
.map(
(child) =>
editorState.service.renderPluginService.buildPluginWidget(
child is TextNode
? NodeWidgetContext<TextNode>(
context: context,
node: child,
editorState: editorState,
)
: NodeWidgetContext<Node>(
context: context,
node: child,
editorState: editorState,
),
),
)
.toList(),
);
}
}

View File

@ -1,16 +1,16 @@
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flowy_editor/document/node.dart';
import 'package:flowy_editor/document/path.dart';
import 'package:flowy_editor/document/position.dart';
import 'package:flowy_editor/document/selection.dart';
import 'package:flowy_editor/document/text_delta.dart';
import 'package:flowy_editor/editor_state.dart';
import 'package:flowy_editor/document/path.dart';
import 'package:flowy_editor/render/rich_text/rich_text_style.dart';
import 'package:flowy_editor/render/selection/selectable.dart';
import 'package:flowy_editor/service/render_plugin_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
class RichTextNodeWidgetBuilder extends NodeWidgetBuilder<TextNode> {
@override
Widget build(NodeWidgetContext<TextNode> context) {
@ -173,11 +173,12 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
}
TextSpan get _textSpan => TextSpan(
children: widget.textNode.delta.operations
.whereType<TextInsert>()
.map((insert) => RichTextStyle(
attributes: insert.attributes ?? {},
text: insert.content,
).toTextSpan())
.toList(growable: false));
children: widget.textNode.delta.operations
.whereType<TextInsert>()
.map((insert) => RichTextStyle(
attributes: insert.attributes ?? {},
text: insert.content,
).toTextSpan())
.toList(growable: false),
);
}

View File

@ -1,5 +1,4 @@
import 'package:flowy_editor/service/internal_key_event_handlers/delete_text_handler.dart';
import 'package:flowy_editor/service/internal_key_event_handlers/update_text_style_by_command_x_handler.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flowy_editor/editor_state.dart';
@ -13,10 +12,13 @@ import 'package:flowy_editor/render/rich_text/quoted_text.dart';
import 'package:flowy_editor/service/input_service.dart';
import 'package:flowy_editor/service/internal_key_event_handlers/arrow_keys_handler.dart';
import 'package:flowy_editor/service/internal_key_event_handlers/delete_nodes_handler.dart';
import 'package:flowy_editor/service/internal_key_event_handlers/delete_text_handler.dart';
import 'package:flowy_editor/service/internal_key_event_handlers/enter_in_edge_of_text_node_handler.dart';
import 'package:flowy_editor/service/internal_key_event_handlers/slash_handler.dart';
import 'package:flowy_editor/service/internal_key_event_handlers/update_text_style_by_command_x_handler.dart';
import 'package:flowy_editor/service/keyboard_service.dart';
import 'package:flowy_editor/service/render_plugin_service.dart';
import 'package:flowy_editor/service/scroll_service.dart';
import 'package:flowy_editor/service/selection_service.dart';
import 'package:flowy_editor/service/toolbar_service.dart';
@ -60,15 +62,25 @@ class FlowyEditor extends StatefulWidget {
}
class _FlowyEditorState extends State<FlowyEditor> {
late ScrollController _scrollController;
EditorState get editorState => widget.editorState;
@override
void initState() {
super.initState();
_scrollController = ScrollController()..addListener(_scrollCallback);
editorState.service.renderPluginService = _createRenderPlugin();
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
void didUpdateWidget(covariant FlowyEditor oldWidget) {
super.didUpdateWidget(oldWidget);
@ -80,33 +92,36 @@ class _FlowyEditorState extends State<FlowyEditor> {
@override
Widget build(BuildContext context) {
return FlowySelection(
key: editorState.service.selectionServiceKey,
editorState: editorState,
child: FlowyInput(
key: editorState.service.inputServiceKey,
editorState: editorState,
child: FlowyKeyboard(
key: editorState.service.keyboardServiceKey,
handlers: [
...defaultKeyEventHandler,
...widget.keyEventHandlers,
],
return FlowyScroll(
key: editorState.service.scrollServiceKey,
child: FlowySelection(
key: editorState.service.selectionServiceKey,
editorState: editorState,
child: FlowyToolbar(
key: editorState.service.toolbarServiceKey,
child: FlowyInput(
key: editorState.service.inputServiceKey,
editorState: editorState,
child: editorState.service.renderPluginService.buildPluginWidget(
NodeWidgetContext(
context: context,
node: editorState.document.root,
child: FlowyKeyboard(
key: editorState.service.keyboardServiceKey,
handlers: [
...defaultKeyEventHandler,
...widget.keyEventHandlers,
],
editorState: editorState,
child: FlowyToolbar(
key: editorState.service.toolbarServiceKey,
editorState: editorState,
child:
editorState.service.renderPluginService.buildPluginWidget(
NodeWidgetContext(
context: context,
node: editorState.document.root,
editorState: editorState,
),
),
),
),
),
),
),
);
));
}
FlowyRenderPlugin _createRenderPlugin() => FlowyRenderPlugin(
@ -116,4 +131,8 @@ class _FlowyEditorState extends State<FlowyEditor> {
...widget.customBuilders,
},
);
void _scrollCallback() {
debugPrint('scrolling');
}
}

View File

@ -0,0 +1,65 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
mixin FlowyScrollService<T extends StatefulWidget> on State<T> {
double get dy;
void scrollTo(double dy);
RenderObject? scrollRenderObject();
}
class FlowyScroll extends StatefulWidget {
const FlowyScroll({
Key? key,
required this.child,
}) : super(key: key);
final Widget child;
@override
State<FlowyScroll> createState() => _FlowyScrollState();
}
class _FlowyScrollState extends State<FlowyScroll> with FlowyScrollService {
final _scrollController = ScrollController();
final _scrollViewKey = GlobalKey();
@override
double get dy => _scrollController.position.pixels;
@override
Widget build(BuildContext context) {
return Listener(
onPointerSignal: _onPointerSignal,
child: SingleChildScrollView(
key: _scrollViewKey,
physics: const NeverScrollableScrollPhysics(),
controller: _scrollController,
child: widget.child,
),
);
}
@override
void scrollTo(double dy) {
_scrollController.position.jumpTo(
dy.clamp(
_scrollController.position.minScrollExtent,
_scrollController.position.maxScrollExtent,
),
);
}
void _onPointerSignal(PointerSignalEvent event) {
if (event is PointerScrollEvent) {
final dy = (_scrollController.position.pixels + event.scrollDelta.dy);
scrollTo(dy);
}
}
@override
RenderObject? scrollRenderObject() {
return _scrollViewKey.currentContext?.findRenderObject();
}
}

View File

@ -1,5 +1,6 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
@ -99,102 +100,18 @@ class FlowySelection extends StatefulWidget {
State<FlowySelection> createState() => _FlowySelectionState();
}
/// Because the flutter's [DoubleTapGestureRecognizer] will block the [TapGestureRecognizer]
/// for a while. So we need to implement our own GestureDetector.
@immutable
class _SelectionGestureDetector extends StatefulWidget {
const _SelectionGestureDetector(
{Key? key,
this.child,
this.onTapDown,
this.onDoubleTapDown,
this.onPanStart,
this.onPanUpdate,
this.onPanEnd})
: super(key: key);
@override
State<_SelectionGestureDetector> createState() =>
_SelectionGestureDetectorState();
final Widget? child;
final GestureTapDownCallback? onTapDown;
final GestureTapDownCallback? onDoubleTapDown;
final GestureDragStartCallback? onPanStart;
final GestureDragUpdateCallback? onPanUpdate;
final GestureDragEndCallback? onPanEnd;
}
class _SelectionGestureDetectorState extends State<_SelectionGestureDetector> {
bool _isDoubleTap = false;
Timer? _doubleTapTimer;
@override
Widget build(BuildContext context) {
return RawGestureDetector(
behavior: HitTestBehavior.translucent,
gestures: {
PanGestureRecognizer:
GestureRecognizerFactoryWithHandlers<PanGestureRecognizer>(
() => PanGestureRecognizer(),
(recognizer) {
recognizer
..onStart = widget.onPanStart
..onUpdate = widget.onPanUpdate
..onEnd = widget.onPanEnd;
},
),
TapGestureRecognizer:
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
() => TapGestureRecognizer(),
(recognizer) {
recognizer.onTapDown = _tapDownDelegate;
},
),
},
child: widget.child,
);
}
_tapDownDelegate(TapDownDetails tapDownDetails) {
if (_isDoubleTap) {
_isDoubleTap = false;
_doubleTapTimer?.cancel();
_doubleTapTimer = null;
if (widget.onDoubleTapDown != null) {
widget.onDoubleTapDown!(tapDownDetails);
}
} else {
if (widget.onTapDown != null) {
widget.onTapDown!(tapDownDetails);
}
_isDoubleTap = true;
_doubleTapTimer?.cancel();
_doubleTapTimer = Timer(kDoubleTapTimeout, () {
_isDoubleTap = false;
_doubleTapTimer = null;
});
}
}
@override
void dispose() {
_doubleTapTimer?.cancel();
super.dispose();
}
}
class _FlowySelectionState extends State<FlowySelection>
with FlowySelectionService, WidgetsBindingObserver {
final _cursorKey = GlobalKey(debugLabel: 'cursor');
final List<OverlayEntry> _selectionOverlays = [];
final List<OverlayEntry> _cursorOverlays = [];
OverlayEntry? _debugOverlay;
/// [Pan] and [Tap] must be mutually exclusive.
/// Pan
Offset? panStartOffset;
double? panStartScrollDy;
Offset? panEndOffset;
/// Tap
@ -259,7 +176,7 @@ class _FlowySelectionState extends State<FlowySelection>
@override
void updateSelection(Selection selection) {
_rects.clear();
_clearSelection();
clearSelection();
// cursor
if (selection.isCollapsed) {
@ -273,7 +190,19 @@ class _FlowySelectionState extends State<FlowySelection>
@override
void clearSelection() {
_clearSelection();
currentSelection = null;
currentSelectedNodes.value = [];
// clear selection
_selectionOverlays
..forEach((overlay) => overlay.remove())
..clear();
// clear cursors
_cursorOverlays
..forEach((overlay) => overlay.remove())
..clear();
// clear toolbar
editorState.service.toolbarService?.hide();
}
@override
@ -325,7 +254,7 @@ class _FlowySelectionState extends State<FlowySelection>
}
}
for (final child in node.children) {
result.addAll(computeNodesInRange(child, start, end));
result.addAll(_computeNodesInRange(child, start, end));
}
return result;
}
@ -411,12 +340,24 @@ class _FlowySelectionState extends State<FlowySelection>
clearSelection();
panStartOffset = details.globalPosition;
panStartScrollDy = editorState.service.scrollService?.dy;
debugPrint('[_onPanStart] panStartOffset = $panStartOffset');
}
void _onPanUpdate(DragUpdateDetails details) {
panEndOffset = details.globalPosition;
if (panStartOffset == null || panStartScrollDy == null) {
return;
}
final nodes = getNodesInRange(panStartOffset!, panEndOffset!);
panEndOffset = details.globalPosition;
final dy = editorState.service.scrollService?.dy;
var panStartOffsetWithScrollDyGap = panStartOffset!;
if (dy != null) {
panStartOffsetWithScrollDyGap =
panStartOffsetWithScrollDyGap.translate(0, panStartScrollDy! - dy);
}
final nodes = getNodesInRange(panStartOffsetWithScrollDyGap, panEndOffset!);
if (nodes.isEmpty) {
return;
}
@ -427,40 +368,30 @@ class _FlowySelectionState extends State<FlowySelection>
if (first != null && last != null) {
bool isDownward;
if (first == last) {
isDownward = panStartOffset!.dx < panEndOffset!.dx;
isDownward = panStartOffsetWithScrollDyGap.dx < panEndOffset!.dx;
} else {
isDownward = panStartOffset!.dy < panEndOffset!.dy;
isDownward = panStartOffsetWithScrollDyGap.dy < panEndOffset!.dy;
}
final start =
first.getSelectionInRange(panStartOffset!, panEndOffset!).start;
final end = last.getSelectionInRange(panStartOffset!, panEndOffset!).end;
final start = first
.getSelectionInRange(panStartOffsetWithScrollDyGap, panEndOffset!)
.start;
final end = last
.getSelectionInRange(panStartOffsetWithScrollDyGap, panEndOffset!)
.end;
final selection = Selection(
start: isDownward ? start : end, end: isDownward ? end : start);
debugPrint('[_onPanUpdate] isDownward = $isDownward, $selection');
editorState.service.selectionService.updateSelection(selection);
}
_scrollUpOrDownIfNeeded(panEndOffset!);
_showDebugLayerIfNeeded();
}
void _onPanEnd(DragEndDetails details) {
// do nothing
}
void _clearSelection() {
currentSelection = null;
currentSelectedNodes.value = [];
// clear selection
_selectionOverlays
..forEach((overlay) => overlay.remove())
..clear();
// clear cursors
_cursorOverlays
..forEach((overlay) => overlay.remove())
..clear();
// clear toolbar
editorState.service.toolbarService?.hide();
}
void _updateSelection(Selection selection) {
final nodes =
_selectedNodesInSelection(editorState.document.root, selection);
@ -554,12 +485,12 @@ class _FlowySelectionState extends State<FlowySelection>
if (rect != null) {
_rects.add(_transformRectToGlobal(selectable!, rect));
final cursor = OverlayEntry(
builder: ((context) => CursorWidget(
key: _cursorKey,
rect: rect,
color: widget.cursorColor,
layerLink: node.layerLink,
)),
builder: (context) => CursorWidget(
key: _cursorKey,
rect: rect,
color: widget.cursorColor,
layerLink: node.layerLink,
),
);
_cursorOverlays.add(cursor);
Overlay.of(context)?.insertAll(_cursorOverlays);
@ -584,4 +515,139 @@ class _FlowySelectionState extends State<FlowySelection>
}
return result;
}
void _scrollUpOrDownIfNeeded(Offset offset) {
final dy = editorState.service.scrollService?.dy;
if (dy == null) {
assert(false, 'Dy could not be null');
return;
}
final topLimit = MediaQuery.of(context).size.height * 0.2;
final bottomLimit = MediaQuery.of(context).size.height * 0.8;
/// TODO: It is necessary to calculate the relative speed
/// according to the gap and move forward more gently.
final distance = 10.0;
if (offset.dy <= topLimit) {
// up
editorState.service.scrollService?.scrollTo(dy - distance);
} else if (offset.dy >= bottomLimit) {
//down
editorState.service.scrollService?.scrollTo(dy + distance);
}
}
void _showDebugLayerIfNeeded() {
// remove false to show debug overlay.
if (kDebugMode && false) {
_debugOverlay?.remove();
if (panStartOffset != null) {
_debugOverlay = OverlayEntry(
builder: (context) => Positioned.fromRect(
rect: Rect.fromPoints(
panStartOffset?.translate(
0,
-(editorState.service.scrollService!.dy -
panStartScrollDy!),
) ??
Offset.zero,
panEndOffset ?? Offset.zero)
.translate(0, 0),
child: Container(
color: Colors.red.withOpacity(0.2),
),
),
);
Overlay.of(context)?.insert(_debugOverlay!);
} else {
_debugOverlay = null;
}
}
}
}
/// Because the flutter's [DoubleTapGestureRecognizer] will block the [TapGestureRecognizer]
/// for a while. So we need to implement our own GestureDetector.
@immutable
class _SelectionGestureDetector extends StatefulWidget {
const _SelectionGestureDetector(
{Key? key,
this.child,
this.onTapDown,
this.onDoubleTapDown,
this.onPanStart,
this.onPanUpdate,
this.onPanEnd})
: super(key: key);
@override
State<_SelectionGestureDetector> createState() =>
_SelectionGestureDetectorState();
final Widget? child;
final GestureTapDownCallback? onTapDown;
final GestureTapDownCallback? onDoubleTapDown;
final GestureDragStartCallback? onPanStart;
final GestureDragUpdateCallback? onPanUpdate;
final GestureDragEndCallback? onPanEnd;
}
class _SelectionGestureDetectorState extends State<_SelectionGestureDetector> {
bool _isDoubleTap = false;
Timer? _doubleTapTimer;
@override
Widget build(BuildContext context) {
return RawGestureDetector(
behavior: HitTestBehavior.translucent,
gestures: {
PanGestureRecognizer:
GestureRecognizerFactoryWithHandlers<PanGestureRecognizer>(
() => PanGestureRecognizer(),
(recognizer) {
recognizer
..onStart = widget.onPanStart
..onUpdate = widget.onPanUpdate
..onEnd = widget.onPanEnd;
},
),
TapGestureRecognizer:
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
() => TapGestureRecognizer(),
(recognizer) {
recognizer.onTapDown = _tapDownDelegate;
},
),
},
child: widget.child,
);
}
_tapDownDelegate(TapDownDetails tapDownDetails) {
if (_isDoubleTap) {
_isDoubleTap = false;
_doubleTapTimer?.cancel();
_doubleTapTimer = null;
if (widget.onDoubleTapDown != null) {
widget.onDoubleTapDown!(tapDownDetails);
}
} else {
if (widget.onTapDown != null) {
widget.onTapDown!(tapDownDetails);
}
_isDoubleTap = true;
_doubleTapTimer?.cancel();
_doubleTapTimer = Timer(kDoubleTapTimeout, () {
_isDoubleTap = false;
_doubleTapTimer = null;
});
}
}
@override
void dispose() {
_doubleTapTimer?.cancel();
super.dispose();
}
}

View File

@ -1,8 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flowy_editor/service/keyboard_service.dart';
import 'package:flowy_editor/service/render_plugin_service.dart';
import 'package:flowy_editor/service/toolbar_service.dart';
import 'package:flowy_editor/service/scroll_service.dart';
import 'package:flowy_editor/service/selection_service.dart';
import 'package:flutter/material.dart';
import 'package:flowy_editor/service/toolbar_service.dart';
class FlowyService {
// selection service
@ -31,10 +33,20 @@ class FlowyService {
// toolbar service
final toolbarServiceKey = GlobalKey(debugLabel: 'flowy_toolbar_service');
ToolbarService? get toolbarService {
FlowyToolbarService? get toolbarService {
if (toolbarServiceKey.currentState != null &&
toolbarServiceKey.currentState is ToolbarService) {
return toolbarServiceKey.currentState! as ToolbarService;
toolbarServiceKey.currentState is FlowyToolbarService) {
return toolbarServiceKey.currentState! as FlowyToolbarService;
}
return null;
}
// scroll service
final scrollServiceKey = GlobalKey(debugLabel: 'flowy_scroll_service');
FlowyScrollService? get scrollService {
if (scrollServiceKey.currentState != null &&
scrollServiceKey.currentState is FlowyScrollService) {
return scrollServiceKey.currentState! as FlowyScrollService;
}
return null;
}

View File

@ -1,8 +1,9 @@
import 'package:flowy_editor/flowy_editor.dart';
import 'package:flowy_editor/render/selection/toolbar_widget.dart';
import 'package:flutter/material.dart';
mixin ToolbarService {
import 'package:flowy_editor/flowy_editor.dart';
import 'package:flowy_editor/render/selection/toolbar_widget.dart';
mixin FlowyToolbarService {
/// Show the toolbar widget beside the offset.
void showInOffset(Offset offset, LayerLink layerLink);
@ -24,7 +25,7 @@ class FlowyToolbar extends StatefulWidget {
State<FlowyToolbar> createState() => _FlowyToolbarState();
}
class _FlowyToolbarState extends State<FlowyToolbar> with ToolbarService {
class _FlowyToolbarState extends State<FlowyToolbar> with FlowyToolbarService {
OverlayEntry? _toolbarOverlay;
@override