diff --git a/packages/flutter/lib/src/cupertino/scrollbar.dart b/packages/flutter/lib/src/cupertino/scrollbar.dart index 54f8eada04..d73b394655 100644 --- a/packages/flutter/lib/src/cupertino/scrollbar.dart +++ b/packages/flutter/lib/src/cupertino/scrollbar.dart @@ -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]. /// - /// Here is an example of using PrimaryScrollController to enable scrollbar - /// dragging: + /// 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 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, - /// child: CupertinoScrollbar( - /// controller: controller, - /// child: ListView.builder( - /// itemCount: 150, - /// itemBuilder: (BuildContext context, int index) => Text('item $index'), - /// ), - /// ), + /// return Column( + /// children: [ + /// Container( + /// height: 200, + /// child: CupertinoScrollbar( + /// controller: _controllerOne, + /// child: ListView.builder( + /// 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 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 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 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 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( + (_) => HapticFeedback.mediumImpact(), + ); } void _handleLongPressMoveUpdate(LongPressMoveUpdateDetails details) { - _assertVertical(); + if (!_checkVertical()) { + return; + } _dragScrollbar(details.localPosition.dy - _dragScrollbarPositionY); _dragScrollbarPositionY = details.localPosition.dy; } void _handleLongPressEnd(LongPressEndDetails details) { + if (!_checkVertical()) { + return; + } _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(); - 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); + if (details.velocity.pixelsPerSecond.dy.abs() < 10 && + (details.localPosition.dy - _pressStartY).abs() > 0) { + HapticFeedback.mediumImpact(); + } + _currentController = null; } void _handleDragScrollEnd(double trackVelocityY) { - _assertVertical(); _startFadeoutTimer(); _thicknessAnimationController.reverse(); _dragScrollbarPositionY = null; @@ -308,40 +334,23 @@ class _CupertinoScrollbarState extends State with TickerProv // Get the GestureRecognizerFactories used to detect gestures on the scrollbar // thumb. Map get _gestures { - final Map gestures = {}; - if (widget.controller == null) { - return gestures; - } + final Map 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; - }, - ); + gestures[_ThumbPressGestureRecognizer] = + GestureRecognizerFactoryWithHandlers<_ThumbPressGestureRecognizer>( + () => _ThumbPressGestureRecognizer( + debugOwner: this, + customPaintKey: _customPaintKey, + ), + (_ThumbPressGestureRecognizer instance) { + instance + ..onLongPressStart = _handleLongPressStart + ..onLongPress = _handleLongPress + ..onLongPressMoveUpdate = _handleLongPressMoveUpdate + ..onLongPressEnd = _handleLongPressEnd; + }, + ); return gestures; } @@ -375,8 +384,8 @@ class _CupertinoScrollbarState extends State 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. diff --git a/packages/flutter/lib/src/gestures/long_press.dart b/packages/flutter/lib/src/gestures/long_press.dart index 93054f1e6c..7fa4eaea8b 100644 --- a/packages/flutter/lib/src/gestures/long_press.dart +++ b/packages/flutter/lib/src/gestures/long_press.dart @@ -156,16 +156,20 @@ 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, - postAcceptSlopTolerance: postAcceptSlopTolerance, - kind: kind, - debugOwner: debugOwner, - ); + deadline: duration ?? kLongPressTimeout, + postAcceptSlopTolerance: postAcceptSlopTolerance, + kind: kind, + debugOwner: debugOwner, + ); bool _longPressAccepted = false; OffsetPair _longPressOrigin; diff --git a/packages/flutter/lib/src/material/scrollbar.dart b/packages/flutter/lib/src/material/scrollbar.dart index 61089607b2..23142422d0 100644 --- a/packages/flutter/lib/src/material/scrollbar.dart +++ b/packages/flutter/lib/src/material/scrollbar.dart @@ -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 with TickerProviderStateMixin { ScrollbarPainter _materialPainter; TextDirection _textDirection; @@ -148,6 +151,7 @@ class _ScrollbarState extends State with TickerProviderStateMixin { if (_useCupertinoScrollbar) { return CupertinoScrollbar( child: widget.child, + controller: widget.controller, ); } return NotificationListener( diff --git a/packages/flutter/test/cupertino/scrollbar_test.dart b/packages/flutter/test/cupertino/scrollbar_test.dart index 511f94ff67..e06844a263 100644 --- a/packages/flutter/test/cupertino/scrollbar_test.dart +++ b/packages/flutter/test/cupertino/scrollbar_test.dart @@ -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(); diff --git a/packages/flutter/test/gestures/long_press_test.dart b/packages/flutter/test/gestures/long_press_test.dart index 746ba6b304..e4ab2f205c 100644 --- a/packages/flutter/test/gestures/long_press_test.dart +++ b/packages/flutter/test/gestures/long_press_test.dart @@ -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); diff --git a/packages/flutter/test/material/scrollbar_test.dart b/packages/flutter/test/material/scrollbar_test.dart index 903fa2b7a2..c4d18be886 100644 --- a/packages/flutter/test/material/scrollbar_test.dart +++ b/packages/flutter/test/material/scrollbar_test.dart @@ -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); + }); + }