iOS 13 scrollbar (#35829)
You can drag the cupertinoscrollbar if you pass an active scrollcontroller to the scrollbar.
This commit is contained in:
parent
c7596da5a4
commit
fb2f3e580e
@ -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;
|
|||||||
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 {
|
|||||||
/// 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 {
|
|||||||
/// 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
|
|||||||
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
|
|||||||
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
|
|||||||
_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
|
|||||||
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';
|
|||||||
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 {
|
|||||||
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 {
|
|||||||
|
|
||||||
/// 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 {
|
|||||||
/// 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 {
|
|||||||
|
|
||||||
@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 {
|
|||||||
|
|
||||||
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 {
|
|||||||
_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 {
|
|||||||
/// 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 {
|
|||||||
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 {
|
|||||||
}) : 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 {
|
|||||||
}) : 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 {
|
|||||||
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';
|
|||||||
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 {
|
|||||||
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 {
|
|||||||
/// [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 {
|
|||||||
|
|
||||||
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 {
|
|||||||
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 {
|
|||||||
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 {
|
|||||||
|
|
||||||
// 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 {
|
|||||||
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 {
|
|||||||
|| 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';
|
|||||||
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);
|
|||||||
// 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() {
|
|||||||
// 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() {
|
|||||||
// 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';
|
|||||||
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() {
|
|||||||
));
|
));
|
||||||
|
|
||||||
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user