iOS 13 scrollbar (#35829)

You can drag the cupertinoscrollbar if you pass an active scrollcontroller to the scrollbar.
diff --git a/packages/flutter/lib/src/cupertino/scrollbar.dart b/packages/flutter/lib/src/cupertino/scrollbar.dart
index 119aecd..eb66fbf 100644
--- a/packages/flutter/lib/src/cupertino/scrollbar.dart
+++ b/packages/flutter/lib/src/cupertino/scrollbar.dart
@@ -4,18 +4,22 @@
 
 import 'dart:async';
 
+import 'package:flutter/gestures.dart';
 import 'package:flutter/widgets.dart';
 
 // All values eyeballed.
 const Color _kScrollbarColor = Color(0x99777777);
 const double _kScrollbarMinLength = 36.0;
 const double _kScrollbarMinOverscrollLength = 8.0;
-const Radius _kScrollbarRadius = Radius.circular(1.25);
-const Duration _kScrollbarTimeToFade = Duration(milliseconds: 50);
+const Radius _kScrollbarRadius = Radius.circular(1.5);
+const Radius _kScrollbarRadiusDragging = Radius.circular(4.0);
+const Duration _kScrollbarTimeToFade = Duration(milliseconds: 1200);
 const Duration _kScrollbarFadeDuration = Duration(milliseconds: 250);
+const Duration _kScrollbarResizeDuration = Duration(milliseconds: 150);
 
-// These values are measured using screenshots from an iPhone XR 12.1 simulator.
+// These values are measured using screenshots from an iPhone XR 13.0 simulator.
 const double _kScrollbarThickness = 2.5;
+const double _kScrollbarThicknessDragging = 8.0;
 // This is the amount of space from the top of a vertical scrollbar to the
 // top edge of the scrollable, measured when the vertical scrollbar overscrolls
 // to the top.
@@ -23,7 +27,6 @@
 const double _kScrollbarMainAxisMargin = 3.0;
 const double _kScrollbarCrossAxisMargin = 3.0;
 
-
 /// An iOS style scrollbar.
 ///
 /// A scrollbar indicates which portion of a [Scrollable] widget is actually
@@ -45,6 +48,7 @@
   /// typically a [Scrollable] widget.
   const CupertinoScrollbar({
     Key key,
+    this.controller,
     @required this.child,
   }) : super(key: key);
 
@@ -54,17 +58,64 @@
   /// typically a [Scrollable] widget.
   final Widget child;
 
+  /// The [ScrollController] used to implement Scrollbar dragging.
+  ///
+  /// Scrollbar dragging is started with a long press or a drag in from the side
+  /// on top of the scrollbar thumb, which enlarges the thumb and makes it
+  /// interactive. Dragging it then causes the view to scroll. This feature was
+  /// introduced in iOS 13.
+  ///
+  /// In order to enable this feature, pass an active ScrollController to this
+  /// parameter.  A stateful ancestor of this CupertinoScrollbar needs to
+  /// manage the ScrollController and either pass it to a scrollable descendant
+  /// or use a PrimaryScrollController to share it.
+  ///
+  /// Here is an example of using PrimaryScrollController to enable scrollbar
+  /// dragging:
+  ///
+  /// {@tool sample}
+  ///
+  /// ```dart
+  /// build(BuildContext context) {
+  ///   final ScrollController controller = ScrollController();
+  ///   return PrimaryScrollController(
+  ///     controller: controller,
+  ///     child: CupertinoScrollbar(
+  ///       controller: controller,
+  ///       child: ListView.builder(
+  ///         itemCount: 150,
+  ///         itemBuilder: (BuildContext context, int index) => Text('item $index'),
+  ///       ),
+  ///     ),
+  ///   );
+  /// }
+  /// ```
+  /// {@end-tool}
+  final ScrollController controller;
+
   @override
   _CupertinoScrollbarState createState() => _CupertinoScrollbarState();
 }
 
 class _CupertinoScrollbarState extends State<CupertinoScrollbar> with TickerProviderStateMixin {
+  final GlobalKey _customPaintKey = GlobalKey();
   ScrollbarPainter _painter;
   TextDirection _textDirection;
 
   AnimationController _fadeoutAnimationController;
   Animation<double> _fadeoutOpacityAnimation;
+  AnimationController _thicknessAnimationController;
   Timer _fadeoutTimer;
+  double _dragScrollbarPositionY;
+  Drag _drag;
+
+  double get _thickness {
+    return _kScrollbarThickness + _thicknessAnimationController.value * (_kScrollbarThicknessDragging - _kScrollbarThickness);
+  }
+
+  Radius get _radius {
+    return Radius.lerp(_kScrollbarRadius, _kScrollbarRadiusDragging, _thicknessAnimationController.value);
+  }
 
   @override
   void initState() {
@@ -77,6 +128,13 @@
       parent: _fadeoutAnimationController,
       curve: Curves.fastOutSlowIn,
     );
+    _thicknessAnimationController = AnimationController(
+      vsync: this,
+      duration: _kScrollbarResizeDuration,
+    );
+    _thicknessAnimationController.addListener(() {
+      _painter.updateThickness(_thickness, _radius);
+    });
   }
 
   @override
@@ -91,17 +149,123 @@
     return ScrollbarPainter(
       color: _kScrollbarColor,
       textDirection: _textDirection,
-      thickness: _kScrollbarThickness,
+      thickness: _thickness,
       fadeoutOpacityAnimation: _fadeoutOpacityAnimation,
       mainAxisMargin: _kScrollbarMainAxisMargin,
       crossAxisMargin: _kScrollbarCrossAxisMargin,
-      radius: _kScrollbarRadius,
+      radius: _radius,
       padding: MediaQuery.of(context).padding,
       minLength: _kScrollbarMinLength,
       minOverscrollLength: _kScrollbarMinOverscrollLength,
     );
   }
 
+  // Handle a gesture that drags the scrollbar by the given amount.
+  void _dragScrollbar(double primaryDelta) {
+    assert(widget.controller != null);
+
+    // Convert primaryDelta, the amount that the scrollbar moved since the last
+    // time _dragScrollbar was called, into the coordinate space of the scroll
+    // position, and create/update the drag event with that position.
+    final double scrollOffsetLocal = _painter.getTrackToScroll(primaryDelta);
+    final double scrollOffsetGlobal = scrollOffsetLocal + widget.controller.position.pixels;
+
+    if (_drag == null) {
+      _drag = widget.controller.position.drag(
+        DragStartDetails(
+          globalPosition: Offset(0.0, scrollOffsetGlobal),
+        ),
+        () {},
+      );
+    } else {
+      _drag.update(DragUpdateDetails(
+        globalPosition: Offset(0.0, scrollOffsetGlobal),
+        delta: Offset(0.0, -scrollOffsetLocal),
+        primaryDelta: -scrollOffsetLocal,
+      ));
+    }
+  }
+
+  void _startFadeoutTimer() {
+    _fadeoutTimer?.cancel();
+    _fadeoutTimer = Timer(_kScrollbarTimeToFade, () {
+      _fadeoutAnimationController.reverse();
+      _fadeoutTimer = null;
+    });
+  }
+
+  void _assertVertical() {
+    assert(
+      widget.controller.position.axis == Axis.vertical,
+      'Scrollbar dragging is only supported for vertical scrolling. Don\'t pass the controller param to a horizontal scrollbar.',
+    );
+  }
+
+  // Long press event callbacks handle the gesture where the user long presses
+  // on the scrollbar thumb and then drags the scrollbar without releasing.
+  void _handleLongPressStart(LongPressStartDetails details) {
+    _assertVertical();
+    _fadeoutTimer?.cancel();
+    _fadeoutAnimationController.forward();
+    _dragScrollbar(details.localPosition.dy);
+    _dragScrollbarPositionY = details.localPosition.dy;
+  }
+
+  void _handleLongPress() {
+    _assertVertical();
+    _fadeoutTimer?.cancel();
+    _thicknessAnimationController.forward();
+  }
+
+  void _handleLongPressMoveUpdate(LongPressMoveUpdateDetails details) {
+    _assertVertical();
+    _dragScrollbar(details.localPosition.dy - _dragScrollbarPositionY);
+    _dragScrollbarPositionY = details.localPosition.dy;
+  }
+
+  void _handleLongPressEnd(LongPressEndDetails details) {
+    _handleDragScrollEnd(details.velocity.pixelsPerSecond.dy);
+  }
+
+  // Horizontal drag event callbacks handle the gesture where the user swipes in
+  // from the right on top of the scrollbar thumb and then drags the scrollbar
+  // without releasing.
+  void _handleHorizontalDragStart(DragStartDetails details) {
+    _assertVertical();
+    _fadeoutTimer?.cancel();
+    _thicknessAnimationController.forward();
+    _dragScrollbar(details.localPosition.dy);
+    _dragScrollbarPositionY = details.localPosition.dy;
+  }
+
+  void _handleHorizontalDragUpdate(DragUpdateDetails details) {
+    _assertVertical();
+    _dragScrollbar(details.localPosition.dy - _dragScrollbarPositionY);
+    _dragScrollbarPositionY = details.localPosition.dy;
+  }
+
+  void _handleHorizontalDragEnd(DragEndDetails details) {
+    _handleDragScrollEnd(details.velocity.pixelsPerSecond.dy);
+  }
+
+  void _handleDragScrollEnd(double trackVelocityY) {
+    _assertVertical();
+    _startFadeoutTimer();
+    _thicknessAnimationController.reverse();
+    _dragScrollbarPositionY = null;
+    final double scrollVelocityY = _painter.getTrackToScroll(trackVelocityY);
+    _drag?.end(DragEndDetails(
+      primaryVelocity: -scrollVelocityY,
+      velocity: Velocity(
+        pixelsPerSecond: Offset(
+          0.0,
+          -scrollVelocityY,
+        ),
+      ),
+    ));
+    _drag = null;
+  }
+
   bool _handleScrollNotification(ScrollNotification notification) {
     final ScrollMetrics metrics = notification.metrics;
     if (metrics.maxScrollExtent <= metrics.minScrollExtent) {
@@ -119,19 +283,58 @@
       _painter.update(notification.metrics, notification.metrics.axisDirection);
     } else if (notification is ScrollEndNotification) {
       // On iOS, the scrollbar can only go away once the user lifted the finger.
-
-      _fadeoutTimer?.cancel();
-      _fadeoutTimer = Timer(_kScrollbarTimeToFade, () {
-        _fadeoutAnimationController.reverse();
-        _fadeoutTimer = null;
-      });
+      if (_dragScrollbarPositionY == null) {
+        _startFadeoutTimer();
+      }
     }
     return false;
   }
 
+  // Get the GestureRecognizerFactories used to detect gestures on the scrollbar
+  // thumb.
+  Map<Type, GestureRecognizerFactory> get _gestures {
+    final Map<Type, GestureRecognizerFactory> gestures = <Type, GestureRecognizerFactory>{};
+    if (widget.controller == null) {
+      return gestures;
+    }
+
+    gestures[_ThumbLongPressGestureRecognizer] =
+      GestureRecognizerFactoryWithHandlers<_ThumbLongPressGestureRecognizer>(
+        () => _ThumbLongPressGestureRecognizer(
+          debugOwner: this,
+          kind: PointerDeviceKind.touch,
+          customPaintKey: _customPaintKey,
+        ),
+        (_ThumbLongPressGestureRecognizer instance) {
+          instance
+            ..onLongPressStart = _handleLongPressStart
+            ..onLongPress = _handleLongPress
+            ..onLongPressMoveUpdate = _handleLongPressMoveUpdate
+            ..onLongPressEnd = _handleLongPressEnd;
+        },
+      );
+    gestures[_ThumbHorizontalDragGestureRecognizer] =
+      GestureRecognizerFactoryWithHandlers<_ThumbHorizontalDragGestureRecognizer>(
+        () => _ThumbHorizontalDragGestureRecognizer(
+          debugOwner: this,
+          kind: PointerDeviceKind.touch,
+          customPaintKey: _customPaintKey,
+        ),
+        (_ThumbHorizontalDragGestureRecognizer instance) {
+          instance
+            ..onStart = _handleHorizontalDragStart
+            ..onUpdate = _handleHorizontalDragUpdate
+            ..onEnd = _handleHorizontalDragEnd;
+        },
+      );
+
+    return gestures;
+  }
+
   @override
   void dispose() {
     _fadeoutAnimationController.dispose();
+    _thicknessAnimationController.dispose();
     _fadeoutTimer?.cancel();
     _painter.dispose();
     super.dispose();
@@ -142,13 +345,90 @@
     return NotificationListener<ScrollNotification>(
       onNotification: _handleScrollNotification,
       child: RepaintBoundary(
-        child: CustomPaint(
-          foregroundPainter: _painter,
-          child: RepaintBoundary(
-            child: widget.child,
+        child: RawGestureDetector(
+          gestures: _gestures,
+          child: CustomPaint(
+            key: _customPaintKey,
+            foregroundPainter: _painter,
+            child: RepaintBoundary(
+              child: widget.child,
+            ),
           ),
         ),
       ),
     );
   }
 }
+
+// A longpress gesture detector that only responds to events on the scrollbar's
+// thumb and ignores everything else.
+class _ThumbLongPressGestureRecognizer extends LongPressGestureRecognizer {
+  _ThumbLongPressGestureRecognizer({
+    double postAcceptSlopTolerance,
+    PointerDeviceKind kind,
+    Object debugOwner,
+    GlobalKey customPaintKey,
+  }) :  _customPaintKey = customPaintKey,
+        super(
+          postAcceptSlopTolerance: postAcceptSlopTolerance,
+          kind: kind,
+          debugOwner: debugOwner,
+        );
+
+  final GlobalKey _customPaintKey;
+
+  @override
+  bool isPointerAllowed(PointerDownEvent event) {
+    if (!_hitTestInteractive(_customPaintKey, event.position)) {
+      return false;
+    }
+    return super.isPointerAllowed(event);
+  }
+}
+
+// A horizontal drag gesture detector that only responds to events on the
+// scrollbar's thumb and ignores everything else.
+class _ThumbHorizontalDragGestureRecognizer extends HorizontalDragGestureRecognizer {
+  _ThumbHorizontalDragGestureRecognizer({
+    PointerDeviceKind kind,
+    Object debugOwner,
+    GlobalKey customPaintKey,
+  }) :  _customPaintKey = customPaintKey,
+        super(
+          kind: kind,
+          debugOwner: debugOwner,
+        );
+
+  final GlobalKey _customPaintKey;
+
+  @override
+  bool isPointerAllowed(PointerEvent event) {
+    if (!_hitTestInteractive(_customPaintKey, event.position)) {
+      return false;
+    }
+    return super.isPointerAllowed(event);
+  }
+
+  // Flings are actually in the vertical direction. Even though the event starts
+  // horizontal, the scrolling is tracked vertically.
+  @override
+  bool isFlingGesture(VelocityEstimate estimate) {
+    final double minVelocity = minFlingVelocity ?? kMinFlingVelocity;
+    final double minDistance = minFlingDistance ?? kTouchSlop;
+    return estimate.pixelsPerSecond.dy.abs() > minVelocity && estimate.offset.dy.abs() > minDistance;
+  }
+}
+
+// foregroundPainter also hit tests its children by default, but the
+// scrollbar should only respond to a gesture directly on its thumb, so
+// manually check for a hit on the thumb here.
+bool _hitTestInteractive(GlobalKey customPaintKey, Offset offset) {
+  if (customPaintKey.currentContext == null) {
+    return false;
+  }
+  final CustomPaint customPaint = customPaintKey.currentContext.widget;
+  final ScrollbarPainter painter = customPaint.foregroundPainter;
+  final RenderBox renderBox = customPaintKey.currentContext.findRenderObject();
+  final Offset localOffset = renderBox.globalToLocal(offset);
+  return painter.hitTestInteractive(localOffset);
+}
diff --git a/packages/flutter/lib/src/gestures/long_press.dart b/packages/flutter/lib/src/gestures/long_press.dart
index 932440c..93054f1 100644
--- a/packages/flutter/lib/src/gestures/long_press.dart
+++ b/packages/flutter/lib/src/gestures/long_press.dart
@@ -6,6 +6,7 @@
 import 'constants.dart';
 import 'events.dart';
 import 'recognizer.dart';
+import 'velocity_tracker.dart';
 
 /// Callback signature for [LongPressGestureRecognizer.onLongPress].
 ///
@@ -116,6 +117,7 @@
   const LongPressEndDetails({
     this.globalPosition = Offset.zero,
     Offset localPosition,
+    this.velocity = Velocity.zero,
   }) : assert(globalPosition != null),
        localPosition = localPosition ?? globalPosition;
 
@@ -124,6 +126,11 @@
 
   /// The local position at which the pointer contacted the screen.
   final Offset localPosition;
+
+  /// The pointer's velocity when it stopped contacting the screen.
+  ///
+  /// Defaults to zero if not specified in the constructor.
+  final Velocity velocity;
 }
 
 /// Recognizes when the user has pressed down at the same location for a long
@@ -214,6 +221,8 @@
   ///    callback.
   GestureLongPressEndCallback onLongPressEnd;
 
+  VelocityTracker _velocityTracker;
+
   @override
   bool isPointerAllowed(PointerDownEvent event) {
     switch (event.buttons) {
@@ -242,6 +251,17 @@
 
   @override
   void handlePrimaryPointer(PointerEvent event) {
+    if (!event.synthesized) {
+      if (event is PointerDownEvent) {
+        _velocityTracker = VelocityTracker();
+        _velocityTracker.addPosition(event.timeStamp, event.localPosition);
+      }
+      if (event is PointerMoveEvent) {
+        assert(_velocityTracker != null);
+        _velocityTracker.addPosition(event.timeStamp, event.localPosition);
+      }
+    }
+
     if (event is PointerUpEvent) {
       if (_longPressAccepted == true) {
         _checkLongPressEnd(event);
@@ -295,10 +315,16 @@
 
   void _checkLongPressEnd(PointerEvent event) {
     assert(_initialButtons == kPrimaryButton);
+
+    final VelocityEstimate estimate = _velocityTracker.getVelocityEstimate();
+    final Velocity velocity = estimate == null ? Velocity.zero : Velocity(pixelsPerSecond: estimate.pixelsPerSecond);
     final LongPressEndDetails details = LongPressEndDetails(
       globalPosition: event.position,
       localPosition: event.localPosition,
+      velocity: velocity,
     );
+
+    _velocityTracker = null;
     if (onLongPressEnd != null)
       invokeCallback<void>('onLongPressEnd', () => onLongPressEnd(details));
     if (onLongPressUp != null)
@@ -309,6 +335,7 @@
     _longPressAccepted = false;
     _longPressOrigin = null;
     _initialButtons = null;
+    _velocityTracker = null;
   }
 
   @override
diff --git a/packages/flutter/lib/src/gestures/monodrag.dart b/packages/flutter/lib/src/gestures/monodrag.dart
index af235ea..c2f4f2c 100644
--- a/packages/flutter/lib/src/gestures/monodrag.dart
+++ b/packages/flutter/lib/src/gestures/monodrag.dart
@@ -184,7 +184,13 @@
   /// differentiate the direction of the drag.
   double _globalDistanceMoved;
 
-  bool _isFlingGesture(VelocityEstimate estimate);
+  /// Determines if a gesture is a fling or not based on velocity.
+  ///
+  /// A fling calls its gesture end callback with a velocity, allowing the
+  /// provider of the callback to respond by carrying the gesture forward with
+  /// inertia, for example.
+  bool isFlingGesture(VelocityEstimate estimate);
+
   Offset _getDeltaForDetails(Offset delta);
   double _getPrimaryValueFromOffset(Offset value);
   bool get _hasSufficientGlobalDistanceToAccept;
@@ -395,7 +401,7 @@
     void Function() debugReport;
 
     final VelocityEstimate estimate = tracker.getVelocityEstimate();
-    if (estimate != null && _isFlingGesture(estimate)) {
+    if (estimate != null && isFlingGesture(estimate)) {
       final Velocity velocity = Velocity(pixelsPerSecond: estimate.pixelsPerSecond)
         .clampMagnitude(minFlingVelocity ?? kMinFlingVelocity, maxFlingVelocity ?? kMaxFlingVelocity);
       details = DragEndDetails(
@@ -457,7 +463,7 @@
   }) : super(debugOwner: debugOwner, kind: kind);
 
   @override
-  bool _isFlingGesture(VelocityEstimate estimate) {
+  bool isFlingGesture(VelocityEstimate estimate) {
     final double minVelocity = minFlingVelocity ?? kMinFlingVelocity;
     final double minDistance = minFlingDistance ?? kTouchSlop;
     return estimate.pixelsPerSecond.dy.abs() > minVelocity && estimate.offset.dy.abs() > minDistance;
@@ -496,7 +502,7 @@
   }) : super(debugOwner: debugOwner, kind: kind);
 
   @override
-  bool _isFlingGesture(VelocityEstimate estimate) {
+  bool isFlingGesture(VelocityEstimate estimate) {
     final double minVelocity = minFlingVelocity ?? kMinFlingVelocity;
     final double minDistance = minFlingDistance ?? kTouchSlop;
     return estimate.pixelsPerSecond.dx.abs() > minVelocity && estimate.offset.dx.abs() > minDistance;
@@ -529,7 +535,7 @@
   PanGestureRecognizer({ Object debugOwner }) : super(debugOwner: debugOwner);
 
   @override
-  bool _isFlingGesture(VelocityEstimate estimate) {
+  bool isFlingGesture(VelocityEstimate estimate) {
     final double minVelocity = minFlingVelocity ?? kMinFlingVelocity;
     final double minDistance = minFlingDistance ?? kTouchSlop;
     return estimate.pixelsPerSecond.distanceSquared > minVelocity * minVelocity
diff --git a/packages/flutter/lib/src/widgets/scrollbar.dart b/packages/flutter/lib/src/widgets/scrollbar.dart
index c5a0604..ebf405a 100644
--- a/packages/flutter/lib/src/widgets/scrollbar.dart
+++ b/packages/flutter/lib/src/widgets/scrollbar.dart
@@ -11,6 +11,7 @@
 import 'scroll_metrics.dart';
 
 const double _kMinThumbExtent = 18.0;
+const double _kMinInteractiveSize = 48.0;
 
 /// A [CustomPainter] for painting scrollbars.
 ///
@@ -77,7 +78,7 @@
   final TextDirection textDirection;
 
   /// Thickness of the scrollbar in its cross-axis in logical pixels. Mustn't be null.
-  final double thickness;
+  double thickness;
 
   /// An opacity [Animation] that dictates the opacity of the thumb.
   /// Changes in value of this [Listenable] will automatically trigger repaints.
@@ -98,7 +99,7 @@
   /// [Radius] of corners if the scrollbar should have rounded corners.
   ///
   /// Scrollbar will be rectangular if [radius] is null.
-  final Radius radius;
+  Radius radius;
 
   /// The amount of space by which to inset the scrollbar's start and end, as
   /// well as its side to the nearest edge, in logical pixels.
@@ -138,6 +139,7 @@
 
   ScrollMetrics _lastMetrics;
   AxisDirection _lastAxisDirection;
+  Rect _thumbRect;
 
   /// Update with new [ScrollMetrics]. The scrollbar will show and redraw itself
   /// based on these new metrics.
@@ -152,6 +154,13 @@
     notifyListeners();
   }
 
+  /// Update and redraw with new scrollbar thickness and radius.
+  void updateThickness(double nextThickness, Radius nextRadius) {
+    thickness = nextThickness;
+    radius = nextRadius;
+    notifyListeners();
+  }
+
   Paint get _paint {
     return Paint()..color =
         color.withOpacity(color.opacity * fadeoutOpacityAnimation.value);
@@ -188,35 +197,28 @@
         break;
     }
 
-    final Rect thumbRect = Offset(x, y) & thumbSize;
+    _thumbRect = Offset(x, y) & thumbSize;
     if (radius == null)
-      canvas.drawRect(thumbRect, _paint);
+      canvas.drawRect(_thumbRect, _paint);
     else
-      canvas.drawRRect(RRect.fromRectAndRadius(thumbRect, radius), _paint);
+      canvas.drawRRect(RRect.fromRectAndRadius(_thumbRect, radius), _paint);
   }
 
-  double _thumbExtent(
-    double mainAxisPadding,
-    double extentInside,
-    double contentExtent,
-    double beforeExtent,
-    double afterExtent,
-    double trackExtent
-  ) {
+  double _thumbExtent() {
     // Thumb extent reflects fraction of content visible, as long as this
     // isn't less than the absolute minimum size.
-    // contentExtent >= viewportDimension, so (contentExtent - mainAxisPadding) > 0
-    final double fractionVisible = ((extentInside - mainAxisPadding) / (contentExtent - mainAxisPadding))
+    // _totalContentExtent >= viewportDimension, so (_totalContentExtent - _mainAxisPadding) > 0
+    final double fractionVisible = ((_lastMetrics.extentInside - _mainAxisPadding) / (_totalContentExtent - _mainAxisPadding))
       .clamp(0.0, 1.0);
 
     final double thumbExtent = math.max(
-      math.min(trackExtent, minOverscrollLength),
-      trackExtent * fractionVisible
+      math.min(_trackExtent, minOverscrollLength),
+      _trackExtent * fractionVisible
     );
 
-    final double fractionOverscrolled = 1.0 - extentInside / _lastMetrics.viewportDimension;
-    final double safeMinLength = math.min(minLength, trackExtent);
-    final double newMinLength = (beforeExtent > 0 && afterExtent > 0)
+    final double fractionOverscrolled = 1.0 - _lastMetrics.extentInside / _lastMetrics.viewportDimension;
+    final double safeMinLength = math.min(minLength, _trackExtent);
+    final double newMinLength = (_beforeExtent > 0 && _afterExtent > 0)
       // Thumb extent is no smaller than minLength if scrolling normally.
       ? safeMinLength
       // User is overscrolling. Thumb extent can be less than minLength
@@ -234,7 +236,7 @@
 
     // The `thumbExtent` should be no greater than `trackSize`, otherwise
     // the scrollbar may scroll towards the wrong direction.
-    return thumbExtent.clamp(newMinLength, trackExtent);
+    return thumbExtent.clamp(newMinLength, _trackExtent);
   }
 
   @override
@@ -243,6 +245,47 @@
     super.dispose();
   }
 
+  bool get _isVertical => _lastAxisDirection == AxisDirection.down || _lastAxisDirection == AxisDirection.up;
+  bool get _isReversed => _lastAxisDirection == AxisDirection.up || _lastAxisDirection == AxisDirection.left;
+  // The amount of scroll distance before and after the current position.
+  double get _beforeExtent => _isReversed ? _lastMetrics.extentAfter : _lastMetrics.extentBefore;
+  double get _afterExtent => _isReversed ? _lastMetrics.extentBefore : _lastMetrics.extentAfter;
+  // Padding of the thumb track.
+  double get _mainAxisPadding => _isVertical ? padding.vertical : padding.horizontal;
+  // The size of the thumb track.
+  double get _trackExtent => _lastMetrics.viewportDimension - 2 * mainAxisMargin - _mainAxisPadding;
+
+  // The total size of the scrollable content.
+  double get _totalContentExtent {
+    return _lastMetrics.maxScrollExtent
+      - _lastMetrics.minScrollExtent
+      + _lastMetrics.viewportDimension;
+  }
+
+  /// Convert between a thumb track position and the corresponding scroll
+  /// position.
+  ///
+  /// thumbOffsetLocal is a position in the thumb track. Cannot be null.
+  double getTrackToScroll(double thumbOffsetLocal) {
+    assert(thumbOffsetLocal != null);
+    final double scrollableExtent = _lastMetrics.maxScrollExtent - _lastMetrics.minScrollExtent;
+    final double thumbMovableExtent = _trackExtent - _thumbExtent();
+
+    return scrollableExtent * thumbOffsetLocal / thumbMovableExtent;
+  }
+
+  // Converts between a scroll position and the corresponding position in the
+  // thumb track.
+  double _getScrollToTrack(ScrollMetrics metrics, double thumbExtent) {
+    final double scrollableExtent = metrics.maxScrollExtent - metrics.minScrollExtent;
+
+    final double fractionPast = (scrollableExtent > 0)
+      ? ((metrics.pixels - metrics.minScrollExtent) / scrollableExtent).clamp(0.0, 1.0)
+      : 0;
+
+    return (_isReversed ? 1 - fractionPast : fractionPast) * (_trackExtent - thumbExtent);
+  }
+
   @override
   void paint(Canvas canvas, Size size) {
     if (_lastAxisDirection == null
@@ -250,45 +293,47 @@
         || fadeoutOpacityAnimation.value == 0.0)
       return;
 
-    final bool isVertical = _lastAxisDirection == AxisDirection.down || _lastAxisDirection == AxisDirection.up;
-    final bool isReversed = _lastAxisDirection == AxisDirection.up || _lastAxisDirection == AxisDirection.left;
-
-    final double mainAxisPadding = isVertical ? padding.vertical : padding.horizontal;
-    // The size of the scrollable area.
-    final double trackExtent = _lastMetrics.viewportDimension - 2 * mainAxisMargin - mainAxisPadding;
-
     // Skip painting if there's not enough space.
-    if (_lastMetrics.viewportDimension <= mainAxisPadding || trackExtent <= 0) {
+    if (_lastMetrics.viewportDimension <= _mainAxisPadding || _trackExtent <= 0) {
       return;
     }
 
-    final double totalContentExtent =
-      _lastMetrics.maxScrollExtent
-      - _lastMetrics.minScrollExtent
-      + _lastMetrics.viewportDimension;
-
-    final double beforeExtent = isReversed ? _lastMetrics.extentAfter : _lastMetrics.extentBefore;
-    final double afterExtent = isReversed ? _lastMetrics.extentBefore : _lastMetrics.extentAfter;
-
-    final double thumbExtent = _thumbExtent(mainAxisPadding, _lastMetrics.extentInside, totalContentExtent,
-      beforeExtent, afterExtent, trackExtent);
-
-    final double beforePadding = isVertical ? padding.top : padding.left;
-    final double scrollableExtent = _lastMetrics.maxScrollExtent - _lastMetrics.minScrollExtent;
-
-    final double fractionPast = (scrollableExtent > 0)
-      ? ((_lastMetrics.pixels - _lastMetrics.minScrollExtent) / scrollableExtent).clamp(0.0, 1.0)
-      : 0;
-
-    final double thumbOffset = (isReversed ? 1 - fractionPast : fractionPast) * (trackExtent - thumbExtent)
-      + mainAxisMargin + beforePadding;
+    final double beforePadding = _isVertical ? padding.top : padding.left;
+    final double thumbExtent = _thumbExtent();
+    final double thumbOffsetLocal = _getScrollToTrack(_lastMetrics, thumbExtent);
+    final double thumbOffset = thumbOffsetLocal + mainAxisMargin + beforePadding;
 
     return _paintThumbCrossAxis(canvas, size, thumbOffset, thumbExtent, _lastAxisDirection);
   }
 
-  // Scrollbars are (currently) not interactive.
+  /// Same as hitTest, but includes some padding to make sure that the region
+  /// isn't too small to be interacted with by the user.
+  bool hitTestInteractive(Offset position) {
+    if (_thumbRect == null) {
+      return false;
+    }
+    // The thumb is not able to be hit when transparent.
+    if (fadeoutOpacityAnimation.value == 0.0) {
+      return false;
+    }
+    final Rect interactiveThumbRect = _thumbRect.expandToInclude(
+      Rect.fromCircle(center: _thumbRect.center, radius: _kMinInteractiveSize / 2),
+    );
+    return interactiveThumbRect.contains(position);
+  }
+
+  // Scrollbars can be interactive in Cupertino.
   @override
-  bool hitTest(Offset position) => null;
+  bool hitTest(Offset position) {
+    if (_thumbRect == null) {
+      return null;
+    }
+    // The thumb is not able to be hit when transparent.
+    if (fadeoutOpacityAnimation.value == 0.0) {
+      return false;
+    }
+    return _thumbRect.contains(position);
+  }
 
   @override
   bool shouldRepaint(ScrollbarPainter old) {
diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart
index af17c6c..424d7c7 100644
--- a/packages/flutter/lib/src/widgets/text_selection.dart
+++ b/packages/flutter/lib/src/widgets/text_selection.dart
@@ -6,7 +6,6 @@
 import 'dart:math' as math;
 
 import 'package:flutter/foundation.dart';
-import 'package:flutter/gestures.dart' show kDoubleTapTimeout, kDoubleTapSlop;
 import 'package:flutter/gestures.dart';
 import 'package:flutter/rendering.dart';
 import 'package:flutter/scheduler.dart';
diff --git a/packages/flutter/test/cupertino/scrollbar_paint_test.dart b/packages/flutter/test/cupertino/scrollbar_paint_test.dart
index 3de11ec..3959b0c 100644
--- a/packages/flutter/test/cupertino/scrollbar_paint_test.dart
+++ b/packages/flutter/test/cupertino/scrollbar_paint_test.dart
@@ -12,6 +12,7 @@
 // The `y` offset has to be larger than `ScrollDragController._bigThresholdBreakDistance`
 // to prevent [motionStartDistanceThreshold] from affecting the actual drag distance.
 const Offset _kGestureOffset = Offset(0, -25);
+const Radius _kScrollbarRadius = Radius.circular(1.5);
 
 void main() {
   testWidgets('Paints iOS spec', (WidgetTester tester) async {
@@ -47,7 +48,7 @@
           // Fraction in viewport * scrollbar height - top, bottom margin.
           600.0 / 4000.0 * (600.0 - 2 * 3),
         ),
-        const Radius.circular(1.25),
+        _kScrollbarRadius,
       ),
     ));
   });
@@ -92,7 +93,7 @@
           // where Fraction visible = (viewport size - padding) / content size
           (600.0 - 34 - 44 - 20) / 4000.0 * (600.0 - 2 * 3 - 34 - 44 - 20),
         ),
-        const Radius.circular(1.25),
+        _kScrollbarRadius,
       ),
     ));
   });
diff --git a/packages/flutter/test/cupertino/scrollbar_test.dart b/packages/flutter/test/cupertino/scrollbar_test.dart
index d5b1460..5abdac7 100644
--- a/packages/flutter/test/cupertino/scrollbar_test.dart
+++ b/packages/flutter/test/cupertino/scrollbar_test.dart
@@ -8,6 +8,10 @@
 import '../rendering/mock_canvas.dart';
 
 void main() {
+  const Duration _kScrollbarTimeToFade = Duration(milliseconds: 1200);
+  const Duration _kScrollbarFadeDuration = Duration(milliseconds: 250);
+  const Duration _kScrollbarResizeDuration = Duration(milliseconds: 150);
+
   testWidgets('Scrollbar never goes away until finger lift', (WidgetTester tester) async {
     await tester.pumpWidget(
       const Directionality(
@@ -37,12 +41,132 @@
     ));
 
     await gesture.up();
-    await tester.pump(const Duration(milliseconds: 200));
-    await tester.pump(const Duration(milliseconds: 200));
+    await tester.pump(_kScrollbarTimeToFade);
+    await tester.pump(_kScrollbarFadeDuration * 0.5);
 
     // Opacity going down now.
     expect(find.byType(CupertinoScrollbar), paints..rrect(
-      color: const Color(0x15777777),
+      color: const Color(0x77777777),
     ));
   });
+
+  testWidgets('Scrollbar thumb can be dragged with long press', (WidgetTester tester) async {
+    final ScrollController scrollController = ScrollController();
+    await tester.pumpWidget(
+      Directionality(
+        textDirection: TextDirection.ltr,
+        child: MediaQuery(
+          data: const MediaQueryData(),
+          child: PrimaryScrollController(
+            controller: scrollController,
+            child: CupertinoScrollbar(
+              controller: scrollController,
+              child: const SingleChildScrollView(child: SizedBox(width: 4000.0, height: 4000.0)),
+            ),
+          ),
+        ),
+      ),
+    );
+
+    expect(scrollController.offset, 0.0);
+
+    // Scroll a bit.
+    const double scrollAmount = 10.0;
+    final TestGesture scrollGesture = await tester.startGesture(tester.getCenter(find.byType(SingleChildScrollView)));
+    // Scroll down by swiping up.
+    await scrollGesture.moveBy(const Offset(0.0, -scrollAmount));
+    await tester.pump();
+    await tester.pump(const Duration(milliseconds: 500));
+    // Scrollbar thumb is fully showing and scroll offset has moved by
+    // scrollAmount.
+    expect(find.byType(CupertinoScrollbar), paints..rrect(
+      color: const Color(0x99777777),
+    ));
+    expect(scrollController.offset, scrollAmount);
+    await scrollGesture.up();
+    await tester.pump();
+
+    // Longpress on the scrollbar thumb.
+    final TestGesture dragScrollbarGesture = await tester.startGesture(const Offset(796.0, 50.0));
+    await tester.pump(const Duration(milliseconds: 500));
+
+    // Drag the thumb down to scroll down.
+    await dragScrollbarGesture.moveBy(const Offset(0.0, scrollAmount));
+    await tester.pump(const Duration(milliseconds: 500));
+    await dragScrollbarGesture.up();
+    await tester.pumpAndSettle();
+
+    // The view has scrolled more than it would have by a swipe gesture of the
+    // same distance.
+    expect(scrollController.offset, greaterThan(scrollAmount * 2));
+    // The scrollbar thumb is still fully visible.
+    expect(find.byType(CupertinoScrollbar), paints..rrect(
+      color: const Color(0x99777777),
+    ));
+
+    // Let the thumb fade out so all timers have resolved.
+    await tester.pump(_kScrollbarTimeToFade);
+    await tester.pump(_kScrollbarFadeDuration);
+  });
+
+  testWidgets('Scrollbar thumb can be dragged by swiping in from right', (WidgetTester tester) async {
+    final ScrollController scrollController = ScrollController();
+    await tester.pumpWidget(
+      Directionality(
+        textDirection: TextDirection.ltr,
+        child: MediaQuery(
+          data: const MediaQueryData(),
+          child: PrimaryScrollController(
+            controller: scrollController,
+            child: CupertinoScrollbar(
+              controller: scrollController,
+              child: const SingleChildScrollView(child: SizedBox(width: 4000.0, height: 4000.0)),
+            ),
+          ),
+        ),
+      ),
+    );
+
+    expect(scrollController.offset, 0.0);
+
+    // Scroll a bit.
+    const double scrollAmount = 10.0;
+    final TestGesture scrollGesture = await tester.startGesture(tester.getCenter(find.byType(SingleChildScrollView)));
+    // Scroll down by swiping up.
+    await scrollGesture.moveBy(const Offset(0.0, -scrollAmount));
+    await tester.pump();
+    await tester.pump(const Duration(milliseconds: 500));
+    // Scrollbar thumb is fully showing and scroll offset has moved by
+    // scrollAmount.
+    expect(find.byType(CupertinoScrollbar), paints..rrect(
+      color: const Color(0x99777777),
+    ));
+    expect(scrollController.offset, scrollAmount);
+    await scrollGesture.up();
+    await tester.pump();
+
+    // Drag in from the right side on top of the scrollbar thumb.
+    final TestGesture dragScrollbarGesture = await tester.startGesture(const Offset(796.0, 50.0));
+    await tester.pump();
+    await dragScrollbarGesture.moveBy(const Offset(-50.0, 0.0));
+    await tester.pump(_kScrollbarResizeDuration);
+
+    // Drag the thumb down to scroll down.
+    await dragScrollbarGesture.moveBy(const Offset(0.0, scrollAmount));
+    await tester.pump(const Duration(milliseconds: 500));
+    await dragScrollbarGesture.up();
+    await tester.pumpAndSettle();
+
+    // The view has scrolled more than it would have by a swipe gesture of the
+    // same distance.
+    expect(scrollController.offset, greaterThan(scrollAmount * 2));
+    // The scrollbar thumb is still fully visible.
+    expect(find.byType(CupertinoScrollbar), paints..rrect(
+      color: const Color(0x99777777),
+    ));
+
+    // Let the thumb fade out so all timers have resolved.
+    await tester.pump(_kScrollbarTimeToFade);
+    await tester.pump(_kScrollbarFadeDuration);
+  });
 }