diff --git a/examples/api/lib/widgets/scroll_end_notification/scroll_end_notification.0.dart b/examples/api/lib/widgets/scroll_end_notification/scroll_end_notification.0.dart new file mode 100644 index 0000000000..f1f0b67011 --- /dev/null +++ b/examples/api/lib/widgets/scroll_end_notification/scroll_end_notification.0.dart @@ -0,0 +1,131 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; + +/// Flutter code sample for [ScrollEndNotification]. + +void main() { + runApp(const ScrollEndNotificationApp()); +} + +class ScrollEndNotificationApp extends StatelessWidget { + const ScrollEndNotificationApp({ super.key }); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + home: ScrollEndNotificationExample(), + ); + } +} + +class ScrollEndNotificationExample extends StatefulWidget { + const ScrollEndNotificationExample({ super.key }); + + @override + State createState() => _ScrollEndNotificationExampleState(); +} + +class _ScrollEndNotificationExampleState extends State { + static const int itemCount = 25; + static const double itemExtent = 100; + + late final ScrollController scrollController; + late double lastScrollOffset; + + @override + void initState() { + scrollController = ScrollController(); + super.initState(); + } + + @override + void dispose() { + super.dispose(); + scrollController.dispose(); + } + + // After an interactive scroll "ends", auto-scroll so that last item in the + // viewport is completely visible. To accomodate mouse-wheel scrolls, other small + // adjustments, and scrolling to the top, scrolls that put the scroll offset at + // zero or change the scroll offset by less than itemExtent don't trigger + // an auto-scroll. This also prevents the auto-scroll from triggering itself, + // since the alignedScrollOffset is guaranteed to be less than itemExtent. + bool handleScrollNotification(ScrollNotification notification) { + if (notification is ScrollStartNotification) { + lastScrollOffset = scrollController.position.pixels; + } + if (notification is ScrollEndNotification) { + final ScrollMetrics m = notification.metrics; + final int lastIndex = ((m.extentBefore + m.extentInside) ~/ itemExtent).clamp(0, itemCount - 1); + final double alignedScrollOffset = itemExtent * (lastIndex + 1) - m.extentInside; + final double scrollOffset = scrollController.position.pixels; + if (scrollOffset > 0 && (scrollOffset - lastScrollOffset).abs() > itemExtent) { + SchedulerBinding.instance.addPostFrameCallback((Duration duration) { + scrollController.animateTo( + alignedScrollOffset, + duration: const Duration(milliseconds: 400), + curve: Curves.fastOutSlowIn, + ); + }); + } + } + return true; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: Scrollbar( + controller: scrollController, + thumbVisibility: true, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: NotificationListener( + onNotification: handleScrollNotification, + child: CustomScrollView( + controller: scrollController, + slivers: [ + SliverFixedExtentList( + itemExtent: itemExtent, + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) { + return Item( + title: 'Item $index', + color: Color.lerp(Colors.red, Colors.blue, index / itemCount)! + ); + }, + childCount: itemCount, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class Item extends StatelessWidget { + const Item({ super.key, required this.title, required this.color }); + + final String title; + final Color color; + + @override + Widget build(BuildContext context) { + return Card( + color: color, + child: ListTile( + textColor: Colors.white, + title: Text(title), + ), + ); + } +} diff --git a/examples/api/lib/widgets/scroll_position/is_scrolling_listener.0.dart b/examples/api/lib/widgets/scroll_position/is_scrolling_listener.0.dart new file mode 100644 index 0000000000..26f59f81c2 --- /dev/null +++ b/examples/api/lib/widgets/scroll_position/is_scrolling_listener.0.dart @@ -0,0 +1,139 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; + +/// Flutter code sample for [IsScrollingListener]. +void main() { + runApp(const IsScrollingListenerApp()); +} + +class IsScrollingListenerApp extends StatelessWidget { + const IsScrollingListenerApp({ super.key }); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + home: IsScrollingListenerExample(), + ); + } +} + +class IsScrollingListenerExample extends StatefulWidget { + const IsScrollingListenerExample({ super.key }); + + @override + State createState() => _IsScrollingListenerExampleState(); +} + +class _IsScrollingListenerExampleState extends State { + static const int itemCount = 25; + static const double itemExtent = 100; + + late final ScrollController scrollController; + late double lastScrollOffset; + bool isScrolling = false; + + @override + void initState() { + scrollController = ScrollController( + onAttach: (ScrollPosition position) { + position.isScrollingNotifier.addListener(handleScrollChange); + }, + onDetach: (ScrollPosition position) { + position.isScrollingNotifier.removeListener(handleScrollChange); + }, + ); + super.initState(); + } + + @override + void dispose() { + scrollController.dispose(); + super.dispose(); + } + + // After an interactive scroll "ends", auto-scroll so that last item in the + // viewport is completely visible. To accomodate mouse-wheel scrolls, other small + // adjustments, and scrolling to the top, scrolls that put the scroll offset at + // zero or change the scroll offset by less than itemExtent don't trigger + // an auto-scroll. + void handleScrollChange() { + final bool isScrollingNow = scrollController.position.isScrollingNotifier.value; + if (isScrolling == isScrollingNow) { + return; + } + isScrolling = isScrollingNow; + if (isScrolling) { + // scroll-start + lastScrollOffset = scrollController.position.pixels; + } else { + // scroll-end + final ScrollPosition p = scrollController.position; + final int lastIndex = ((p.extentBefore + p.extentInside) ~/ itemExtent).clamp(0, itemCount - 1); + final double alignedScrollOffset = itemExtent * (lastIndex + 1) - p.extentInside; + final double scrollOffset = scrollController.position.pixels; + if (scrollOffset > 0 && (scrollOffset - lastScrollOffset).abs() > itemExtent) { + SchedulerBinding.instance.addPostFrameCallback((Duration duration) { + scrollController.animateTo( + alignedScrollOffset, + duration: const Duration(milliseconds: 400), + curve: Curves.fastOutSlowIn, + ); + }); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: Scrollbar( + controller: scrollController, + thumbVisibility: true, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: CustomScrollView( + controller: scrollController, + slivers: [ + SliverFixedExtentList( + itemExtent: itemExtent, + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) { + return Item( + title: 'Item $index', + color: Color.lerp(Colors.red, Colors.blue, index / itemCount)! + ); + }, + childCount: itemCount, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class Item extends StatelessWidget { + const Item({ super.key, required this.title, required this.color }); + + final String title; + final Color color; + + @override + Widget build(BuildContext context) { + return Card( + color: color, + child: ListTile( + textColor: Colors.white, + title: Text(title), + ), + ); + } +} diff --git a/examples/api/test/widgets/scroll_end_notification/scroll_end_notification.0_test.dart b/examples/api/test/widgets/scroll_end_notification/scroll_end_notification.0_test.dart new file mode 100644 index 0000000000..d3db5a363c --- /dev/null +++ b/examples/api/test/widgets/scroll_end_notification/scroll_end_notification.0_test.dart @@ -0,0 +1,46 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_api_samples/widgets/scroll_end_notification/scroll_end_notification.0.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('IsScrollingListenerApp smoke test', (WidgetTester tester) async { + await tester.pumpWidget( + const example.ScrollEndNotificationApp(), + ); + + expect(find.byType(CustomScrollView), findsOneWidget); + expect(find.byType(Scrollbar), findsOneWidget); + + ScrollPosition getScrollPosition() { + return tester.widget(find.byType(CustomScrollView)).controller!.position; + } + + // Viewport is 600 pixels high, each item's height is 100, 6 items are visible. + expect(getScrollPosition().viewportDimension, 600); + expect(getScrollPosition().pixels, 0); + expect(find.text('Item 0'), findsOneWidget); + expect(find.text('Item 5'), findsOneWidget); + + // Small (< 100) scrolls don't trigger an auto-scroll + await tester.drag(find.byType(Scrollbar), const Offset(0, -20.0)); + await tester.pumpAndSettle(); + expect(getScrollPosition().pixels, 20); + expect(find.text('Item 0'), findsOneWidget); + + // Initial scroll is to 220: items 0,1 are scrolled off the top, + // the bottom 80 pixels of item 2 are visible, items 4-7 are + // completely visible, the first 20 pixels of item 8 are visible. + // After the auto-scroll, items 3-8 are completely visible. + await tester.drag(find.byType(Scrollbar), const Offset(0, -200.0)); + await tester.pumpAndSettle(); + expect(getScrollPosition().pixels, 300); + expect(find.text('Item 0'), findsNothing); + expect(find.text('Item 2'), findsNothing); + expect(find.text('Item 3'), findsOneWidget); + expect(find.text('Item 8'), findsOneWidget); + }); +} diff --git a/examples/api/test/widgets/scroll_position/is_scrolling_listener.0_test.dart b/examples/api/test/widgets/scroll_position/is_scrolling_listener.0_test.dart new file mode 100644 index 0000000000..6016039f81 --- /dev/null +++ b/examples/api/test/widgets/scroll_position/is_scrolling_listener.0_test.dart @@ -0,0 +1,46 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_api_samples/widgets/scroll_position/is_scrolling_listener.0.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('IsScrollingListenerApp smoke test', (WidgetTester tester) async { + await tester.pumpWidget( + const example.IsScrollingListenerApp(), + ); + + expect(find.byType(CustomScrollView), findsOneWidget); + expect(find.byType(Scrollbar), findsOneWidget); + + ScrollPosition getScrollPosition() { + return tester.widget(find.byType(CustomScrollView)).controller!.position; + } + + // Viewport is 600 pixels high, each item's height is 100, 6 items are visible. + expect(getScrollPosition().viewportDimension, 600); + expect(getScrollPosition().pixels, 0); + expect(find.text('Item 0'), findsOneWidget); + expect(find.text('Item 5'), findsOneWidget); + + // Small (< 100) scrolls don't trigger an auto-scroll + await tester.drag(find.byType(Scrollbar), const Offset(0, -20.0)); + await tester.pumpAndSettle(); + expect(getScrollPosition().pixels, 20); + expect(find.text('Item 0'), findsOneWidget); + + // Initial scroll is to 220: items 0,1 are scrolled off the top, + // the bottom 80 pixels of item 2 are visible, items 4-7 are + // completely visible, the first 20 pixels of item 8 are visible. + // After the auto-scroll, items 3-8 are completely visible. + await tester.drag(find.byType(Scrollbar), const Offset(0, -200.0)); + await tester.pumpAndSettle(); + expect(getScrollPosition().pixels, 300); + expect(find.text('Item 0'), findsNothing); + expect(find.text('Item 2'), findsNothing); + expect(find.text('Item 3'), findsOneWidget); + expect(find.text('Item 8'), findsOneWidget); + }); +} diff --git a/packages/flutter/lib/src/widgets/scroll_notification.dart b/packages/flutter/lib/src/widgets/scroll_notification.dart index 1108a99151..40270b6ee7 100644 --- a/packages/flutter/lib/src/widgets/scroll_notification.dart +++ b/packages/flutter/lib/src/widgets/scroll_notification.dart @@ -263,6 +263,17 @@ class OverscrollNotification extends ScrollNotification { /// A notification that a [Scrollable] widget has stopped scrolling. /// +/// {@tool dartpad} +/// This sample shows how you can trigger an auto-scroll, which aligns the last +/// partially visible fixed-height list item, by listening for this +/// notification with a [NotificationListener]. This sort of thing can also +/// be done by listening to the [ScrollController]'s +/// [ScrollPosition.isScrollingNotifier]. An alternative example is provided +/// with [ScrollPosition.isScrollingNotifier]. +/// +/// ** See code in examples/api/lib/widgets/scroll_end_notification/scroll_end_notification.0.dart ** +/// {@end-tool} +/// /// See also: /// /// * [ScrollStartNotification], which indicates that scrolling has started. diff --git a/packages/flutter/lib/src/widgets/scroll_position.dart b/packages/flutter/lib/src/widgets/scroll_position.dart index eb6ae9bc67..5eef58c730 100644 --- a/packages/flutter/lib/src/widgets/scroll_position.dart +++ b/packages/flutter/lib/src/widgets/scroll_position.dart @@ -849,6 +849,16 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics { /// /// Listeners added by stateful widgets should be removed in the widget's /// [State.dispose] method. + /// + /// {@tool dartpad} + /// This sample shows how you can trigger an auto-scroll, which aligns the last + /// partially visible fixed-height list item, by listening to this + /// notifier's value. This sort of thing can also be done by listening for + /// [ScrollEndNotification]s with a [NotificationListener]. An alternative + /// example is provided with [ScrollEndNotification]. + /// + /// ** See code in examples/api/lib/widgets/scroll_position/is_scrolling_listener.0.dart ** + /// {@end-tool} final ValueNotifier isScrollingNotifier = ValueNotifier(false); /// Animates the position from its current value to the given value. diff --git a/packages/flutter/lib/src/widgets/scrollbar.dart b/packages/flutter/lib/src/widgets/scrollbar.dart index 3ea8dfe589..a9240f6de5 100644 --- a/packages/flutter/lib/src/widgets/scrollbar.dart +++ b/packages/flutter/lib/src/widgets/scrollbar.dart @@ -1323,7 +1323,10 @@ class RawScrollbarState extends State with TickerProv late CurvedAnimation _fadeoutOpacityAnimation; final GlobalKey _scrollbarPainterKey = GlobalKey(); bool _hoverIsActive = false; - bool _thumbDragging = false; + Drag? _thumbDrag; + ScrollHoldController? _thumbHold; + Axis? _axis; + final GlobalKey _gestureDetectorKey = GlobalKey(); ScrollController? get _effectiveScrollController => widget.controller ?? PrimaryScrollController.maybeOf(context); @@ -1561,7 +1564,34 @@ class RawScrollbarState extends State with TickerProv } } - void _updateScrollPosition(Offset updatedOffset) { + void _maybeStartFadeoutTimer() { + if (!showScrollbar) { + _fadeoutTimer?.cancel(); + _fadeoutTimer = Timer(widget.timeToFade, () { + _fadeoutAnimationController.reverse(); + _fadeoutTimer = null; + }); + } + } + + /// Returns the [Axis] of the child scroll view, or null if the + /// we haven't seen a ScrollMetrics notification yet. + @protected + Axis? getScrollbarDirection() => _axis; + + void _disposeThumbDrag() { + _thumbDrag = null; + } + + void _disposeThumbHold() { + _thumbHold = null; + } + + // Given the drag's localPosition (see handleThumbPressUpdate) compute the + // scroll position delta in the scroll axis direction. Deal with the complications + // arising from scroll metrics changes that have occurred since the last + // drag update and the need to prevent overscrolling on some platforms. + double? _getPrimaryDelta(Offset localPosition) { assert(_cachedController != null); assert(_startDragScrollbarAxisOffset != null); assert(_lastDragUpdateOffset != null); @@ -1572,22 +1602,22 @@ class RawScrollbarState extends State with TickerProv late double primaryDeltaFromLastDragUpdate; switch (position.axisDirection) { case AxisDirection.up: - primaryDeltaFromDragStart = _startDragScrollbarAxisOffset!.dy - updatedOffset.dy; - primaryDeltaFromLastDragUpdate = _lastDragUpdateOffset!.dy - updatedOffset.dy; + primaryDeltaFromDragStart = _startDragScrollbarAxisOffset!.dy - localPosition.dy; + primaryDeltaFromLastDragUpdate = _lastDragUpdateOffset!.dy - localPosition.dy; case AxisDirection.right: - primaryDeltaFromDragStart = updatedOffset.dx -_startDragScrollbarAxisOffset!.dx; - primaryDeltaFromLastDragUpdate = updatedOffset.dx -_lastDragUpdateOffset!.dx; + primaryDeltaFromDragStart = localPosition.dx -_startDragScrollbarAxisOffset!.dx; + primaryDeltaFromLastDragUpdate = localPosition.dx -_lastDragUpdateOffset!.dx; case AxisDirection.down: - primaryDeltaFromDragStart = updatedOffset.dy -_startDragScrollbarAxisOffset!.dy; - primaryDeltaFromLastDragUpdate = updatedOffset.dy -_lastDragUpdateOffset!.dy; + primaryDeltaFromDragStart = localPosition.dy -_startDragScrollbarAxisOffset!.dy; + primaryDeltaFromLastDragUpdate = localPosition.dy -_lastDragUpdateOffset!.dy; case AxisDirection.left: - primaryDeltaFromDragStart = _startDragScrollbarAxisOffset!.dx - updatedOffset.dx; - primaryDeltaFromLastDragUpdate = _lastDragUpdateOffset!.dx - updatedOffset.dx; + primaryDeltaFromDragStart = _startDragScrollbarAxisOffset!.dx - localPosition.dx; + primaryDeltaFromLastDragUpdate = _lastDragUpdateOffset!.dx - localPosition.dx; } // Convert primaryDelta, the amount that the scrollbar moved since the last // time when drag started or last updated, into the coordinate space of the scroll - // position, and jump to that position. + // position. double scrollOffsetGlobal = scrollbarPainter.getTrackToScroll(primaryDeltaFromDragStart + _startDragThumbOffset!); if (primaryDeltaFromDragStart > 0 && scrollOffsetGlobal < position.pixels || primaryDeltaFromDragStart < 0 && scrollOffsetGlobal > position.pixels) { @@ -1614,27 +1644,8 @@ class RawScrollbarState extends State with TickerProv // platforms, and only then if the physics allow it. break; } - position.jumpTo(newPosition); - } - } - - void _maybeStartFadeoutTimer() { - if (!showScrollbar) { - _fadeoutTimer?.cancel(); - _fadeoutTimer = Timer(widget.timeToFade, () { - _fadeoutAnimationController.reverse(); - _fadeoutTimer = null; - }); - } - } - - /// Returns the [Axis] of the child scroll view, or null if the - /// current scroll controller does not have any attached positions. - @protected - Axis? getScrollbarDirection() { - assert(_cachedController != null); - if (_cachedController!.hasClients) { - return _cachedController!.position.axis; + final bool isReversed = axisDirectionIsReversed(position.axisDirection); + return isReversed ? newPosition - position.pixels : position.pixels - newPosition; } return null; } @@ -1646,35 +1657,47 @@ class RawScrollbarState extends State with TickerProv @mustCallSuper void handleThumbPress() { assert(_debugCheckHasValidScrollPosition()); + _cachedController = _effectiveScrollController; if (getScrollbarDirection() == null) { return; } _fadeoutTimer?.cancel(); + _thumbHold = _cachedController!.position.hold(_disposeThumbHold); } /// Handler called when a long press gesture has started. /// - /// Begins the fade out animation and initializes dragging the scrollbar thumb. + /// Begins the fade out animation and creates the thumb's DragScrollController. @protected @mustCallSuper void handleThumbPressStart(Offset localPosition) { assert(_debugCheckHasValidScrollPosition()); - _cachedController = _effectiveScrollController; final Axis? direction = getScrollbarDirection(); if (direction == null) { return; } _fadeoutTimer?.cancel(); _fadeoutAnimationController.forward(); + + assert(_thumbDrag == null); + final ScrollPosition position = _cachedController!.position; + final RenderBox renderBox = _scrollbarPainterKey.currentContext!.findRenderObject()! as RenderBox; + final DragStartDetails details = DragStartDetails( + localPosition: localPosition, + globalPosition: renderBox.localToGlobal(localPosition), + ); + _thumbDrag = position.drag(details, _disposeThumbDrag); + assert(_thumbDrag != null); + assert(_thumbHold == null); + _startDragScrollbarAxisOffset = localPosition; _lastDragUpdateOffset = localPosition; _startDragThumbOffset = scrollbarPainter.getThumbScrollOffset(); - _thumbDragging = true; } /// Handler called when a currently active long press gesture moves. /// - /// Updates the position of the child scrollable. + /// Updates the position of the child scrollable via the _drag ScrollDragController. @protected @mustCallSuper void handleThumbPressUpdate(Offset localPosition) { @@ -1690,7 +1713,30 @@ class RawScrollbarState extends State with TickerProv if (direction == null) { return; } - _updateScrollPosition(localPosition); + // _thumbDrag might be null if the drag activity ended and called _disposeThumbDrag. + assert(_thumbHold == null || _thumbDrag == null); + if (_thumbDrag == null) { + return; + } + + final double? primaryDelta = _getPrimaryDelta(localPosition); + if (primaryDelta == null) { + return; + } + + final Offset delta = switch (direction) { + Axis.horizontal => Offset(primaryDelta, 0), + Axis.vertical => Offset(0, primaryDelta), + }; + final RenderBox renderBox = _scrollbarPainterKey.currentContext!.findRenderObject()! as RenderBox; + final DragUpdateDetails scrollDetails = DragUpdateDetails( + delta: delta, + primaryDelta: primaryDelta, + globalPosition: renderBox.localToGlobal(localPosition), + localPosition: localPosition, + ); + _thumbDrag!.update(scrollDetails); // Triggers updates to the ScrollPosition and ScrollbarPainter + _lastDragUpdateOffset = localPosition; } @@ -1699,12 +1745,45 @@ class RawScrollbarState extends State with TickerProv @mustCallSuper void handleThumbPressEnd(Offset localPosition, Velocity velocity) { assert(_debugCheckHasValidScrollPosition()); - _thumbDragging = false; final Axis? direction = getScrollbarDirection(); if (direction == null) { return; } _maybeStartFadeoutTimer(); + _cachedController = null; + _lastDragUpdateOffset = null; + + // _thumbDrag might be null if the drag activity ended and called _disposeThumbDrag. + assert(_thumbHold == null || _thumbDrag == null); + if (_thumbDrag == null) { + return; + } + + // On mobile platforms flinging the scrollbar thumb causes a ballistic + // scroll, just like it via a touch drag. + final TargetPlatform platform = ScrollConfiguration.of(context).getPlatform(context); + final (Velocity adjustedVelocity, double primaryVelocity) = switch (platform) { + TargetPlatform.iOS || TargetPlatform.android => ( + -velocity, + switch (direction) { + Axis.horizontal => -velocity.pixelsPerSecond.dx, + Axis.vertical => -velocity.pixelsPerSecond.dy, + }, + ), + _ => (Velocity.zero, 0), + }; + + final RenderBox renderBox = _scrollbarPainterKey.currentContext!.findRenderObject()! as RenderBox; + final DragEndDetails details = DragEndDetails( + localPosition: localPosition, + globalPosition: renderBox.localToGlobal(localPosition), + velocity: adjustedVelocity, + primaryVelocity: primaryVelocity, + ); + + _thumbDrag?.end(details); + assert(_thumbDrag == null); + _startDragScrollbarAxisOffset = null; _lastDragUpdateOffset = null; _startDragThumbOffset = null; @@ -1787,6 +1866,10 @@ class RawScrollbarState extends State with TickerProv if (_shouldUpdatePainter(metrics.axis)) { scrollbarPainter.update(metrics, metrics.axisDirection); } + if (metrics.axis != _axis) { + setState(() { _axis = metrics.axis; }); + } + return false; } @@ -1821,33 +1904,83 @@ class RawScrollbarState extends State with TickerProv scrollbarPainter.update(metrics, metrics.axisDirection); } } else if (notification is ScrollEndNotification) { - if (_startDragScrollbarAxisOffset == null) { + if (_thumbDrag == null) { _maybeStartFadeoutTimer(); } } return false; } + void _handleThumbDragDown(DragDownDetails details) { + handleThumbPress(); + } + + void _handleThumbDragStart(DragStartDetails details) { + handleThumbPressStart(details.localPosition); + } + + void _handleThumbDragUpdate(DragUpdateDetails details) { + handleThumbPressUpdate(details.localPosition); + } + + void _handleThumbDragEnd(DragEndDetails details) { + handleThumbPressEnd(details.localPosition, details.velocity); + } + + void _handleThumbDragCancel() { + if (_gestureDetectorKey.currentContext == null) { + // The cancel was caused by the GestureDetector getting disposed, which + // means we will get disposed momentarily as well and shouldn't do + // any work. + return; + } + // _thumbHold might be null if the drag started. + // _thumbDrag might be null if the drag activity ended and called _disposeThumbDrag. + assert(_thumbHold == null || _thumbDrag == null); + _thumbHold?.cancel(); + _thumbDrag?.cancel(); + assert(_thumbHold == null); + assert(_thumbDrag == null); + } + + void _initThumbDragGestureRecognizer(DragGestureRecognizer instance) { + instance.onDown = _handleThumbDragDown; + instance.onStart = _handleThumbDragStart; + instance.onUpdate = _handleThumbDragUpdate; + instance.onEnd = _handleThumbDragEnd; + instance.onCancel = _handleThumbDragCancel; + instance.gestureSettings = const DeviceGestureSettings(touchSlop: 0); + instance.dragStartBehavior = DragStartBehavior.down; + } + Map get _gestures { final Map gestures = {}; if (_effectiveScrollController == null || !enableGestures) { return gestures; } - gestures[_ThumbPressGestureRecognizer] = - GestureRecognizerFactoryWithHandlers<_ThumbPressGestureRecognizer>( - () => _ThumbPressGestureRecognizer( - debugOwner: this, - customPaintKey: _scrollbarPainterKey, - duration: widget.pressDuration, - ), - (_ThumbPressGestureRecognizer instance) { - instance.onLongPress = handleThumbPress; - instance.onLongPressStart = (LongPressStartDetails details) => handleThumbPressStart(details.localPosition); - instance.onLongPressMoveUpdate = (LongPressMoveUpdateDetails details) => handleThumbPressUpdate(details.localPosition); - instance.onLongPressEnd = (LongPressEndDetails details) => handleThumbPressEnd(details.localPosition, details.velocity); - }, - ); + switch (_axis) { + case Axis.horizontal: + gestures[_HorizontalThumbDragGestureRecognizer] = + GestureRecognizerFactoryWithHandlers<_HorizontalThumbDragGestureRecognizer>( + () => _HorizontalThumbDragGestureRecognizer( + debugOwner: this, + customPaintKey: _scrollbarPainterKey, + ), + _initThumbDragGestureRecognizer, + ); + case Axis.vertical: + gestures[_VerticalThumbDragGestureRecognizer] = + GestureRecognizerFactoryWithHandlers<_VerticalThumbDragGestureRecognizer>( + () => _VerticalThumbDragGestureRecognizer( + debugOwner: this, + customPaintKey: _scrollbarPainterKey, + ), + _initThumbDragGestureRecognizer, + ); + case null: + return gestures; + } gestures[_TrackTapGestureRecognizer] = GestureRecognizerFactoryWithHandlers<_TrackTapGestureRecognizer>( @@ -1862,6 +1995,7 @@ class RawScrollbarState extends State with TickerProv return gestures; } + /// Returns true if the provided [Offset] is located over the track of the /// [RawScrollbar]. /// @@ -1976,7 +2110,7 @@ class RawScrollbarState extends State with TickerProv if ((scrollbarPainter.hitTest(event.localPosition) ?? false) && _cachedController != null && _cachedController!.hasClients && - (!_thumbDragging || kIsWeb)) { + (_thumbDrag == null || kIsWeb)) { final ScrollPosition position = _cachedController!.position; if (event is PointerScrollEvent) { if (!position.physics.shouldAcceptUserOffset(position)) { @@ -2015,6 +2149,7 @@ class RawScrollbarState extends State with TickerProv child: Listener( onPointerSignal: _receivedPointerSignal, child: RawGestureDetector( + key: _gestureDetectorKey, gestures: _gestures, child: MouseRegion( onExit: (PointerExitEvent event) { @@ -2059,38 +2194,33 @@ class RawScrollbarState extends State with TickerProv } } -// A long press gesture detector that only responds to events on the scrollbar's -// thumb and ignores everything else. -class _ThumbPressGestureRecognizer extends LongPressGestureRecognizer { - _ThumbPressGestureRecognizer({ - required Object super.debugOwner, - required GlobalKey customPaintKey, - required super.duration, - }) : _customPaintKey = customPaintKey; - - final GlobalKey _customPaintKey; - - @override - bool isPointerAllowed(PointerDownEvent event) { - if (!_hitTestInteractive(_customPaintKey, event.position, event.kind)) { - return false; - } - return super.isPointerAllowed(event); - } - - bool _hitTestInteractive(GlobalKey customPaintKey, Offset offset, PointerDeviceKind kind) { - if (customPaintKey.currentContext == null) { - return false; - } - final CustomPaint customPaint = customPaintKey.currentContext!.widget as CustomPaint; - final ScrollbarPainter painter = customPaint.foregroundPainter! as ScrollbarPainter; - final Offset localOffset = _getLocalOffset(customPaintKey, offset); - return painter.hitTestOnlyThumbInteractive(localOffset, kind); - } +Offset _getLocalOffset(GlobalKey scrollbarPainterKey, Offset position) { + final RenderBox renderBox = scrollbarPainterKey.currentContext!.findRenderObject()! as RenderBox; + return renderBox.globalToLocal(position); +} + +bool _isThumbEvent(GlobalKey customPaintKey, PointerEvent event) { + if (customPaintKey.currentContext == null) { + return false; + } + + final CustomPaint customPaint = customPaintKey.currentContext!.widget as CustomPaint; + final ScrollbarPainter painter = customPaint.foregroundPainter! as ScrollbarPainter; + final Offset localOffset = _getLocalOffset(customPaintKey, event.position); + return painter.hitTestOnlyThumbInteractive(localOffset, event.kind); +} + +bool _isTrackEvent(GlobalKey customPaintKey, PointerEvent event) { + if (customPaintKey.currentContext == null) { + return false; + } + final CustomPaint customPaint = customPaintKey.currentContext!.widget as CustomPaint; + final ScrollbarPainter painter = customPaint.foregroundPainter! as ScrollbarPainter; + final Offset localOffset = _getLocalOffset(customPaintKey, event.position); + final PointerDeviceKind kind = event.kind; + return painter.hitTestInteractive(localOffset, kind) && !painter.hitTestOnlyThumbInteractive(localOffset, kind); } -// A tap gesture detector that only responds to events on the scrollbar's -// track and ignores everything else, including the thumb. class _TrackTapGestureRecognizer extends TapGestureRecognizer { _TrackTapGestureRecognizer({ required super.debugOwner, @@ -2101,25 +2231,34 @@ class _TrackTapGestureRecognizer extends TapGestureRecognizer { @override bool isPointerAllowed(PointerDownEvent event) { - if (!_hitTestInteractive(_customPaintKey, event.position, event.kind)) { - return false; - } - return super.isPointerAllowed(event); - } - - bool _hitTestInteractive(GlobalKey customPaintKey, Offset offset, PointerDeviceKind kind) { - if (customPaintKey.currentContext == null) { - return false; - } - final CustomPaint customPaint = customPaintKey.currentContext!.widget as CustomPaint; - final ScrollbarPainter painter = customPaint.foregroundPainter! as ScrollbarPainter; - final Offset localOffset = _getLocalOffset(customPaintKey, offset); - // We only receive track taps that are not on the thumb. - return painter.hitTestInteractive(localOffset, kind) && !painter.hitTestOnlyThumbInteractive(localOffset, kind); + return _isTrackEvent(_customPaintKey, event) && super.isPointerAllowed(event); } } -Offset _getLocalOffset(GlobalKey scrollbarPainterKey, Offset position) { - final RenderBox renderBox = scrollbarPainterKey.currentContext!.findRenderObject()! as RenderBox; - return renderBox.globalToLocal(position); +class _VerticalThumbDragGestureRecognizer extends VerticalDragGestureRecognizer { + _VerticalThumbDragGestureRecognizer({ + required Object super.debugOwner, + required GlobalKey customPaintKey, + }) : _customPaintKey = customPaintKey; + + final GlobalKey _customPaintKey; + + @override + bool isPointerAllowed(PointerEvent event) { + return _isThumbEvent(_customPaintKey, event) && super.isPointerAllowed(event); + } +} + +class _HorizontalThumbDragGestureRecognizer extends HorizontalDragGestureRecognizer { + _HorizontalThumbDragGestureRecognizer({ + required Object super.debugOwner, + required GlobalKey customPaintKey, + }) : _customPaintKey = customPaintKey; + + final GlobalKey _customPaintKey; + + @override + bool isPointerAllowed(PointerEvent event) { + return _isThumbEvent(_customPaintKey, event) && super.isPointerAllowed(event); + } } diff --git a/packages/flutter/test/widgets/scroll_notification_test.dart b/packages/flutter/test/widgets/scroll_notification_test.dart index e9ea132a45..7070f1e423 100644 --- a/packages/flutter/test/widgets/scroll_notification_test.dart +++ b/packages/flutter/test/widgets/scroll_notification_test.dart @@ -280,4 +280,60 @@ void main() { await tester.pumpAndSettle(); expect(notification, isNull); }); + + testWidgets('ScrollBar thumb drag triggers scroll start-update-end notifications', (WidgetTester tester) async { + final ScrollController scrollController = ScrollController(); + ScrollNotification? notification; + + addTearDown(scrollController.dispose); + + bool handleScrollNotification(ScrollNotification value) { + if (value is ScrollStartNotification || value is ScrollUpdateNotification || value is ScrollEndNotification) { + notification = value; + } + return true; + } + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox.expand( + child: Scrollbar( + thumbVisibility: true, + controller: scrollController, + child: NotificationListener( + onNotification: handleScrollNotification, + child: SingleChildScrollView( + controller: scrollController, + child: const SizedBox(height: 1200.0), + ), + ), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(scrollController.offset, 0); + expect(notification, isNull); + + final TestGesture dragScrollbarGesture = await tester.startGesture(const Offset(790, 45)); + await tester.pumpAndSettle(); + expect(notification, isA()); + + await dragScrollbarGesture.moveBy(const Offset(0, -20)); + await tester.pumpAndSettle(); + expect(notification, isA()); + expect(scrollController.offset, 20); + + await dragScrollbarGesture.moveBy(const Offset(0, -20)); + await tester.pumpAndSettle(); + expect(notification, isA()); + expect(scrollController.offset, 40); + + await dragScrollbarGesture.up(); + await tester.pumpAndSettle(); + expect(notification, isA()); + }); } diff --git a/packages/flutter/test/widgets/scrollbar_test.dart b/packages/flutter/test/widgets/scrollbar_test.dart index c211a72039..be62c9ca35 100644 --- a/packages/flutter/test/widgets/scrollbar_test.dart +++ b/packages/flutter/test/widgets/scrollbar_test.dart @@ -2809,7 +2809,7 @@ The provided ScrollController cannot be shared by multiple ScrollView widgets.'' }); 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 + // Regression test for https://github.com/flutter/flutter/issues/112072 final ScrollController scrollController = ScrollController(); addTearDown(scrollController.dispose); await tester.pumpWidget( @@ -2880,7 +2880,7 @@ The provided ScrollController cannot be shared by multiple ScrollView widgets.'' }); testWidgets('The scrollable should not stutter when the scroll metrics shrink during dragging', (WidgetTester tester) async { - // Regressing test for https://github.com/flutter/flutter/issues/121574 + // Regression test for https://github.com/flutter/flutter/issues/121574 final ScrollController scrollController = ScrollController(); addTearDown(scrollController.dispose); await tester.pumpWidget( @@ -2990,4 +2990,226 @@ The provided ScrollController cannot be shared by multiple ScrollView widgets.'' expect(scrollController.offset, 100.0); }, variant: TargetPlatformVariant.all()); + + testWidgets('Flinging a vertical scrollbar thumb does not cause a ballistic scroll - non-mobile platforms', (WidgetTester tester) async { + final ScrollController scrollController = ScrollController(); + addTearDown(scrollController.dispose); + + bool isMobilePlatform() { + return const {TargetPlatform.iOS, TargetPlatform.android} + .contains(debugDefaultTargetPlatformOverride); + } + + Widget buildFrame({ required bool reverse }) { + return Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: PrimaryScrollController( + controller: scrollController, + child: RawScrollbar( + thumbVisibility: true, + controller: scrollController, + child: CustomScrollView( + controller: scrollController, + reverse: reverse, + slivers: [ + SliverList( + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) { + return Container( + height: 100, + alignment: Alignment.center, + child: Text('$index'), + ); + }, + childCount: 10, + ), + ), + ], + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(reverse: false)); + await tester.pumpAndSettle(); + expect(scrollController.offset, 0.0); + + // Try flinging downward. The flingFrom() method generates 50 moves of about 2 + // pixels and then a fling with the indicated velocity. + await tester.flingFrom(const Offset(797.0, 45.0), const Offset(0, 60.0), 500.0); + await tester.pumpAndSettle(); + + if (isMobilePlatform()) { + expect(scrollController.offset, greaterThan(100.0), reason: 'Ballistic scroll expected on $debugDefaultTargetPlatformOverride'); + } else { + expect(scrollController.offset, 100.0, reason: 'Ballistic scroll not expected on $debugDefaultTargetPlatformOverride'); + } + + // Tap at the top of the track to scroll back to the origin. + await tester.tapAt(const Offset(797.0, 5.0)); + await tester.pumpAndSettle(); + expect(scrollController.offset, 0.0); + + // Drag the thumb to the bottom. + await tester.dragFrom(const Offset(797.0, 45.0), const Offset(0, 1000.0)); + await tester.pumpAndSettle(); + expect(scrollController.offset, 400.0); + + // Try flinging upward. + await tester.flingFrom(const Offset(797.0, 545.0), const Offset(0, -60), 500.0); + await tester.pumpAndSettle(); + if (isMobilePlatform()) { + expect(scrollController.offset, lessThan(300.0), reason: 'Ballistic scroll expected on $debugDefaultTargetPlatformOverride'); + } else { + expect(scrollController.offset, 300.0, reason: 'Ballistic scroll not expected on $debugDefaultTargetPlatformOverride'); + } + + // Tap at the top of the track to scroll back to the origin. + await tester.tapAt(const Offset(797.0, 5.0)); + await tester.pumpAndSettle(); + expect(scrollController.offset, 0.0); + + // Same tests with reverse: true + + await tester.pumpWidget(buildFrame(reverse: true)); + await tester.pumpAndSettle(); + expect(scrollController.offset, 0.0); + + // Try flinging upward. + await tester.flingFrom(const Offset(797.0, 545.0), const Offset(0, -60), 500.0); + await tester.pumpAndSettle(); + if (isMobilePlatform()) { + expect(scrollController.offset, greaterThan(100.0), reason: 'Ballistic scroll expected on $debugDefaultTargetPlatformOverride'); + } else { + expect(scrollController.offset, 100.0, reason: 'Ballistic scroll not expected on $debugDefaultTargetPlatformOverride'); + } + + // Tap at the top of the track to scroll to the limit + await tester.tapAt(const Offset(797.0, 5.0)); + await tester.pumpAndSettle(); + expect(scrollController.offset, 400.0); + + // Try flinging downward. + await tester.flingFrom(const Offset(797.0, 45.0), const Offset(0, 60.0), 500.0); + await tester.pumpAndSettle(); + if (isMobilePlatform()) { + expect(scrollController.offset, lessThan(300.0), reason: 'Ballistic scroll expected on $debugDefaultTargetPlatformOverride'); + } else { + expect(scrollController.offset, 300.0, reason: 'Ballistic scroll not expected on $debugDefaultTargetPlatformOverride'); + } + }, variant: TargetPlatformVariant.all()); + + testWidgets('Flinging a horizontal scrollbar thumb does not cause a ballistic scroll - non-mobile platforms', (WidgetTester tester) async { + final ScrollController scrollController = ScrollController(); + addTearDown(scrollController.dispose); + + bool isMobilePlatform() { + return const {TargetPlatform.iOS, TargetPlatform.android} + .contains(debugDefaultTargetPlatformOverride); + } + + Widget buildFrame({ required bool reverse }) { + return Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: PrimaryScrollController( + controller: scrollController, + child: RawScrollbar( + thumbVisibility: true, + controller: scrollController, + child: CustomScrollView( + controller: scrollController, + reverse: reverse, + scrollDirection: Axis.horizontal, + slivers: [ + SliverList( + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) { + return Container( + width: 100, + alignment: Alignment.center, + child: Text('$index'), + ); + }, + childCount: 10, + ), + ), + ], + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(reverse: false)); + await tester.pumpAndSettle(); + expect(scrollController.offset, 0.0); + + // Try flinging to the right. + await tester.flingFrom(const Offset(45.0, 597.0), const Offset(80, 0.0), 500.0); + await tester.pumpAndSettle(); + if (isMobilePlatform()) { + expect(scrollController.offset, greaterThan(100.0), reason: 'Ballistic scroll expected on $debugDefaultTargetPlatformOverride'); + } else { + expect(scrollController.offset, 100.0, reason: 'Ballistic scroll not expected on $debugDefaultTargetPlatformOverride'); + } + + // Tap at the end of the track to scroll to the limit. + await tester.tapAt(const Offset(794.0, 597.0)); + await tester.pumpAndSettle(); + expect(scrollController.offset, 200.0); + + // Try flinging to the left. + await tester.flingFrom(const Offset(794.0, 597.0), const Offset(-80, 0), 500.0); + await tester.pumpAndSettle(); + if (isMobilePlatform()) { + expect(scrollController.offset, lessThan(100.0), reason: 'Ballistic scroll expected on $debugDefaultTargetPlatformOverride'); + } else { + expect(scrollController.offset, 100.0, reason: 'Ballistic scroll not expected on $debugDefaultTargetPlatformOverride'); + } + + // Tap at the beginning of the track to scroll back to the origin. + await tester.tapAt(const Offset(6.0, 597.0)); + await tester.pumpAndSettle(); + expect(scrollController.offset, 0.0); + + // Same tests with reverse: true + + await tester.pumpWidget(buildFrame(reverse: true)); + await tester.pumpAndSettle(); + expect(scrollController.offset, 0.0); + + // Try flinging to the left. + await tester.flingFrom(const Offset(794.0, 597.0), const Offset(-80, 0), 500.0); + await tester.pumpAndSettle(); + if (isMobilePlatform()) { + expect(scrollController.offset, greaterThan(100.0), reason: 'Ballistic scroll expected on $debugDefaultTargetPlatformOverride'); + } else { + expect(scrollController.offset, 100.0, reason: 'Ballistic scroll not expected on $debugDefaultTargetPlatformOverride'); + } + + // Tap at the beginning of the track to scroll to the limit. + await tester.tapAt(const Offset(6.0, 597.0)); + await tester.pumpAndSettle(); + expect(scrollController.offset, 200.0); + + // Try flinging to the right. + await tester.flingFrom(const Offset(6.0, 597.0), const Offset(80, 0), 500.0); + await tester.pumpAndSettle(); + + if (isMobilePlatform()) { + expect(scrollController.offset, lessThan(100.0), reason: 'Ballistic scroll expected on $debugDefaultTargetPlatformOverride'); + } else { + expect(scrollController.offset, 100.0, reason: 'Ballistic scroll not expected on $debugDefaultTargetPlatformOverride'); + } + + }, + variant: TargetPlatformVariant.all(), + ); }