overscroll_indicator.dart 20.2 KB
Newer Older
1 2 3 4 5 6 7
// Copyright 2016 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' show Timer;
import 'dart:math' as math;

8
import 'package:flutter/animation.dart';
9 10 11 12
import 'package:flutter/foundation.dart';
import 'package:flutter/physics.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
13 14 15 16 17 18

import 'basic.dart';
import 'framework.dart';
import 'notification_listener.dart';
import 'scroll_notification.dart';
import 'ticker_provider.dart';
19

20 21 22 23
/// A visual indication that a scroll view has overscrolled.
///
/// A [GlowingOverscrollIndicator] listens for [ScrollNotification]s in order
/// to control the overscroll indication. These notifications are typically
24
/// generated by a [ScrollView], such as a [ListView] or a [GridView].
25 26 27 28 29 30 31 32
///
/// [GlowingOverscrollIndicator] generates [OverscrollIndicatorNotification]
/// before showing an overscroll indication. To prevent the indicator from
/// showing the indication, call [OverscrollIndicatorNotification.disallowGlow]
/// on the notification.
///
/// Created automatically by [ScrollBehavior.buildViewportChrome] on platforms
/// (e.g., Android) that commonly use this type of overscroll indication.
33 34
///
/// In a [MaterialApp], the edge glow color is the [ThemeData.accentColor].
35
class GlowingOverscrollIndicator extends StatefulWidget {
36 37 38 39 40 41
  /// Creates a visual indication that a scroll view has overscrolled.
  ///
  /// In order for this widget to display an overscroll indication, the [child]
  /// widget must contain a widget that generates a [ScrollNotification], such
  /// as a [ListView] or a [GridView].
  ///
42 43
  /// The [showLeading], [showTrailing], [axisDirection], [color], and
  /// [notificationPredicate] arguments must not be null.
44
  const GlowingOverscrollIndicator({
45
    Key key,
46 47
    this.showLeading = true,
    this.showTrailing = true,
48 49
    @required this.axisDirection,
    @required this.color,
50
    this.notificationPredicate = defaultScrollNotificationPredicate,
51
    this.child,
52 53 54 55
  }) : assert(showLeading != null),
       assert(showTrailing != null),
       assert(axisDirection != null),
       assert(color != null),
56
       assert(notificationPredicate != null),
57
       super(key: key);
58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80

  /// Whether to show the overscroll glow on the side with negative scroll
  /// offsets.
  ///
  /// For a vertical downwards viewport, this is the top side.
  ///
  /// Defaults to true.
  ///
  /// See [showTrailing] for the corresponding control on the other side of the
  /// viewport.
  final bool showLeading;

  /// Whether to show the overscroll glow on the side with positive scroll
  /// offsets.
  ///
  /// For a vertical downwards viewport, this is the bottom side.
  ///
  /// Defaults to true.
  ///
  /// See [showLeading] for the corresponding control on the other side of the
  /// viewport.
  final bool showTrailing;

81 82
  /// The direction of positive scroll offsets in the [Scrollable] whose
  /// overscrolls are to be visualized.
83 84
  final AxisDirection axisDirection;

85 86
  /// The axis along which scrolling occurs in the [Scrollable] whose
  /// overscrolls are to be visualized.
87 88 89 90
  Axis get axis => axisDirectionToAxis(axisDirection);

  /// The color of the glow. The alpha channel is ignored.
  final Color color;
91

92 93 94 95 96 97
  /// A check that specifies whether a [ScrollNotification] should be
  /// handled by this widget.
  ///
  /// By default, checks whether `notification.depth == 0`. Set it to something
  /// else for more complicated layouts.
  final ScrollNotificationPredicate notificationPredicate;
98

99 100 101 102
  /// The widget below this widget in the tree.
  ///
  /// The overscroll indicator will paint on top of this child. This child (and its
  /// subtree) should include a source of [ScrollNotification] notifications.
103 104
  ///
  /// Typically a [GlowingOverscrollIndicator] is created by a
Adam Barth's avatar
Adam Barth committed
105
  /// [ScrollBehavior.buildViewportChrome] method, in which case
106
  /// the child is usually the one provided as an argument to that method.
107 108 109
  final Widget child;

  @override
110
  _GlowingOverscrollIndicatorState createState() => _GlowingOverscrollIndicatorState();
111 112

  @override
113 114
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
115
    properties.add(EnumProperty<AxisDirection>('axisDirection', axisDirection));
116
    String showDescription;
117
    if (showLeading && showTrailing) {
118
      showDescription = 'both sides';
119
    } else if (showLeading) {
120
      showDescription = 'leading side only';
121
    } else if (showTrailing) {
122
      showDescription = 'trailing side only';
123
    } else {
124
      showDescription = 'neither side (!)';
125
    }
126 127
    properties.add(MessageProperty('show', showDescription));
    properties.add(DiagnosticsProperty<Color>('color', color, showName: false));
128 129 130 131 132 133
  }
}

class _GlowingOverscrollIndicatorState extends State<GlowingOverscrollIndicator> with TickerProviderStateMixin {
  _GlowController _leadingController;
  _GlowController _trailingController;
134
  Listenable _leadingAndTrailingListener;
135 136 137 138

  @override
  void initState() {
    super.initState();
139 140 141
    _leadingController = _GlowController(vsync: this, color: widget.color, axis: widget.axis);
    _trailingController = _GlowController(vsync: this, color: widget.color, axis: widget.axis);
    _leadingAndTrailingListener = Listenable.merge(<Listenable>[_leadingController, _trailingController]);
142 143 144
  }

  @override
145
  void didUpdateWidget(GlowingOverscrollIndicator oldWidget) {
146
    super.didUpdateWidget(oldWidget);
147 148 149 150 151
    if (oldWidget.color != widget.color || oldWidget.axis != widget.axis) {
      _leadingController.color = widget.color;
      _leadingController.axis = widget.axis;
      _trailingController.color = widget.color;
      _trailingController.axis = widget.axis;
152 153 154
    }
  }

155 156 157
  Type _lastNotificationType;
  final Map<bool, bool> _accepted = <bool, bool>{false: true, true: true};

Adam Barth's avatar
Adam Barth committed
158
  bool _handleScrollNotification(ScrollNotification notification) {
159
    if (!widget.notificationPredicate(notification))
160
      return false;
161 162 163 164 165 166 167 168 169
    if (notification is OverscrollNotification) {
      _GlowController controller;
      if (notification.overscroll < 0.0) {
        controller = _leadingController;
      } else if (notification.overscroll > 0.0) {
        controller = _trailingController;
      } else {
        assert(false);
      }
170
      final bool isLeading = controller == _leadingController;
171
      if (_lastNotificationType != OverscrollNotification) {
172
        final OverscrollIndicatorNotification confirmationNotification = OverscrollIndicatorNotification(leading: isLeading);
173 174 175
        confirmationNotification.dispatch(context);
        _accepted[isLeading] = confirmationNotification._accepted;
      }
176
      assert(controller != null);
177
      assert(notification.metrics.axis == widget.axis);
178 179 180 181 182 183 184 185 186 187 188 189
      if (_accepted[isLeading]) {
        if (notification.velocity != 0.0) {
          assert(notification.dragDetails == null);
          controller.absorbImpact(notification.velocity.abs());
        } else {
          assert(notification.overscroll != 0.0);
          if (notification.dragDetails != null) {
            assert(notification.dragDetails.globalPosition != null);
            final RenderBox renderer = notification.context.findRenderObject();
            assert(renderer != null);
            assert(renderer.hasSize);
            final Size size = renderer.size;
190
            final Offset position = renderer.globalToLocal(notification.dragDetails.globalPosition);
191
            switch (notification.metrics.axis) {
192
              case Axis.horizontal:
193
                controller.pull(notification.overscroll.abs(), size.width, position.dy.clamp(0.0, size.height), size.height);
194 195
                break;
              case Axis.vertical:
196
                controller.pull(notification.overscroll.abs(), size.height, position.dx.clamp(0.0, size.width), size.width);
197 198
                break;
            }
199 200 201 202
          }
        }
      }
    } else if (notification is ScrollEndNotification || notification is ScrollUpdateNotification) {
203
      if ((notification as dynamic).dragDetails != null) {
204 205 206 207
        _leadingController.scrollEnd();
        _trailingController.scrollEnd();
      }
    }
208
    _lastNotificationType = notification.runtimeType;
209 210 211 212 213 214 215 216 217 218 219 220
    return false;
  }

  @override
  void dispose() {
    _leadingController.dispose();
    _trailingController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
221
    return NotificationListener<ScrollNotification>(
222
      onNotification: _handleScrollNotification,
223 224 225
      child: RepaintBoundary(
        child: CustomPaint(
          foregroundPainter: _GlowingOverscrollIndicatorPainter(
226 227 228
            leadingController: widget.showLeading ? _leadingController : null,
            trailingController: widget.showTrailing ? _trailingController : null,
            axisDirection: widget.axisDirection,
229
            repaint: _leadingAndTrailingListener,
230
          ),
231
          child: RepaintBoundary(
232
            child: widget.child,
233 234 235 236 237 238 239 240 241 242 243 244 245 246 247
          ),
        ),
      ),
    );
  }
}

// The Glow logic is a port of the logic in the following file:
// https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/widget/EdgeEffect.java
// as of December 2016.

enum _GlowState { idle, absorb, pull, recede }

class _GlowController extends ChangeNotifier {
  _GlowController({
248
    @required TickerProvider vsync,
249 250
    @required Color color,
    @required Axis axis,
251 252 253 254
  }) : assert(vsync != null),
       assert(color != null),
       assert(axis != null),
       _color = color,
255
       _axis = axis {
256
    _glowController = AnimationController(vsync: vsync)
257
      ..addStatusListener(_changePhase);
258
    final Animation<double> decelerator = CurvedAnimation(
259 260 261
      parent: _glowController,
      curve: Curves.decelerate,
    )..addListener(notifyListeners);
262 263
    _glowOpacity = decelerator.drive(_glowOpacityTween);
    _glowSize = decelerator.drive(_glowSizeTween);
264 265 266 267 268 269 270 271 272
    _displacementTicker = vsync.createTicker(_tickDisplacement);
  }

  // animation of the main axis direction
  _GlowState _state = _GlowState.idle;
  AnimationController _glowController;
  Timer _pullRecedeTimer;

  // animation values
273
  final Tween<double> _glowOpacityTween = Tween<double>(begin: 0.0, end: 0.0);
274
  Animation<double> _glowOpacity;
275
  final Tween<double> _glowSizeTween = Tween<double>(begin: 0.0, end: 0.0);
276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306
  Animation<double> _glowSize;

  // animation of the cross axis position
  Ticker _displacementTicker;
  Duration _displacementTickerLastElapsed;
  double _displacementTarget = 0.5;
  double _displacement = 0.5;

  // tracking the pull distance
  double _pullDistance = 0.0;

  Color get color => _color;
  Color _color;
  set color(Color value) {
    assert(color != null);
    if (color == value)
      return;
    _color = value;
    notifyListeners();
  }

  Axis get axis => _axis;
  Axis _axis;
  set axis(Axis value) {
    assert(axis != null);
    if (axis == value)
      return;
    _axis = value;
    notifyListeners();
  }

307 308 309 310
  static const Duration _recedeTime = Duration(milliseconds: 600);
  static const Duration _pullTime = Duration(milliseconds: 167);
  static const Duration _pullHoldTime = Duration(milliseconds: 167);
  static const Duration _pullDecayTime = Duration(milliseconds: 2000);
311
  static final Duration _crossAxisHalfTime = Duration(microseconds: (Duration.microsecondsPerSecond / 60.0).round());
312 313 314 315

  static const double _maxOpacity = 0.5;
  static const double _pullOpacityGlowFactor = 0.8;
  static const double _velocityGlowFactor = 0.00006;
316 317
  static const double _sqrt3 = 1.73205080757; // const math.sqrt(3)
  static const double _widthToHeightFactor = (3.0 / 4.0) * (2.0 - _sqrt3);
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

  // absorbed velocities are clamped to the range _minVelocity.._maxVelocity
  static const double _minVelocity = 100.0; // logical pixels per second
  static const double _maxVelocity = 10000.0; // logical pixels per second

  @override
  void dispose() {
    _glowController.dispose();
    _displacementTicker.dispose();
    _pullRecedeTimer?.cancel();
    super.dispose();
  }

  /// Handle a scroll slamming into the edge at a particular velocity.
  ///
  /// The velocity must be positive.
  void absorbImpact(double velocity) {
    assert(velocity >= 0.0);
    _pullRecedeTimer?.cancel();
    _pullRecedeTimer = null;
    velocity = velocity.clamp(_minVelocity, _maxVelocity);
    _glowOpacityTween.begin = _state == _GlowState.idle ? 0.3 : _glowOpacity.value;
    _glowOpacityTween.end = (velocity * _velocityGlowFactor).clamp(_glowOpacityTween.begin, _maxOpacity);
    _glowSizeTween.begin = _glowSize.value;
    _glowSizeTween.end = math.min(0.025 + 7.5e-7 * velocity * velocity, 1.0);
343
    _glowController.duration = Duration(milliseconds: (0.15 + velocity * 0.02).round());
344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364
    _glowController.forward(from: 0.0);
    _displacement = 0.5;
    _state = _GlowState.absorb;
  }

  /// Handle a user-driven overscroll.
  ///
  /// The `overscroll` argument should be the scroll distance in logical pixels,
  /// the `extent` argument should be the total dimension of the viewport in the
  /// main axis in logical pixels, the `crossAxisOffset` argument should be the
  /// distance from the leading (left or top) edge of the cross axis of the
  /// viewport, and the `crossExtent` should be the size of the cross axis. For
  /// example, a pull of 50 pixels up the middle of a 200 pixel high and 100
  /// pixel wide vertical viewport should result in a call of `pull(50.0, 200.0,
  /// 50.0, 100.0)`. The `overscroll` value should be positive regardless of the
  /// direction.
  void pull(double overscroll, double extent, double crossAxisOffset, double crossExtent) {
    _pullRecedeTimer?.cancel();
    _pullDistance += overscroll / 200.0; // This factor is magic. Not clear why we need it to match Android.
    _glowOpacityTween.begin = _glowOpacity.value;
    _glowOpacityTween.end = math.min(_glowOpacity.value + overscroll / extent * _pullOpacityGlowFactor, _maxOpacity);
365
    final double height = math.min(extent, crossExtent * _widthToHeightFactor);
366
    _glowSizeTween.begin = _glowSize.value;
367
    _glowSizeTween.end = math.max(1.0 - 1.0 / (0.7 * math.sqrt(_pullDistance * height)), _glowSize.value);
368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387
    _displacementTarget = crossAxisOffset / crossExtent;
    if (_displacementTarget != _displacement) {
      if (!_displacementTicker.isTicking) {
        assert(_displacementTickerLastElapsed == null);
        _displacementTicker.start();
      }
    } else {
      _displacementTicker.stop();
      _displacementTickerLastElapsed = null;
    }
    _glowController.duration = _pullTime;
    if (_state != _GlowState.pull) {
      _glowController.forward(from: 0.0);
      _state = _GlowState.pull;
    } else {
      if (!_glowController.isAnimating) {
        assert(_glowController.value == 1.0);
        notifyListeners();
      }
    }
388
    _pullRecedeTimer = Timer(_pullHoldTime, () => _recede(_pullDecayTime));
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
  }

  void scrollEnd() {
    if (_state == _GlowState.pull)
      _recede(_recedeTime);
  }

  void _changePhase(AnimationStatus status) {
    if (status != AnimationStatus.completed)
      return;
    switch (_state) {
      case _GlowState.absorb:
        _recede(_recedeTime);
        break;
      case _GlowState.recede:
        _state = _GlowState.idle;
        _pullDistance = 0.0;
        break;
      case _GlowState.pull:
      case _GlowState.idle:
        break;
    }
  }

  void _recede(Duration duration) {
    if (_state == _GlowState.recede || _state == _GlowState.idle)
      return;
    _pullRecedeTimer?.cancel();
    _pullRecedeTimer = null;
    _glowOpacityTween.begin = _glowOpacity.value;
    _glowOpacityTween.end = 0.0;
    _glowSizeTween.begin = _glowSize.value;
    _glowSizeTween.end = 0.0;
    _glowController.duration = duration;
    _glowController.forward(from: 0.0);
    _state = _GlowState.recede;
  }

  void _tickDisplacement(Duration elapsed) {
    if (_displacementTickerLastElapsed != null) {
429
      final double t = (elapsed.inMicroseconds - _displacementTickerLastElapsed.inMicroseconds).toDouble();
430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445
      _displacement = _displacementTarget - (_displacementTarget - _displacement) * math.pow(2.0, -t / _crossAxisHalfTime.inMicroseconds);
      notifyListeners();
    }
    if (nearEqual(_displacementTarget, _displacement, Tolerance.defaultTolerance.distance)) {
      _displacementTicker.stop();
      _displacementTickerLastElapsed = null;
    } else {
      _displacementTickerLastElapsed = elapsed;
    }
  }

  void paint(Canvas canvas, Size size) {
    if (_glowOpacity.value == 0.0)
      return;
    final double baseGlowScale = size.width > size.height ? size.height / size.width : 1.0;
    final double radius = size.width * 3.0 / 2.0;
446
    final double height = math.min(size.height, size.width * _widthToHeightFactor);
447
    final double scaleY = _glowSize.value * baseGlowScale;
448 449 450
    final Rect rect = Rect.fromLTWH(0.0, 0.0, size.width, height);
    final Offset center = Offset((size.width / 2.0) * (0.5 + _displacement), height - radius);
    final Paint paint = Paint()..color = color.withOpacity(_glowOpacity.value);
451 452 453 454 455 456 457 458 459 460 461 462 463
    canvas.save();
    canvas.scale(1.0, scaleY);
    canvas.clipRect(rect);
    canvas.drawCircle(center, radius, paint);
    canvas.restore();
  }
}

class _GlowingOverscrollIndicatorPainter extends CustomPainter {
  _GlowingOverscrollIndicatorPainter({
    this.leadingController,
    this.trailingController,
    this.axisDirection,
464
    Listenable repaint,
465
  }) : super(
466
    repaint: repaint,
467 468 469 470 471 472 473 474 475 476 477 478 479 480 481
  );

  /// The controller for the overscroll glow on the side with negative scroll offsets.
  ///
  /// For a vertical downwards viewport, this is the top side.
  final _GlowController leadingController;

  /// The controller for the overscroll glow on the side with positive scroll offsets.
  ///
  /// For a vertical downwards viewport, this is the bottom side.
  final _GlowController trailingController;

  /// The direction of the viewport.
  final AxisDirection axisDirection;

482
  static const double piOver2 = math.pi / 2.0;
483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499

  void _paintSide(Canvas canvas, Size size, _GlowController controller, AxisDirection axisDirection, GrowthDirection growthDirection) {
    if (controller == null)
      return;
    switch (applyGrowthDirectionToAxisDirection(axisDirection, growthDirection)) {
      case AxisDirection.up:
        controller.paint(canvas, size);
        break;
      case AxisDirection.down:
        canvas.save();
        canvas.translate(0.0, size.height);
        canvas.scale(1.0, -1.0);
        controller.paint(canvas, size);
        canvas.restore();
        break;
      case AxisDirection.left:
        canvas.save();
500 501
        canvas.rotate(piOver2);
        canvas.scale(1.0, -1.0);
502
        controller.paint(canvas, Size(size.height, size.width));
503 504 505 506
        canvas.restore();
        break;
      case AxisDirection.right:
        canvas.save();
507 508
        canvas.translate(size.width, 0.0);
        canvas.rotate(piOver2);
509
        controller.paint(canvas, Size(size.height, size.width));
510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526
        canvas.restore();
        break;
    }
  }

  @override
  void paint(Canvas canvas, Size size) {
    _paintSide(canvas, size, leadingController, axisDirection, GrowthDirection.reverse);
    _paintSide(canvas, size, trailingController, axisDirection, GrowthDirection.forward);
  }

  @override
  bool shouldRepaint(_GlowingOverscrollIndicatorPainter oldDelegate) {
    return oldDelegate.leadingController != leadingController
        || oldDelegate.trailingController != trailingController;
  }
}
527

528 529 530 531 532 533 534 535 536
/// A notification that an [GlowingOverscrollIndicator] will start showing an
/// overscroll indication.
///
/// To prevent the indicator from showing the indication, call [disallowGlow] on
/// the notification.
///
/// See also:
///
///  * [GlowingOverscrollIndicator], which generates this type of notification.
537
class OverscrollIndicatorNotification extends Notification with ViewportNotificationMixin {
538 539 540 541
  /// Creates a notification that an [GlowingOverscrollIndicator] will start
  /// showing an overscroll indication.
  ///
  /// The [leading] argument must not be null.
542
  OverscrollIndicatorNotification({
543
    @required this.leading,
544 545
  });

546 547
  /// Whether the indication will be shown on the leading edge of the scroll
  /// view.
548 549 550 551 552 553 554 555 556 557 558 559 560 561
  final bool leading;

  bool _accepted = true;

  /// Call this method if the glow should be prevented.
  void disallowGlow() {
    _accepted = false;
  }

  @override
  void debugFillDescription(List<String> description) {
    super.debugFillDescription(description);
    description.add('side: ${leading ? "leading edge" : "trailing edge"}');
  }
Adam Barth's avatar
Adam Barth committed
562
}