overscroll_indicator.dart 37.2 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 Tolerance, nearEqual;
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
///
/// [GlowingOverscrollIndicator] generates [OverscrollIndicatorNotification]
/// before showing an overscroll indication. To prevent the indicator from
29 30
/// showing the indication, call
/// [OverscrollIndicatorNotification.disallowIndicator] on the notification.
31
///
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
  /// 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].
82
  const GlowingOverscrollIndicator({
83
    super.key,
84 85
    this.showLeading = true,
    this.showTrailing = true,
86 87
    required this.axisDirection,
    required this.color,
88
    this.notificationPredicate = defaultScrollNotificationPredicate,
89
    this.child,
90
  });
91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113

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

114
  /// {@template flutter.overscroll.axisDirection}
115 116
  /// The direction of positive scroll offsets in the [Scrollable] whose
  /// overscrolls are to be visualized.
117
  /// {@endtemplate}
118 119
  final AxisDirection axisDirection;

120
  /// {@template flutter.overscroll.axis}
121 122
  /// The axis along which scrolling occurs in the [Scrollable] whose
  /// overscrolls are to be visualized.
123
  /// {@endtemplate}
124 125 126 127
  Axis get axis => axisDirectionToAxis(axisDirection);

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

129
  /// {@template flutter.overscroll.notificationPredicate}
130 131 132 133
  /// A check that specifies whether a [ScrollNotification] should be
  /// handled by this widget.
  ///
  /// By default, checks whether `notification.depth == 0`. Set it to something
134 135
  /// else for more complicated layouts, such as nested [ScrollView]s.
  /// {@endtemplate}
136
  final ScrollNotificationPredicate notificationPredicate;
137

138 139 140 141
  /// 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.
142 143
  ///
  /// Typically a [GlowingOverscrollIndicator] is created by a
144
  /// [ScrollBehavior.buildOverscrollIndicator] method, in which case
145
  /// the child is usually the one provided as an argument to that method.
146
  final Widget? child;
147 148

  @override
149
  State<GlowingOverscrollIndicator> createState() => _GlowingOverscrollIndicatorState();
150 151

  @override
152 153
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
154
    properties.add(EnumProperty<AxisDirection>('axisDirection', axisDirection));
155
    final String showDescription;
156
    if (showLeading && showTrailing) {
157
      showDescription = 'both sides';
158
    } else if (showLeading) {
159
      showDescription = 'leading side only';
160
    } else if (showTrailing) {
161
      showDescription = 'trailing side only';
162
    } else {
163
      showDescription = 'neither side (!)';
164
    }
165
    properties.add(MessageProperty('show', showDescription));
166
    properties.add(ColorProperty('color', color, showName: false));
167 168 169 170
  }
}

class _GlowingOverscrollIndicatorState extends State<GlowingOverscrollIndicator> with TickerProviderStateMixin {
171 172 173
  _GlowController? _leadingController;
  _GlowController? _trailingController;
  Listenable? _leadingAndTrailingListener;
174 175 176 177

  @override
  void initState() {
    super.initState();
178 179
    _leadingController = _GlowController(vsync: this, color: widget.color, axis: widget.axis);
    _trailingController = _GlowController(vsync: this, color: widget.color, axis: widget.axis);
180
    _leadingAndTrailingListener = Listenable.merge(<Listenable>[_leadingController!, _trailingController!]);
181 182 183
  }

  @override
184
  void didUpdateWidget(GlowingOverscrollIndicator oldWidget) {
185
    super.didUpdateWidget(oldWidget);
186
    if (oldWidget.color != widget.color || oldWidget.axis != widget.axis) {
187 188 189 190
      _leadingController!.color = widget.color;
      _leadingController!.axis = widget.axis;
      _trailingController!.color = widget.color;
      _trailingController!.axis = widget.axis;
191 192 193
    }
  }

194
  Type? _lastNotificationType;
195 196
  final Map<bool, bool> _accepted = <bool, bool>{false: true, true: true};

Adam Barth's avatar
Adam Barth committed
197
  bool _handleScrollNotification(ScrollNotification notification) {
198
    if (!widget.notificationPredicate(notification)) {
199
      return false;
200
    }
201 202 203 204 205
    if (notification.metrics.axis != widget.axis) {
      // This widget is explicitly configured to one axis. If a notification
      // from a different axis bubbles up, do nothing.
      return false;
    }
206 207 208 209 210 211

    // 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.
212 213 214 215 216
    // 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.
217 218 219 220
    _leadingController!._paintOffsetScrollPixels =
      -math.min(notification.metrics.pixels - notification.metrics.minScrollExtent, _leadingController!._paintOffset);
    _trailingController!._paintOffsetScrollPixels =
      -math.min(notification.metrics.maxScrollExtent - notification.metrics.pixels, _trailingController!._paintOffset);
221

222
    if (notification is OverscrollNotification) {
223
      _GlowController? controller;
224 225 226 227 228 229 230
      if (notification.overscroll < 0.0) {
        controller = _leadingController;
      } else if (notification.overscroll > 0.0) {
        controller = _trailingController;
      } else {
        assert(false);
      }
231
      final bool isLeading = controller == _leadingController;
232
      if (_lastNotificationType is! OverscrollNotification) {
233
        final OverscrollIndicatorNotification confirmationNotification = OverscrollIndicatorNotification(leading: isLeading);
234
        confirmationNotification.dispatch(context);
235
        _accepted[isLeading] = confirmationNotification.accepted;
236 237
        if (_accepted[isLeading]!) {
          controller!._paintOffset = confirmationNotification.paintOffset;
238
        }
239
      }
240
      assert(controller != null);
241
      if (_accepted[isLeading]!) {
242 243
        if (notification.velocity != 0.0) {
          assert(notification.dragDetails == null);
244
          controller!.absorbImpact(notification.velocity.abs());
245 246 247
        } else {
          assert(notification.overscroll != 0.0);
          if (notification.dragDetails != null) {
248
            final RenderBox renderer = notification.context!.findRenderObject()! as RenderBox;
249 250
            assert(renderer.hasSize);
            final Size size = renderer.size;
251
            final Offset position = renderer.globalToLocal(notification.dragDetails!.globalPosition);
252
            switch (notification.metrics.axis) {
253
              case Axis.horizontal:
254
                controller!.pull(notification.overscroll.abs(), size.width, clampDouble(position.dy, 0.0, size.height), size.height);
255
              case Axis.vertical:
256
                controller!.pull(notification.overscroll.abs(), size.height, clampDouble(position.dx, 0.0, size.width), size.width);
257
            }
258 259 260
          }
        }
      }
261 262 263 264
    } else if ((notification is ScrollEndNotification && notification.dragDetails != null) ||
               (notification is ScrollUpdateNotification && notification.dragDetails != null)) {
      _leadingController!.scrollEnd();
      _trailingController!.scrollEnd();
265
    }
266
    _lastNotificationType = notification.runtimeType;
267 268 269 270 271
    return false;
  }

  @override
  void dispose() {
272 273
    _leadingController!.dispose();
    _trailingController!.dispose();
274 275 276 277 278
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
279
    return NotificationListener<ScrollNotification>(
280
      onNotification: _handleScrollNotification,
281 282 283
      child: RepaintBoundary(
        child: CustomPaint(
          foregroundPainter: _GlowingOverscrollIndicatorPainter(
284 285 286
            leadingController: widget.showLeading ? _leadingController : null,
            trailingController: widget.showTrailing ? _trailingController : null,
            axisDirection: widget.axisDirection,
287
            repaint: _leadingAndTrailingListener,
288
          ),
289
          child: RepaintBoundary(
290
            child: widget.child,
291 292 293 294 295 296 297 298 299 300 301 302 303 304 305
          ),
        ),
      ),
    );
  }
}

// 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({
306 307 308
    required TickerProvider vsync,
    required Color color,
    required Axis axis,
309
  }) : _color = color,
310
       _axis = axis {
311
    _glowController = AnimationController(vsync: vsync)
312
      ..addStatusListener(_changePhase);
313
    final Animation<double> decelerator = CurvedAnimation(
314 315 316
      parent: _glowController,
      curve: Curves.decelerate,
    )..addListener(notifyListeners);
317 318
    _glowOpacity = decelerator.drive(_glowOpacityTween);
    _glowSize = decelerator.drive(_glowSizeTween);
319 320 321 322 323
    _displacementTicker = vsync.createTicker(_tickDisplacement);
  }

  // animation of the main axis direction
  _GlowState _state = _GlowState.idle;
324 325
  late final AnimationController _glowController;
  Timer? _pullRecedeTimer;
326 327
  double _paintOffset = 0.0;
  double _paintOffsetScrollPixels = 0.0;
328 329

  // animation values
330
  final Tween<double> _glowOpacityTween = Tween<double>(begin: 0.0, end: 0.0);
331
  late final Animation<double> _glowOpacity;
332
  final Tween<double> _glowSizeTween = Tween<double>(begin: 0.0, end: 0.0);
333
  late final Animation<double> _glowSize;
334 335

  // animation of the cross axis position
336 337
  late final Ticker _displacementTicker;
  Duration? _displacementTickerLastElapsed;
338 339 340 341 342 343 344 345 346
  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) {
347
    if (color == value) {
348
      return;
349
    }
350 351 352 353 354 355 356
    _color = value;
    notifyListeners();
  }

  Axis get axis => _axis;
  Axis _axis;
  set axis(Axis value) {
357
    if (axis == value) {
358
      return;
359
    }
360 361 362 363
    _axis = value;
    notifyListeners();
  }

364 365 366 367
  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);
368
  static final Duration _crossAxisHalfTime = Duration(microseconds: (Duration.microsecondsPerSecond / 60.0).round());
369 370 371 372

  static const double _maxOpacity = 0.5;
  static const double _pullOpacityGlowFactor = 0.8;
  static const double _velocityGlowFactor = 0.00006;
373 374
  static const double _sqrt3 = 1.73205080757; // const math.sqrt(3)
  static const double _widthToHeightFactor = (3.0 / 4.0) * (2.0 - _sqrt3);
375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394

  // 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;
395
    velocity = clampDouble(velocity, _minVelocity, _maxVelocity);
396
    _glowOpacityTween.begin = _state == _GlowState.idle ? 0.3 : _glowOpacity.value;
397
    _glowOpacityTween.end = clampDouble(velocity * _velocityGlowFactor, _glowOpacityTween.begin!, _maxOpacity);
398 399
    _glowSizeTween.begin = _glowSize.value;
    _glowSizeTween.end = math.min(0.025 + 7.5e-7 * velocity * velocity, 1.0);
400
    _glowController.duration = Duration(milliseconds: (0.15 + velocity * 0.02).round());
401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421
    _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);
422
    final double height = math.min(extent, crossExtent * _widthToHeightFactor);
423
    _glowSizeTween.begin = _glowSize.value;
424
    _glowSizeTween.end = math.max(1.0 - 1.0 / (0.7 * math.sqrt(_pullDistance * height)), _glowSize.value);
425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444
    _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();
      }
    }
445
    _pullRecedeTimer = Timer(_pullHoldTime, () => _recede(_pullDecayTime));
446 447 448
  }

  void scrollEnd() {
449
    if (_state == _GlowState.pull) {
450
      _recede(_recedeTime);
451
    }
452 453 454
  }

  void _changePhase(AnimationStatus status) {
455
    if (status != AnimationStatus.completed) {
456
      return;
457
    }
458 459 460 461 462 463 464 465 466 467 468 469 470
    switch (_state) {
      case _GlowState.absorb:
        _recede(_recedeTime);
      case _GlowState.recede:
        _state = _GlowState.idle;
        _pullDistance = 0.0;
      case _GlowState.pull:
      case _GlowState.idle:
        break;
    }
  }

  void _recede(Duration duration) {
471
    if (_state == _GlowState.recede || _state == _GlowState.idle) {
472
      return;
473
    }
474 475 476 477 478 479 480 481 482 483 484 485 486
    _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) {
487
      final double t = (elapsed.inMicroseconds - _displacementTickerLastElapsed!.inMicroseconds).toDouble();
488 489 490 491 492 493 494 495 496 497 498 499
      _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) {
500
    if (_glowOpacity.value == 0.0) {
501
      return;
502
    }
503 504
    final double baseGlowScale = size.width > size.height ? size.height / size.width : 1.0;
    final double radius = size.width * 3.0 / 2.0;
505
    final double height = math.min(size.height, size.width * _widthToHeightFactor);
506
    final double scaleY = _glowSize.value * baseGlowScale;
507 508 509
    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);
510
    canvas.save();
511
    canvas.translate(0.0, _paintOffset + _paintOffsetScrollPixels);
512 513 514 515 516
    canvas.scale(1.0, scaleY);
    canvas.clipRect(rect);
    canvas.drawCircle(center, radius, paint);
    canvas.restore();
  }
Ian Hickson's avatar
Ian Hickson committed
517 518 519

  @override
  String toString() {
520
    return '_GlowController(color: $color, axis: ${axis.name})';
Ian Hickson's avatar
Ian Hickson committed
521
  }
522 523 524 525 526 527
}

class _GlowingOverscrollIndicatorPainter extends CustomPainter {
  _GlowingOverscrollIndicatorPainter({
    this.leadingController,
    this.trailingController,
528
    required this.axisDirection,
529 530
    super.repaint,
  });
531 532 533 534

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

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

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

545
  static const double piOver2 = math.pi / 2.0;
546

547
  void _paintSide(Canvas canvas, Size size, _GlowController? controller, AxisDirection axisDirection, GrowthDirection growthDirection) {
548
    if (controller == null) {
549
      return;
550
    }
551 552 553 554 555 556 557 558 559 560 561
    switch (applyGrowthDirectionToAxisDirection(axisDirection, growthDirection)) {
      case AxisDirection.up:
        controller.paint(canvas, size);
      case AxisDirection.down:
        canvas.save();
        canvas.translate(0.0, size.height);
        canvas.scale(1.0, -1.0);
        controller.paint(canvas, size);
        canvas.restore();
      case AxisDirection.left:
        canvas.save();
562 563
        canvas.rotate(piOver2);
        canvas.scale(1.0, -1.0);
564
        controller.paint(canvas, Size(size.height, size.width));
565 566 567
        canvas.restore();
      case AxisDirection.right:
        canvas.save();
568 569
        canvas.translate(size.width, 0.0);
        canvas.rotate(piOver2);
570
        controller.paint(canvas, Size(size.height, size.width));
571 572 573 574 575 576 577 578 579 580 581 582 583 584 585
        canvas.restore();
    }
  }

  @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
586 587 588 589 590

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

593 594 595 596 597 598 599 600 601
enum _StretchDirection {
  /// The [trailing] direction indicates that the content will be stretched toward
  /// the trailing edge.
  trailing,
  /// The [leading] direction indicates that the content will be stretched toward
  /// the leading edge.
  leading,
}

602
/// A Material Design visual indication that a scroll view has overscrolled.
603
///
604 605 606 607 608 609 610 611 612
/// 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.
///
613
/// Created by [MaterialScrollBehavior.buildOverscrollIndicator] on platforms
614
/// (e.g., Android) that commonly use this type of overscroll indication when
615 616
/// [ThemeData.useMaterial3] is true. Otherwise, when [ThemeData.useMaterial3]
/// is false, a [GlowingOverscrollIndicator] is used instead.=
617 618 619
///
/// See also:
///
620 621
///  * [OverscrollIndicatorNotification], which can be used to prevent the
///    stretch effect from being applied at all.
622 623 624 625 626 627 628 629 630 631 632 633
///  * [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].
  const StretchingOverscrollIndicator({
634
    super.key,
635 636
    required this.axisDirection,
    this.notificationPredicate = defaultScrollNotificationPredicate,
637
    this.clipBehavior = Clip.hardEdge,
638
    this.child,
639
  });
640 641 642 643 644 645 646 647 648 649

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

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

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

650 651 652 653 654
  /// {@macro flutter.material.Material.clipBehavior}
  ///
  /// Defaults to [Clip.hardEdge].
  final Clip clipBehavior;

655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675
  /// 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.
  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;
676 677 678

  double _totalOverscroll = 0.0;

679 680 681
  bool _accepted = true;

  bool _handleScrollNotification(ScrollNotification notification) {
682
    if (!widget.notificationPredicate(notification)) {
683
      return false;
684
    }
685 686 687 688 689
    if (notification.metrics.axis != widget.axis) {
      // This widget is explicitly configured to one axis. If a notification
      // from a different axis bubbles up, do nothing.
      return false;
    }
690 691 692 693 694 695

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

      if (_accepted) {
700 701
        _totalOverscroll += notification.overscroll;

702 703
        if (notification.velocity != 0.0) {
          assert(notification.dragDetails == null);
704
          _stretchController.absorbImpact(notification.velocity.abs(), _totalOverscroll);
705 706 707
        } else {
          assert(notification.overscroll != 0.0);
          if (notification.dragDetails != null) {
708 709 710 711
            // 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
712

713
            final double viewportDimension = notification.metrics.viewportDimension;
714
            final double distanceForPull = _totalOverscroll.abs() / viewportDimension;
715
            final double clampedOverscroll = clampDouble(distanceForPull, 0, 1.0);
716
            _stretchController.pull(clampedOverscroll, _totalOverscroll);
717 718 719
          }
        }
      }
720
    } else if (notification is ScrollEndNotification || notification is ScrollUpdateNotification) {
721 722
      // Since the overscrolling ended, we reset the total overscroll amount.
      _totalOverscroll = 0;
723 724 725 726 727 728
      _stretchController.scrollEnd();
    }
    _lastNotification = notification;
    return false;
  }

729
  AlignmentGeometry _getAlignmentForAxisDirection(_StretchDirection stretchDirection) {
730 731 732
    // Accounts for reversed scrollables by checking the AxisDirection
    switch (widget.axisDirection) {
      case AxisDirection.up:
733
        return stretchDirection == _StretchDirection.trailing
734 735 736
            ? AlignmentDirectional.topCenter
            : AlignmentDirectional.bottomCenter;
      case AxisDirection.right:
737
        return stretchDirection == _StretchDirection.trailing
738 739
            ? Alignment.centerRight
            : Alignment.centerLeft;
740
      case AxisDirection.down:
741
        return stretchDirection == _StretchDirection.trailing
742 743 744
            ? AlignmentDirectional.bottomCenter
            : AlignmentDirectional.topCenter;
      case AxisDirection.left:
745
        return stretchDirection == _StretchDirection.trailing
746 747
            ? Alignment.centerLeft
            : Alignment.centerRight;
748 749 750
    }
  }

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

  @override
  Widget build(BuildContext context) {
759
    final Size size = MediaQuery.sizeOf(context);
760
    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
            case Axis.vertical:
              y += stretch;
776
              mainAxisSize = size.height;
777 778
          }

779
          final AlignmentGeometry alignment = _getAlignmentForAxisDirection(
780
            _stretchController.stretchDirection,
781 782
          );

783 784 785 786
          final double viewportDimension = _lastOverscrollNotification?.metrics.viewportDimension ?? mainAxisSize;
          final Widget transform = Transform(
            alignment: alignment,
            transform: Matrix4.diagonal3Values(x, y, 1.0),
787
            filterQuality: stretch == 0 ? null : FilterQuality.low,
788
            child: widget.child,
789
          );
790 791 792 793

          // 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.
794 795
          return ClipRect(
            clipBehavior: stretch != 0.0 && viewportDimension != mainAxisSize
796 797
              ? widget.clipBehavior
              : Clip.none,
798 799
            child: transform,
          );
800 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
        },
      ),
    );
  }
}

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;
828 829

  double get pullDistance => _pullDistance;
830 831
  double _pullDistance = 0.0;

832 833 834
  _StretchDirection get stretchDirection => _stretchDirection;
  _StretchDirection _stretchDirection = _StretchDirection.trailing;

835 836 837 838 839 840 841 842 843 844 845
  // 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.
846
  void absorbImpact(double velocity, double totalOverscroll) {
847
    assert(velocity >= 0.0);
848
    velocity = clampDouble(velocity, 1, 10000);
849 850 851 852 853
    _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;
854
    _stretchDirection = totalOverscroll > 0 ? _StretchDirection.trailing : _StretchDirection.leading;
855 856 857 858 859 860 861
  }

  /// 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.
862
  void pull(double normalizedOverscroll, double totalOverscroll) {
863
    assert(normalizedOverscroll >= 0.0);
864 865 866 867 868 869 870 871 872 873 874

    final _StretchDirection newStretchDirection = totalOverscroll > 0 ? _StretchDirection.trailing : _StretchDirection.leading;
    if (_stretchDirection != newStretchDirection && _state == _StretchState.recede) {
      // When the stretch direction changes while we are in the recede state, we need to ignore the change.
      // If we don't, the stretch will instantly jump to the new direction with the recede animation still playing, which causes
      // a unwanted visual abnormality (https://github.com/flutter/flutter/pull/116548#issuecomment-1414872567).
      // By ignoring the directional change until the recede state is finished, we can avoid this.
      return;
    }

    _stretchDirection = newStretchDirection;
875
    _pullDistance = normalizedOverscroll;
876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892
    _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() {
893
    if (_state == _StretchState.pull) {
894
      _recede(_stretchDuration);
895
    }
896 897 898
  }

  void _changePhase(AnimationStatus status) {
899
    if (status != AnimationStatus.completed) {
900
      return;
901
    }
902 903 904 905 906 907 908 909 910 911 912 913 914
    switch (_state) {
      case _StretchState.absorb:
        _recede(_stretchDuration);
      case _StretchState.recede:
        _state = _StretchState.idle;
        _pullDistance = 0.0;
      case _StretchState.pull:
      case _StretchState.idle:
        break;
    }
  }

  void _recede(Duration duration) {
915
    if (_state == _StretchState.recede || _state == _StretchState.idle) {
916
      return;
917
    }
918 919 920 921 922 923 924 925 926 927 928 929
    _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
930 931 932

  @override
  String toString() => '_StretchController()';
933 934 935 936 937 938 939 940 941 942 943 944 945 946
}

/// 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.
947
class OverscrollIndicatorNotification extends Notification with ViewportNotificationMixin {
948 949
  /// Creates a notification that an [GlowingOverscrollIndicator] or a
  /// [StretchingOverscrollIndicator] will start showing an overscroll indication.
950
  OverscrollIndicatorNotification({
951
    required this.leading,
952 953
  });

954 955
  /// Whether the indication will be shown on the leading edge of the scroll
  /// view.
956 957
  final bool leading;

958
  /// Controls at which offset a [GlowingOverscrollIndicator] draws.
959 960 961 962 963 964 965 966 967
  ///
  /// 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.
968 969
  ///
  /// This has no effect on a [StretchingOverscrollIndicator].
970 971
  double paintOffset = 0.0;

972 973 974 975 976 977 978 979
  @protected
  @visibleForTesting
  /// Whether the current overscroll event will allow for the indicator to be
  /// shown.
  ///
  /// Calling [disallowIndicator] sets this to false, preventing the over scroll
  /// indicator from showing.
  ///
980
  /// Defaults to true.
981
  bool accepted = true;
982

983 984
  /// Call this method if the overscroll indicator should be prevented.
  void disallowIndicator() {
985
    accepted = false;
986 987
  }

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