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
class GlowingOverscrollIndicator extends StatefulWidget {
34 35 36 37 38 39
  /// 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].
  ///
40 41
  /// The [showLeading], [showTrailing], [axisDirection], [color], and
  /// [notificationPredicate] arguments must not be null.
42
  const GlowingOverscrollIndicator({
43 44 45 46 47
    Key key,
    this.showLeading: true,
    this.showTrailing: true,
    @required this.axisDirection,
    @required this.color,
48
    this.notificationPredicate: defaultScrollNotificationPredicate,
49
    this.child,
50 51 52 53
  }) : assert(showLeading != null),
       assert(showTrailing != null),
       assert(axisDirection != null),
       assert(color != null),
54
       assert(notificationPredicate != null),
55
       super(key: key);
56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78

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

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

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

  /// The color of the glow. The alpha channel is ignored.
  final Color color;
89 90 91 92 93 94 95
  
  /// 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;
96 97

  /// The subtree to place inside the overscroll indicator. This should include
Adam Barth's avatar
Adam Barth committed
98
  /// a source of [ScrollNotification] notifications, typically a [Scrollable]
99 100 101
  /// widget.
  ///
  /// Typically a [GlowingOverscrollIndicator] is created by a
Adam Barth's avatar
Adam Barth committed
102
  /// [ScrollBehavior.buildViewportChrome] method, in which case
103
  /// the child is usually the one provided as an argument to that method.
104 105 106 107 108 109
  final Widget child;

  @override
  _GlowingOverscrollIndicatorState createState() => new _GlowingOverscrollIndicatorState();

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

class _GlowingOverscrollIndicatorState extends State<GlowingOverscrollIndicator> with TickerProviderStateMixin {
  _GlowController _leadingController;
  _GlowController _trailingController;
131
  Listenable _leadingAndTrailingListener;
132 133 134 135

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

  @override
142
  void didUpdateWidget(GlowingOverscrollIndicator oldWidget) {
143
    super.didUpdateWidget(oldWidget);
144 145 146 147 148
    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;
149 150 151
    }
  }

152 153 154
  Type _lastNotificationType;
  final Map<bool, bool> _accepted = <bool, bool>{false: true, true: true};

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

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

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

// 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({
245
    @required TickerProvider vsync,
246 247
    @required Color color,
    @required Axis axis,
248 249 250 251
  }) : assert(vsync != null),
       assert(color != null),
       assert(axis != null),
       _color = color,
252 253 254
       _axis = axis {
    _glowController = new AnimationController(vsync: vsync)
      ..addStatusListener(_changePhase);
255
    final Animation<double> decelerator = new CurvedAnimation(
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 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 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 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 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
      parent: _glowController,
      curve: Curves.decelerate,
    )..addListener(notifyListeners);
    _glowOpacity = _glowOpacityTween.animate(decelerator);
    _glowSize = _glowSizeTween.animate(decelerator);
    _displacementTicker = vsync.createTicker(_tickDisplacement);
  }

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

  // animation values
  final Tween<double> _glowOpacityTween = new Tween<double>(begin: 0.0, end: 0.0);
  Animation<double> _glowOpacity;
  final Tween<double> _glowSizeTween = new Tween<double>(begin: 0.0, end: 0.0);
  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();
  }

  static const Duration _recedeTime = const Duration(milliseconds: 600);
  static const Duration _pullTime = const Duration(milliseconds: 167);
  static const Duration _pullHoldTime = const Duration(milliseconds: 167);
  static const Duration _pullDecayTime = const Duration(milliseconds: 2000);
  static final Duration _crossAxisHalfTime = new Duration(microseconds: (Duration.MICROSECONDS_PER_SECOND / 60.0).round());

  static const double _maxOpacity = 0.5;
  static const double _pullOpacityGlowFactor = 0.8;
  static const double _velocityGlowFactor = 0.00006;
  static const double _SQRT3 = 1.73205080757; // const math.sqrt(3)
  static const double _kWidthToHeightFactor = (3.0 / 4.0) * (2.0 - _SQRT3);

  // 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);
    _glowController.duration = new Duration(milliseconds: (0.15 + velocity * 0.02).round());
    _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);
    final double height = math.min(extent, crossExtent * _kWidthToHeightFactor);
    _glowSizeTween.begin = _glowSize.value;
    _glowSizeTween.end = math.max((1.0 - 1.0 / (0.7 * math.sqrt(_pullDistance * height))), _glowSize.value);
    _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();
      }
    }
    _pullRecedeTimer = new Timer(_pullHoldTime, () => _recede(_pullDecayTime));
  }

  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) {
426
      final double t = (elapsed.inMicroseconds - _displacementTickerLastElapsed.inMicroseconds).toDouble();
427 428 429 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;
    final double height = math.min(size.height, size.width * _kWidthToHeightFactor);
    final double scaleY = _glowSize.value * baseGlowScale;
    final Rect rect = new Rect.fromLTWH(0.0, 0.0, size.width, height);
446
    final Offset center = new Offset((size.width / 2.0) * (0.5 + _displacement), height - radius);
447 448 449 450 451 452 453 454 455 456 457 458 459 460
    final Paint paint = new Paint()..color = color.withOpacity(_glowOpacity.value);
    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,
461
    Listenable repaint,
462
  }) : super(
463
    repaint: repaint,
464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496
  );

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

  static const double piOver2 = math.PI / 2.0;

  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();
497 498
        canvas.rotate(piOver2);
        canvas.scale(1.0, -1.0);
499 500 501 502 503
        controller.paint(canvas, new Size(size.height, size.width));
        canvas.restore();
        break;
      case AxisDirection.right:
        canvas.save();
504 505
        canvas.translate(size.width, 0.0);
        canvas.rotate(piOver2);
506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523
        controller.paint(canvas, new Size(size.height, size.width));
        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;
  }
}
524

525 526 527 528 529 530 531 532 533
/// 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.
534
class OverscrollIndicatorNotification extends Notification with ViewportNotificationMixin {
535 536 537 538
  /// Creates a notification that an [GlowingOverscrollIndicator] will start
  /// showing an overscroll indication.
  ///
  /// The [leading] argument must not be null.
539
  OverscrollIndicatorNotification({
540
    @required this.leading,
541 542
  });

543 544
  /// Whether the indication will be shown on the leading edge of the scroll
  /// view.
545 546 547 548 549 550 551 552 553 554 555 556 557 558
  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
559
}