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