Commit 0120c414 authored by Pieter van Loon's avatar Pieter van Loon Committed by Justin McCandless

Improved ios 13 scrollbar fidelity (#41799)

Drag from the right is no more
Longpress is now only 100ms instead of 500
Added optional duration field to longpressgesturerecognizer
Added controller field to material scrollbar api
Haptic feedback only triggers when scrollbar is fully expanded
Added haptic feedback when releasing the scrollbar after dragging it
parent 2cedd559
......@@ -15,7 +15,7 @@ const double _kScrollbarMinLength = 36.0;
const double _kScrollbarMinOverscrollLength = 8.0;
const Duration _kScrollbarTimeToFade = Duration(milliseconds: 1200);
const Duration _kScrollbarFadeDuration = Duration(milliseconds: 250);
const Duration _kScrollbarResizeDuration = Duration(milliseconds: 150);
const Duration _kScrollbarResizeDuration = Duration(milliseconds: 100);
// Extracted from iOS 13.1 beta using Debug View Hierarchy.
const Color _kScrollbarColor = CupertinoDynamicColor.withBrightness(
......@@ -42,6 +42,10 @@ const double _kScrollbarCrossAxisMargin = 3.0;
/// To add a scrollbar to a [ScrollView], simply wrap the scroll view widget in
/// a [CupertinoScrollbar] widget.
///
/// By default, the CupertinoScrollbar will be draggable (a feature introduced
/// in iOS 13), it uses the PrimaryScrollController. For multiple scrollbars, or
/// other more complicated situations, see the [controller] parameter.
///
/// See also:
///
/// * [ListView], which display a linear, scrollable list of children.
......@@ -65,39 +69,60 @@ class CupertinoScrollbar extends StatefulWidget {
/// typically a [Scrollable] widget.
final Widget child;
/// {@template flutter.cupertino.cupertinoScrollbar.controller}
/// 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.
/// If nothing is passed to controller, the default behavior is to automatically
/// enable scrollbar dragging on the nearest ScrollController using
/// [PrimaryScrollController.of].
///
/// If a ScrollController is passed, then scrollbar dragging will be enabled on
/// the given ScrollController. 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:
/// Here is an example of using the `controller` parameter to enable
/// scrollbar dragging for multiple independent ListViews:
///
/// {@tool sample}
///
/// ```dart
/// final ScrollController _controllerOne = ScrollController();
/// final ScrollController _controllerTwo = ScrollController();
///
/// build(BuildContext context) {
/// final ScrollController controller = ScrollController();
/// return PrimaryScrollController(
/// controller: controller,
/// return Column(
/// children: <Widget>[
/// Container(
/// height: 200,
/// child: CupertinoScrollbar(
/// controller: controller,
/// controller: _controllerOne,
/// child: ListView.builder(
/// itemCount: 150,
/// controller: _controllerOne,
/// itemCount: 120,
/// itemBuilder: (BuildContext context, int index) => Text('item $index'),
/// ),
/// ),
/// ),
/// Container(
/// height: 200,
/// child: CupertinoScrollbar(
/// controller: _controllerTwo,
/// child: ListView.builder(
/// controller: _controllerTwo,
/// itemCount: 120,
/// itemBuilder: (BuildContext context, int index) => Text('list 2 item $index'),
/// ),
/// ),
/// ),
/// ],
/// );
/// }
/// ```
/// {@end-tool}
/// {@endtemplate}
final ScrollController controller;
@override
......@@ -123,6 +148,10 @@ class _CupertinoScrollbarState extends State<CupertinoScrollbar> with TickerProv
return Radius.lerp(_kScrollbarRadius, _kScrollbarRadiusDragging, _thicknessAnimationController.value);
}
ScrollController _currentController;
ScrollController get _controller =>
widget.controller ?? PrimaryScrollController.of(context);
@override
void initState() {
super.initState();
......@@ -148,8 +177,7 @@ class _CupertinoScrollbarState extends State<CupertinoScrollbar> with TickerProv
super.didChangeDependencies();
if (_painter == null) {
_painter = _buildCupertinoScrollbarPainter(context);
}
else {
} else {
_painter
..textDirection = Directionality.of(context)
..color = CupertinoDynamicColor.resolve(_kScrollbarColor, context)
......@@ -175,16 +203,16 @@ class _CupertinoScrollbarState extends State<CupertinoScrollbar> with TickerProv
// Handle a gesture that drags the scrollbar by the given amount.
void _dragScrollbar(double primaryDelta) {
assert(widget.controller != null);
assert(_currentController != 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;
final double scrollOffsetGlobal = scrollOffsetLocal + _currentController.position.pixels;
if (_drag == null) {
_drag = widget.controller.position.drag(
_drag = _currentController.position.drag(
DragStartDetails(
globalPosition: Offset(0.0, scrollOffsetGlobal),
),
......@@ -207,64 +235,62 @@ class _CupertinoScrollbarState extends State<CupertinoScrollbar> with TickerProv
});
}
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.',
);
bool _checkVertical() {
try {
return _currentController.position.axis == Axis.vertical;
} catch (_) {
// Ignore the gesture if we cannot determine the direction.
return false;
}
}
double _pressStartY = 0.0;
// 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();
_currentController = _controller;
if (!_checkVertical()) {
return;
}
_pressStartY = details.localPosition.dy;
_fadeoutTimer?.cancel();
_fadeoutAnimationController.forward();
HapticFeedback.mediumImpact();
_dragScrollbar(details.localPosition.dy);
_dragScrollbarPositionY = details.localPosition.dy;
}
void _handleLongPress() {
_assertVertical();
if (!_checkVertical()) {
return;
}
_fadeoutTimer?.cancel();
_thicknessAnimationController.forward();
_thicknessAnimationController.forward().then<void>(
(_) => HapticFeedback.mediumImpact(),
);
}
void _handleLongPressMoveUpdate(LongPressMoveUpdateDetails details) {
_assertVertical();
if (!_checkVertical()) {
return;
}
_dragScrollbar(details.localPosition.dy - _dragScrollbarPositionY);
_dragScrollbarPositionY = details.localPosition.dy;
}
void _handleLongPressEnd(LongPressEndDetails details) {
_handleDragScrollEnd(details.velocity.pixelsPerSecond.dy);
if (!_checkVertical()) {
return;
}
// 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();
_handleDragScrollEnd(details.velocity.pixelsPerSecond.dy);
if (details.velocity.pixelsPerSecond.dy.abs() < 10 &&
(details.localPosition.dy - _pressStartY).abs() > 0) {
HapticFeedback.mediumImpact();
_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);
_currentController = null;
}
void _handleDragScrollEnd(double trackVelocityY) {
_assertVertical();
_startFadeoutTimer();
_thicknessAnimationController.reverse();
_dragScrollbarPositionY = null;
......@@ -308,19 +334,16 @@ class _CupertinoScrollbarState extends State<CupertinoScrollbar> with TickerProv
// 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;
}
final Map<Type, GestureRecognizerFactory> gestures =
<Type, GestureRecognizerFactory>{};
gestures[_ThumbLongPressGestureRecognizer] =
GestureRecognizerFactoryWithHandlers<_ThumbLongPressGestureRecognizer>(
() => _ThumbLongPressGestureRecognizer(
gestures[_ThumbPressGestureRecognizer] =
GestureRecognizerFactoryWithHandlers<_ThumbPressGestureRecognizer>(
() => _ThumbPressGestureRecognizer(
debugOwner: this,
kind: PointerDeviceKind.touch,
customPaintKey: _customPaintKey,
),
(_ThumbLongPressGestureRecognizer instance) {
(_ThumbPressGestureRecognizer instance) {
instance
..onLongPressStart = _handleLongPressStart
..onLongPress = _handleLongPress
......@@ -328,20 +351,6 @@ class _CupertinoScrollbarState extends State<CupertinoScrollbar> with TickerProv
..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;
}
......@@ -375,8 +384,8 @@ class _CupertinoScrollbarState extends State<CupertinoScrollbar> with TickerProv
// A longpress gesture detector that only responds to events on the scrollbar's
// thumb and ignores everything else.
class _ThumbLongPressGestureRecognizer extends LongPressGestureRecognizer {
_ThumbLongPressGestureRecognizer({
class _ThumbPressGestureRecognizer extends LongPressGestureRecognizer {
_ThumbPressGestureRecognizer({
double postAcceptSlopTolerance,
PointerDeviceKind kind,
Object debugOwner,
......@@ -386,6 +395,7 @@ class _ThumbLongPressGestureRecognizer extends LongPressGestureRecognizer {
postAcceptSlopTolerance: postAcceptSlopTolerance,
kind: kind,
debugOwner: debugOwner,
duration: const Duration(milliseconds: 100),
);
final GlobalKey _customPaintKey;
......@@ -399,39 +409,6 @@ class _ThumbLongPressGestureRecognizer extends LongPressGestureRecognizer {
}
}
// 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.
......
......@@ -156,12 +156,16 @@ class LongPressGestureRecognizer extends PrimaryPointerGestureRecognizer {
/// subsequent callbacks ([onLongPressMoveUpdate], [onLongPressUp],
/// [onLongPressEnd]) will stop. Defaults to null, which means the gesture
/// can be moved without limit once the long press is accepted.
///
/// The [duration] argument can be used to overwrite the default duration
/// after which the long press will be recognized.
LongPressGestureRecognizer({
Duration duration,
double postAcceptSlopTolerance,
PointerDeviceKind kind,
Object debugOwner,
}) : super(
deadline: kLongPressTimeout,
deadline: duration ?? kLongPressTimeout,
postAcceptSlopTolerance: postAcceptSlopTolerance,
kind: kind,
debugOwner: debugOwner,
......
......@@ -36,6 +36,7 @@ class Scrollbar extends StatefulWidget {
const Scrollbar({
Key key,
@required this.child,
this.controller,
}) : super(key: key);
/// The widget below this widget in the tree.
......@@ -46,11 +47,13 @@ class Scrollbar extends StatefulWidget {
/// Typically a [ListView] or [CustomScrollView].
final Widget child;
/// {@macro flutter.cupertino.cupertinoScrollbar.controller}
final ScrollController controller;
@override
_ScrollbarState createState() => _ScrollbarState();
}
class _ScrollbarState extends State<Scrollbar> with TickerProviderStateMixin {
ScrollbarPainter _materialPainter;
TextDirection _textDirection;
......@@ -148,6 +151,7 @@ class _ScrollbarState extends State<Scrollbar> with TickerProviderStateMixin {
if (_useCupertinoScrollbar) {
return CupertinoScrollbar(
child: widget.child,
controller: widget.controller,
);
}
return NotificationListener<ScrollNotification>(
......
......@@ -10,13 +10,13 @@ import '../rendering/mock_canvas.dart';
const CupertinoDynamicColor _kScrollbarColor = CupertinoDynamicColor.withBrightness(
color: Color(0x59000000),
darkColor:Color(0x80FFFFFF),
darkColor: Color(0x80FFFFFF),
);
void main() {
const Duration _kScrollbarTimeToFade = Duration(milliseconds: 1200);
const Duration _kScrollbarFadeDuration = Duration(milliseconds: 250);
const Duration _kScrollbarResizeDuration = Duration(milliseconds: 150);
const Duration _kScrollbarResizeDuration = Duration(milliseconds: 100);
testWidgets('Scrollbar never goes away until finger lift', (WidgetTester tester) async {
await tester.pumpWidget(
......@@ -102,9 +102,8 @@ void main() {
data: const MediaQueryData(),
child: PrimaryScrollController(
controller: scrollController,
child: CupertinoScrollbar(
controller: scrollController,
child: const SingleChildScrollView(child: SizedBox(width: 4000.0, height: 4000.0)),
child: const CupertinoScrollbar(
child: SingleChildScrollView(child: SizedBox(width: 4000.0, height: 4000.0)),
),
),
),
......@@ -136,86 +135,19 @@ void main() {
}
});
// Longpress on the scrollbar thumb and expect a vibration.
// Longpress on the scrollbar thumb and expect a vibration after it resizes.
expect(hapticFeedbackCalls, 0);
final TestGesture dragScrollbarGesture = await tester.startGesture(const Offset(796.0, 50.0));
await tester.pump(const Duration(milliseconds: 500));
expect(hapticFeedbackCalls, 1);
// 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: _kScrollbarColor.color,
));
// 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: _kScrollbarColor.color,
));
expect(scrollController.offset, scrollAmount);
await scrollGesture.up();
await tester.pump();
int hapticFeedbackCalls = 0;
SystemChannels.platform.setMockMethodCallHandler((MethodCall methodCall) async {
if (methodCall.method == 'HapticFeedback.vibrate') {
hapticFeedbackCalls++;
}
});
// Drag in from the right side on top of the scrollbar thumb and expect a
// vibration.
await tester.pump(const Duration(milliseconds: 100));
expect(hapticFeedbackCalls, 0);
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);
// Allow the haptic feedback some slack.
await tester.pump(const Duration(milliseconds: 1));
expect(hapticFeedbackCalls, 1);
// Drag the thumb down to scroll down.
await dragScrollbarGesture.moveBy(const Offset(0.0, scrollAmount));
await tester.pump(const Duration(milliseconds: 500));
await tester.pump(const Duration(milliseconds: 100));
await dragScrollbarGesture.up();
await tester.pumpAndSettle();
......
......@@ -80,6 +80,29 @@ void main() {
longPress.dispose();
});
testGesture('Should recognize long press with altered duration', (GestureTester tester) {
longPress = LongPressGestureRecognizer(duration: const Duration(milliseconds: 100));
longPressDown = false;
longPress.onLongPress = () {
longPressDown = true;
};
longPressUp = false;
longPress.onLongPressUp = () {
longPressUp = true;
};
longPress.addPointer(down);
tester.closeArena(5);
expect(longPressDown, isFalse);
tester.route(down);
expect(longPressDown, isFalse);
tester.async.elapse(const Duration(milliseconds: 50));
expect(longPressDown, isFalse);
tester.async.elapse(const Duration(milliseconds: 50));
expect(longPressDown, isTrue);
longPress.dispose();
});
testGesture('Up cancels long press', (GestureTester tester) {
longPress.addPointer(down);
tester.closeArena(5);
......
......@@ -2,6 +2,7 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_test/flutter_test.dart';
......@@ -151,6 +152,38 @@ void main() {
await tester.drag(find.byType(SingleChildScrollView), const Offset(0.0, -10.0));
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
expect(find.byType(Scrollbar), paints..rrect());
expect(find.byType(CupertinoScrollbar), paints..rrect());
});
testWidgets('Scrollbar passes controller to CupertinoScrollbar', (WidgetTester tester) async {
final ScrollController controller = ScrollController();
Widget viewWithScroll(TargetPlatform platform) {
return _buildBoilerplate(
child: Theme(
data: ThemeData(
platform: platform
),
child: Scrollbar(
controller: controller,
child: const SingleChildScrollView(
child: SizedBox(width: 4000.0, height: 4000.0),
),
),
),
);
}
await tester.pumpWidget(viewWithScroll(TargetPlatform.iOS));
final TestGesture gesture = await tester.startGesture(
tester.getCenter(find.byType(SingleChildScrollView))
);
await gesture.moveBy(const Offset(0.0, -10.0));
await tester.drag(find.byType(SingleChildScrollView), const Offset(0.0, -10.0));
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
expect(find.byType(CupertinoScrollbar), paints..rrect());
final CupertinoScrollbar scrollbar = find.byType(CupertinoScrollbar).evaluate().first.widget;
expect(scrollbar.controller, isNotNull);
});
}
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