chore: optimize auto scroller speed (#1147)

* chore: bump version 6.0.0

* chore: bump version 6.0.0

* feat: optimize the auto scroller speed

* chore: code format
This commit is contained in:
Lucas 2025-10-27 14:49:14 +08:00 • committed by GitHub
commit bcafa80b78
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 335 additions and 71 deletions

View file

@ -1,6 +1,10 @@
import 'package:appflowy_editor/src/editor/editor_component/service/scroll/auto_scroller.dart';
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/editor/util/platform_extension.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
const _selectionDragModeKey = 'selection_drag_mode';
class AutoScrollableWidget extends StatefulWidget {
const AutoScrollableWidget({
@ -51,11 +55,28 @@ class _AutoScrollableWidgetState extends State<AutoScrollableWidget> {
}
void _initAutoScroller() {
final bool isDesktopOrWeb = PlatformExtension.isDesktopOrWeb;
_autoScroller = AutoScroller(
_scrollableState,
velocityScalar: PlatformExtension.isDesktopOrWeb ? 25 : 100,
velocityScalar: isDesktopOrWeb ? 0.125 : 0.02,
minimumAutoScrollDelta: isDesktopOrWeb ? 0.07 : 0.004,
maxAutoScrollDelta: isDesktopOrWeb ? 2.75 : 0.053,
onScrollViewScrolled: () {
// _autoScroller.continueToAutoScroll();
if (!isDesktopOrWeb) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
final editorState = context.read<EditorState?>();
final dynamic dragMode =
editorState?.selectionExtraInfo?[_selectionDragModeKey];
final bool isDraggingSelection = dragMode != null &&
dragMode.toString() != 'MobileSelectionDragMode.none';
if (!isDraggingSelection) {
return;
}
_autoScroller.continueToAutoScroll();
}
});
}
},
);
}

View file

@ -18,11 +18,16 @@ class AutoScroller extends EdgeDraggingAutoScroller
super.scrollable, {
super.onScrollViewScrolled,
super.velocityScalar = _kDefaultAutoScrollVelocityScalar,
super.minimumAutoScrollDelta = _kDefaultMinAutoScrollDelta,
super.maxAutoScrollDelta = _kDefaultMaxAutoScrollDelta,
});
static const double _kDefaultAutoScrollVelocityScalar = 7;
static const double _kDefaultMinAutoScrollDelta = 1.0;
static const double _kDefaultMaxAutoScrollDelta = 20.0;
Offset? lastOffset;
Duration? lastDuration;
@override
void startAutoScroll(
@ -31,13 +36,14 @@ class AutoScroller extends EdgeDraggingAutoScroller
AxisDirection? direction,
Duration? duration,
}) {
lastOffset = offset;
lastDuration = duration;
if (direction != null && direction == AxisDirection.up) {
return startAutoScrollIfNecessary(
offset & Size(1, edgeOffset),
);
}
lastOffset = offset;
final dragTarget = Rect.fromCenter(
center: offset,
width: edgeOffset,
@ -52,12 +58,16 @@ class AutoScroller extends EdgeDraggingAutoScroller
@override
void stopAutoScroll() {
lastOffset = null;
lastDuration = null;
super.stopAutoScroll();
}
void continueToAutoScroll() {
if (lastOffset != null) {
startAutoScroll(lastOffset!);
startAutoScroll(
lastOffset!,
duration: lastDuration,
);
}
}
}

View file

@ -2,6 +2,8 @@ import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
const Duration _kDesktopAutoScrollTickDuration = Duration(milliseconds: 80);
class DesktopScrollService extends StatefulWidget {
const DesktopScrollService({
super.key,
@ -108,7 +110,7 @@ class _DesktopScrollServiceState extends State<DesktopScrollService>
offset,
edgeOffset: edgeOffset,
direction: direction,
duration: duration,
duration: duration ?? _kDesktopAutoScrollTickDuration,
);
}

View file

@ -92,8 +92,27 @@ class _ScrollServiceWidgetState extends State<ScrollServiceWidget>
}
WidgetsBinding.instance.addPostFrameCallback((_) {
final selectionRect = editorState.selectionRects();
final endTouchPoint = selectionRect.lastOrNull?.centerRight;
final selectionRects = editorState.selectionRects();
if (selectionRects.isEmpty) {
return;
}
Rect targetRect;
AxisDirection? direction;
final dynamic dragMode =
editorState.selectionExtraInfo?['selection_drag_mode'];
switch (dragMode?.toString()) {
case 'MobileSelectionDragMode.leftSelectionHandle':
targetRect = selectionRects.first;
direction = AxisDirection.up;
case 'MobileSelectionDragMode.rightSelectionHandle':
targetRect = selectionRects.last;
direction = AxisDirection.down;
default:
targetRect = selectionRects.last;
}
final endTouchPoint = targetRect.centerRight;
if (PlatformExtension.isMobile) {
// soft keyboard
@ -106,34 +125,23 @@ class _ScrollServiceWidgetState extends State<ScrollServiceWidget>
if (_forwardKey.currentContext == null) {
return;
}
if (endTouchPoint == null) {
jumpTo(selection.end.path.first);
} else {
startAutoScroll(
endTouchPoint,
edgeOffset: editorState.autoScrollEdgeOffset,
duration: Duration.zero,
);
}
startAutoScroll(
endTouchPoint,
edgeOffset: editorState.autoScrollEdgeOffset,
direction: direction,
duration: Duration.zero,
);
});
} else {
if (_forwardKey.currentContext == null) {
return;
}
if (endTouchPoint == null) {
// check if the selection is valid
final node = editorState.getNodeAtPath(selection.end.path);
if (node == null) {
return;
}
jumpTo(selection.end.path.first);
} else {
startAutoScroll(
endTouchPoint,
edgeOffset: editorState.autoScrollEdgeOffset,
duration: Duration.zero,
);
}
startAutoScroll(
endTouchPoint,
edgeOffset: editorState.autoScrollEdgeOffset,
direction: direction,
duration: Duration.zero,
);
}
});
}

View file

@ -51,6 +51,9 @@ class _DesktopSelectionServiceWidgetState
Position? _panStartPosition;
bool _isDraggingSelection = false;
Offset? _lastPanOffset;
OverlayEntry? _dropTargetEntry;
late EditorState editorState = Provider.of<EditorState>(
@ -64,6 +67,7 @@ class _DesktopSelectionServiceWidgetState
WidgetsBinding.instance.addObserver(this);
editorState.selectionNotifier.addListener(_updateSelection);
editorState.addScrollViewScrolledListener(_handleAutoScrollWhileDragging);
}
@override
@ -85,6 +89,9 @@ class _DesktopSelectionServiceWidgetState
clearSelection();
WidgetsBinding.instance.removeObserver(this);
editorState.selectionNotifier.removeListener(_updateSelection);
editorState.removeScrollViewScrolledListener(
_handleAutoScrollWhileDragging,
);
currentSelection.dispose();
removeDropTarget();
super.dispose();
@ -116,6 +123,7 @@ class _DesktopSelectionServiceWidgetState
@override
void clearSelection() {
// currentSelectedNodes = [];
_resetPanState();
currentSelection.value = null;
_clearSelection();
@ -140,6 +148,14 @@ class _DesktopSelectionServiceWidgetState
..clear();
}
void _resetPanState() {
_isDraggingSelection = false;
_panStartOffset = null;
_panStartScrollDy = null;
_panStartPosition = null;
_lastPanOffset = null;
}
void _clearContextMenu() {
_contextMenuAreas
..forEach((overlay) => overlay.remove())
@ -300,6 +316,13 @@ class _DesktopSelectionServiceWidgetState
_panStartPosition = getNodeInOffset(_panStartOffset!)
?.selectable
?.getPositionInOffset(_panStartOffset!);
if (_panStartPosition == null) {
_resetPanState();
return;
}
_lastPanOffset = _panStartOffset;
_isDraggingSelection = true;
}
void _onPanUpdate(DragUpdateDetails details) {
@ -311,35 +334,18 @@ class _DesktopSelectionServiceWidgetState
return;
}
if (_panStartOffset == null ||
_panStartScrollDy == null ||
if (!_isDraggingSelection ||
_panStartOffset == null ||
_panStartPosition == null) {
return;
}
final panEndOffset = details.globalPosition;
final dy = editorState.service.scrollService?.dy;
final panStartOffset = dy == null
? _panStartOffset!
: _panStartOffset!.translate(0, _panStartScrollDy! - dy);
// this one maybe redundant.
final last = getNodeInOffset(panEndOffset)?.selectable;
// compute the selection in range.
if (last != null) {
final start = _panStartPosition!;
final end = last.getSelectionInRange(panStartOffset, panEndOffset).end;
final selection = Selection(start: start, end: end);
if (selection != currentSelection.value) {
updateSelection(selection);
}
}
_lastPanOffset = details.globalPosition;
_updateSelectionDuringDrag(_lastPanOffset!);
editorState.service.scrollService?.startAutoScroll(
panEndOffset,
edgeOffset: 100,
_lastPanOffset!,
edgeOffset: 80,
);
}
@ -351,9 +357,62 @@ class _DesktopSelectionServiceWidgetState
return;
}
_panStartPosition = null;
editorState.service.scrollService?.stopAutoScroll();
_resetPanState();
}
void _updateSelectionDuringDrag(Offset panEndOffset) {
if (!_isDraggingSelection ||
_panStartPosition == null ||
_panStartOffset == null) {
return;
}
final double? currentDy = editorState.service.scrollService?.dy;
final Offset panStartOffset = currentDy == null || _panStartScrollDy == null
? _panStartOffset!
: _panStartOffset!.translate(
0,
_panStartScrollDy! - currentDy,
);
final selectable = getNodeInOffset(panEndOffset)?.selectable;
if (selectable == null) {
return;
}
final Selection selection = Selection(
start: _panStartPosition!,
end: selectable
.getSelectionInRange(
panStartOffset,
panEndOffset,
)
.end,
);
if (selection != currentSelection.value) {
updateSelection(selection);
}
}
void _handleAutoScrollWhileDragging() {
if (!mounted || !_isDraggingSelection || _lastPanOffset == null) {
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted ||
!_isDraggingSelection ||
_lastPanOffset == null ||
_panStartOffset == null ||
_panStartPosition == null) {
return;
}
_updateSelectionDuringDrag(_lastPanOffset!);
editorState.autoScroller?.continueToAutoScroll();
});
}
void _updateSelection() {

View file

@ -109,6 +109,7 @@ class _MobileSelectionServiceWidgetState
WidgetsBinding.instance.addObserver(this);
editorState.selectionNotifier.addListener(_updateSelection);
editorState.addScrollViewScrolledListener(_handleAutoScrollWhileDragging);
}
@override
@ -117,6 +118,9 @@ class _MobileSelectionServiceWidgetState
WidgetsBinding.instance.removeObserver(this);
selectionNotifierAfterLayout.dispose();
editorState.selectionNotifier.removeListener(_updateSelection);
editorState.removeScrollViewScrolledListener(
_handleAutoScrollWhileDragging,
);
collapsedHandleTimer?.cancel();
super.dispose();
@ -394,6 +398,79 @@ class _MobileSelectionServiceWidgetState
selectionRects.clear();
}
void _handleAutoScrollWhileDragging() {
if (!mounted || dragMode == MobileSelectionDragMode.none) {
return;
}
if (_panStartOffset == null ||
_panStartSelection == null ||
_lastPanOffset.value == null) {
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted ||
dragMode == MobileSelectionDragMode.none ||
_panStartOffset == null ||
_panStartSelection == null) {
return;
}
final offset = _lastPanOffset.value;
if (offset == null) {
return;
}
_updateSelectionDuringDrag(offset);
});
}
void _updateSelectionDuringDrag(Offset panEndOffset) {
if (_panStartOffset == null || _panStartSelection == null) {
return;
}
final double? dy = editorState.service.scrollService?.dy;
final Offset panStartOffset;
if (dy == null || _panStartScrollDy == null) {
panStartOffset = _panStartOffset!;
} else {
panStartOffset = _panStartOffset!.translate(
0,
_panStartScrollDy! - dy,
);
}
final selectionInRange = getNodeInOffset(panEndOffset)
?.selectable
?.getSelectionInRange(panStartOffset, panEndOffset);
final end = selectionInRange?.end;
if (end == null) {
return;
}
late final Selection newSelection;
switch (dragMode) {
case MobileSelectionDragMode.leftSelectionHandle:
newSelection = Selection(
start: _panStartSelection!.normalized.end,
end: end,
).normalized;
break;
case MobileSelectionDragMode.rightSelectionHandle:
newSelection = Selection(
start: _panStartSelection!.normalized.start,
end: end,
).normalized;
break;
case MobileSelectionDragMode.cursor:
newSelection = Selection.collapsed(end);
break;
case MobileSelectionDragMode.none:
return;
}
updateSelection(newSelection);
}
@override
Node? getNodeInOffset(Offset offset) {
final List<Node> sortedNodes = editorState.getVisibleNodes(