From 3bc57c009023a7f0651e43bf03c20d872c658706 Mon Sep 17 00:00:00 2001 From: chunhtai <47866232+chunhtai@users.noreply.github.com> Date: Thu, 13 Jan 2022 16:47:56 -0800 Subject: [PATCH] Modularize ReorderableListView auto scrolling logic (#96563) * Modularize ReorderableListView auto scrolling logic * comment * fix test * addressing comment --- .../lib/src/widgets/reorderable_list.dart | 229 +++++++++++++----- 1 file changed, 173 insertions(+), 56 deletions(-) diff --git a/packages/flutter/lib/src/widgets/reorderable_list.dart b/packages/flutter/lib/src/widgets/reorderable_list.dart index 3e7ed2796e..0b2639a56a 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 'media_query.dart'; 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 @@ class SliverReorderableListState extends State with Ticke 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 @@ class SliverReorderableListState extends State with Ticke // 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 @@ class SliverReorderableListState extends State with Ticke void didChangeDependencies() { super.didChangeDependencies(); _scrollable = Scrollable.of(context)!; + if (_autoScroller?.scrollable != _scrollable) { + _autoScroller?.stopAutoScroll(); + _autoScroller = _EdgeDraggingAutoScroller( + _scrollable, + onScrollViewScrolled: _handleScrollableAutoScrolled + ); + } } @override @@ -557,6 +564,7 @@ class SliverReorderableListState extends State with Ticke @override void dispose() { _dragInfo?.dispose(); + _autoScroller?.stopAutoScroll(); super.dispose(); } @@ -669,7 +677,7 @@ class SliverReorderableListState extends State with Ticke setState(() { _overlayEntry?.markNeedsBuild(); _dragUpdateItems(); - _autoScrollIfNecessary(); + _autoScroller?.startAutoScrollIfNecessary(_dragTargetRect); }); } @@ -716,6 +724,7 @@ class SliverReorderableListState extends State with Ticke } _dragInfo?.dispose(); _dragInfo = null; + _autoScroller?.stopAutoScroll(); _resetItemGap(); _recognizer?.dispose(); _recognizer = null; @@ -732,6 +741,14 @@ class SliverReorderableListState extends State with Ticke } } + 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 @@ class SliverReorderableListState extends State with Ticke } } - Future _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 @@ class SliverReorderableListState extends State with Ticke } } +/// 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 = * [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 = * 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 _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 @@ class ReorderableDragStartListener extends StatelessWidget { 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(), ); } }