Unverified Commit fb2f3e58 authored by Justin McCandless's avatar Justin McCandless Committed by GitHub

iOS 13 scrollbar (#35829)

You can drag the cupertinoscrollbar if you pass an active scrollcontroller to the scrollbar.
parent c7596da5
...@@ -4,18 +4,22 @@ ...@@ -4,18 +4,22 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/gestures.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
// All values eyeballed. // All values eyeballed.
const Color _kScrollbarColor = Color(0x99777777); const Color _kScrollbarColor = Color(0x99777777);
const double _kScrollbarMinLength = 36.0; const double _kScrollbarMinLength = 36.0;
const double _kScrollbarMinOverscrollLength = 8.0; const double _kScrollbarMinOverscrollLength = 8.0;
const Radius _kScrollbarRadius = Radius.circular(1.25); const Radius _kScrollbarRadius = Radius.circular(1.5);
const Duration _kScrollbarTimeToFade = Duration(milliseconds: 50); const Radius _kScrollbarRadiusDragging = Radius.circular(4.0);
const Duration _kScrollbarTimeToFade = Duration(milliseconds: 1200);
const Duration _kScrollbarFadeDuration = Duration(milliseconds: 250); 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 _kScrollbarThickness = 2.5;
const double _kScrollbarThicknessDragging = 8.0;
// This is the amount of space from the top of a vertical scrollbar to the // 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 // top edge of the scrollable, measured when the vertical scrollbar overscrolls
// to the top. // to the top.
...@@ -23,7 +27,6 @@ const double _kScrollbarThickness = 2.5; ...@@ -23,7 +27,6 @@ const double _kScrollbarThickness = 2.5;
const double _kScrollbarMainAxisMargin = 3.0; const double _kScrollbarMainAxisMargin = 3.0;
const double _kScrollbarCrossAxisMargin = 3.0; const double _kScrollbarCrossAxisMargin = 3.0;
/// An iOS style scrollbar. /// An iOS style scrollbar.
/// ///
/// A scrollbar indicates which portion of a [Scrollable] widget is actually /// A scrollbar indicates which portion of a [Scrollable] widget is actually
...@@ -45,6 +48,7 @@ class CupertinoScrollbar extends StatefulWidget { ...@@ -45,6 +48,7 @@ class CupertinoScrollbar extends StatefulWidget {
/// typically a [Scrollable] widget. /// typically a [Scrollable] widget.
const CupertinoScrollbar({ const CupertinoScrollbar({
Key key, Key key,
this.controller,
@required this.child, @required this.child,
}) : super(key: key); }) : super(key: key);
...@@ -54,17 +58,64 @@ class CupertinoScrollbar extends StatefulWidget { ...@@ -54,17 +58,64 @@ class CupertinoScrollbar extends StatefulWidget {
/// typically a [Scrollable] widget. /// typically a [Scrollable] widget.
final Widget child; 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 @override
_CupertinoScrollbarState createState() => _CupertinoScrollbarState(); _CupertinoScrollbarState createState() => _CupertinoScrollbarState();
} }
class _CupertinoScrollbarState extends State<CupertinoScrollbar> with TickerProviderStateMixin { class _CupertinoScrollbarState extends State<CupertinoScrollbar> with TickerProviderStateMixin {
final GlobalKey _customPaintKey = GlobalKey();
ScrollbarPainter _painter; ScrollbarPainter _painter;
TextDirection _textDirection; TextDirection _textDirection;
AnimationController _fadeoutAnimationController; AnimationController _fadeoutAnimationController;
Animation<double> _fadeoutOpacityAnimation; Animation<double> _fadeoutOpacityAnimation;
AnimationController _thicknessAnimationController;
Timer _fadeoutTimer; 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 @override
void initState() { void initState() {
...@@ -77,6 +128,13 @@ class _CupertinoScrollbarState extends State<CupertinoScrollbar> with TickerProv ...@@ -77,6 +128,13 @@ class _CupertinoScrollbarState extends State<CupertinoScrollbar> with TickerProv
parent: _fadeoutAnimationController, parent: _fadeoutAnimationController,
curve: Curves.fastOutSlowIn, curve: Curves.fastOutSlowIn,
); );
_thicknessAnimationController = AnimationController(
vsync: this,
duration: _kScrollbarResizeDuration,
);
_thicknessAnimationController.addListener(() {
_painter.updateThickness(_thickness, _radius);
});
} }
@override @override
...@@ -91,17 +149,123 @@ class _CupertinoScrollbarState extends State<CupertinoScrollbar> with TickerProv ...@@ -91,17 +149,123 @@ class _CupertinoScrollbarState extends State<CupertinoScrollbar> with TickerProv
return ScrollbarPainter( return ScrollbarPainter(
color: _kScrollbarColor, color: _kScrollbarColor,
textDirection: _textDirection, textDirection: _textDirection,
thickness: _kScrollbarThickness, thickness: _thickness,
fadeoutOpacityAnimation: _fadeoutOpacityAnimation, fadeoutOpacityAnimation: _fadeoutOpacityAnimation,
mainAxisMargin: _kScrollbarMainAxisMargin, mainAxisMargin: _kScrollbarMainAxisMargin,
crossAxisMargin: _kScrollbarCrossAxisMargin, crossAxisMargin: _kScrollbarCrossAxisMargin,
radius: _kScrollbarRadius, radius: _radius,
padding: MediaQuery.of(context).padding, padding: MediaQuery.of(context).padding,
minLength: _kScrollbarMinLength, minLength: _kScrollbarMinLength,
minOverscrollLength: _kScrollbarMinOverscrollLength, 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) { bool _handleScrollNotification(ScrollNotification notification) {
final ScrollMetrics metrics = notification.metrics; final ScrollMetrics metrics = notification.metrics;
if (metrics.maxScrollExtent <= metrics.minScrollExtent) { if (metrics.maxScrollExtent <= metrics.minScrollExtent) {
...@@ -119,19 +283,58 @@ class _CupertinoScrollbarState extends State<CupertinoScrollbar> with TickerProv ...@@ -119,19 +283,58 @@ class _CupertinoScrollbarState extends State<CupertinoScrollbar> with TickerProv
_painter.update(notification.metrics, notification.metrics.axisDirection); _painter.update(notification.metrics, notification.metrics.axisDirection);
} else if (notification is ScrollEndNotification) { } else if (notification is ScrollEndNotification) {
// On iOS, the scrollbar can only go away once the user lifted the finger. // On iOS, the scrollbar can only go away once the user lifted the finger.
if (_dragScrollbarPositionY == null) {
_fadeoutTimer?.cancel(); _startFadeoutTimer();
_fadeoutTimer = Timer(_kScrollbarTimeToFade, () { }
_fadeoutAnimationController.reverse();
_fadeoutTimer = null;
});
} }
return false; 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 @override
void dispose() { void dispose() {
_fadeoutAnimationController.dispose(); _fadeoutAnimationController.dispose();
_thicknessAnimationController.dispose();
_fadeoutTimer?.cancel(); _fadeoutTimer?.cancel();
_painter.dispose(); _painter.dispose();
super.dispose(); super.dispose();
...@@ -142,13 +345,90 @@ class _CupertinoScrollbarState extends State<CupertinoScrollbar> with TickerProv ...@@ -142,13 +345,90 @@ class _CupertinoScrollbarState extends State<CupertinoScrollbar> with TickerProv
return NotificationListener<ScrollNotification>( return NotificationListener<ScrollNotification>(
onNotification: _handleScrollNotification, onNotification: _handleScrollNotification,
child: RepaintBoundary( child: RepaintBoundary(
child: RawGestureDetector(
gestures: _gestures,
child: CustomPaint( child: CustomPaint(
key: _customPaintKey,
foregroundPainter: _painter, foregroundPainter: _painter,
child: RepaintBoundary( child: RepaintBoundary(
child: widget.child, 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);
}
...@@ -6,6 +6,7 @@ import 'arena.dart'; ...@@ -6,6 +6,7 @@ import 'arena.dart';
import 'constants.dart'; import 'constants.dart';
import 'events.dart'; import 'events.dart';
import 'recognizer.dart'; import 'recognizer.dart';
import 'velocity_tracker.dart';
/// Callback signature for [LongPressGestureRecognizer.onLongPress]. /// Callback signature for [LongPressGestureRecognizer.onLongPress].
/// ///
...@@ -116,6 +117,7 @@ class LongPressEndDetails { ...@@ -116,6 +117,7 @@ class LongPressEndDetails {
const LongPressEndDetails({ const LongPressEndDetails({
this.globalPosition = Offset.zero, this.globalPosition = Offset.zero,
Offset localPosition, Offset localPosition,
this.velocity = Velocity.zero,
}) : assert(globalPosition != null), }) : assert(globalPosition != null),
localPosition = localPosition ?? globalPosition; localPosition = localPosition ?? globalPosition;
...@@ -124,6 +126,11 @@ class LongPressEndDetails { ...@@ -124,6 +126,11 @@ class LongPressEndDetails {
/// The local position at which the pointer contacted the screen. /// The local position at which the pointer contacted the screen.
final Offset localPosition; 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 /// Recognizes when the user has pressed down at the same location for a long
...@@ -214,6 +221,8 @@ class LongPressGestureRecognizer extends PrimaryPointerGestureRecognizer { ...@@ -214,6 +221,8 @@ class LongPressGestureRecognizer extends PrimaryPointerGestureRecognizer {
/// callback. /// callback.
GestureLongPressEndCallback onLongPressEnd; GestureLongPressEndCallback onLongPressEnd;
VelocityTracker _velocityTracker;
@override @override
bool isPointerAllowed(PointerDownEvent event) { bool isPointerAllowed(PointerDownEvent event) {
switch (event.buttons) { switch (event.buttons) {
...@@ -242,6 +251,17 @@ class LongPressGestureRecognizer extends PrimaryPointerGestureRecognizer { ...@@ -242,6 +251,17 @@ class LongPressGestureRecognizer extends PrimaryPointerGestureRecognizer {
@override @override
void handlePrimaryPointer(PointerEvent event) { 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 (event is PointerUpEvent) {
if (_longPressAccepted == true) { if (_longPressAccepted == true) {
_checkLongPressEnd(event); _checkLongPressEnd(event);
...@@ -295,10 +315,16 @@ class LongPressGestureRecognizer extends PrimaryPointerGestureRecognizer { ...@@ -295,10 +315,16 @@ class LongPressGestureRecognizer extends PrimaryPointerGestureRecognizer {
void _checkLongPressEnd(PointerEvent event) { void _checkLongPressEnd(PointerEvent event) {
assert(_initialButtons == kPrimaryButton); assert(_initialButtons == kPrimaryButton);
final VelocityEstimate estimate = _velocityTracker.getVelocityEstimate();
final Velocity velocity = estimate == null ? Velocity.zero : Velocity(pixelsPerSecond: estimate.pixelsPerSecond);
final LongPressEndDetails details = LongPressEndDetails( final LongPressEndDetails details = LongPressEndDetails(
globalPosition: event.position, globalPosition: event.position,
localPosition: event.localPosition, localPosition: event.localPosition,
velocity: velocity,
); );
_velocityTracker = null;
if (onLongPressEnd != null) if (onLongPressEnd != null)
invokeCallback<void>('onLongPressEnd', () => onLongPressEnd(details)); invokeCallback<void>('onLongPressEnd', () => onLongPressEnd(details));
if (onLongPressUp != null) if (onLongPressUp != null)
...@@ -309,6 +335,7 @@ class LongPressGestureRecognizer extends PrimaryPointerGestureRecognizer { ...@@ -309,6 +335,7 @@ class LongPressGestureRecognizer extends PrimaryPointerGestureRecognizer {
_longPressAccepted = false; _longPressAccepted = false;
_longPressOrigin = null; _longPressOrigin = null;
_initialButtons = null; _initialButtons = null;
_velocityTracker = null;
} }
@override @override
......
...@@ -184,7 +184,13 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer { ...@@ -184,7 +184,13 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer {
/// differentiate the direction of the drag. /// differentiate the direction of the drag.
double _globalDistanceMoved; 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); Offset _getDeltaForDetails(Offset delta);
double _getPrimaryValueFromOffset(Offset value); double _getPrimaryValueFromOffset(Offset value);
bool get _hasSufficientGlobalDistanceToAccept; bool get _hasSufficientGlobalDistanceToAccept;
...@@ -395,7 +401,7 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer { ...@@ -395,7 +401,7 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer {
void Function() debugReport; void Function() debugReport;
final VelocityEstimate estimate = tracker.getVelocityEstimate(); final VelocityEstimate estimate = tracker.getVelocityEstimate();
if (estimate != null && _isFlingGesture(estimate)) { if (estimate != null && isFlingGesture(estimate)) {
final Velocity velocity = Velocity(pixelsPerSecond: estimate.pixelsPerSecond) final Velocity velocity = Velocity(pixelsPerSecond: estimate.pixelsPerSecond)
.clampMagnitude(minFlingVelocity ?? kMinFlingVelocity, maxFlingVelocity ?? kMaxFlingVelocity); .clampMagnitude(minFlingVelocity ?? kMinFlingVelocity, maxFlingVelocity ?? kMaxFlingVelocity);
details = DragEndDetails( details = DragEndDetails(
...@@ -457,7 +463,7 @@ class VerticalDragGestureRecognizer extends DragGestureRecognizer { ...@@ -457,7 +463,7 @@ class VerticalDragGestureRecognizer extends DragGestureRecognizer {
}) : super(debugOwner: debugOwner, kind: kind); }) : super(debugOwner: debugOwner, kind: kind);
@override @override
bool _isFlingGesture(VelocityEstimate estimate) { bool isFlingGesture(VelocityEstimate estimate) {
final double minVelocity = minFlingVelocity ?? kMinFlingVelocity; final double minVelocity = minFlingVelocity ?? kMinFlingVelocity;
final double minDistance = minFlingDistance ?? kTouchSlop; final double minDistance = minFlingDistance ?? kTouchSlop;
return estimate.pixelsPerSecond.dy.abs() > minVelocity && estimate.offset.dy.abs() > minDistance; return estimate.pixelsPerSecond.dy.abs() > minVelocity && estimate.offset.dy.abs() > minDistance;
...@@ -496,7 +502,7 @@ class HorizontalDragGestureRecognizer extends DragGestureRecognizer { ...@@ -496,7 +502,7 @@ class HorizontalDragGestureRecognizer extends DragGestureRecognizer {
}) : super(debugOwner: debugOwner, kind: kind); }) : super(debugOwner: debugOwner, kind: kind);
@override @override
bool _isFlingGesture(VelocityEstimate estimate) { bool isFlingGesture(VelocityEstimate estimate) {
final double minVelocity = minFlingVelocity ?? kMinFlingVelocity; final double minVelocity = minFlingVelocity ?? kMinFlingVelocity;
final double minDistance = minFlingDistance ?? kTouchSlop; final double minDistance = minFlingDistance ?? kTouchSlop;
return estimate.pixelsPerSecond.dx.abs() > minVelocity && estimate.offset.dx.abs() > minDistance; return estimate.pixelsPerSecond.dx.abs() > minVelocity && estimate.offset.dx.abs() > minDistance;
...@@ -529,7 +535,7 @@ class PanGestureRecognizer extends DragGestureRecognizer { ...@@ -529,7 +535,7 @@ class PanGestureRecognizer extends DragGestureRecognizer {
PanGestureRecognizer({ Object debugOwner }) : super(debugOwner: debugOwner); PanGestureRecognizer({ Object debugOwner }) : super(debugOwner: debugOwner);
@override @override
bool _isFlingGesture(VelocityEstimate estimate) { bool isFlingGesture(VelocityEstimate estimate) {
final double minVelocity = minFlingVelocity ?? kMinFlingVelocity; final double minVelocity = minFlingVelocity ?? kMinFlingVelocity;
final double minDistance = minFlingDistance ?? kTouchSlop; final double minDistance = minFlingDistance ?? kTouchSlop;
return estimate.pixelsPerSecond.distanceSquared > minVelocity * minVelocity return estimate.pixelsPerSecond.distanceSquared > minVelocity * minVelocity
......
...@@ -11,6 +11,7 @@ import 'package:flutter/rendering.dart'; ...@@ -11,6 +11,7 @@ import 'package:flutter/rendering.dart';
import 'scroll_metrics.dart'; import 'scroll_metrics.dart';
const double _kMinThumbExtent = 18.0; const double _kMinThumbExtent = 18.0;
const double _kMinInteractiveSize = 48.0;
/// A [CustomPainter] for painting scrollbars. /// A [CustomPainter] for painting scrollbars.
/// ///
...@@ -77,7 +78,7 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter { ...@@ -77,7 +78,7 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter {
final TextDirection textDirection; final TextDirection textDirection;
/// Thickness of the scrollbar in its cross-axis in logical pixels. Mustn't be null. /// 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. /// An opacity [Animation] that dictates the opacity of the thumb.
/// Changes in value of this [Listenable] will automatically trigger repaints. /// Changes in value of this [Listenable] will automatically trigger repaints.
...@@ -98,7 +99,7 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter { ...@@ -98,7 +99,7 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter {
/// [Radius] of corners if the scrollbar should have rounded corners. /// [Radius] of corners if the scrollbar should have rounded corners.
/// ///
/// Scrollbar will be rectangular if [radius] is null. /// 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 /// 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. /// well as its side to the nearest edge, in logical pixels.
...@@ -138,6 +139,7 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter { ...@@ -138,6 +139,7 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter {
ScrollMetrics _lastMetrics; ScrollMetrics _lastMetrics;
AxisDirection _lastAxisDirection; AxisDirection _lastAxisDirection;
Rect _thumbRect;
/// Update with new [ScrollMetrics]. The scrollbar will show and redraw itself /// Update with new [ScrollMetrics]. The scrollbar will show and redraw itself
/// based on these new metrics. /// based on these new metrics.
...@@ -152,6 +154,13 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter { ...@@ -152,6 +154,13 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter {
notifyListeners(); notifyListeners();
} }
/// Update and redraw with new scrollbar thickness and radius.
void updateThickness(double nextThickness, Radius nextRadius) {
thickness = nextThickness;
radius = nextRadius;
notifyListeners();
}
Paint get _paint { Paint get _paint {
return Paint()..color = return Paint()..color =
color.withOpacity(color.opacity * fadeoutOpacityAnimation.value); color.withOpacity(color.opacity * fadeoutOpacityAnimation.value);
...@@ -188,35 +197,28 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter { ...@@ -188,35 +197,28 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter {
break; break;
} }
final Rect thumbRect = Offset(x, y) & thumbSize; _thumbRect = Offset(x, y) & thumbSize;
if (radius == null) if (radius == null)
canvas.drawRect(thumbRect, _paint); canvas.drawRect(_thumbRect, _paint);
else else
canvas.drawRRect(RRect.fromRectAndRadius(thumbRect, radius), _paint); canvas.drawRRect(RRect.fromRectAndRadius(_thumbRect, radius), _paint);
} }
double _thumbExtent( double _thumbExtent() {
double mainAxisPadding,
double extentInside,
double contentExtent,
double beforeExtent,
double afterExtent,
double trackExtent
) {
// Thumb extent reflects fraction of content visible, as long as this // Thumb extent reflects fraction of content visible, as long as this
// isn't less than the absolute minimum size. // isn't less than the absolute minimum size.
// contentExtent >= viewportDimension, so (contentExtent - mainAxisPadding) > 0 // _totalContentExtent >= viewportDimension, so (_totalContentExtent - _mainAxisPadding) > 0
final double fractionVisible = ((extentInside - mainAxisPadding) / (contentExtent - mainAxisPadding)) final double fractionVisible = ((_lastMetrics.extentInside - _mainAxisPadding) / (_totalContentExtent - _mainAxisPadding))
.clamp(0.0, 1.0); .clamp(0.0, 1.0);
final double thumbExtent = math.max( final double thumbExtent = math.max(
math.min(trackExtent, minOverscrollLength), math.min(_trackExtent, minOverscrollLength),
trackExtent * fractionVisible _trackExtent * fractionVisible
); );
final double fractionOverscrolled = 1.0 - extentInside / _lastMetrics.viewportDimension; final double fractionOverscrolled = 1.0 - _lastMetrics.extentInside / _lastMetrics.viewportDimension;
final double safeMinLength = math.min(minLength, trackExtent); final double safeMinLength = math.min(minLength, _trackExtent);
final double newMinLength = (beforeExtent > 0 && afterExtent > 0) final double newMinLength = (_beforeExtent > 0 && _afterExtent > 0)
// Thumb extent is no smaller than minLength if scrolling normally. // Thumb extent is no smaller than minLength if scrolling normally.
? safeMinLength ? safeMinLength
// User is overscrolling. Thumb extent can be less than minLength // User is overscrolling. Thumb extent can be less than minLength
...@@ -234,7 +236,7 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter { ...@@ -234,7 +236,7 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter {
// The `thumbExtent` should be no greater than `trackSize`, otherwise // The `thumbExtent` should be no greater than `trackSize`, otherwise
// the scrollbar may scroll towards the wrong direction. // the scrollbar may scroll towards the wrong direction.
return thumbExtent.clamp(newMinLength, trackExtent); return thumbExtent.clamp(newMinLength, _trackExtent);
} }
@override @override
...@@ -243,6 +245,47 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter { ...@@ -243,6 +245,47 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter {
super.dispose(); 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 @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
if (_lastAxisDirection == null if (_lastAxisDirection == null
...@@ -250,45 +293,47 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter { ...@@ -250,45 +293,47 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter {
|| fadeoutOpacityAnimation.value == 0.0) || fadeoutOpacityAnimation.value == 0.0)
return; 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. // Skip painting if there's not enough space.
if (_lastMetrics.viewportDimension <= mainAxisPadding || trackExtent <= 0) { if (_lastMetrics.viewportDimension <= _mainAxisPadding || _trackExtent <= 0) {
return; return;
} }
final double totalContentExtent = final double beforePadding = _isVertical ? padding.top : padding.left;
_lastMetrics.maxScrollExtent final double thumbExtent = _thumbExtent();
- _lastMetrics.minScrollExtent final double thumbOffsetLocal = _getScrollToTrack(_lastMetrics, thumbExtent);
+ _lastMetrics.viewportDimension; final double thumbOffset = thumbOffsetLocal + mainAxisMargin + beforePadding;
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;
return _paintThumbCrossAxis(canvas, size, thumbOffset, thumbExtent, _lastAxisDirection); 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 @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 @override
bool shouldRepaint(ScrollbarPainter old) { bool shouldRepaint(ScrollbarPainter old) {
......
...@@ -6,7 +6,6 @@ import 'dart:async'; ...@@ -6,7 +6,6 @@ import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart' show kDoubleTapTimeout, kDoubleTapSlop;
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
......
...@@ -12,6 +12,7 @@ const Color _kScrollbarColor = Color(0x99777777); ...@@ -12,6 +12,7 @@ const Color _kScrollbarColor = Color(0x99777777);
// The `y` offset has to be larger than `ScrollDragController._bigThresholdBreakDistance` // The `y` offset has to be larger than `ScrollDragController._bigThresholdBreakDistance`
// to prevent [motionStartDistanceThreshold] from affecting the actual drag distance. // to prevent [motionStartDistanceThreshold] from affecting the actual drag distance.
const Offset _kGestureOffset = Offset(0, -25); const Offset _kGestureOffset = Offset(0, -25);
const Radius _kScrollbarRadius = Radius.circular(1.5);
void main() { void main() {
testWidgets('Paints iOS spec', (WidgetTester tester) async { testWidgets('Paints iOS spec', (WidgetTester tester) async {
...@@ -47,7 +48,7 @@ void main() { ...@@ -47,7 +48,7 @@ void main() {
// Fraction in viewport * scrollbar height - top, bottom margin. // Fraction in viewport * scrollbar height - top, bottom margin.
600.0 / 4000.0 * (600.0 - 2 * 3), 600.0 / 4000.0 * (600.0 - 2 * 3),
), ),
const Radius.circular(1.25), _kScrollbarRadius,
), ),
)); ));
}); });
...@@ -92,7 +93,7 @@ void main() { ...@@ -92,7 +93,7 @@ void main() {
// where Fraction visible = (viewport size - padding) / content size // where Fraction visible = (viewport size - padding) / content size
(600.0 - 34 - 44 - 20) / 4000.0 * (600.0 - 2 * 3 - 34 - 44 - 20), (600.0 - 34 - 44 - 20) / 4000.0 * (600.0 - 2 * 3 - 34 - 44 - 20),
), ),
const Radius.circular(1.25), _kScrollbarRadius,
), ),
)); ));
}); });
......
...@@ -8,6 +8,10 @@ import 'package:flutter_test/flutter_test.dart'; ...@@ -8,6 +8,10 @@ import 'package:flutter_test/flutter_test.dart';
import '../rendering/mock_canvas.dart'; import '../rendering/mock_canvas.dart';
void main() { 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 { testWidgets('Scrollbar never goes away until finger lift', (WidgetTester tester) async {
await tester.pumpWidget( await tester.pumpWidget(
const Directionality( const Directionality(
...@@ -37,12 +41,132 @@ void main() { ...@@ -37,12 +41,132 @@ void main() {
)); ));
await gesture.up(); await gesture.up();
await tester.pump(const Duration(milliseconds: 200)); await tester.pump(_kScrollbarTimeToFade);
await tester.pump(const Duration(milliseconds: 200)); await tester.pump(_kScrollbarFadeDuration * 0.5);
// Opacity going down now. // Opacity going down now.
expect(find.byType(CupertinoScrollbar), paints..rrect( 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);
});
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment