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