overscroll_indicator.dart 24.8 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

5 6
// @dart = 2.8

7 8 9
import 'dart:async' show Timer;
import 'dart:math' as math;

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

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

22 23 24 25
/// 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
26
/// generated by a [ScrollView], such as a [ListView] or a [GridView].
27 28 29 30 31 32 33 34
///
/// [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.
35 36
///
/// In a [MaterialApp], the edge glow color is the [ThemeData.accentColor].
37
///
38 39
/// ## Customizing the Glow Position for Advanced Scroll Views
///
40 41 42 43 44 45
/// When building a [CustomScrollView] with a [GlowingOverscrollIndicator], the
/// indicator will apply to the entire scrollable area, regardless of what
/// slivers the CustomScrollView contains.
///
/// For example, if your CustomScrollView contains a SliverAppBar in the first
/// position, the GlowingOverscrollIndicator will overlay the SliverAppBar. To
46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84
/// manipulate the position of the GlowingOverscrollIndicator in this case,
/// you can either make use of a [NotificationListener] and provide a
/// [OverscrollIndicatorNotification.paintOffset] to the
/// notification, or use a [NestedScrollView].
///
/// {@tool dartpad --template=stateless_widget_scaffold}
///
/// This example demonstrates how to use a [NotificationListener] to manipulate
/// the placement of a [GlowingOverscrollIndicator] when building a
/// [CustomScrollView]. Drag the scrollable to see the bounds of the overscroll
/// indicator.
///
/// ```dart
/// Widget build(BuildContext context) {
///   double leadingPaintOffset = MediaQuery.of(context).padding.top + AppBar().preferredSize.height;
///   return NotificationListener<OverscrollIndicatorNotification>(
///     onNotification: (notification) {
///       if (notification.leading) {
///         notification.paintOffset = leadingPaintOffset;
///       }
///       return false;
///     },
///     child: CustomScrollView(
///       slivers: [
///         SliverAppBar(title: Text('Custom PaintOffset')),
///         SliverToBoxAdapter(
///           child: Container(
///             color: Colors.amberAccent,
///             height: 100,
///             child: Center(child: Text('Glow all day!')),
///           ),
///         ),
///         SliverFillRemaining(child: FlutterLogo()),
///       ],
///     ),
///   );
/// }
/// ```
/// {@end-tool}
85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116
///
/// {@tool dartpad --template=stateless_widget_scaffold}
///
/// This example demonstrates how to use a [NestedScrollView] to manipulate the
/// placement of a [GlowingOverscrollIndicator] when building a
/// [CustomScrollView]. Drag the scrollable to see the bounds of the overscroll
/// indicator.
///
/// ```dart
/// Widget build(BuildContext context) {
///   return NestedScrollView(
///     headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
///       return <Widget>[
///         SliverAppBar(title: Text('Custom NestedScrollViews')),
///       ];
///     },
///     body: CustomScrollView(
///       slivers: <Widget>[
///         SliverToBoxAdapter(
///           child: Container(
///             color: Colors.amberAccent,
///             height: 100,
///             child: Center(child: Text('Glow all day!')),
///           ),
///         ),
///         SliverFillRemaining(child: FlutterLogo()),
///       ],
///     ),
///   );
/// }
/// ```
/// {@end-tool}
117 118 119 120 121 122 123
///
/// See also:
///
///  * [OverscrollIndicatorNotification], which can be used to manipulate the
///    glow position or prevent the glow from being painted at all
///  * [NotificationListener], to listen for the
///    [OverscrollIndicatorNotification]
124
class GlowingOverscrollIndicator extends StatefulWidget {
125 126 127 128 129 130
  /// 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].
  ///
131 132
  /// The [showLeading], [showTrailing], [axisDirection], [color], and
  /// [notificationPredicate] arguments must not be null.
133
  const GlowingOverscrollIndicator({
134
    Key key,
135 136
    this.showLeading = true,
    this.showTrailing = true,
137 138
    @required this.axisDirection,
    @required this.color,
139
    this.notificationPredicate = defaultScrollNotificationPredicate,
140
    this.child,
141 142 143 144
  }) : assert(showLeading != null),
       assert(showTrailing != null),
       assert(axisDirection != null),
       assert(color != null),
145
       assert(notificationPredicate != null),
146
       super(key: key);
147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169

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

170 171
  /// The direction of positive scroll offsets in the [Scrollable] whose
  /// overscrolls are to be visualized.
172 173
  final AxisDirection axisDirection;

174 175
  /// The axis along which scrolling occurs in the [Scrollable] whose
  /// overscrolls are to be visualized.
176 177 178 179
  Axis get axis => axisDirectionToAxis(axisDirection);

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

181 182 183 184 185 186
  /// 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;
187

188 189 190 191
  /// 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.
192 193
  ///
  /// Typically a [GlowingOverscrollIndicator] is created by a
Adam Barth's avatar
Adam Barth committed
194
  /// [ScrollBehavior.buildViewportChrome] method, in which case
195
  /// the child is usually the one provided as an argument to that method.
196 197 198
  final Widget child;

  @override
199
  _GlowingOverscrollIndicatorState createState() => _GlowingOverscrollIndicatorState();
200 201

  @override
202 203
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
204
    properties.add(EnumProperty<AxisDirection>('axisDirection', axisDirection));
205
    String showDescription;
206
    if (showLeading && showTrailing) {
207
      showDescription = 'both sides';
208
    } else if (showLeading) {
209
      showDescription = 'leading side only';
210
    } else if (showTrailing) {
211
      showDescription = 'trailing side only';
212
    } else {
213
      showDescription = 'neither side (!)';
214
    }
215
    properties.add(MessageProperty('show', showDescription));
216
    properties.add(ColorProperty('color', color, showName: false));
217 218 219 220 221 222
  }
}

class _GlowingOverscrollIndicatorState extends State<GlowingOverscrollIndicator> with TickerProviderStateMixin {
  _GlowController _leadingController;
  _GlowController _trailingController;
223
  Listenable _leadingAndTrailingListener;
224 225 226 227

  @override
  void initState() {
    super.initState();
228 229 230
    _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]);
231 232 233
  }

  @override
234
  void didUpdateWidget(GlowingOverscrollIndicator oldWidget) {
235
    super.didUpdateWidget(oldWidget);
236 237 238 239 240
    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;
241 242 243
    }
  }

244 245 246
  Type _lastNotificationType;
  final Map<bool, bool> _accepted = <bool, bool>{false: true, true: true};

Adam Barth's avatar
Adam Barth committed
247
  bool _handleScrollNotification(ScrollNotification notification) {
248
    if (!widget.notificationPredicate(notification))
249
      return false;
250 251 252 253 254 255 256 257 258 259

    // Update the paint offset with the current scroll position. This makes
    // sure that the glow effect correctly scrolls in line with the current
    // scroll, e.g. when scrolling in the opposite direction again to hide
    // the glow. Otherwise, the glow would always stay in a fixed position,
    // even if the top of the content already scrolled away.
    _leadingController._paintOffsetScrollPixels = -notification.metrics.pixels;
    _trailingController._paintOffsetScrollPixels =
        -(notification.metrics.maxScrollExtent - notification.metrics.pixels);

260 261 262 263 264 265 266 267 268
    if (notification is OverscrollNotification) {
      _GlowController controller;
      if (notification.overscroll < 0.0) {
        controller = _leadingController;
      } else if (notification.overscroll > 0.0) {
        controller = _trailingController;
      } else {
        assert(false);
      }
269
      final bool isLeading = controller == _leadingController;
270
      if (_lastNotificationType != OverscrollNotification) {
271
        final OverscrollIndicatorNotification confirmationNotification = OverscrollIndicatorNotification(leading: isLeading);
272 273 274
        // It is possible that the scroll extent starts at non-zero.
        if (isLeading)
          confirmationNotification.paintOffset = notification.metrics.minScrollExtent;
275 276
        confirmationNotification.dispatch(context);
        _accepted[isLeading] = confirmationNotification._accepted;
277 278 279
        if (_accepted[isLeading]) {
          controller._paintOffset = confirmationNotification.paintOffset;
        }
280
      }
281
      assert(controller != null);
282
      assert(notification.metrics.axis == widget.axis);
283 284 285 286 287 288 289 290
      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);
291
            final RenderBox renderer = notification.context.findRenderObject() as RenderBox;
292 293 294
            assert(renderer != null);
            assert(renderer.hasSize);
            final Size size = renderer.size;
295
            final Offset position = renderer.globalToLocal(notification.dragDetails.globalPosition);
296
            switch (notification.metrics.axis) {
297
              case Axis.horizontal:
298
                controller.pull(notification.overscroll.abs(), size.width, position.dy.clamp(0.0, size.height) as double, size.height);
299 300
                break;
              case Axis.vertical:
301
                controller.pull(notification.overscroll.abs(), size.height, position.dx.clamp(0.0, size.width) as double, size.width);
302 303
                break;
            }
304 305 306 307
          }
        }
      }
    } else if (notification is ScrollEndNotification || notification is ScrollUpdateNotification) {
308
      if ((notification as dynamic).dragDetails != null) {
309 310 311 312
        _leadingController.scrollEnd();
        _trailingController.scrollEnd();
      }
    }
313
    _lastNotificationType = notification.runtimeType;
314 315 316 317 318 319 320 321 322 323 324 325
    return false;
  }

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

  @override
  Widget build(BuildContext context) {
326
    return NotificationListener<ScrollNotification>(
327
      onNotification: _handleScrollNotification,
328 329 330
      child: RepaintBoundary(
        child: CustomPaint(
          foregroundPainter: _GlowingOverscrollIndicatorPainter(
331 332 333
            leadingController: widget.showLeading ? _leadingController : null,
            trailingController: widget.showTrailing ? _trailingController : null,
            axisDirection: widget.axisDirection,
334
            repaint: _leadingAndTrailingListener,
335
          ),
336
          child: RepaintBoundary(
337
            child: widget.child,
338 339 340 341 342 343 344 345 346 347 348 349 350 351 352
          ),
        ),
      ),
    );
  }
}

// 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({
353
    @required TickerProvider vsync,
354 355
    @required Color color,
    @required Axis axis,
356 357 358 359
  }) : assert(vsync != null),
       assert(color != null),
       assert(axis != null),
       _color = color,
360
       _axis = axis {
361
    _glowController = AnimationController(vsync: vsync)
362
      ..addStatusListener(_changePhase);
363
    final Animation<double> decelerator = CurvedAnimation(
364 365 366
      parent: _glowController,
      curve: Curves.decelerate,
    )..addListener(notifyListeners);
367 368
    _glowOpacity = decelerator.drive(_glowOpacityTween);
    _glowSize = decelerator.drive(_glowSizeTween);
369 370 371 372 373 374 375
    _displacementTicker = vsync.createTicker(_tickDisplacement);
  }

  // animation of the main axis direction
  _GlowState _state = _GlowState.idle;
  AnimationController _glowController;
  Timer _pullRecedeTimer;
376 377
  double _paintOffset = 0.0;
  double _paintOffsetScrollPixels = 0.0;
378 379

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

414 415 416 417
  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);
418
  static final Duration _crossAxisHalfTime = Duration(microseconds: (Duration.microsecondsPerSecond / 60.0).round());
419 420 421 422

  static const double _maxOpacity = 0.5;
  static const double _pullOpacityGlowFactor = 0.8;
  static const double _velocityGlowFactor = 0.00006;
423 424
  static const double _sqrt3 = 1.73205080757; // const math.sqrt(3)
  static const double _widthToHeightFactor = (3.0 / 4.0) * (2.0 - _sqrt3);
425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444

  // 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;
445
    velocity = velocity.clamp(_minVelocity, _maxVelocity) as double;
446
    _glowOpacityTween.begin = _state == _GlowState.idle ? 0.3 : _glowOpacity.value;
447
    _glowOpacityTween.end = (velocity * _velocityGlowFactor).clamp(_glowOpacityTween.begin, _maxOpacity) as double;
448 449
    _glowSizeTween.begin = _glowSize.value;
    _glowSizeTween.end = math.min(0.025 + 7.5e-7 * velocity * velocity, 1.0);
450
    _glowController.duration = Duration(milliseconds: (0.15 + velocity * 0.02).round());
451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471
    _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);
472
    final double height = math.min(extent, crossExtent * _widthToHeightFactor);
473
    _glowSizeTween.begin = _glowSize.value;
474
    _glowSizeTween.end = math.max(1.0 - 1.0 / (0.7 * math.sqrt(_pullDistance * height)), _glowSize.value);
475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494
    _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();
      }
    }
495
    _pullRecedeTimer = Timer(_pullHoldTime, () => _recede(_pullDecayTime));
496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535
  }

  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) {
536
      final double t = (elapsed.inMicroseconds - _displacementTickerLastElapsed.inMicroseconds).toDouble();
537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552
      _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;
553
    final double height = math.min(size.height, size.width * _widthToHeightFactor);
554
    final double scaleY = _glowSize.value * baseGlowScale;
555 556 557
    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);
558
    canvas.save();
559
    canvas.translate(0.0, _paintOffset + _paintOffsetScrollPixels);
560 561 562 563 564 565 566 567 568 569 570 571
    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,
572
    Listenable repaint,
573
  }) : super(
574
    repaint: repaint,
575 576 577 578 579 580 581 582 583 584 585 586 587 588 589
  );

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

590
  static const double piOver2 = math.pi / 2.0;
591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607

  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();
608 609
        canvas.rotate(piOver2);
        canvas.scale(1.0, -1.0);
610
        controller.paint(canvas, Size(size.height, size.width));
611 612 613 614
        canvas.restore();
        break;
      case AxisDirection.right:
        canvas.save();
615 616
        canvas.translate(size.width, 0.0);
        canvas.rotate(piOver2);
617
        controller.paint(canvas, Size(size.height, size.width));
618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634
        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;
  }
}
635

636 637 638 639 640 641 642 643 644
/// 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.
645
class OverscrollIndicatorNotification extends Notification with ViewportNotificationMixin {
646 647 648 649
  /// Creates a notification that an [GlowingOverscrollIndicator] will start
  /// showing an overscroll indication.
  ///
  /// The [leading] argument must not be null.
650
  OverscrollIndicatorNotification({
651
    @required this.leading,
652 653
  });

654 655
  /// Whether the indication will be shown on the leading edge of the scroll
  /// view.
656 657
  final bool leading;

658 659 660 661 662 663 664 665 666 667 668 669
  /// Controls at which offset the glow should be drawn.
  ///
  /// A positive offset will move the glow away from its edge,
  /// i.e. for a vertical, [leading] indicator, a [paintOffset] of 100.0 will
  /// draw the indicator 100.0 pixels from the top of the edge.
  /// For a vertical indicator with [leading] set to `false`, a [paintOffset]
  /// of 100.0 will draw the indicator 100.0 pixels from the bottom instead.
  ///
  /// A negative [paintOffset] is generally not useful, since the glow will be
  /// clipped.
  double paintOffset = 0.0;

670 671 672 673 674 675 676 677 678 679 680 681
  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
682
}