Improve the behavior of scrollbar drag-scrolls triggered by the trackpad (#150275)
Corrects some problems related to trackpad scrolls introduced by https://github.com/flutter/flutter/pull/146654.
Fixes https://github.com/flutter/flutter/issues/149999
Fixes #150342
Fixes https://github.com/flutter/flutter/issues/150236
diff --git a/packages/flutter/lib/src/widgets/scrollbar.dart b/packages/flutter/lib/src/widgets/scrollbar.dart
index 11b59db..ba58ee7 100644
--- a/packages/flutter/lib/src/widgets/scrollbar.dart
+++ b/packages/flutter/lib/src/widgets/scrollbar.dart
@@ -1324,6 +1324,7 @@
final GlobalKey _scrollbarPainterKey = GlobalKey();
bool _hoverIsActive = false;
Drag? _thumbDrag;
+ bool _maxScrollExtentPermitsScrolling = false;
ScrollHoldController? _thumbHold;
Axis? _axis;
final GlobalKey<RawGestureDetectorState> _gestureDetectorKey = GlobalKey<RawGestureDetectorState>();
@@ -1618,7 +1619,8 @@
// 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.
- double scrollOffsetGlobal = scrollbarPainter.getTrackToScroll(primaryDeltaFromDragStart + _startDragThumbOffset!);
+ double scrollOffsetGlobal = scrollbarPainter.getTrackToScroll(_startDragThumbOffset! + primaryDeltaFromDragStart);
+
if (primaryDeltaFromDragStart > 0 && scrollOffsetGlobal < position.pixels
|| primaryDeltaFromDragStart < 0 && scrollOffsetGlobal > position.pixels) {
// Adjust the position value if the scrolling direction conflicts with
@@ -1642,7 +1644,6 @@
case TargetPlatform.android:
// We can only drag the scrollbar into overscroll on mobile
// platforms, and only then if the physics allow it.
- break;
}
final bool isReversed = axisDirectionIsReversed(position.axisDirection);
return isReversed ? newPosition - position.pixels : position.pixels - newPosition;
@@ -1760,25 +1761,22 @@
}
// On mobile platforms flinging the scrollbar thumb causes a ballistic
- // scroll, just like it via a touch drag.
+ // scroll, just like it does via a touch drag. Likewise for desktops when
+ // dragging on the trackpad or with a stylus.
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 Velocity adjustedVelocity = switch (platform) {
+ TargetPlatform.iOS || TargetPlatform.android => -velocity,
+ _ => Velocity.zero,
};
-
final RenderBox renderBox = _scrollbarPainterKey.currentContext!.findRenderObject()! as RenderBox;
final DragEndDetails details = DragEndDetails(
localPosition: localPosition,
globalPosition: renderBox.localToGlobal(localPosition),
velocity: adjustedVelocity,
- primaryVelocity: primaryVelocity,
+ primaryVelocity: switch (direction) {
+ Axis.horizontal => adjustedVelocity.pixelsPerSecond.dx,
+ Axis.vertical => adjustedVelocity.pixelsPerSecond.dy,
+ },
);
_thumbDrag?.end(details);
@@ -1869,6 +1867,9 @@
if (metrics.axis != _axis) {
setState(() { _axis = metrics.axis; });
}
+ if (_maxScrollExtentPermitsScrolling != notification.metrics.maxScrollExtent > 0.0) {
+ setState(() { _maxScrollExtentPermitsScrolling = !_maxScrollExtentPermitsScrolling; });
+ }
return false;
}
@@ -1891,8 +1892,7 @@
return false;
}
- if (notification is ScrollUpdateNotification ||
- notification is OverscrollNotification) {
+ if (notification is ScrollUpdateNotification || notification is OverscrollNotification) {
// Any movements always makes the scrollbar start showing up.
if (!_fadeoutAnimationController.isForwardOrCompleted) {
_fadeoutAnimationController.forward();
@@ -1915,16 +1915,26 @@
handleThumbPress();
}
+ // The protected RawScrollbar API methods - handleThumbPressStart,
+ // handleThumbPressUpdate, handleThumbPressEnd - all depend on a
+ // localPosition parameter that defines the event's location relative
+ // to the scrollbar. Ensure that the localPosition is reported consistently,
+ // even if the source of the event is a trackpad or a stylus.
+ Offset _globalToScrollbar(Offset offset) {
+ final RenderBox renderBox = _scrollbarPainterKey.currentContext!.findRenderObject()! as RenderBox;
+ return renderBox.globalToLocal(offset);
+ }
+
void _handleThumbDragStart(DragStartDetails details) {
- handleThumbPressStart(details.localPosition);
+ handleThumbPressStart(_globalToScrollbar(details.globalPosition));
}
void _handleThumbDragUpdate(DragUpdateDetails details) {
- handleThumbPressUpdate(details.localPosition);
+ handleThumbPressUpdate(_globalToScrollbar(details.globalPosition));
}
void _handleThumbDragEnd(DragEndDetails details) {
- handleThumbPressEnd(details.localPosition, details.velocity);
+ handleThumbPressEnd(_globalToScrollbar(details.globalPosition), details.velocity);
}
void _handleThumbDragCancel() {
@@ -2252,6 +2262,11 @@
final GlobalKey _customPaintKey;
@override
+ bool isPointerPanZoomAllowed(PointerPanZoomStartEvent event) {
+ return false;
+ }
+
+ @override
bool isPointerAllowed(PointerEvent event) {
return _isThumbEvent(_customPaintKey, event) && super.isPointerAllowed(event);
}
@@ -2266,6 +2281,11 @@
final GlobalKey _customPaintKey;
@override
+ bool isPointerPanZoomAllowed(PointerPanZoomStartEvent event) {
+ return false;
+ }
+
+ @override
bool isPointerAllowed(PointerEvent event) {
return _isThumbEvent(_customPaintKey, event) && super.isPointerAllowed(event);
}
diff --git a/packages/flutter/test/widgets/scrollbar_test.dart b/packages/flutter/test/widgets/scrollbar_test.dart
index 5c1ae5b..9df73eb 100644
--- a/packages/flutter/test/widgets/scrollbar_test.dart
+++ b/packages/flutter/test/widgets/scrollbar_test.dart
@@ -3240,20 +3240,254 @@
expect(scrollController.offset, 0.0);
expect(scrollController.position.maxScrollExtent, 0.0);
- await tester.drag(find.byType(SingleChildScrollView), const Offset(0, -100), kind: PointerDeviceKind.trackpad);
+ await tester.trackpadFling(find.byType(SingleChildScrollView), const Offset(0, -100), 500);
await tester.pumpAndSettle();
expect(scrollController.offset, 0.0);
- expect(scrollController.position.maxScrollExtent, 0.0);
+
+ await tester.trackpadFling(find.byType(SingleChildScrollView), const Offset(0, 100), 500);
+ await tester.pumpAndSettle();
+ expect(scrollController.offset, 0.0);
await tester.pumpWidget(buildFrame(700));
await tester.pumpAndSettle();
expect(scrollController.offset, 0.0);
expect(scrollController.position.maxScrollExtent, 100.0);
- await tester.drag(find.byType(SingleChildScrollView), const Offset(0, -100), kind: PointerDeviceKind.trackpad);
+ await tester.trackpadFling(find.byType(SingleChildScrollView), const Offset(0, -100), 500);
await tester.pumpAndSettle();
expect(scrollController.offset, 100.0);
- expect(scrollController.position.maxScrollExtent, 100.0);
+ await tester.trackpadFling(find.byType(SingleChildScrollView), const Offset(0, 100), 500);
+ await tester.pumpAndSettle();
+ expect(scrollController.offset, 0.0);
+ });
+
+ testWidgets('Desktop trackpad drag direction: -X,-Y produces positive scroll offset changes', (WidgetTester tester) async {
+ // Regression test for https://github.com/flutter/flutter/issues/149999.
+ // This test doesn't strictly test the scrollbar: trackpad flings
+ // that begin in the center of the scrollable are handled by the
+ // scrollable, not the scrollbar. However: the scrollbar widget does
+ // contain the scrollable and this test verifies that it doesn't
+ // inadvertantly handle thumb down/start/update/end gestures due
+ // to trackpad pan/zoom events. Those callbacks are prevented by
+ // the overrides of isPointerPanZoomAllowed in the scrollbar
+ // gesture recognizers.
+
+ final ScrollController scrollController = ScrollController();
+ addTearDown(scrollController.dispose);
+
+ Widget buildFrame(Axis scrollDirection) {
+ return Directionality(
+ textDirection: TextDirection.ltr,
+ child: MediaQuery(
+ data: const MediaQueryData(),
+ child: RawScrollbar(
+ controller: scrollController,
+ child: SingleChildScrollView(
+ scrollDirection: scrollDirection,
+ controller: scrollController,
+ child: const SizedBox(width: 1600, height: 1200),
+ ),
+ ),
+ ),
+ );
+ }
+
+ // Vertical scrolling: -Y trackpad motion produces positive scroll offset change
+
+ await tester.pumpWidget(buildFrame(Axis.vertical));
+ expect(scrollController.offset, 0);
+ expect(scrollController.position.maxScrollExtent, 600);
+
+ await tester.trackpadFling(find.byType(SingleChildScrollView), const Offset(0, -600), 500);
+ await tester.pumpAndSettle();
+ expect(scrollController.offset, 600);
+
+ await tester.trackpadFling(find.byType(SingleChildScrollView), const Offset(0, 600), 500);
+ await tester.pumpAndSettle();
+ expect(scrollController.offset, 0);
+
+ // Overscroll is OK for (vertical) trackpad gestures.
+
+ await tester.trackpadFling(find.byType(SingleChildScrollView), const Offset(0, -100), 500);
+ await tester.pumpAndSettle();
+ expect(scrollController.offset, greaterThan(100));
+ scrollController.jumpTo(600);
+
+ await tester.trackpadFling(find.byType(SingleChildScrollView), const Offset(0, 100), 500);
+ await tester.pumpAndSettle();
+ expect(scrollController.offset, lessThan(500));
+ scrollController.jumpTo(0);
+
+ // Horizontal scrolling: -X trackpad motion produces positive scroll offset change
+
+ await tester.pumpWidget(buildFrame(Axis.horizontal));
+ expect(scrollController.offset, 0);
+ expect(scrollController.position.maxScrollExtent, 800);
+
+ await tester.trackpadFling(find.byType(SingleChildScrollView), const Offset(-800, 0), 500);
+ await tester.pumpAndSettle();
+ expect(scrollController.offset, 800);
+
+ await tester.trackpadFling(find.byType(SingleChildScrollView), const Offset(800, 0), 500);
+ await tester.pumpAndSettle();
+ expect(scrollController.offset, 0);
+
+ // Overscroll is OK for (horizontal) trackpad gestures.
+
+ await tester.trackpadFling(find.byType(SingleChildScrollView), const Offset(-100, 0), 500);
+ await tester.pumpAndSettle();
+ expect(scrollController.offset, greaterThan(100));
+ scrollController.jumpTo(800);
+
+ await tester.trackpadFling(find.byType(SingleChildScrollView), const Offset(100, 0), 500);
+ await tester.pumpAndSettle();
+ expect(scrollController.offset, lessThan(700));
+ scrollController.jumpTo(0);
+
+ }, variant: const TargetPlatformVariant(<TargetPlatform>{
+ TargetPlatform.macOS,
+ TargetPlatform.linux,
+ TargetPlatform.windows,
+ TargetPlatform.fuchsia,
+ }));
+
+ testWidgets('Desktop trackpad, nested ListViews, no explicit scrollbars, horizontal drag succeeds', (WidgetTester tester) async {
+ // Regression test for https://github.com/flutter/flutter/issues/150236.
+ // This test is similar to "Desktop trackpad drag direction: -X,-Y...".
+ // It's really only verifying that trackpad gestures are being handled
+ // by the scrollable, not the scrollbar.
+
+ final Key outerListViewKey = UniqueKey();
+ final ScrollController scrollControllerY = ScrollController();
+ final ScrollController scrollControllerX = ScrollController();
+ addTearDown(scrollControllerY.dispose);
+ addTearDown(scrollControllerX.dispose);
+
+ await tester.pumpWidget(
+ Directionality(
+ textDirection: TextDirection.ltr,
+ child: MediaQuery(
+ data: const MediaQueryData(),
+ child: ListView(
+ key: outerListViewKey,
+ controller: scrollControllerY,
+ children: <Widget>[
+ const SizedBox(width: 200, height: 200),
+ SizedBox(
+ height: 200,
+ child: ListView( // vertically centered within the 600 high viewport
+ scrollDirection: Axis.horizontal,
+ controller: scrollControllerX,
+ children: List<Widget>.generate(5, (int index) {
+ return SizedBox(
+ width: 200,
+ child: Center(child: Text('item $index')),
+ );
+ }),
+ ),
+ ),
+ const SizedBox(width: 200, height: 200),
+ const SizedBox(width: 200, height: 200),
+ const SizedBox(width: 200, height: 200),
+ ],
+ ),
+ ),
+ ),
+ );
+
+ Finder outerListView() => find.byKey(outerListViewKey);
+
+ // 800x600 viewport content is 1000x1000
+ expect(tester.getSize(outerListView()), const Size(800, 600));
+ expect(scrollControllerY.offset, 0);
+ expect(scrollControllerY.position.maxScrollExtent, 400);
+ expect(scrollControllerX.offset, 0);
+ expect(scrollControllerX.position.maxScrollExtent, 200);
+
+ // Vertical scrolling: -Y trackpad motion produces positive scroll offset change
+ await tester.trackpadFling(outerListView(), const Offset(0, -600), 500);
+ await tester.pumpAndSettle();
+ expect(scrollControllerY.offset, 400);
+ await tester.trackpadFling(outerListView(), const Offset(0, 600), 500);
+ await tester.pumpAndSettle();
+ expect(scrollControllerY.offset, 0);
+
+ // Horizontal scrolling: -X trackpad motion produces positive scroll offset change
+ await tester.trackpadFling(outerListView(), const Offset(-800, 0), 500);
+ await tester.pumpAndSettle();
+ expect(scrollControllerX.offset, 200);
+ await tester.trackpadFling(outerListView(), const Offset(800, 0), 500);
+ await tester.pumpAndSettle();
+ expect(scrollControllerX.offset, 0);
+
+ }, variant: const TargetPlatformVariant(<TargetPlatform>{
+ TargetPlatform.macOS,
+ TargetPlatform.linux,
+ TargetPlatform.windows,
+ TargetPlatform.fuchsia,
+ }));
+
+ testWidgets('Desktop trackpad, nested ListViews, no explicit scrollbars, horizontal drag succeeds', (WidgetTester tester) async {
+ // Regression test for https://github.com/flutter/flutter/issues/150342
+
+ final ScrollController scrollController = ScrollController();
+ addTearDown(scrollController.dispose);
+
+ late Size childSize;
+ late StateSetter rebuildScrollViewChild;
+
+ Widget buildFrame(Axis scrollDirection) {
+ return Directionality(
+ textDirection: TextDirection.ltr,
+ child: MediaQuery(
+ data: const MediaQueryData(),
+ child: RawScrollbar(
+ controller: scrollController,
+ child: SingleChildScrollView(
+ controller: scrollController,
+ scrollDirection: scrollDirection,
+ child: StatefulBuilder(
+ builder: (BuildContext context, StateSetter setState) {
+ rebuildScrollViewChild = setState;
+ return SizedBox(width: childSize.width, height: childSize.height);
+ },
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+
+ RawGestureDetector getScrollbarGestureDetector() {
+ return tester.widget<RawGestureDetector>(
+ find.descendant(of: find.byType(RawScrollbar), matching: find.byType(RawGestureDetector)).first
+ );
+ }
+
+ // Vertical scrollDirection
+
+ childSize = const Size(800, 600);
+ await tester.pumpWidget(buildFrame(Axis.vertical));
+ // Scrolling isn't possible, so there are no scrollbar gesture recognizers.
+ expect(getScrollbarGestureDetector().gestures.length, 0);
+
+ rebuildScrollViewChild(() { childSize = const Size(800, 800); });
+ await tester.pumpAndSettle();
+ // Scrolling is now possible, so there are scrollbar (thumb and track) gesture recognizers.
+ expect(getScrollbarGestureDetector().gestures.length, greaterThan(1));
+
+ // Horizontal scrollDirection
+
+ childSize = const Size(800, 600);
+ await tester.pumpWidget(buildFrame(Axis.horizontal));
+ await tester.pumpAndSettle();
+ // Scrolling isn't possible, so there are no scrollbar gesture recognizers.
+ expect(getScrollbarGestureDetector().gestures.length, 0);
+
+ rebuildScrollViewChild(() { childSize = const Size(1000, 600); });
+ await tester.pumpAndSettle();
+ // Scrolling is now possible, so there are scrollbar (thumb and track) gesture recognizers.
+ expect(getScrollbarGestureDetector().gestures.length, greaterThan(1));
});
}