From b4c91febe9253b0ee617b3833457e9c71653380c Mon Sep 17 00:00:00 2001 From: xubaolin Date: Tue, 27 Sep 2022 11:19:51 +0800 Subject: [PATCH] Improve Scrollbar drag behavior --- .../flutter/lib/src/widgets/scrollbar.dart | 40 +++++++---- .../flutter/test/widgets/scrollbar_test.dart | 71 +++++++++++++++++++ 2 files changed, 97 insertions(+), 14 deletions(-) diff --git a/packages/flutter/lib/src/widgets/scrollbar.dart b/packages/flutter/lib/src/widgets/scrollbar.dart index 00cd2a7407d8a..630d72636c71e 100644 --- a/packages/flutter/lib/src/widgets/scrollbar.dart +++ b/packages/flutter/lib/src/widgets/scrollbar.dart @@ -686,6 +686,17 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter { return scrollableExtent * thumbOffsetLocal / thumbMovableExtent; } + /// The thumb's corresponding scroll offset in the track. + double getThumbScrollOffset() { + final double scrollableExtent = _lastMetrics!.maxScrollExtent - _lastMetrics!.minScrollExtent; + + final double fractionPast = (scrollableExtent > 0) + ? clampDouble(_lastMetrics!.pixels / scrollableExtent, 0.0, 1.0) + : 0; + + return fractionPast * (_traversableTrackExtent - _thumbExtent); + } + // Converts between a scroll position and the corresponding position in the // thumb track. double _getScrollToTrack(ScrollMetrics metrics, double thumbExtent) { @@ -1446,7 +1457,8 @@ class RawScrollbar extends StatefulWidget { /// Provides defaults gestures for dragging the scrollbar thumb and tapping on the /// scrollbar track. class RawScrollbarState extends State with TickerProviderStateMixin { - Offset? _dragScrollbarAxisOffset; + Offset? _startDragScrollbarAxisOffset; + double? _startDragThumbOffset; ScrollController? _currentController; Timer? _fadeoutTimer; late AnimationController _fadeoutAnimationController; @@ -1454,7 +1466,6 @@ class RawScrollbarState extends State with TickerProv final GlobalKey _scrollbarPainterKey = GlobalKey(); bool _hoverIsActive = false; - /// Used to paint the scrollbar. /// /// Can be customized by subclasses to change scrollbar behavior by overriding @@ -1688,30 +1699,30 @@ class RawScrollbarState extends State with TickerProv void _updateScrollPosition(Offset updatedOffset) { assert(_currentController != null); - assert(_dragScrollbarAxisOffset != null); + assert(_startDragScrollbarAxisOffset != null); + assert(_startDragThumbOffset != null); final ScrollPosition position = _currentController!.position; late double primaryDelta; switch (position.axisDirection) { case AxisDirection.up: - primaryDelta = _dragScrollbarAxisOffset!.dy - updatedOffset.dy; + primaryDelta = _startDragScrollbarAxisOffset!.dy - updatedOffset.dy; break; case AxisDirection.right: - primaryDelta = updatedOffset.dx -_dragScrollbarAxisOffset!.dx; + primaryDelta = updatedOffset.dx -_startDragScrollbarAxisOffset!.dx; break; case AxisDirection.down: - primaryDelta = updatedOffset.dy -_dragScrollbarAxisOffset!.dy; + primaryDelta = updatedOffset.dy -_startDragScrollbarAxisOffset!.dy; break; case AxisDirection.left: - primaryDelta = _dragScrollbarAxisOffset!.dx - updatedOffset.dx; + primaryDelta = _startDragScrollbarAxisOffset!.dx - updatedOffset.dx; break; } // Convert primaryDelta, the amount that the scrollbar moved since the last - // time _updateScrollPosition was called, into the coordinate space of the scroll + // time when drag started, into the coordinate space of the scroll // position, and jump to that position. - final double scrollOffsetLocal = scrollbarPainter.getTrackToScroll(primaryDelta); - final double scrollOffsetGlobal = scrollOffsetLocal + position.pixels; + final double scrollOffsetGlobal = scrollbarPainter.getTrackToScroll(primaryDelta + _startDragThumbOffset!); if (scrollOffsetGlobal != position.pixels) { // Ensure we don't drag into overscroll if the physics do not allow it. final double physicsAdjustment = position.physics.applyBoundaryConditions(position, scrollOffsetGlobal); @@ -1784,7 +1795,8 @@ class RawScrollbarState extends State with TickerProv } _fadeoutTimer?.cancel(); _fadeoutAnimationController.forward(); - _dragScrollbarAxisOffset = localPosition; + _startDragScrollbarAxisOffset = localPosition; + _startDragThumbOffset = scrollbarPainter.getThumbScrollOffset(); } /// Handler called when a currently active long press gesture moves. @@ -1803,7 +1815,6 @@ class RawScrollbarState extends State with TickerProv return; } _updateScrollPosition(localPosition); - _dragScrollbarAxisOffset = localPosition; } /// Handler called when a long press has ended. @@ -1816,7 +1827,8 @@ class RawScrollbarState extends State with TickerProv return; } _maybeStartFadeoutTimer(); - _dragScrollbarAxisOffset = null; + _startDragScrollbarAxisOffset = null; + _startDragThumbOffset = null; _currentController = null; } @@ -1958,7 +1970,7 @@ class RawScrollbarState extends State with TickerProv scrollbarPainter.update(metrics, metrics.axisDirection); } } else if (notification is ScrollEndNotification) { - if (_dragScrollbarAxisOffset == null) { + if (_startDragScrollbarAxisOffset == null) { _maybeStartFadeoutTimer(); } } diff --git a/packages/flutter/test/widgets/scrollbar_test.dart b/packages/flutter/test/widgets/scrollbar_test.dart index 2c320426e7b47..e509f6927b135 100644 --- a/packages/flutter/test/widgets/scrollbar_test.dart +++ b/packages/flutter/test/widgets/scrollbar_test.dart @@ -2717,4 +2717,75 @@ void main() { expect(scrollController.offset, 0.0); }); + + testWidgets('The thumb should follow the pointer when the scroll metrics changed during dragging', (WidgetTester tester) async { + // Regressing test for https://github.com/flutter/flutter/issues/112072 + final ScrollController scrollController = ScrollController(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: PrimaryScrollController( + controller: scrollController, + child: RawScrollbar( + isAlwaysShown: true, + controller: scrollController, + child: CustomScrollView( + controller: scrollController, + // cacheExtent: double.maxFinite, + slivers: [ + SliverList( + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) { + final double height; + if (index < 10) { + height = 100; + } else { + height = 500; + } + return SizedBox( + height: height, + child: Text('$index'), + ); + }, + childCount: 100, + ), + ), + ], + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(scrollController.offset, 0.0); + + // Drag the thumb down to scroll down. + const double scrollAmount = 100; + final TestGesture dragScrollbarGesture = await tester.startGesture(const Offset(797.0, 5.0)); + await tester.pumpAndSettle(); + await dragScrollbarGesture.moveBy(const Offset(0.0, scrollAmount)); + await tester.pumpAndSettle(); + + await dragScrollbarGesture.moveBy(const Offset(0.0, scrollAmount)); + await tester.pumpAndSettle(); + + await dragScrollbarGesture.up(); + await tester.pumpAndSettle(); + + // The view has scrolled more than it would have by a swipe gesture of the + // same distance. + expect(scrollController.offset, greaterThan((100.0 * 10 + 500.0 * 90) / 3)); + expect( + find.byType(RawScrollbar), + paints + ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0)) + ..rect( + rect: const Rect.fromLTRB(794.0, 200.0, 800.0, 218.0), + color: const Color(0x66BCBCBC), + ), + ); + }); }