From fb2f3e580e8c00abdf749b1c2567064d9a153d37 Mon Sep 17 00:00:00 2001 From: Justin McCandless Date: Wed, 31 Jul 2019 15:52:51 -0700 Subject: [PATCH] iOS 13 scrollbar (#35829) You can drag the cupertinoscrollbar if you pass an active scrollcontroller to the scrollbar. --- .../flutter/lib/src/cupertino/scrollbar.dart | 312 +++++++++++++++++- .../flutter/lib/src/gestures/long_press.dart | 27 ++ .../flutter/lib/src/gestures/monodrag.dart | 16 +- .../flutter/lib/src/widgets/scrollbar.dart | 147 ++++++--- .../lib/src/widgets/text_selection.dart | 1 - .../test/cupertino/scrollbar_paint_test.dart | 5 +- .../test/cupertino/scrollbar_test.dart | 130 +++++++- 7 files changed, 560 insertions(+), 78 deletions(-) diff --git a/packages/flutter/lib/src/cupertino/scrollbar.dart b/packages/flutter/lib/src/cupertino/scrollbar.dart index 119aecd14ba..eb66fbf4521 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 _kScrollbarThickness = 2.5; 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 @@ class CupertinoScrollbar extends StatefulWidget { /// typically a [Scrollable] widget. const CupertinoScrollbar({ Key key, + this.controller, @required this.child, }) : super(key: key); @@ -54,17 +58,64 @@ class CupertinoScrollbar extends StatefulWidget { /// 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 with TickerProviderStateMixin { + final GlobalKey _customPaintKey = GlobalKey(); ScrollbarPainter _painter; TextDirection _textDirection; AnimationController _fadeoutAnimationController; Animation _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 @@ class _CupertinoScrollbarState extends State with TickerProv parent: _fadeoutAnimationController, curve: Curves.fastOutSlowIn, ); + _thicknessAnimationController = AnimationController( + vsync: this, + duration: _kScrollbarResizeDuration, + ); + _thicknessAnimationController.addListener(() { + _painter.updateThickness(_thickness, _radius); + }); } @override @@ -91,17 +149,123 @@ class _CupertinoScrollbarState extends State with TickerProv 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 @@ class _CupertinoScrollbarState extends State with TickerProv _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 get _gestures { + final Map gestures = {}; + 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 @@ class _CupertinoScrollbarState extends State with TickerProv return NotificationListener( 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 932440ce484..93054f1e6c6 100644 --- a/packages/flutter/lib/src/gestures/long_press.dart +++ b/packages/flutter/lib/src/gestures/long_press.dart @@ -6,6 +6,7 @@ import 'arena.dart'; import 'constants.dart'; import 'events.dart'; import 'recognizer.dart'; +import 'velocity_tracker.dart'; /// Callback signature for [LongPressGestureRecognizer.onLongPress]. /// @@ -116,6 +117,7 @@ class LongPressEndDetails { const LongPressEndDetails({ this.globalPosition = Offset.zero, Offset localPosition, + this.velocity = Velocity.zero, }) : assert(globalPosition != null), localPosition = localPosition ?? globalPosition; @@ -124,6 +126,11 @@ class LongPressEndDetails { /// 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 @@ class LongPressGestureRecognizer extends PrimaryPointerGestureRecognizer { /// callback. GestureLongPressEndCallback onLongPressEnd; + VelocityTracker _velocityTracker; + @override bool isPointerAllowed(PointerDownEvent event) { switch (event.buttons) { @@ -242,6 +251,17 @@ class LongPressGestureRecognizer extends PrimaryPointerGestureRecognizer { @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 @@ class LongPressGestureRecognizer extends PrimaryPointerGestureRecognizer { 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('onLongPressEnd', () => onLongPressEnd(details)); if (onLongPressUp != null) @@ -309,6 +335,7 @@ class LongPressGestureRecognizer extends PrimaryPointerGestureRecognizer { _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 af235ea69a6..c2f4f2cfb63 100644 --- a/packages/flutter/lib/src/gestures/monodrag.dart +++ b/packages/flutter/lib/src/gestures/monodrag.dart @@ -184,7 +184,13 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer { /// 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 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer { 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 @@ class VerticalDragGestureRecognizer extends DragGestureRecognizer { }) : 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 @@ class HorizontalDragGestureRecognizer extends DragGestureRecognizer { }) : 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 @@ class PanGestureRecognizer extends DragGestureRecognizer { 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 c5a06043fdd..ebf405a70bc 100644 --- a/packages/flutter/lib/src/widgets/scrollbar.dart +++ b/packages/flutter/lib/src/widgets/scrollbar.dart @@ -11,6 +11,7 @@ import 'package:flutter/rendering.dart'; import 'scroll_metrics.dart'; const double _kMinThumbExtent = 18.0; +const double _kMinInteractiveSize = 48.0; /// A [CustomPainter] for painting scrollbars. /// @@ -77,7 +78,7 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter { 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 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter { /// [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 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter { 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 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter { 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 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter { 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 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter { // 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 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter { 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 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter { || 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 af17c6c1205..424d7c75d67 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:async'; 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 3de11ec1d74..3959b0ce53d 100644 --- a/packages/flutter/test/cupertino/scrollbar_paint_test.dart +++ b/packages/flutter/test/cupertino/scrollbar_paint_test.dart @@ -12,6 +12,7 @@ const Color _kScrollbarColor = Color(0x99777777); // 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 @@ void main() { // 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 @@ void main() { // 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 d5b14600795..5abdac781fc 100644 --- a/packages/flutter/test/cupertino/scrollbar_test.dart +++ b/packages/flutter/test/cupertino/scrollbar_test.dart @@ -8,6 +8,10 @@ import 'package:flutter_test/flutter_test.dart'; 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 @@ void main() { )); 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); + }); }