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);
|
||||
});
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user