Modularize ReorderableListView auto scrolling logic (#96563)
* Modularize ReorderableListView auto scrolling logic
* comment
* fix test
* addressing comment
diff --git a/packages/flutter/lib/src/widgets/reorderable_list.dart b/packages/flutter/lib/src/widgets/reorderable_list.dart
index 3e7ed27..0b2639a 100644
--- a/packages/flutter/lib/src/widgets/reorderable_list.dart
+++ b/packages/flutter/lib/src/widgets/reorderable_list.dart
@@ -2,7 +2,7 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
-import 'dart:math';
+import 'dart:math' as math;
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
@@ -16,7 +16,6 @@
import 'overlay.dart';
import 'scroll_controller.dart';
import 'scroll_physics.dart';
-import 'scroll_position.dart';
import 'scroll_view.dart';
import 'scrollable.dart';
import 'sliver.dart';
@@ -523,7 +522,6 @@
Offset? _finalDropPosition;
MultiDragGestureRecognizer? _recognizer;
int? _recognizerPointer;
- bool _autoScrolling = false;
// To implement the gap for the dragged item, we replace the dragged item
// with a zero sized box, and then translate all of the later items down
// by the size of the dragged item. This allows us to keep the order of the
@@ -534,6 +532,8 @@
// so the gap calculation can compensate for it.
bool _dragStartTransitionComplete = false;
+ _EdgeDraggingAutoScroller? _autoScroller;
+
late ScrollableState _scrollable;
Axis get _scrollDirection => axisDirectionToAxis(_scrollable.axisDirection);
bool get _reverse =>
@@ -544,6 +544,13 @@
void didChangeDependencies() {
super.didChangeDependencies();
_scrollable = Scrollable.of(context)!;
+ if (_autoScroller?.scrollable != _scrollable) {
+ _autoScroller?.stopAutoScroll();
+ _autoScroller = _EdgeDraggingAutoScroller(
+ _scrollable,
+ onScrollViewScrolled: _handleScrollableAutoScrolled
+ );
+ }
}
@override
@@ -557,6 +564,7 @@
@override
void dispose() {
_dragInfo?.dispose();
+ _autoScroller?.stopAutoScroll();
super.dispose();
}
@@ -669,7 +677,7 @@
setState(() {
_overlayEntry?.markNeedsBuild();
_dragUpdateItems();
- _autoScrollIfNecessary();
+ _autoScroller?.startAutoScrollIfNecessary(_dragTargetRect);
});
}
@@ -716,6 +724,7 @@
}
_dragInfo?.dispose();
_dragInfo = null;
+ _autoScroller?.stopAutoScroll();
_resetItemGap();
_recognizer?.dispose();
_recognizer = null;
@@ -732,6 +741,14 @@
}
}
+ void _handleScrollableAutoScrolled() {
+ if (_dragInfo == null)
+ return;
+ _dragUpdateItems();
+ // Continue scrolling if the drag is still in progress.
+ _autoScroller?.startAutoScrollIfNecessary(_dragTargetRect);
+ }
+
void _dragUpdateItems() {
assert(_dragInfo != null);
final double gapExtent = _dragInfo!.itemExtent;
@@ -810,55 +827,9 @@
}
}
- Future<void> _autoScrollIfNecessary() async {
- if (!_autoScrolling && _dragInfo != null && _dragInfo!.scrollable != null) {
- final ScrollPosition position = _dragInfo!.scrollable!.position;
- double? newOffset;
- const Duration duration = Duration(milliseconds: 14);
- const double step = 1.0;
- const double overDragMax = 20.0;
- const double overDragCoef = 10;
-
- final RenderBox scrollRenderBox = _dragInfo!.scrollable!.context.findRenderObject()! as RenderBox;
- final Offset scrollOrigin = scrollRenderBox.localToGlobal(Offset.zero);
- final double scrollStart = _offsetExtent(scrollOrigin, _scrollDirection);
- final double scrollEnd = scrollStart + _sizeExtent(scrollRenderBox.size, _scrollDirection);
-
- final double proxyStart = _offsetExtent(_dragInfo!.dragPosition - _dragInfo!.dragOffset, _scrollDirection);
- final double proxyEnd = proxyStart + _dragInfo!.itemExtent;
-
- if (_reverse) {
- if (proxyEnd > scrollEnd && position.pixels > position.minScrollExtent) {
- final double overDrag = max(proxyEnd - scrollEnd, overDragMax);
- newOffset = max(position.minScrollExtent, position.pixels - step * overDrag / overDragCoef);
- } else if (proxyStart < scrollStart && position.pixels < position.maxScrollExtent) {
- final double overDrag = max(scrollStart - proxyStart, overDragMax);
- newOffset = min(position.maxScrollExtent, position.pixels + step * overDrag / overDragCoef);
- }
- } else {
- if (proxyStart < scrollStart && position.pixels > position.minScrollExtent) {
- final double overDrag = max(scrollStart - proxyStart, overDragMax);
- newOffset = max(position.minScrollExtent, position.pixels - step * overDrag / overDragCoef);
- } else if (proxyEnd > scrollEnd && position.pixels < position.maxScrollExtent) {
- final double overDrag = max(proxyEnd - scrollEnd, overDragMax);
- newOffset = min(position.maxScrollExtent, position.pixels + step * overDrag / overDragCoef);
- }
- }
-
- if (newOffset != null && (newOffset - position.pixels).abs() >= 1.0) {
- _autoScrolling = true;
- await position.animateTo(
- newOffset,
- duration: duration,
- curve: Curves.linear,
- );
- _autoScrolling = false;
- if (_dragInfo != null) {
- _dragUpdateItems();
- _autoScrollIfNecessary();
- }
- }
- }
+ Rect get _dragTargetRect {
+ final Offset origin = _dragInfo!.dragPosition - _dragInfo!.dragOffset;
+ return Rect.fromLTWH(origin.dx, origin.dy, _dragInfo!.itemSize.width, _dragInfo!.itemSize.height);
}
Offset _itemOffsetAt(int index) {
@@ -911,6 +882,152 @@
}
}
+/// An auto scroller that scrolls the [scrollable] if a drag gesture drag close
+/// to its edge.
+///
+/// The scroll velocity is controlled by the [velocityScalar]:
+///
+/// velocity = <distance of overscroll> * [velocityScalar].
+class _EdgeDraggingAutoScroller {
+ /// Creates a auto scroller that scrolls the [scrollable].
+ _EdgeDraggingAutoScroller(this.scrollable, {this.onScrollViewScrolled, this.velocityScalar = _kDefaultAutoScrollVelocityScalar});
+
+ // An eyeball value
+ static const double _kDefaultAutoScrollVelocityScalar = 7;
+
+ /// The [Scrollable] this auto scroller is scrolling.
+ final ScrollableState scrollable;
+
+ /// Called when a scroll view is scrolled.
+ ///
+ /// The scroll view may be scrolled multiple times in a roll until the drag
+ /// target no longer triggers the auto scroll. This callback will be called
+ /// in between each scroll.
+ final VoidCallback? onScrollViewScrolled;
+
+ /// The velocity scalar per pixel over scroll.
+ ///
+ /// How the velocity scale with the over scroll distance. The auto scroll
+ /// velocity = <distance of overscroll> * velocityScalar.
+ final double velocityScalar;
+
+ late Rect _dragTargetRelatedToScrollOrigin;
+
+ /// Whether the auto scroll is in progress.
+ bool get scrolling => _scrolling;
+ bool _scrolling = false;
+
+ double _offsetExtent(Offset offset, Axis scrollDirection) {
+ switch (scrollDirection) {
+ case Axis.horizontal:
+ return offset.dx;
+ case Axis.vertical:
+ return offset.dy;
+ }
+ }
+
+ double _sizeExtent(Size size, Axis scrollDirection) {
+ switch (scrollDirection) {
+ case Axis.horizontal:
+ return size.width;
+ case Axis.vertical:
+ return size.height;
+ }
+ }
+
+ AxisDirection get _axisDirection => scrollable.axisDirection;
+ Axis get _scrollDirection => axisDirectionToAxis(_axisDirection);
+
+ /// Starts the auto scroll if the [dragTarget] is close to the edge.
+ ///
+ /// The scroll starts to scroll the [scrollable] if the target rect is close
+ /// to the edge of the [scrollable]; otherwise, it remains stationary.
+ ///
+ /// If the scrollable is already scrolling, calling this method updates the
+ /// previous dragTarget to the new value and continue scrolling if necessary.
+ void startAutoScrollIfNecessary(Rect dragTarget) {
+ final Offset deltaToOrigin = _getDeltaToScrollOrigin(scrollable);
+ _dragTargetRelatedToScrollOrigin = dragTarget.translate(deltaToOrigin.dx, deltaToOrigin.dy);
+ if (_scrolling) {
+ // The change will be picked up in the next scroll.
+ return;
+ }
+ if (!_scrolling)
+ _scroll();
+ }
+
+ /// Stop any ongoing auto scrolling.
+ void stopAutoScroll() {
+ _scrolling = false;
+ }
+
+ Future<void> _scroll() async {
+ final RenderBox scrollRenderBox = scrollable.context.findRenderObject()! as RenderBox;
+ final Rect globalRect = MatrixUtils.transformRect(
+ scrollRenderBox.getTransformTo(null),
+ Rect.fromLTWH(0, 0, scrollRenderBox.size.width, scrollRenderBox.size.height)
+ );
+ _scrolling = true;
+ double? newOffset;
+ const double overDragMax = 20.0;
+
+ final Offset deltaToOrigin = _getDeltaToScrollOrigin(scrollable);
+ final Offset viewportOrigin = globalRect.topLeft.translate(deltaToOrigin.dx, deltaToOrigin.dy);
+ final double viewportStart = _offsetExtent(viewportOrigin, _scrollDirection);
+ final double viewportEnd = viewportStart + _sizeExtent(globalRect.size, _scrollDirection);
+
+ final double proxyStart = _offsetExtent(_dragTargetRelatedToScrollOrigin.topLeft, _scrollDirection);
+ final double proxyEnd = _offsetExtent(_dragTargetRelatedToScrollOrigin.bottomRight, _scrollDirection);
+ late double overDrag;
+ if (_axisDirection == AxisDirection.up || _axisDirection == AxisDirection.left) {
+ if (proxyEnd > viewportEnd && scrollable.position.pixels > scrollable.position.minScrollExtent) {
+ overDrag = math.max(proxyEnd - viewportEnd, overDragMax);
+ newOffset = math.max(scrollable.position.minScrollExtent, scrollable.position.pixels - overDrag);
+ } else if (proxyStart < viewportStart && scrollable.position.pixels < scrollable.position.maxScrollExtent) {
+ overDrag = math.max(viewportStart - proxyStart, overDragMax);
+ newOffset = math.min(scrollable.position.maxScrollExtent, scrollable.position.pixels + overDrag);
+ }
+ } else {
+ if (proxyStart < viewportStart && scrollable.position.pixels > scrollable.position.minScrollExtent) {
+ overDrag = math.max(viewportStart - proxyStart, overDragMax);
+ newOffset = math.max(scrollable.position.minScrollExtent, scrollable.position.pixels - overDrag);
+ } else if (proxyEnd > viewportEnd && scrollable.position.pixels < scrollable.position.maxScrollExtent) {
+ overDrag = math.max(proxyEnd - viewportEnd, overDragMax);
+ newOffset = math.min(scrollable.position.maxScrollExtent, scrollable.position.pixels + overDrag);
+ }
+ }
+
+ if (newOffset == null || (newOffset - scrollable.position.pixels).abs() < 1.0) {
+ // 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,
+ );
+ if (onScrollViewScrolled != null)
+ onScrollViewScrolled!();
+ if (_scrolling)
+ await _scroll();
+ }
+}
+
+Offset _getDeltaToScrollOrigin(ScrollableState scrollableState) {
+ switch (scrollableState.axisDirection) {
+ case AxisDirection.down:
+ return Offset(0, scrollableState.position.pixels);
+ case AxisDirection.up:
+ return Offset(0, -scrollableState.position.pixels);
+ case AxisDirection.left:
+ return Offset(-scrollableState.position.pixels, 0);
+ case AxisDirection.right:
+ return Offset(scrollableState.position.pixels, 0);
+ }
+}
+
class _ReorderableItem extends StatefulWidget {
const _ReorderableItem({
required Key key,
@@ -1116,9 +1233,9 @@
void _startDragging(BuildContext context, PointerDownEvent event) {
final SliverReorderableListState? list = SliverReorderableList.maybeOf(context);
list?.startItemDragReorder(
- index: index,
- event: event,
- recognizer: createRecognizer(),
+ index: index,
+ event: event,
+ recognizer: createRecognizer(),
);
}
}