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