overscroll_indicator.dart 36.6 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4 5 6 7 8
// 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;

import 'package:flutter/foundation.dart';
9
import 'package:flutter/physics.dart' show nearEqual, Tolerance;
10 11
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
12 13 14

import 'basic.dart';
import 'framework.dart';
15
import 'media_query.dart';
16 17 18
import 'notification_listener.dart';
import 'scroll_notification.dart';
import 'ticker_provider.dart';
19
import 'transitions.dart';
20

21 22 23 24
/// 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
25
/// generated by a [ScrollView], such as a [ListView] or a [GridView].
26 27 28 29 30 31
///
/// [GlowingOverscrollIndicator] generates [OverscrollIndicatorNotification]
/// before showing an overscroll indication. To prevent the indicator from
/// showing the indication, call [OverscrollIndicatorNotification.disallowGlow]
/// on the notification.
///
32
/// Created automatically by [ScrollBehavior.buildOverscrollIndicator] on platforms
33
/// (e.g., Android) that commonly use this type of overscroll indication.
34
///
35 36
/// In a [MaterialApp], the edge glow color is the overall theme's
/// [ColorScheme.secondary] color.
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
/// 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].
///
51
/// {@tool dartpad}
52 53 54 55 56
/// 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.
///
57
/// ** See code in examples/api/lib/widgets/overscroll_indicator/glowing_overscroll_indicator.0.dart **
58
/// {@end-tool}
59
///
60
/// {@tool dartpad}
61 62 63 64 65
/// 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.
///
66
/// ** See code in examples/api/lib/widgets/overscroll_indicator/glowing_overscroll_indicator.1.dart **
67
/// {@end-tool}
68 69 70 71
///
/// See also:
///
///  * [OverscrollIndicatorNotification], which can be used to manipulate the
72
///    glow position or prevent the glow from being painted at all.
73
///  * [NotificationListener], to listen for the
74 75
///    [OverscrollIndicatorNotification].
///  * [StretchingOverscrollIndicator], a Material Design overscroll indicator.
76
class GlowingOverscrollIndicator extends StatefulWidget {
77 78 79 80 81 82
  /// 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].
  ///
83 84
  /// The [showLeading], [showTrailing], [axisDirection], [color], and
  /// [notificationPredicate] arguments must not be null.
85
  const GlowingOverscrollIndicator({
86
    Key? key,
87 88
    this.showLeading = true,
    this.showTrailing = true,
89 90
    required this.axisDirection,
    required this.color,
91
    this.notificationPredicate = defaultScrollNotificationPredicate,
92
    this.child,
93 94 95 96
  }) : assert(showLeading != null),
       assert(showTrailing != null),
       assert(axisDirection != null),
       assert(color != null),
97
       assert(notificationPredicate != null),
98
       super(key: key);
99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121

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

122
  /// {@template flutter.overscroll.axisDirection}
123 124
  /// The direction of positive scroll offsets in the [Scrollable] whose
  /// overscrolls are to be visualized.
125
  /// {@endtemplate}
126 127
  final AxisDirection axisDirection;

128
  /// {@template flutter.overscroll.axis}
129 130
  /// The axis along which scrolling occurs in the [Scrollable] whose
  /// overscrolls are to be visualized.
131
  /// {@endtemplate}
132 133 134 135
  Axis get axis => axisDirectionToAxis(axisDirection);

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

137
  /// {@template flutter.overscroll.notificationPredicate}
138 139 140 141
  /// A check that specifies whether a [ScrollNotification] should be
  /// handled by this widget.
  ///
  /// By default, checks whether `notification.depth == 0`. Set it to something
142 143
  /// else for more complicated layouts, such as nested [ScrollView]s.
  /// {@endtemplate}
144
  final ScrollNotificationPredicate notificationPredicate;
145

146 147 148 149
  /// 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.
150 151
  ///
  /// Typically a [GlowingOverscrollIndicator] is created by a
152
  /// [ScrollBehavior.buildOverscrollIndicator] method, in which case
153
  /// the child is usually the one provided as an argument to that method.
154
  final Widget? child;
155 156

  @override
157
  State<GlowingOverscrollIndicator> createState() => _GlowingOverscrollIndicatorState();
158 159

  @override
160 161
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
162
    properties.add(EnumProperty<AxisDirection>('axisDirection', axisDirection));
163
    final String showDescription;
164
    if (showLeading && showTrailing) {
165
      showDescription = 'both sides';
166
    } else if (showLeading) {
167
      showDescription = 'leading side only';
168
    } else if (showTrailing) {
169
      showDescription = 'trailing side only';
170
    } else {
171
      showDescription = 'neither side (!)';
172
    }
173
    properties.add(MessageProperty('show', showDescription));
174
    properties.add(ColorProperty('color', color, showName: false));
175 176 177 178
  }
}

class _GlowingOverscrollIndicatorState extends State<GlowingOverscrollIndicator> with TickerProviderStateMixin {
179 180 181
  _GlowController? _leadingController;
  _GlowController? _trailingController;
  Listenable? _leadingAndTrailingListener;
182 183 184 185

  @override
  void initState() {
    super.initState();
186 187
    _leadingController = _GlowController(vsync: this, color: widget.color, axis: widget.axis);
    _trailingController = _GlowController(vsync: this, color: widget.color, axis: widget.axis);
188
    _leadingAndTrailingListener = Listenable.merge(<Listenable>[_leadingController!, _trailingController!]);
189 190 191
  }

  @override
192
  void didUpdateWidget(GlowingOverscrollIndicator oldWidget) {
193
    super.didUpdateWidget(oldWidget);
194
    if (oldWidget.color != widget.color || oldWidget.axis != widget.axis) {
195 196 197 198
      _leadingController!.color = widget.color;
      _leadingController!.axis = widget.axis;
      _trailingController!.color = widget.color;
      _trailingController!.axis = widget.axis;
199 200 201
    }
  }

202
  Type? _lastNotificationType;
203 204
  final Map<bool, bool> _accepted = <bool, bool>{false: true, true: true};

Adam Barth's avatar
Adam Barth committed
205
  bool _handleScrollNotification(ScrollNotification notification) {
206
    if (!widget.notificationPredicate(notification))
207
      return false;
208 209 210 211 212 213

    // 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.
214 215 216 217 218
    // For example (CustomScrollView with sliver before center), the scroll
    // extent is [-200.0, 300.0], scroll in the opposite direction with 10.0 pixels
    // before glow disappears, so the current pixels is -190.0,
    // in this case, we should move the glow up 10.0 pixels and should not
    // overflow the scrollable widget's edge. https://github.com/flutter/flutter/issues/64149.
219 220 221 222
    _leadingController!._paintOffsetScrollPixels =
      -math.min(notification.metrics.pixels - notification.metrics.minScrollExtent, _leadingController!._paintOffset);
    _trailingController!._paintOffsetScrollPixels =
      -math.min(notification.metrics.maxScrollExtent - notification.metrics.pixels, _trailingController!._paintOffset);
223

224
    if (notification is OverscrollNotification) {
225
      _GlowController? controller;
226 227 228 229 230 231 232
      if (notification.overscroll < 0.0) {
        controller = _leadingController;
      } else if (notification.overscroll > 0.0) {
        controller = _trailingController;
      } else {
        assert(false);
      }
233
      final bool isLeading = controller == _leadingController;
234
      if (_lastNotificationType is! OverscrollNotification) {
235
        final OverscrollIndicatorNotification confirmationNotification = OverscrollIndicatorNotification(leading: isLeading);
236 237
        confirmationNotification.dispatch(context);
        _accepted[isLeading] = confirmationNotification._accepted;
238 239
        if (_accepted[isLeading]!) {
          controller!._paintOffset = confirmationNotification.paintOffset;
240
        }
241
      }
242
      assert(controller != null);
243
      assert(notification.metrics.axis == widget.axis);
244
      if (_accepted[isLeading]!) {
245 246
        if (notification.velocity != 0.0) {
          assert(notification.dragDetails == null);
247
          controller!.absorbImpact(notification.velocity.abs());
248 249 250
        } else {
          assert(notification.overscroll != 0.0);
          if (notification.dragDetails != null) {
251
            assert(notification.dragDetails!.globalPosition != null);
252
            final RenderBox renderer = notification.context!.findRenderObject()! as RenderBox;
253 254 255
            assert(renderer != null);
            assert(renderer.hasSize);
            final Size size = renderer.size;
256
            final Offset position = renderer.globalToLocal(notification.dragDetails!.globalPosition);
257
            switch (notification.metrics.axis) {
258
              case Axis.horizontal:
259
                controller!.pull(notification.overscroll.abs(), size.width, position.dy.clamp(0.0, size.height), size.height);
260 261
                break;
              case Axis.vertical:
262
                controller!.pull(notification.overscroll.abs(), size.height, position.dx.clamp(0.0, size.width), size.width);
263 264
                break;
            }
265 266 267 268
          }
        }
      }
    } else if (notification is ScrollEndNotification || notification is ScrollUpdateNotification) {
269 270 271
      // Using dynamic here to avoid layer violations of importing
      // drag_details.dart from gestures.
      // ignore: avoid_dynamic_calls
272
      if ((notification as dynamic).dragDetails != null) {
273 274
        _leadingController!.scrollEnd();
        _trailingController!.scrollEnd();
275 276
      }
    }
277
    _lastNotificationType = notification.runtimeType;
278 279 280 281 282
    return false;
  }

  @override
  void dispose() {
283 284
    _leadingController!.dispose();
    _trailingController!.dispose();
285 286 287 288 289
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
290
    return NotificationListener<ScrollNotification>(
291
      onNotification: _handleScrollNotification,
292 293 294
      child: RepaintBoundary(
        child: CustomPaint(
          foregroundPainter: _GlowingOverscrollIndicatorPainter(
295 296 297
            leadingController: widget.showLeading ? _leadingController : null,
            trailingController: widget.showTrailing ? _trailingController : null,
            axisDirection: widget.axisDirection,
298
            repaint: _leadingAndTrailingListener,
299
          ),
300
          child: RepaintBoundary(
301
            child: widget.child,
302 303 304 305 306 307 308 309 310 311 312 313 314 315 316
          ),
        ),
      ),
    );
  }
}

// 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({
317 318 319
    required TickerProvider vsync,
    required Color color,
    required Axis axis,
320 321 322 323
  }) : assert(vsync != null),
       assert(color != null),
       assert(axis != null),
       _color = color,
324
       _axis = axis {
325
    _glowController = AnimationController(vsync: vsync)
326
      ..addStatusListener(_changePhase);
327
    final Animation<double> decelerator = CurvedAnimation(
328 329 330
      parent: _glowController,
      curve: Curves.decelerate,
    )..addListener(notifyListeners);
331 332
    _glowOpacity = decelerator.drive(_glowOpacityTween);
    _glowSize = decelerator.drive(_glowSizeTween);
333 334 335 336 337
    _displacementTicker = vsync.createTicker(_tickDisplacement);
  }

  // animation of the main axis direction
  _GlowState _state = _GlowState.idle;
338 339
  late final AnimationController _glowController;
  Timer? _pullRecedeTimer;
340 341
  double _paintOffset = 0.0;
  double _paintOffsetScrollPixels = 0.0;
342 343

  // animation values
344
  final Tween<double> _glowOpacityTween = Tween<double>(begin: 0.0, end: 0.0);
345
  late final Animation<double> _glowOpacity;
346
  final Tween<double> _glowSizeTween = Tween<double>(begin: 0.0, end: 0.0);
347
  late final Animation<double> _glowSize;
348 349

  // animation of the cross axis position
350 351
  late final Ticker _displacementTicker;
  Duration? _displacementTickerLastElapsed;
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
  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();
  }

378 379 380 381
  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);
382
  static final Duration _crossAxisHalfTime = Duration(microseconds: (Duration.microsecondsPerSecond / 60.0).round());
383 384 385 386

  static const double _maxOpacity = 0.5;
  static const double _pullOpacityGlowFactor = 0.8;
  static const double _velocityGlowFactor = 0.00006;
387 388
  static const double _sqrt3 = 1.73205080757; // const math.sqrt(3)
  static const double _widthToHeightFactor = (3.0 / 4.0) * (2.0 - _sqrt3);
389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408

  // 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;
409
    velocity = velocity.clamp(_minVelocity, _maxVelocity);
410
    _glowOpacityTween.begin = _state == _GlowState.idle ? 0.3 : _glowOpacity.value;
411
    _glowOpacityTween.end = (velocity * _velocityGlowFactor).clamp(_glowOpacityTween.begin!, _maxOpacity);
412 413
    _glowSizeTween.begin = _glowSize.value;
    _glowSizeTween.end = math.min(0.025 + 7.5e-7 * velocity * velocity, 1.0);
414
    _glowController.duration = Duration(milliseconds: (0.15 + velocity * 0.02).round());
415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435
    _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);
436
    final double height = math.min(extent, crossExtent * _widthToHeightFactor);
437
    _glowSizeTween.begin = _glowSize.value;
438
    _glowSizeTween.end = math.max(1.0 - 1.0 / (0.7 * math.sqrt(_pullDistance * height)), _glowSize.value);
439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458
    _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();
      }
    }
459
    _pullRecedeTimer = Timer(_pullHoldTime, () => _recede(_pullDecayTime));
460 461 462 463 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 497 498 499
  }

  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) {
500
      final double t = (elapsed.inMicroseconds - _displacementTickerLastElapsed!.inMicroseconds).toDouble();
501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516
      _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;
517
    final double height = math.min(size.height, size.width * _widthToHeightFactor);
518
    final double scaleY = _glowSize.value * baseGlowScale;
519 520 521
    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);
522
    canvas.save();
523
    canvas.translate(0.0, _paintOffset + _paintOffsetScrollPixels);
524 525 526 527 528
    canvas.scale(1.0, scaleY);
    canvas.clipRect(rect);
    canvas.drawCircle(center, radius, paint);
    canvas.restore();
  }
Ian Hickson's avatar
Ian Hickson committed
529 530 531 532 533

  @override
  String toString() {
    return '_GlowController(color: $color, axis: ${describeEnum(axis)})';
  }
534 535 536 537 538 539
}

class _GlowingOverscrollIndicatorPainter extends CustomPainter {
  _GlowingOverscrollIndicatorPainter({
    this.leadingController,
    this.trailingController,
540 541
    required this.axisDirection,
    Listenable? repaint,
542
  }) : super(
543
    repaint: repaint,
544 545 546 547 548
  );

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

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

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

559
  static const double piOver2 = math.pi / 2.0;
560

561
  void _paintSide(Canvas canvas, Size size, _GlowController? controller, AxisDirection axisDirection, GrowthDirection growthDirection) {
562 563 564 565 566 567 568 569 570 571 572 573 574 575 576
    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();
577 578
        canvas.rotate(piOver2);
        canvas.scale(1.0, -1.0);
579
        controller.paint(canvas, Size(size.height, size.width));
580 581 582 583
        canvas.restore();
        break;
      case AxisDirection.right:
        canvas.save();
584 585
        canvas.translate(size.width, 0.0);
        canvas.rotate(piOver2);
586
        controller.paint(canvas, Size(size.height, size.width));
587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602
        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;
  }
Ian Hickson's avatar
Ian Hickson committed
603 604 605 606 607

  @override
  String toString() {
    return '_GlowingOverscrollIndicatorPainter($leadingController, $trailingController)';
  }
608
}
609

610
/// A Material Design visual indication that a scroll view has overscrolled.
611
///
612 613 614 615 616 617 618 619 620 621 622 623 624 625
/// A [StretchingOverscrollIndicator] listens for [ScrollNotification]s in order
/// to stretch the content of the [Scrollable]. These notifications are typically
/// generated by a [ScrollView], such as a [ListView] or a [GridView].
///
/// When triggered, the [StretchingOverscrollIndicator] generates an
/// [OverscrollIndicatorNotification] before showing an overscroll indication.
/// To prevent the indicator from showing the indication, call
/// [OverscrollIndicatorNotification.disallowIndicator] on the notification.
///
/// Created by [ScrollBehavior.buildOverscrollIndicator] on platforms
/// (e.g., Android) that commonly use this type of overscroll indication when
/// [ScrollBehavior.androidOverscrollIndicator] is
/// [AndroidOverscrollIndicator.stretch]. Otherwise, the default
/// [GlowingOverscrollIndicator] is applied.
626 627 628
///
/// See also:
///
629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709
///  * [OverscrollIndicatorNotification], which can be used to prevent the stretch
///    effect from being applied at all.
///  * [NotificationListener], to listen for the
///    [OverscrollIndicatorNotification].
///  * [GlowingOverscrollIndicator], the default overscroll indicator for
///    [TargetPlatform.android] and [TargetPlatform.fuchsia].
class StretchingOverscrollIndicator extends StatefulWidget {
  /// Creates a visual indication that a scroll view has overscrolled by
  /// applying a stretch transformation to the content.
  ///
  /// 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].
  ///
  /// The [axisDirection] and [notificationPredicate] arguments must not be null.
  const StretchingOverscrollIndicator({
    Key? key,
    required this.axisDirection,
    this.notificationPredicate = defaultScrollNotificationPredicate,
    this.child,
  }) : assert(axisDirection != null),
       assert(notificationPredicate != null),
       super(key: key);

  /// {@macro flutter.overscroll.axisDirection}
  final AxisDirection axisDirection;

  /// {@macro flutter.overscroll.axis}
  Axis get axis => axisDirectionToAxis(axisDirection);

  /// {@macro flutter.overscroll.notificationPredicate}
  final ScrollNotificationPredicate notificationPredicate;

  /// The widget below this widget in the tree.
  ///
  /// The overscroll indicator will apply a stretch effect to this child. This
  /// child (and its subtree) should include a source of [ScrollNotification]
  /// notifications.
  ///
  /// Typically a [StretchingOverscrollIndicator] is created by a
  /// [ScrollBehavior.buildOverscrollIndicator] method when opted-in using the
  /// [ScrollBehavior.androidOverscrollIndicator] flag. In this case
  /// the child is usually the one provided as an argument to that method.
  final Widget? child;

  @override
  State<StretchingOverscrollIndicator> createState() => _StretchingOverscrollIndicatorState();

  @override
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
    properties.add(EnumProperty<AxisDirection>('axisDirection', axisDirection));
  }
}

class _StretchingOverscrollIndicatorState extends State<StretchingOverscrollIndicator> with TickerProviderStateMixin {
  late final _StretchController _stretchController = _StretchController(vsync: this);
  ScrollNotification? _lastNotification;
  OverscrollNotification? _lastOverscrollNotification;
  bool _accepted = true;

  bool _handleScrollNotification(ScrollNotification notification) {
    if (!widget.notificationPredicate(notification))
      return false;

    if (notification is OverscrollNotification) {
      _lastOverscrollNotification = notification;
      if (_lastNotification.runtimeType is! OverscrollNotification) {
        final OverscrollIndicatorNotification confirmationNotification = OverscrollIndicatorNotification(leading: notification.overscroll < 0.0);
        confirmationNotification.dispatch(context);
        _accepted = confirmationNotification._accepted;
      }

      assert(notification.metrics.axis == widget.axis);
      if (_accepted) {
        if (notification.velocity != 0.0) {
          assert(notification.dragDetails == null);
          _stretchController.absorbImpact(notification.velocity.abs());
        } else {
          assert(notification.overscroll != 0.0);
          if (notification.dragDetails != null) {
710 711 712 713 714 715 716 717 718
            // We clamp the overscroll amount relative to the length of the viewport,
            // which is the furthest distance a single pointer could pull on the
            // screen. This is because more than one pointer will multiply the
            // amount of overscroll - https://github.com/flutter/flutter/issues/11884
            final double viewportDimension = notification.metrics.viewportDimension;
            final double distanceForPull =
              (notification.overscroll.abs() / viewportDimension) + _stretchController.pullDistance;
            final double clampedOverscroll = distanceForPull.clamp(0, 1.0);
            _stretchController.pull(clampedOverscroll);
719 720 721
          }
        }
      }
722
    } else if (notification is ScrollEndNotification || notification is ScrollUpdateNotification) {
723 724 725 726 727 728
      _stretchController.scrollEnd();
    }
    _lastNotification = notification;
    return false;
  }

729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750
  AlignmentDirectional _getAlignmentForAxisDirection(double overscroll) {
    // Accounts for reversed scrollables by checking the AxisDirection
    switch (widget.axisDirection) {
      case AxisDirection.up:
        return overscroll > 0
            ? AlignmentDirectional.topCenter
            : AlignmentDirectional.bottomCenter;
      case AxisDirection.right:
        return overscroll > 0
            ? AlignmentDirectional.centerEnd
            : AlignmentDirectional.centerStart;
      case AxisDirection.down:
        return overscroll > 0
            ? AlignmentDirectional.bottomCenter
            : AlignmentDirectional.topCenter;
      case AxisDirection.left:
        return overscroll > 0
            ? AlignmentDirectional.centerStart
            : AlignmentDirectional.centerEnd;
    }
  }

751 752 753 754 755 756 757 758
  @override
  void dispose() {
    _stretchController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
759 760
    final Size size = MediaQuery.of(context).size;
    double mainAxisSize;
761 762 763 764 765 766 767 768 769 770 771 772
    return NotificationListener<ScrollNotification>(
      onNotification: _handleScrollNotification,
      child: AnimatedBuilder(
        animation: _stretchController,
        builder: (BuildContext context, Widget? child) {
          final double stretch = _stretchController.value;
          double x = 1.0;
          double y = 1.0;

          switch (widget.axis) {
            case Axis.horizontal:
              x += stretch;
773
              mainAxisSize = size.width;
774 775 776
              break;
            case Axis.vertical:
              y += stretch;
777
              mainAxisSize = size.height;
778 779 780
              break;
          }

781 782 783 784
          final AlignmentDirectional alignment = _getAlignmentForAxisDirection(
            _lastOverscrollNotification?.overscroll ?? 0.0
          );

785 786 787 788 789 790
          final double viewportDimension = _lastOverscrollNotification?.metrics.viewportDimension ?? mainAxisSize;

          final Widget transform = Transform(
            alignment: alignment,
            transform: Matrix4.diagonal3Values(x, y, 1.0),
            child: widget.child,
791
          );
792 793 794 795

          // Only clip if the viewport dimension is smaller than that of the
          // screen size in the main axis. If the viewport takes up the whole
          // screen, overflow from transforming the viewport is irrelevant.
796 797 798 799 800
          return ClipRect(
            clipBehavior: stretch != 0.0 && viewportDimension != mainAxisSize
              ? Clip.hardEdge : Clip.none,
            child: transform,
          );
801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828
        },
      ),
    );
  }
}

enum _StretchState {
  idle,
  absorb,
  pull,
  recede,
}

class _StretchController extends ChangeNotifier {
  _StretchController({ required TickerProvider vsync }) {
    _stretchController = AnimationController(vsync: vsync)
      ..addStatusListener(_changePhase);
    final Animation<double> decelerator = CurvedAnimation(
      parent: _stretchController,
      curve: Curves.decelerate,
    )..addListener(notifyListeners);
    _stretchSize = decelerator.drive(_stretchSizeTween);
  }

  late final AnimationController _stretchController;
  late final Animation<double> _stretchSize;
  final Tween<double> _stretchSizeTween = Tween<double>(begin: 0.0, end: 0.0);
  _StretchState _state = _StretchState.idle;
829 830

  double get pullDistance => _pullDistance;
831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860
  double _pullDistance = 0.0;

  // Constants from Android.
  static const double _exponentialScalar = math.e / 0.33;
  static const double _stretchIntensity = 0.016;
  static const double _flingFriction = 1.01;
  static const Duration _stretchDuration = Duration(milliseconds: 400);

  double get value => _stretchSize.value;

  /// Handle a fling to the edge of the viewport at a particular velocity.
  ///
  /// The velocity must be positive.
  void absorbImpact(double velocity) {
    assert(velocity >= 0.0);
    velocity = velocity.clamp(1, 10000);
    _stretchSizeTween.begin = _stretchSize.value;
    _stretchSizeTween.end = math.min(_stretchIntensity + (_flingFriction / velocity), 1.0);
    _stretchController.duration = Duration(milliseconds: (velocity * 0.02).round());
    _stretchController.forward(from: 0.0);
    _state = _StretchState.absorb;
  }

  /// Handle a user-driven overscroll.
  ///
  /// The `normalizedOverscroll` argument should be the absolute value of the
  /// scroll distance in logical pixels, divided by the extent of the viewport
  /// in the main axis.
  void pull(double normalizedOverscroll) {
    assert(normalizedOverscroll >= 0.0);
861
    _pullDistance = normalizedOverscroll;
862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914
    _stretchSizeTween.begin = _stretchSize.value;
    final double linearIntensity =_stretchIntensity * _pullDistance;
    final double exponentialIntensity = _stretchIntensity * (1 - math.exp(-_pullDistance * _exponentialScalar));
    _stretchSizeTween.end = linearIntensity + exponentialIntensity;
    _stretchController.duration = _stretchDuration;
    if (_state != _StretchState.pull) {
      _stretchController.forward(from: 0.0);
      _state = _StretchState.pull;
    } else {
      if (!_stretchController.isAnimating) {
        assert(_stretchController.value == 1.0);
        notifyListeners();
      }
    }
  }

  void scrollEnd() {
    if (_state == _StretchState.pull)
      _recede(_stretchDuration);
  }

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

  void _recede(Duration duration) {
    if (_state == _StretchState.recede || _state == _StretchState.idle)
      return;
    _stretchSizeTween.begin = _stretchSize.value;
    _stretchSizeTween.end = 0.0;
    _stretchController.duration = duration;
    _stretchController.forward(from: 0.0);
    _state = _StretchState.recede;
  }

  @override
  void dispose() {
    _stretchController.dispose();
    super.dispose();
  }
Ian Hickson's avatar
Ian Hickson committed
915 916 917

  @override
  String toString() => '_StretchController()';
918 919 920 921 922 923 924 925 926 927 928 929 930 931
}

/// A notification that either a [GlowingOverscrollIndicator] or a
/// [StretchingOverscrollIndicator] will start showing an overscroll indication.
///
/// To prevent the indicator from showing the indication, call
/// [disallowIndicator] on the notification.
///
/// See also:
///
///  * [GlowingOverscrollIndicator], which generates this type of notification
///    by painting an indicator over the child content.
///  * [StretchingOverscrollIndicator], which generates this type of
///    notification by applying a stretch transformation to the child content.
932
class OverscrollIndicatorNotification extends Notification with ViewportNotificationMixin {
933 934
  /// Creates a notification that an [GlowingOverscrollIndicator] or a
  /// [StretchingOverscrollIndicator] will start showing an overscroll indication.
935 936
  ///
  /// The [leading] argument must not be null.
937
  OverscrollIndicatorNotification({
938
    required this.leading,
939 940
  });

941 942
  /// Whether the indication will be shown on the leading edge of the scroll
  /// view.
943 944
  final bool leading;

945
  /// Controls at which offset a [GlowingOverscrollIndicator] draws.
946 947 948 949 950 951 952 953 954
  ///
  /// 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.
955 956
  ///
  /// This has no effect on a [StretchingOverscrollIndicator].
957 958
  double paintOffset = 0.0;

959 960
  bool _accepted = true;

961 962 963 964 965 966
  /// Call this method if the glow should be prevented. This method is
  /// deprecated in favor of [disallowIndicator].
  @Deprecated(
    'Use disallowIndicator instead. '
    'This feature was deprecated after v2.5.0-6.0.pre.',
  )
967 968 969 970
  void disallowGlow() {
    _accepted = false;
  }

971 972 973 974 975
  /// Call this method if the overscroll indicator should be prevented.
  void disallowIndicator() {
    _accepted = false;
  }

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