Scrollbar thumb drag gestures now produce one start and one end scroll notification (#146654)

The scroll notification events reported for a press-drag-release gesture within a scrollable on a touch screen device begin with a `ScrollStartNotification`, followed by a series of `ScrollUpdateNotifications`, and conclude with a `ScrollEndNotification`. This protocol can be used to defer work until an interactive scroll gesture ends. For example, you might defer updating a scrollable's contents via network requests until the scroll has ended, or you might want to automatically auto-scroll at that time. 

In the example that follows the CustomScrollView automatically scrolls so that the last partially visible fixed-height item is completely visible when the scroll gesture ends. Many iOS applications do this kind of thing. It only makes sense to auto-scroll when the user isn't actively dragging the scrollable around.

It's easy enough to do this by reacting to a ScrollEndNotifcation by auto-scrolling to align the last fixed-height list item ([source code](https://gist.github.com/HansMuller/13e2a7adadc9afb3803ba7848b20c410)).

https://github.com/flutter/flutter/assets/1377460/a6e6fc77-6742-4f98-81ba-446536535f73

Dragging the scrollbar thumb in a desktop application is a similar user gesture. Currently it's not possible to defer work or auto-scroll (or whatever) while the scrollable is actively being dragged via the scrollbar thumb because each scrollbar thumb motion is mapped to a scroll start - scroll update - scroll end series of notifications.  On a desktop platform, the same code behaves quite differently when the scrollbar thumb is dragged.

https://github.com/flutter/flutter/assets/1377460/2593d8a3-639c-407f-80c1-6e6f67fb8c5f

The stream of scroll-end events triggers auto-scrolling every time the thumb moves. From the user's perspective this feels like a losing struggle.

One can also detect the beginning and end of a touch-drag by listening to the value of a ScrollPosition's `isScrollingNotifier`. This approach suffers from a similar problem: during a scrollbar thumb-drag, the `isScrollingNotifier` value isn't updated at all.

This PR refactors the RawScrollbar implementation to effectively use a ScrollDragController to manage scrolls caused by dragging the scrollbar's thumb. Doing so means that dragging the thumb will produce the same notifications as dragging the scrollable on a touch device.

Now desktop applications can choose to respond to scrollbar thumb drags in the same that they respond to drag scrolling on a touch screen. With the changes included here, the desktop or web version of the app works as expected, whether you're listing to scroll notifications or the scroll position's `isScrollingNotifier`.

https://github.com/flutter/flutter/assets/1377460/67435c40-a866-4735-a19b-e3d68eac8139

This PR also makes the second [ScrollPosition API doc example](https://api.flutter.dev/flutter/widgets/ScrollPosition-class.html#cupertino.ScrollPosition.2) work as expected when used with the DartPad that's  part of API doc page.

Desktop applications also see scroll start-update-end notifications due to the mouse wheel.  There is no touch screen analog for the mouse wheel, so an application that wanted to enable this kind of auto-scrolling alignment would have to include a heuristic that dealt with the sequence of small scrolls triggered by the mouse wheel. Here's an example of that: [source code](https://gist.github.com/HansMuller/ce5c474a458f5f4bcc07b0d621843165). This version of the app does not auto-align in response to small changes, wether they're triggered by dragging the scrollbar thumb of the mouse wheel.

Related sliver utility PRs: https://github.com/flutter/flutter/pull/143538,  https://github.com/flutter/flutter/pull/143196, https://github.com/flutter/flutter/pull/143325.
This commit is contained in:
Hans Muller
2024-06-04 15:02:12 -07:00
committed by GitHub
parent 529a4d2bac
commit 92f8455ff8
9 changed files with 904 additions and 104 deletions

View File

@@ -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<ScrollEndNotificationExample> createState() => _ScrollEndNotificationExampleState();
}
class _ScrollEndNotificationExampleState extends State<ScrollEndNotificationExample> {
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<ScrollNotification>(
onNotification: handleScrollNotification,
child: CustomScrollView(
controller: scrollController,
slivers: <Widget>[
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),
),
);
}
}

View File

@@ -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<IsScrollingListenerExample> createState() => _IsScrollingListenerExampleState();
}
class _IsScrollingListenerExampleState extends State<IsScrollingListenerExample> {
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: <Widget>[
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),
),
);
}
}

View File

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

View File

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

View File

@@ -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.

View File

@@ -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<bool> isScrollingNotifier = ValueNotifier<bool>(false);
/// Animates the position from its current value to the given value.

View File

@@ -1323,7 +1323,10 @@ class RawScrollbarState<T extends RawScrollbar> extends State<T> with TickerProv
late CurvedAnimation _fadeoutOpacityAnimation;
final GlobalKey _scrollbarPainterKey = GlobalKey();
bool _hoverIsActive = false;
bool _thumbDragging = false;
Drag? _thumbDrag;
ScrollHoldController? _thumbHold;
Axis? _axis;
final GlobalKey<RawGestureDetectorState> _gestureDetectorKey = GlobalKey<RawGestureDetectorState>();
ScrollController? get _effectiveScrollController => widget.controller ?? PrimaryScrollController.maybeOf(context);
@@ -1561,7 +1564,34 @@ class RawScrollbarState<T extends RawScrollbar> extends State<T> 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<T extends RawScrollbar> extends State<T> 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<T extends RawScrollbar> extends State<T> 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<T extends RawScrollbar> extends State<T> 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<T extends RawScrollbar> extends State<T> 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<T extends RawScrollbar> extends State<T> 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<T extends RawScrollbar> extends State<T> 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<T extends RawScrollbar> extends State<T> 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<Type, GestureRecognizerFactory> get _gestures {
final Map<Type, GestureRecognizerFactory> gestures = <Type, GestureRecognizerFactory>{};
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<T extends RawScrollbar> extends State<T> with TickerProv
return gestures;
}
/// Returns true if the provided [Offset] is located over the track of the
/// [RawScrollbar].
///
@@ -1976,7 +2110,7 @@ class RawScrollbarState<T extends RawScrollbar> extends State<T> 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<T extends RawScrollbar> extends State<T> with TickerProv
child: Listener(
onPointerSignal: _receivedPointerSignal,
child: RawGestureDetector(
key: _gestureDetectorKey,
gestures: _gestures,
child: MouseRegion(
onExit: (PointerExitEvent event) {
@@ -2059,38 +2194,33 @@ class RawScrollbarState<T extends RawScrollbar> extends State<T> 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);
}
}

View File

@@ -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<ScrollNotification>(
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<ScrollStartNotification>());
await dragScrollbarGesture.moveBy(const Offset(0, -20));
await tester.pumpAndSettle();
expect(notification, isA<ScrollUpdateNotification>());
expect(scrollController.offset, 20);
await dragScrollbarGesture.moveBy(const Offset(0, -20));
await tester.pumpAndSettle();
expect(notification, isA<ScrollUpdateNotification>());
expect(scrollController.offset, 40);
await dragScrollbarGesture.up();
await tester.pumpAndSettle();
expect(notification, isA<ScrollEndNotification>());
});
}

View File

@@ -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>{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: <Widget>[
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>{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: <Widget>[
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(),
);
}