Skip to content

Commit 92f8455

Browse files
authored
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: flutter/flutter#143538, flutter/flutter#143196, flutter/flutter#143325.
1 parent 529a4d2 commit 92f8455

File tree

9 files changed

+898
-98
lines changed

9 files changed

+898
-98
lines changed
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// Copyright 2014 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter/material.dart';
6+
import 'package:flutter/scheduler.dart';
7+
8+
/// Flutter code sample for [ScrollEndNotification].
9+
10+
void main() {
11+
runApp(const ScrollEndNotificationApp());
12+
}
13+
14+
class ScrollEndNotificationApp extends StatelessWidget {
15+
const ScrollEndNotificationApp({ super.key });
16+
17+
@override
18+
Widget build(BuildContext context) {
19+
return const MaterialApp(
20+
home: ScrollEndNotificationExample(),
21+
);
22+
}
23+
}
24+
25+
class ScrollEndNotificationExample extends StatefulWidget {
26+
const ScrollEndNotificationExample({ super.key });
27+
28+
@override
29+
State<ScrollEndNotificationExample> createState() => _ScrollEndNotificationExampleState();
30+
}
31+
32+
class _ScrollEndNotificationExampleState extends State<ScrollEndNotificationExample> {
33+
static const int itemCount = 25;
34+
static const double itemExtent = 100;
35+
36+
late final ScrollController scrollController;
37+
late double lastScrollOffset;
38+
39+
@override
40+
void initState() {
41+
scrollController = ScrollController();
42+
super.initState();
43+
}
44+
45+
@override
46+
void dispose() {
47+
super.dispose();
48+
scrollController.dispose();
49+
}
50+
51+
// After an interactive scroll "ends", auto-scroll so that last item in the
52+
// viewport is completely visible. To accomodate mouse-wheel scrolls, other small
53+
// adjustments, and scrolling to the top, scrolls that put the scroll offset at
54+
// zero or change the scroll offset by less than itemExtent don't trigger
55+
// an auto-scroll. This also prevents the auto-scroll from triggering itself,
56+
// since the alignedScrollOffset is guaranteed to be less than itemExtent.
57+
bool handleScrollNotification(ScrollNotification notification) {
58+
if (notification is ScrollStartNotification) {
59+
lastScrollOffset = scrollController.position.pixels;
60+
}
61+
if (notification is ScrollEndNotification) {
62+
final ScrollMetrics m = notification.metrics;
63+
final int lastIndex = ((m.extentBefore + m.extentInside) ~/ itemExtent).clamp(0, itemCount - 1);
64+
final double alignedScrollOffset = itemExtent * (lastIndex + 1) - m.extentInside;
65+
final double scrollOffset = scrollController.position.pixels;
66+
if (scrollOffset > 0 && (scrollOffset - lastScrollOffset).abs() > itemExtent) {
67+
SchedulerBinding.instance.addPostFrameCallback((Duration duration) {
68+
scrollController.animateTo(
69+
alignedScrollOffset,
70+
duration: const Duration(milliseconds: 400),
71+
curve: Curves.fastOutSlowIn,
72+
);
73+
});
74+
}
75+
}
76+
return true;
77+
}
78+
79+
@override
80+
Widget build(BuildContext context) {
81+
return Scaffold(
82+
body: SafeArea(
83+
child: Scrollbar(
84+
controller: scrollController,
85+
thumbVisibility: true,
86+
child: Padding(
87+
padding: const EdgeInsets.symmetric(horizontal: 8),
88+
child: NotificationListener<ScrollNotification>(
89+
onNotification: handleScrollNotification,
90+
child: CustomScrollView(
91+
controller: scrollController,
92+
slivers: <Widget>[
93+
SliverFixedExtentList(
94+
itemExtent: itemExtent,
95+
delegate: SliverChildBuilderDelegate(
96+
(BuildContext context, int index) {
97+
return Item(
98+
title: 'Item $index',
99+
color: Color.lerp(Colors.red, Colors.blue, index / itemCount)!
100+
);
101+
},
102+
childCount: itemCount,
103+
),
104+
),
105+
],
106+
),
107+
),
108+
),
109+
),
110+
),
111+
);
112+
}
113+
}
114+
115+
class Item extends StatelessWidget {
116+
const Item({ super.key, required this.title, required this.color });
117+
118+
final String title;
119+
final Color color;
120+
121+
@override
122+
Widget build(BuildContext context) {
123+
return Card(
124+
color: color,
125+
child: ListTile(
126+
textColor: Colors.white,
127+
title: Text(title),
128+
),
129+
);
130+
}
131+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
// Copyright 2014 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter/material.dart';
6+
import 'package:flutter/scheduler.dart';
7+
8+
/// Flutter code sample for [IsScrollingListener].
9+
void main() {
10+
runApp(const IsScrollingListenerApp());
11+
}
12+
13+
class IsScrollingListenerApp extends StatelessWidget {
14+
const IsScrollingListenerApp({ super.key });
15+
16+
@override
17+
Widget build(BuildContext context) {
18+
return const MaterialApp(
19+
home: IsScrollingListenerExample(),
20+
);
21+
}
22+
}
23+
24+
class IsScrollingListenerExample extends StatefulWidget {
25+
const IsScrollingListenerExample({ super.key });
26+
27+
@override
28+
State<IsScrollingListenerExample> createState() => _IsScrollingListenerExampleState();
29+
}
30+
31+
class _IsScrollingListenerExampleState extends State<IsScrollingListenerExample> {
32+
static const int itemCount = 25;
33+
static const double itemExtent = 100;
34+
35+
late final ScrollController scrollController;
36+
late double lastScrollOffset;
37+
bool isScrolling = false;
38+
39+
@override
40+
void initState() {
41+
scrollController = ScrollController(
42+
onAttach: (ScrollPosition position) {
43+
position.isScrollingNotifier.addListener(handleScrollChange);
44+
},
45+
onDetach: (ScrollPosition position) {
46+
position.isScrollingNotifier.removeListener(handleScrollChange);
47+
},
48+
);
49+
super.initState();
50+
}
51+
52+
@override
53+
void dispose() {
54+
scrollController.dispose();
55+
super.dispose();
56+
}
57+
58+
// After an interactive scroll "ends", auto-scroll so that last item in the
59+
// viewport is completely visible. To accomodate mouse-wheel scrolls, other small
60+
// adjustments, and scrolling to the top, scrolls that put the scroll offset at
61+
// zero or change the scroll offset by less than itemExtent don't trigger
62+
// an auto-scroll.
63+
void handleScrollChange() {
64+
final bool isScrollingNow = scrollController.position.isScrollingNotifier.value;
65+
if (isScrolling == isScrollingNow) {
66+
return;
67+
}
68+
isScrolling = isScrollingNow;
69+
if (isScrolling) {
70+
// scroll-start
71+
lastScrollOffset = scrollController.position.pixels;
72+
} else {
73+
// scroll-end
74+
final ScrollPosition p = scrollController.position;
75+
final int lastIndex = ((p.extentBefore + p.extentInside) ~/ itemExtent).clamp(0, itemCount - 1);
76+
final double alignedScrollOffset = itemExtent * (lastIndex + 1) - p.extentInside;
77+
final double scrollOffset = scrollController.position.pixels;
78+
if (scrollOffset > 0 && (scrollOffset - lastScrollOffset).abs() > itemExtent) {
79+
SchedulerBinding.instance.addPostFrameCallback((Duration duration) {
80+
scrollController.animateTo(
81+
alignedScrollOffset,
82+
duration: const Duration(milliseconds: 400),
83+
curve: Curves.fastOutSlowIn,
84+
);
85+
});
86+
}
87+
}
88+
}
89+
90+
@override
91+
Widget build(BuildContext context) {
92+
return Scaffold(
93+
body: SafeArea(
94+
child: Scrollbar(
95+
controller: scrollController,
96+
thumbVisibility: true,
97+
child: Padding(
98+
padding: const EdgeInsets.symmetric(horizontal: 8),
99+
child: CustomScrollView(
100+
controller: scrollController,
101+
slivers: <Widget>[
102+
SliverFixedExtentList(
103+
itemExtent: itemExtent,
104+
delegate: SliverChildBuilderDelegate(
105+
(BuildContext context, int index) {
106+
return Item(
107+
title: 'Item $index',
108+
color: Color.lerp(Colors.red, Colors.blue, index / itemCount)!
109+
);
110+
},
111+
childCount: itemCount,
112+
),
113+
),
114+
],
115+
),
116+
),
117+
),
118+
),
119+
);
120+
}
121+
}
122+
123+
class Item extends StatelessWidget {
124+
const Item({ super.key, required this.title, required this.color });
125+
126+
final String title;
127+
final Color color;
128+
129+
@override
130+
Widget build(BuildContext context) {
131+
return Card(
132+
color: color,
133+
child: ListTile(
134+
textColor: Colors.white,
135+
title: Text(title),
136+
),
137+
);
138+
}
139+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Copyright 2014 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter/material.dart';
6+
import 'package:flutter_api_samples/widgets/scroll_end_notification/scroll_end_notification.0.dart' as example;
7+
import 'package:flutter_test/flutter_test.dart';
8+
9+
void main() {
10+
testWidgets('IsScrollingListenerApp smoke test', (WidgetTester tester) async {
11+
await tester.pumpWidget(
12+
const example.ScrollEndNotificationApp(),
13+
);
14+
15+
expect(find.byType(CustomScrollView), findsOneWidget);
16+
expect(find.byType(Scrollbar), findsOneWidget);
17+
18+
ScrollPosition getScrollPosition() {
19+
return tester.widget<CustomScrollView>(find.byType(CustomScrollView)).controller!.position;
20+
}
21+
22+
// Viewport is 600 pixels high, each item's height is 100, 6 items are visible.
23+
expect(getScrollPosition().viewportDimension, 600);
24+
expect(getScrollPosition().pixels, 0);
25+
expect(find.text('Item 0'), findsOneWidget);
26+
expect(find.text('Item 5'), findsOneWidget);
27+
28+
// Small (< 100) scrolls don't trigger an auto-scroll
29+
await tester.drag(find.byType(Scrollbar), const Offset(0, -20.0));
30+
await tester.pumpAndSettle();
31+
expect(getScrollPosition().pixels, 20);
32+
expect(find.text('Item 0'), findsOneWidget);
33+
34+
// Initial scroll is to 220: items 0,1 are scrolled off the top,
35+
// the bottom 80 pixels of item 2 are visible, items 4-7 are
36+
// completely visible, the first 20 pixels of item 8 are visible.
37+
// After the auto-scroll, items 3-8 are completely visible.
38+
await tester.drag(find.byType(Scrollbar), const Offset(0, -200.0));
39+
await tester.pumpAndSettle();
40+
expect(getScrollPosition().pixels, 300);
41+
expect(find.text('Item 0'), findsNothing);
42+
expect(find.text('Item 2'), findsNothing);
43+
expect(find.text('Item 3'), findsOneWidget);
44+
expect(find.text('Item 8'), findsOneWidget);
45+
});
46+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Copyright 2014 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter/material.dart';
6+
import 'package:flutter_api_samples/widgets/scroll_position/is_scrolling_listener.0.dart' as example;
7+
import 'package:flutter_test/flutter_test.dart';
8+
9+
void main() {
10+
testWidgets('IsScrollingListenerApp smoke test', (WidgetTester tester) async {
11+
await tester.pumpWidget(
12+
const example.IsScrollingListenerApp(),
13+
);
14+
15+
expect(find.byType(CustomScrollView), findsOneWidget);
16+
expect(find.byType(Scrollbar), findsOneWidget);
17+
18+
ScrollPosition getScrollPosition() {
19+
return tester.widget<CustomScrollView>(find.byType(CustomScrollView)).controller!.position;
20+
}
21+
22+
// Viewport is 600 pixels high, each item's height is 100, 6 items are visible.
23+
expect(getScrollPosition().viewportDimension, 600);
24+
expect(getScrollPosition().pixels, 0);
25+
expect(find.text('Item 0'), findsOneWidget);
26+
expect(find.text('Item 5'), findsOneWidget);
27+
28+
// Small (< 100) scrolls don't trigger an auto-scroll
29+
await tester.drag(find.byType(Scrollbar), const Offset(0, -20.0));
30+
await tester.pumpAndSettle();
31+
expect(getScrollPosition().pixels, 20);
32+
expect(find.text('Item 0'), findsOneWidget);
33+
34+
// Initial scroll is to 220: items 0,1 are scrolled off the top,
35+
// the bottom 80 pixels of item 2 are visible, items 4-7 are
36+
// completely visible, the first 20 pixels of item 8 are visible.
37+
// After the auto-scroll, items 3-8 are completely visible.
38+
await tester.drag(find.byType(Scrollbar), const Offset(0, -200.0));
39+
await tester.pumpAndSettle();
40+
expect(getScrollPosition().pixels, 300);
41+
expect(find.text('Item 0'), findsNothing);
42+
expect(find.text('Item 2'), findsNothing);
43+
expect(find.text('Item 3'), findsOneWidget);
44+
expect(find.text('Item 8'), findsOneWidget);
45+
});
46+
}

packages/flutter/lib/src/widgets/scroll_notification.dart

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,17 @@ class OverscrollNotification extends ScrollNotification {
263263

264264
/// A notification that a [Scrollable] widget has stopped scrolling.
265265
///
266+
/// {@tool dartpad}
267+
/// This sample shows how you can trigger an auto-scroll, which aligns the last
268+
/// partially visible fixed-height list item, by listening for this
269+
/// notification with a [NotificationListener]. This sort of thing can also
270+
/// be done by listening to the [ScrollController]'s
271+
/// [ScrollPosition.isScrollingNotifier]. An alternative example is provided
272+
/// with [ScrollPosition.isScrollingNotifier].
273+
///
274+
/// ** See code in examples/api/lib/widgets/scroll_end_notification/scroll_end_notification.0.dart **
275+
/// {@end-tool}
276+
///
266277
/// See also:
267278
///
268279
/// * [ScrollStartNotification], which indicates that scrolling has started.

0 commit comments

Comments
 (0)