scrollbar.dart 15.1 KB
Newer Older
1 2 3 4 5 6
// Copyright 2017 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:async';

7
import 'package:flutter/gestures.dart';
8
import 'package:flutter/services.dart';
9 10
import 'package:flutter/widgets.dart';

11 12
import 'colors.dart';

13
// All values eyeballed.
14 15
const double _kScrollbarMinLength = 36.0;
const double _kScrollbarMinOverscrollLength = 8.0;
16
const Duration _kScrollbarTimeToFade = Duration(milliseconds: 1200);
17
const Duration _kScrollbarFadeDuration = Duration(milliseconds: 250);
18
const Duration _kScrollbarResizeDuration = Duration(milliseconds: 150);
19

20 21 22 23 24 25
// Extracted from iOS 13.1 beta using Debug View Hierarchy.
const Color _kScrollbarColor = CupertinoDynamicColor.withBrightness(
  color: Color(0x59000000),
  darkColor: Color(0x80FFFFFF),
);
const double _kScrollbarThickness = 3;
26
const double _kScrollbarThicknessDragging = 8.0;
27 28 29
const Radius _kScrollbarRadius = Radius.circular(1.5);
const Radius _kScrollbarRadiusDragging = Radius.circular(4.0);

30 31 32 33 34 35 36
// This is the amount of space from the top of a vertical scrollbar to the
// top edge of the scrollable, measured when the vertical scrollbar overscrolls
// to the top.
// TODO(LongCatIsLooong): fix https://github.com/flutter/flutter/issues/32175
const double _kScrollbarMainAxisMargin = 3.0;
const double _kScrollbarCrossAxisMargin = 3.0;

37
/// An iOS style scrollbar.
38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57
///
/// A scrollbar indicates which portion of a [Scrollable] widget is actually
/// visible.
///
/// To add a scrollbar to a [ScrollView], simply wrap the scroll view widget in
/// a [CupertinoScrollbar] widget.
///
/// See also:
///
///  * [ListView], which display a linear, scrollable list of children.
///  * [GridView], which display a 2 dimensional, scrollable array of children.
///  * [Scrollbar], a Material Design scrollbar that dynamically adapts to the
///    platform showing either an Android style or iOS style scrollbar.
class CupertinoScrollbar extends StatefulWidget {
  /// Creates an iOS style scrollbar that wraps the given [child].
  ///
  /// The [child] should be a source of [ScrollNotification] notifications,
  /// typically a [Scrollable] widget.
  const CupertinoScrollbar({
    Key key,
58
    this.controller,
59 60 61 62 63 64 65 66 67
    @required this.child,
  }) : super(key: key);

  /// The subtree to place inside the [CupertinoScrollbar].
  ///
  /// This should include a source of [ScrollNotification] notifications,
  /// typically a [Scrollable] widget.
  final Widget child;

68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102
  /// 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;

103
  @override
104
  _CupertinoScrollbarState createState() => _CupertinoScrollbarState();
105 106 107
}

class _CupertinoScrollbarState extends State<CupertinoScrollbar> with TickerProviderStateMixin {
108
  final GlobalKey _customPaintKey = GlobalKey();
109 110 111 112
  ScrollbarPainter _painter;

  AnimationController _fadeoutAnimationController;
  Animation<double> _fadeoutOpacityAnimation;
113
  AnimationController _thicknessAnimationController;
114
  Timer _fadeoutTimer;
115 116 117 118 119 120 121 122 123 124
  double _dragScrollbarPositionY;
  Drag _drag;

  double get _thickness {
    return _kScrollbarThickness + _thicknessAnimationController.value * (_kScrollbarThicknessDragging - _kScrollbarThickness);
  }

  Radius get _radius {
    return Radius.lerp(_kScrollbarRadius, _kScrollbarRadiusDragging, _thicknessAnimationController.value);
  }
125 126 127 128

  @override
  void initState() {
    super.initState();
129
    _fadeoutAnimationController = AnimationController(
130 131 132
      vsync: this,
      duration: _kScrollbarFadeDuration,
    );
133
    _fadeoutOpacityAnimation = CurvedAnimation(
134
      parent: _fadeoutAnimationController,
135
      curve: Curves.fastOutSlowIn,
136
    );
137 138 139 140 141 142 143
    _thicknessAnimationController = AnimationController(
      vsync: this,
      duration: _kScrollbarResizeDuration,
    );
    _thicknessAnimationController.addListener(() {
      _painter.updateThickness(_thickness, _radius);
    });
144 145 146 147 148
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
149 150 151 152 153 154 155 156 157
    if (_painter == null) {
      _painter = _buildCupertinoScrollbarPainter(context);
    }
    else {
      _painter
        ..textDirection = Directionality.of(context)
        ..color = CupertinoDynamicColor.resolve(_kScrollbarColor, context)
        ..padding = MediaQuery.of(context).padding;
    }
158 159 160
  }

  /// Returns a [ScrollbarPainter] visually styled like the iOS scrollbar.
161
  ScrollbarPainter _buildCupertinoScrollbarPainter(BuildContext context) {
162
    return ScrollbarPainter(
163 164
      color: CupertinoDynamicColor.resolve(_kScrollbarColor, context),
      textDirection: Directionality.of(context),
165
      thickness: _thickness,
166 167 168
      fadeoutOpacityAnimation: _fadeoutOpacityAnimation,
      mainAxisMargin: _kScrollbarMainAxisMargin,
      crossAxisMargin: _kScrollbarCrossAxisMargin,
169
      radius: _radius,
170
      padding: MediaQuery.of(context).padding,
171
      minLength: _kScrollbarMinLength,
172
      minOverscrollLength: _kScrollbarMinOverscrollLength,
173 174 175
    );
  }

176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222
  // 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();
223
    HapticFeedback.mediumImpact();
224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250
    _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();
251
    HapticFeedback.mediumImpact();
252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283
    _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;
  }

284
  bool _handleScrollNotification(ScrollNotification notification) {
285 286 287 288 289
    final ScrollMetrics metrics = notification.metrics;
    if (metrics.maxScrollExtent <= metrics.minScrollExtent) {
      return false;
    }

290 291 292 293 294 295 296 297 298 299 300
    if (notification is ScrollUpdateNotification ||
        notification is OverscrollNotification) {
      // Any movements always makes the scrollbar start showing up.
      if (_fadeoutAnimationController.status != AnimationStatus.forward) {
        _fadeoutAnimationController.forward();
      }

      _fadeoutTimer?.cancel();
      _painter.update(notification.metrics, notification.metrics.axisDirection);
    } else if (notification is ScrollEndNotification) {
      // On iOS, the scrollbar can only go away once the user lifted the finger.
301 302 303
      if (_dragScrollbarPositionY == null) {
        _startFadeoutTimer();
      }
304 305 306 307
    }
    return false;
  }

308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348
  // 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;
  }

349 350 351
  @override
  void dispose() {
    _fadeoutAnimationController.dispose();
352
    _thicknessAnimationController.dispose();
353 354 355 356 357 358 359
    _fadeoutTimer?.cancel();
    _painter.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
360
    return NotificationListener<ScrollNotification>(
361
      onNotification: _handleScrollNotification,
362
      child: RepaintBoundary(
363 364 365 366 367
        child: RawGestureDetector(
          gestures: _gestures,
          child: CustomPaint(
            key: _customPaintKey,
            foregroundPainter: _painter,
368
            child: RepaintBoundary(child: widget.child),
369 370 371 372 373 374
          ),
        ),
      ),
    );
  }
}
375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447

// 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);
}