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
10 changed files with 335 additions and 71 deletions

View file

@ -1,3 +1,9 @@
## 6.0.0
* fix: paste single line text will remove the children block by @LucasXu0 in https://github.com/AppFlowy-IO/appflowy-editor/pull/1100
* feat: support block wrapper by @LucasXu0 in https://github.com/AppFlowy-IO/appflowy-editor/pull/1101
* Adding offline collaboration example using appflowy_editor_sync_plugin by @Musta-Pollo in https://github.com/AppFlowy-IO/appflowy-editor/pull/1103
* chore: upgrade to flutter 3.32.4 by @LucasXu0 in https://github.com/AppFlowy-IO/appflowy-editor/pull/1123
## 5.2.0
* feat: support customizing link button by @LucasXu0 in https://github.com/AppFlowy-IO/appflowy-editor/pull/1048
* feat: add flag to disable keyboard shortcuts by @LucasXu0 in https://github.com/AppFlowy-IO/appflowy-editor/pull/1049

View file

@ -96,7 +96,7 @@ class _EditorState extends State<Editor> {
editorState.logConfiguration
..handler = debugPrint
..level = AppFlowyEditorLogLevel.all;
..level = AppFlowyEditorLogLevel.off;
editorState.transactionStream.listen((event) {
if (event.$1 == TransactionTime.after) {

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(

View file

@ -30,6 +30,7 @@ class EditorStateDebugInfo {
/// set true to this key to prevent attaching the text service when selection is changed.
const selectionExtraInfoDoNotAttachTextService =
'selectionExtraInfoDoNotAttachTextService';
const _selectionDragModeKey = 'selection_drag_mode';
class ApplyOptions {
const ApplyOptions({
@ -631,11 +632,31 @@ class EditorState {
) {
if (this.scrollableState != scrollableState) {
autoScroller?.stopAutoScroll();
autoScroller = AutoScroller(
final bool isDesktopOrWeb = PlatformExtension.isDesktopOrWeb;
late AutoScroller scroller;
scroller = AutoScroller(
scrollableState,
velocityScalar: PlatformExtension.isDesktopOrWeb ? 50 : 100,
onScrollViewScrolled: _notifyScrollViewScrolledListeners,
velocityScalar: isDesktopOrWeb ? 0.125 : 0.02,
minimumAutoScrollDelta: isDesktopOrWeb ? 0.07 : 0.004,
maxAutoScrollDelta: isDesktopOrWeb ? 2.75 : 0.053,
onScrollViewScrolled: () {
_notifyScrollViewScrolledListeners();
if (!isDesktopOrWeb) {
final dynamic dragMode = selectionExtraInfo?[_selectionDragModeKey];
final bool isDraggingSelection = dragMode != null &&
dragMode.toString() != 'MobileSelectionDragMode.none';
if (!isDraggingSelection) {
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (autoScroller == scroller) {
scroller.continueToAutoScroll();
}
});
}
},
);
autoScroller = scroller;
this.scrollableState = scrollableState;
}
}

View file

@ -6,6 +6,7 @@
import 'dart:async';
import 'dart:math' as math;
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@ -147,7 +148,12 @@ class EdgeDraggingAutoScroller {
this.scrollable, {
this.onScrollViewScrolled,
required this.velocityScalar,
});
double minimumAutoScrollDelta = 1.0,
double maxAutoScrollDelta = 20.0,
}) : assert(minimumAutoScrollDelta >= 0),
assert(maxAutoScrollDelta >= minimumAutoScrollDelta),
_minimumAutoScrollDelta = minimumAutoScrollDelta,
_maxAutoScrollDelta = maxAutoScrollDelta;
/// The [Scrollable] this auto scroller is scrolling.
final ScrollableState scrollable;
@ -167,6 +173,15 @@ class EdgeDraggingAutoScroller {
/// {@endtemplate}
final double velocityScalar;
/// The least amount of scroll delta applied per auto scroll tick.
///
/// When the calculated scroll distance is smaller than this value (but still
/// non-zero), the auto scroller will nudge by this minimum to keep the view
/// moving rather than treat it as too small to scroll.
final double _minimumAutoScrollDelta;
final double _maxAutoScrollDelta;
double? _previousScrollDelta;
late Rect _dragTargetRelatedToScrollOrigin;
/// Whether the auto scroll is in progress.
@ -212,9 +227,10 @@ class EdgeDraggingAutoScroller {
/// Stop any ongoing auto scrolling.
void stopAutoScroll() {
_scrolling = false;
_previousScrollDelta = null;
}
Future<void> _scroll() async {
Future<void> _scroll({Duration? duration}) async {
try {
final RenderBox scrollRenderBox =
scrollable.context.findRenderObject()! as RenderBox;
@ -268,18 +284,20 @@ class EdgeDraggingAutoScroller {
scrollable.position.minScrollExtent) {
final double overDrag =
math.min(proxyEnd - viewportEnd, overDragMax);
final double delta = _smoothScrollDelta(overDrag);
newOffset = math.max(
scrollable.position.minScrollExtent,
scrollable.position.pixels - overDrag,
scrollable.position.pixels - delta,
);
} else if (proxyStart < viewportStart &&
scrollable.position.pixels <
scrollable.position.maxScrollExtent) {
final double overDrag =
math.min(viewportStart - proxyStart, overDragMax);
final double delta = _smoothScrollDelta(overDrag);
newOffset = math.min(
scrollable.position.maxScrollExtent,
scrollable.position.pixels + overDrag,
scrollable.position.pixels + delta,
);
}
case AxisDirection.right:
@ -289,35 +307,58 @@ class EdgeDraggingAutoScroller {
scrollable.position.minScrollExtent) {
final double overDrag =
math.min(viewportStart - proxyStart, overDragMax);
final double delta = _smoothScrollDelta(overDrag);
newOffset = math.max(
scrollable.position.minScrollExtent,
scrollable.position.pixels - overDrag,
scrollable.position.pixels - delta,
);
} else if (proxyEnd > viewportEnd &&
scrollable.position.pixels <
scrollable.position.maxScrollExtent) {
final double overDrag =
math.min(proxyEnd - viewportEnd, overDragMax);
final double delta = _smoothScrollDelta(overDrag);
newOffset = math.min(
scrollable.position.maxScrollExtent,
scrollable.position.pixels + overDrag,
scrollable.position.pixels + delta,
);
}
}
if (newOffset == null ||
(newOffset - scrollable.position.pixels).abs() < 1.0) {
final double currentPixels = scrollable.position.pixels;
if (newOffset == null) {
// Drag should not trigger scroll.
_scrolling = false;
return;
}
final Duration duration =
Duration(milliseconds: (1000 / velocityScalar).round());
await scrollable.position
.animateTo(newOffset, duration: duration, curve: Curves.linear);
double delta = newOffset - currentPixels;
if (delta.abs() < _minimumAutoScrollDelta) {
if (delta.abs() <= precisionErrorTolerance) {
_scrolling = false;
return;
}
final double direction = delta.sign;
final double target =
(currentPixels + direction * _minimumAutoScrollDelta).clamp(
scrollable.position.minScrollExtent,
scrollable.position.maxScrollExtent,
);
newOffset = target.toDouble();
delta = newOffset - currentPixels;
if (delta.abs() <= precisionErrorTolerance) {
_scrolling = false;
return;
}
}
await scrollable.position.moveTo(
newOffset,
duration: duration,
curve: Curves.linear,
clamp: true,
);
onScrollViewScrolled?.call();
if (_scrolling) {
await _scroll();
await _scroll(duration: duration);
}
} catch (e) {
debugPrint(e.toString());
@ -325,6 +366,25 @@ class EdgeDraggingAutoScroller {
_scrolling = false;
}
}
double _smoothScrollDelta(double overDrag) {
if (overDrag <= precisionErrorTolerance) {
return 0;
}
final double desiredDelta = overDrag * velocityScalar;
final double clampedDelta = desiredDelta.clamp(
_minimumAutoScrollDelta,
_maxAutoScrollDelta,
);
if (_previousScrollDelta == null) {
_previousScrollDelta = clampedDelta;
return clampedDelta;
}
final double smoothed =
lerpDouble(_previousScrollDelta!, clampedDelta, 0.35)!;
_previousScrollDelta = smoothed;
return smoothed;
}
}
/// A typedef for a function that can calculate the offset for a type of scroll