forked from firka/flutter
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:
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user