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(),
     );
   }
 }