1// Copyright 2014 The Flutter Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5/// @docImport 'package:flutter/material.dart';
6///
7/// @docImport 'nested_scroll_view.dart';
8/// @docImport 'scroll_configuration.dart';
9/// @docImport 'scroll_view.dart';
10/// @docImport 'scrollable.dart';
11library;
12
13import 'dart:async' show Timer;
14import 'dart:math' as math;
15
16import 'package:flutter/foundation.dart';
17import 'package:flutter/physics.dart' show Tolerance, nearEqual;
18import 'package:flutter/rendering.dart';
19import 'package:flutter/scheduler.dart';
20
21import 'basic.dart';
22import 'framework.dart';
23import 'media_query.dart';
24import 'notification_listener.dart';
25import 'scroll_notification.dart';
26import 'ticker_provider.dart';
27import 'transitions.dart';
28
29/// A visual indication that a scroll view has overscrolled.
30///
31/// A [GlowingOverscrollIndicator] listens for [ScrollNotification]s in order
32/// to control the overscroll indication. These notifications are typically
33/// generated by a [ScrollView], such as a [ListView] or a [GridView].
34///
35/// [GlowingOverscrollIndicator] generates [OverscrollIndicatorNotification]
36/// before showing an overscroll indication. To prevent the indicator from
37/// showing the indication, call
38/// [OverscrollIndicatorNotification.disallowIndicator] on the notification.
39///
40/// Created automatically by [ScrollBehavior.buildOverscrollIndicator] on platforms
41/// (e.g., Android) that commonly use this type of overscroll indication.
42///
43/// In a [MaterialApp], the edge glow color is the overall theme's
44/// [ColorScheme.secondary] color.
45///
46/// ## Customizing the Glow Position for Advanced Scroll Views
47///
48/// When building a [CustomScrollView] with a [GlowingOverscrollIndicator], the
49/// indicator will apply to the entire scrollable area, regardless of what
50/// slivers the CustomScrollView contains.
51///
52/// For example, if your CustomScrollView contains a SliverAppBar in the first
53/// position, the GlowingOverscrollIndicator will overlay the SliverAppBar. To
54/// manipulate the position of the GlowingOverscrollIndicator in this case,
55/// you can either make use of a [NotificationListener] and provide a
56/// [OverscrollIndicatorNotification.paintOffset] to the
57/// notification, or use a [NestedScrollView].
58///
59/// {@tool dartpad}
60/// This example demonstrates how to use a [NotificationListener] to manipulate
61/// the placement of a [GlowingOverscrollIndicator] when building a
62/// [CustomScrollView]. Drag the scrollable to see the bounds of the overscroll
63/// indicator.
64///
65/// ** See code in examples/api/lib/widgets/overscroll_indicator/glowing_overscroll_indicator.0.dart **
66/// {@end-tool}
67///
68/// {@tool dartpad}
69/// This example demonstrates how to use a [NestedScrollView] to manipulate the
70/// placement of a [GlowingOverscrollIndicator] when building a
71/// [CustomScrollView]. Drag the scrollable to see the bounds of the overscroll
72/// indicator.
73///
74/// ** See code in examples/api/lib/widgets/overscroll_indicator/glowing_overscroll_indicator.1.dart **
75/// {@end-tool}
76///
77/// See also:
78///
79/// * [OverscrollIndicatorNotification], which can be used to manipulate the
80/// glow position or prevent the glow from being painted at all.
81/// * [NotificationListener], to listen for the
82/// [OverscrollIndicatorNotification].
83/// * [StretchingOverscrollIndicator], a Material Design overscroll indicator.
84class GlowingOverscrollIndicator extends StatefulWidget {
85 /// Creates a visual indication that a scroll view has overscrolled.
86 ///
87 /// In order for this widget to display an overscroll indication, the [child]
88 /// widget must contain a widget that generates a [ScrollNotification], such
89 /// as a [ListView] or a [GridView].
90 const GlowingOverscrollIndicator({
91 super.key,
92 this.showLeading = true,
93 this.showTrailing = true,
94 required this.axisDirection,
95 required this.color,
96 this.notificationPredicate = defaultScrollNotificationPredicate,
97 this.child,
98 });
99
100 /// Whether to show the overscroll glow on the side with negative scroll
101 /// offsets.
102 ///
103 /// For a vertical downwards viewport, this is the top side.
104 ///
105 /// Defaults to true.
106 ///
107 /// See [showTrailing] for the corresponding control on the other side of the
108 /// viewport.
109 final bool showLeading;
110
111 /// Whether to show the overscroll glow on the side with positive scroll
112 /// offsets.
113 ///
114 /// For a vertical downwards viewport, this is the bottom side.
115 ///
116 /// Defaults to true.
117 ///
118 /// See [showLeading] for the corresponding control on the other side of the
119 /// viewport.
120 final bool showTrailing;
121
122 /// {@template flutter.overscroll.axisDirection}
123 /// The direction of positive scroll offsets in the [Scrollable] whose
124 /// overscrolls are to be visualized.
125 /// {@endtemplate}
126 final AxisDirection axisDirection;
127
128 /// {@template flutter.overscroll.axis}
129 /// The axis along which scrolling occurs in the [Scrollable] whose
130 /// overscrolls are to be visualized.
131 /// {@endtemplate}
132 Axis get axis => axisDirectionToAxis(axisDirection);
133
134 /// The color of the glow. The alpha channel is ignored.
135 final Color color;
136
137 /// {@template flutter.overscroll.notificationPredicate}
138 /// A check that specifies whether a [ScrollNotification] should be
139 /// handled by this widget.
140 ///
141 /// By default, checks whether `notification.depth == 0`. Set it to something
142 /// else for more complicated layouts, such as nested [ScrollView]s.
143 /// {@endtemplate}
144 final ScrollNotificationPredicate notificationPredicate;
145
146 /// The widget below this widget in the tree.
147 ///
148 /// The overscroll indicator will paint on top of this child. This child (and its
149 /// 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 void debugFillProperties(DiagnosticPropertiesBuilder properties) {
161 super.debugFillProperties(properties);
162 properties.add(EnumProperty<AxisDirection>('axisDirection', axisDirection));
163 final String showDescription = switch ((showLeading, showTrailing)) {
164 (true, true) => 'both sides',
165 (true, false) => 'leading side only',
166 (false, true) => 'trailing side only',
167 (false, false) => 'neither side (!)',
168 };
169 properties.add(MessageProperty('show', showDescription));
170 properties.add(ColorProperty('color', color, showName: false));
171 }
172}
173
174class _GlowingOverscrollIndicatorState extends State<GlowingOverscrollIndicator>
175 with TickerProviderStateMixin {
176 _GlowController? _leadingController;
177 _GlowController? _trailingController;
178 Listenable? _leadingAndTrailingListener;
179
180 @override
181 void initState() {
182 super.initState();
183 _leadingController = _GlowController(vsync: this, color: widget.color, axis: widget.axis);
184 _trailingController = _GlowController(vsync: this, color: widget.color, axis: widget.axis);
185 _leadingAndTrailingListener = Listenable.merge(<Listenable>[
186 _leadingController!,
187 _trailingController!,
188 ]);
189 }
190
191 @override
192 void didUpdateWidget(GlowingOverscrollIndicator oldWidget) {
193 super.didUpdateWidget(oldWidget);
194 if (oldWidget.color != widget.color || oldWidget.axis != widget.axis) {
195 _leadingController!.color = widget.color;
196 _leadingController!.axis = widget.axis;
197 _trailingController!.color = widget.color;
198 _trailingController!.axis = widget.axis;
199 }
200 }
201
202 Type? _lastNotificationType;
203 final Map<bool, bool> _accepted = <bool, bool>{false: true, true: true};
204
205 bool _handleScrollNotification(ScrollNotification notification) {
206 if (!widget.notificationPredicate(notification)) {
207 return false;
208 }
209 if (notification.metrics.axis != widget.axis) {
210 // This widget is explicitly configured to one axis. If a notification
211 // from a different axis bubbles up, do nothing.
212 return false;
213 }
214
215 // Update the paint offset with the current scroll position. This makes
216 // sure that the glow effect correctly scrolls in line with the current
217 // scroll, e.g. when scrolling in the opposite direction again to hide
218 // the glow. Otherwise, the glow would always stay in a fixed position,
219 // even if the top of the content already scrolled away.
220 // For example (CustomScrollView with sliver before center), the scroll
221 // extent is [-200.0, 300.0], scroll in the opposite direction with 10.0 pixels
222 // before glow disappears, so the current pixels is -190.0,
223 // in this case, we should move the glow up 10.0 pixels and should not
224 // overflow the scrollable widget's edge. https://github.com/flutter/flutter/issues/64149.
225 _leadingController!._paintOffsetScrollPixels =
226 -math.min(
227 notification.metrics.pixels - notification.metrics.minScrollExtent,
228 _leadingController!._paintOffset,
229 );
230 _trailingController!._paintOffsetScrollPixels =
231 -math.min(
232 notification.metrics.maxScrollExtent - notification.metrics.pixels,
233 _trailingController!._paintOffset,
234 );
235
236 if (notification is OverscrollNotification) {
237 _GlowController? controller;
238 if (notification.overscroll < 0.0) {
239 controller = _leadingController;
240 } else if (notification.overscroll > 0.0) {
241 controller = _trailingController;
242 } else {
243 assert(false);
244 }
245 final bool isLeading = controller == _leadingController;
246 if (_lastNotificationType is! OverscrollNotification) {
247 final OverscrollIndicatorNotification confirmationNotification =
248 OverscrollIndicatorNotification(leading: isLeading);
249 confirmationNotification.dispatch(context);
250 _accepted[isLeading] = confirmationNotification.accepted;
251 if (_accepted[isLeading]!) {
252 controller!._paintOffset = confirmationNotification.paintOffset;
253 }
254 }
255 assert(controller != null);
256 if (_accepted[isLeading]!) {
257 if (notification.velocity != 0.0) {
258 assert(notification.dragDetails == null);
259 controller!.absorbImpact(notification.velocity.abs());
260 } else {
261 assert(notification.overscroll != 0.0);
262 if (notification.dragDetails != null) {
263 final RenderBox renderer = notification.context!.findRenderObject()! as RenderBox;
264 assert(renderer.hasSize);
265 final Size size = renderer.size;
266 final Offset position = renderer.globalToLocal(
267 notification.dragDetails!.globalPosition,
268 );
269 switch (notification.metrics.axis) {
270 case Axis.horizontal:
271 controller!.pull(
272 notification.overscroll.abs(),
273 size.width,
274 clampDouble(position.dy, 0.0, size.height),
275 size.height,
276 );
277 case Axis.vertical:
278 controller!.pull(
279 notification.overscroll.abs(),
280 size.height,
281 clampDouble(position.dx, 0.0, size.width),
282 size.width,
283 );
284 }
285 }
286 }
287 }
288 } else if ((notification is ScrollEndNotification && notification.dragDetails != null) ||
289 (notification is ScrollUpdateNotification && notification.dragDetails != null)) {
290 _leadingController!.scrollEnd();
291 _trailingController!.scrollEnd();
292 }
293 _lastNotificationType = notification.runtimeType;
294 return false;
295 }
296
297 @override
298 void dispose() {
299 _leadingController!.dispose();
300 _trailingController!.dispose();
301 super.dispose();
302 }
303
304 @override
305 Widget build(BuildContext context) {
306 return NotificationListener<ScrollNotification>(
307 onNotification: _handleScrollNotification,
308 child: RepaintBoundary(
309 child: CustomPaint(
310 foregroundPainter: _GlowingOverscrollIndicatorPainter(
311 leadingController: widget.showLeading ? _leadingController : null,
312 trailingController: widget.showTrailing ? _trailingController : null,
313 axisDirection: widget.axisDirection,
314 repaint: _leadingAndTrailingListener,
315 ),
316 child: RepaintBoundary(child: widget.child),
317 ),
318 ),
319 );
320 }
321}
322
323// The Glow logic is a port of the logic in the following file:
324// https://android.googlesource.com/platform/frameworks/base/+/main/core/java/android/widget/EdgeEffect.java
325// as of December 2016.
326
327enum _GlowState { idle, absorb, pull, recede }
328
329class _GlowController extends ChangeNotifier {
330 _GlowController({required TickerProvider vsync, required Color color, required Axis axis})
331 : _color = color,
332 _axis = axis {
333 if (kFlutterMemoryAllocationsEnabled) {
334 ChangeNotifier.maybeDispatchObjectCreation(this);
335 }
336 _glowController = AnimationController(vsync: vsync)..addStatusListener(_changePhase);
337 _decelerator = CurvedAnimation(parent: _glowController, curve: Curves.decelerate)
338 ..addListener(notifyListeners);
339 _glowOpacity = _decelerator.drive(_glowOpacityTween);
340 _glowSize = _decelerator.drive(_glowSizeTween);
341 _displacementTicker = vsync.createTicker(_tickDisplacement);
342 }
343
344 // animation of the main axis direction
345 _GlowState _state = _GlowState.idle;
346 late final AnimationController _glowController;
347 Timer? _pullRecedeTimer;
348 double _paintOffset = 0.0;
349 double _paintOffsetScrollPixels = 0.0;
350
351 // animation values
352 late final CurvedAnimation _decelerator;
353 final Tween<double> _glowOpacityTween = Tween<double>(begin: 0.0, end: 0.0);
354 late final Animation<double> _glowOpacity;
355 final Tween<double> _glowSizeTween = Tween<double>(begin: 0.0, end: 0.0);
356 late final Animation<double> _glowSize;
357
358 // animation of the cross axis position
359 late final Ticker _displacementTicker;
360 Duration? _displacementTickerLastElapsed;
361 double _displacementTarget = 0.5;
362 double _displacement = 0.5;
363
364 // tracking the pull distance
365 double _pullDistance = 0.0;
366
367 Color get color => _color;
368 Color _color;
369 set color(Color value) {
370 if (color == value) {
371 return;
372 }
373 _color = value;
374 notifyListeners();
375 }
376
377 Axis get axis => _axis;
378 Axis _axis;
379 set axis(Axis value) {
380 if (axis == value) {
381 return;
382 }
383 _axis = value;
384 notifyListeners();
385 }
386
387 static const Duration _recedeTime = Duration(milliseconds: 600);
388 static const Duration _pullTime = Duration(milliseconds: 167);
389 static const Duration _pullHoldTime = Duration(milliseconds: 167);
390 static const Duration _pullDecayTime = Duration(milliseconds: 2000);
391 static final Duration _crossAxisHalfTime = Duration(
392 microseconds: (Duration.microsecondsPerSecond / 60.0).round(),
393 );
394
395 static const double _maxOpacity = 0.5;
396 static const double _pullOpacityGlowFactor = 0.8;
397 static const double _velocityGlowFactor = 0.00006;
398 static const double _sqrt3 = 1.73205080757; // const math.sqrt(3)
399 static const double _widthToHeightFactor = (3.0 / 4.0) * (2.0 - _sqrt3);
400
401 // absorbed velocities are clamped to the range _minVelocity.._maxVelocity
402 static const double _minVelocity = 100.0; // logical pixels per second
403 static const double _maxVelocity = 10000.0; // logical pixels per second
404
405 @override
406 void dispose() {
407 _glowController.dispose();
408 _decelerator.dispose();
409 _displacementTicker.dispose();
410 _pullRecedeTimer?.cancel();
411 super.dispose();
412 }
413
414 /// Handle a scroll slamming into the edge at a particular velocity.
415 ///
416 /// The velocity must be positive.
417 void absorbImpact(double velocity) {
418 assert(velocity >= 0.0);
419 _pullRecedeTimer?.cancel();
420 _pullRecedeTimer = null;
421 velocity = clampDouble(velocity, _minVelocity, _maxVelocity);
422 _glowOpacityTween.begin = _state == _GlowState.idle ? 0.3 : _glowOpacity.value;
423 _glowOpacityTween.end = clampDouble(
424 velocity * _velocityGlowFactor,
425 _glowOpacityTween.begin!,
426 _maxOpacity,
427 );
428 _glowSizeTween.begin = _glowSize.value;
429 _glowSizeTween.end = math.min(0.025 + 7.5e-7 * velocity * velocity, 1.0);
430 _glowController.duration = Duration(milliseconds: (0.15 + velocity * 0.02).round());
431 _glowController.forward(from: 0.0);
432 _displacement = 0.5;
433 _state = _GlowState.absorb;
434 }
435
436 /// Handle a user-driven overscroll.
437 ///
438 /// The `overscroll` argument should be the scroll distance in logical pixels,
439 /// the `extent` argument should be the total dimension of the viewport in the
440 /// main axis in logical pixels, the `crossAxisOffset` argument should be the
441 /// distance from the leading (left or top) edge of the cross axis of the
442 /// viewport, and the `crossExtent` should be the size of the cross axis. For
443 /// example, a pull of 50 pixels up the middle of a 200 pixel high and 100
444 /// pixel wide vertical viewport should result in a call of `pull(50.0, 200.0,
445 /// 50.0, 100.0)`. The `overscroll` value should be positive regardless of the
446 /// direction.
447 void pull(double overscroll, double extent, double crossAxisOffset, double crossExtent) {
448 _pullRecedeTimer?.cancel();
449 _pullDistance +=
450 overscroll / 200.0; // This factor is magic. Not clear why we need it to match Android.
451 _glowOpacityTween.begin = _glowOpacity.value;
452 _glowOpacityTween.end = math.min(
453 _glowOpacity.value + overscroll / extent * _pullOpacityGlowFactor,
454 _maxOpacity,
455 );
456 final double height = math.min(extent, crossExtent * _widthToHeightFactor);
457 _glowSizeTween.begin = _glowSize.value;
458 _glowSizeTween.end = math.max(
459 1.0 - 1.0 / (0.7 * math.sqrt(_pullDistance * height)),
460 _glowSize.value,
461 );
462 _displacementTarget = crossAxisOffset / crossExtent;
463 if (_displacementTarget != _displacement) {
464 if (!_displacementTicker.isTicking) {
465 assert(_displacementTickerLastElapsed == null);
466 _displacementTicker.start();
467 }
468 } else {
469 _displacementTicker.stop();
470 _displacementTickerLastElapsed = null;
471 }
472 _glowController.duration = _pullTime;
473 if (_state != _GlowState.pull) {
474 _glowController.forward(from: 0.0);
475 _state = _GlowState.pull;
476 } else {
477 if (!_glowController.isAnimating) {
478 assert(_glowController.value == 1.0);
479 notifyListeners();
480 }
481 }
482 _pullRecedeTimer = Timer(_pullHoldTime, () => _recede(_pullDecayTime));
483 }
484
485 void scrollEnd() {
486 if (_state == _GlowState.pull) {
487 _recede(_recedeTime);
488 }
489 }
490
491 void _changePhase(AnimationStatus status) {
492 if (!status.isCompleted) {
493 return;
494 }
495 switch (_state) {
496 case _GlowState.absorb:
497 _recede(_recedeTime);
498 case _GlowState.recede:
499 _state = _GlowState.idle;
500 _pullDistance = 0.0;
501 case _GlowState.pull:
502 case _GlowState.idle:
503 break;
504 }
505 }
506
507 void _recede(Duration duration) {
508 if (_state == _GlowState.recede || _state == _GlowState.idle) {
509 return;
510 }
511 _pullRecedeTimer?.cancel();
512 _pullRecedeTimer = null;
513 _glowOpacityTween.begin = _glowOpacity.value;
514 _glowOpacityTween.end = 0.0;
515 _glowSizeTween.begin = _glowSize.value;
516 _glowSizeTween.end = 0.0;
517 _glowController.duration = duration;
518 _glowController.forward(from: 0.0);
519 _state = _GlowState.recede;
520 }
521
522 void _tickDisplacement(Duration elapsed) {
523 if (_displacementTickerLastElapsed != null) {
524 final double t =
525 (elapsed.inMicroseconds - _displacementTickerLastElapsed!.inMicroseconds).toDouble();
526 _displacement =
527 _displacementTarget -
528 (_displacementTarget - _displacement) *
529 math.pow(2.0, -t / _crossAxisHalfTime.inMicroseconds);
530 notifyListeners();
531 }
532 if (nearEqual(_displacementTarget, _displacement, Tolerance.defaultTolerance.distance)) {
533 _displacementTicker.stop();
534 _displacementTickerLastElapsed = null;
535 } else {
536 _displacementTickerLastElapsed = elapsed;
537 }
538 }
539
540 void paint(Canvas canvas, Size size) {
541 if (_glowOpacity.value == 0.0) {
542 return;
543 }
544 final double baseGlowScale = size.width > size.height ? size.height / size.width : 1.0;
545 final double radius = size.width * 3.0 / 2.0;
546 final double height = math.min(size.height, size.width * _widthToHeightFactor);
547 final double scaleY = _glowSize.value * baseGlowScale;
548 final Rect rect = Rect.fromLTWH(0.0, 0.0, size.width, height);
549 final Offset center = Offset((size.width / 2.0) * (0.5 + _displacement), height - radius);
550 final Paint paint = Paint()..color = color.withOpacity(_glowOpacity.value);
551 canvas.save();
552 canvas.translate(0.0, _paintOffset + _paintOffsetScrollPixels);
553 canvas.scale(1.0, scaleY);
554 canvas.clipRect(rect);
555 canvas.drawCircle(center, radius, paint);
556 canvas.restore();
557 }
558
559 @override
560 String toString() {
561 return '_GlowController(color: $color, axis: ${axis.name})';
562 }
563}
564
565class _GlowingOverscrollIndicatorPainter extends CustomPainter {
566 _GlowingOverscrollIndicatorPainter({
567 this.leadingController,
568 this.trailingController,
569 required this.axisDirection,
570 super.repaint,
571 });
572
573 /// The controller for the overscroll glow on the side with negative scroll offsets.
574 ///
575 /// For a vertical downwards viewport, this is the top side.
576 final _GlowController? leadingController;
577
578 /// The controller for the overscroll glow on the side with positive scroll offsets.
579 ///
580 /// For a vertical downwards viewport, this is the bottom side.
581 final _GlowController? trailingController;
582
583 /// The direction of the viewport.
584 final AxisDirection axisDirection;
585
586 static const double piOver2 = math.pi / 2.0;
587
588 void _paintSide(
589 Canvas canvas,
590 Size size,
591 _GlowController? controller,
592 AxisDirection axisDirection,
593 GrowthDirection growthDirection,
594 ) {
595 if (controller == null) {
596 return;
597 }
598 switch (applyGrowthDirectionToAxisDirection(axisDirection, growthDirection)) {
599 case AxisDirection.up:
600 controller.paint(canvas, size);
601 case AxisDirection.down:
602 canvas.save();
603 canvas.translate(0.0, size.height);
604 canvas.scale(1.0, -1.0);
605 controller.paint(canvas, size);
606 canvas.restore();
607 case AxisDirection.left:
608 canvas.save();
609 canvas.rotate(piOver2);
610 canvas.scale(1.0, -1.0);
611 controller.paint(canvas, Size(size.height, size.width));
612 canvas.restore();
613 case AxisDirection.right:
614 canvas.save();
615 canvas.translate(size.width, 0.0);
616 canvas.rotate(piOver2);
617 controller.paint(canvas, Size(size.height, size.width));
618 canvas.restore();
619 }
620 }
621
622 @override
623 void paint(Canvas canvas, Size size) {
624 _paintSide(canvas, size, leadingController, axisDirection, GrowthDirection.reverse);
625 _paintSide(canvas, size, trailingController, axisDirection, GrowthDirection.forward);
626 }
627
628 @override
629 bool shouldRepaint(_GlowingOverscrollIndicatorPainter oldDelegate) {
630 return oldDelegate.leadingController != leadingController ||
631 oldDelegate.trailingController != trailingController;
632 }
633
634 @override
635 String toString() {
636 return '_GlowingOverscrollIndicatorPainter($leadingController, $trailingController)';
637 }
638}
639
640enum _StretchDirection {
641 /// The [trailing] direction indicates that the content will be stretched toward
642 /// the trailing edge.
643 trailing,
644
645 /// The [leading] direction indicates that the content will be stretched toward
646 /// the leading edge.
647 leading,
648}
649
650/// A Material Design visual indication that a scroll view has overscrolled.
651///
652/// A [StretchingOverscrollIndicator] listens for [ScrollNotification]s in order
653/// to stretch the content of the [Scrollable]. These notifications are typically
654/// generated by a [ScrollView], such as a [ListView] or a [GridView].
655///
656/// When triggered, the [StretchingOverscrollIndicator] generates an
657/// [OverscrollIndicatorNotification] before showing an overscroll indication.
658/// To prevent the indicator from showing the indication, call
659/// [OverscrollIndicatorNotification.disallowIndicator] on the notification.
660///
661/// Created by [MaterialScrollBehavior.buildOverscrollIndicator] on platforms
662/// (e.g., Android) that commonly use this type of overscroll indication when
663/// [ThemeData.useMaterial3] is true. Otherwise, when [ThemeData.useMaterial3]
664/// is false, a [GlowingOverscrollIndicator] is used instead.=
665///
666/// See also:
667///
668/// * [OverscrollIndicatorNotification], which can be used to prevent the
669/// stretch effect from being applied at all.
670/// * [NotificationListener], to listen for the
671/// [OverscrollIndicatorNotification].
672/// * [GlowingOverscrollIndicator], the default overscroll indicator for
673/// [TargetPlatform.android] and [TargetPlatform.fuchsia].
674class StretchingOverscrollIndicator extends StatefulWidget {
675 /// Creates a visual indication that a scroll view has overscrolled by
676 /// applying a stretch transformation to the content.
677 ///
678 /// In order for this widget to display an overscroll indication, the [child]
679 /// widget must contain a widget that generates a [ScrollNotification], such
680 /// as a [ListView] or a [GridView].
681 const StretchingOverscrollIndicator({
682 super.key,
683 required this.axisDirection,
684 this.notificationPredicate = defaultScrollNotificationPredicate,
685 this.clipBehavior = Clip.hardEdge,
686 this.child,
687 });
688
689 /// {@macro flutter.overscroll.axisDirection}
690 final AxisDirection axisDirection;
691
692 /// {@macro flutter.overscroll.axis}
693 Axis get axis => axisDirectionToAxis(axisDirection);
694
695 /// {@macro flutter.overscroll.notificationPredicate}
696 final ScrollNotificationPredicate notificationPredicate;
697
698 /// {@macro flutter.material.Material.clipBehavior}
699 ///
700 /// Defaults to [Clip.hardEdge].
701 final Clip clipBehavior;
702
703 /// The widget below this widget in the tree.
704 ///
705 /// The overscroll indicator will apply a stretch effect to this child. This
706 /// child (and its subtree) should include a source of [ScrollNotification]
707 /// notifications.
708 final Widget? child;
709
710 @override
711 State<StretchingOverscrollIndicator> createState() => _StretchingOverscrollIndicatorState();
712
713 @override
714 void debugFillProperties(DiagnosticPropertiesBuilder properties) {
715 super.debugFillProperties(properties);
716 properties.add(EnumProperty<AxisDirection>('axisDirection', axisDirection));
717 }
718}
719
720class _StretchingOverscrollIndicatorState extends State<StretchingOverscrollIndicator>
721 with TickerProviderStateMixin {
722 late final _StretchController _stretchController = _StretchController(vsync: this);
723 ScrollNotification? _lastNotification;
724 OverscrollNotification? _lastOverscrollNotification;
725
726 double _totalOverscroll = 0.0;
727
728 bool _accepted = true;
729
730 bool _handleScrollNotification(ScrollNotification notification) {
731 if (!widget.notificationPredicate(notification)) {
732 return false;
733 }
734 if (notification.metrics.axis != widget.axis) {
735 // This widget is explicitly configured to one axis. If a notification
736 // from a different axis bubbles up, do nothing.
737 return false;
738 }
739
740 if (notification is OverscrollNotification) {
741 _lastOverscrollNotification = notification;
742 if (_lastNotification.runtimeType is! OverscrollNotification) {
743 final OverscrollIndicatorNotification confirmationNotification =
744 OverscrollIndicatorNotification(leading: notification.overscroll < 0.0);
745 confirmationNotification.dispatch(context);
746 _accepted = confirmationNotification.accepted;
747 }
748
749 if (_accepted) {
750 _totalOverscroll += notification.overscroll;
751
752 if (notification.velocity != 0.0) {
753 assert(notification.dragDetails == null);
754 _stretchController.absorbImpact(notification.velocity.abs(), _totalOverscroll);
755 } else {
756 assert(notification.overscroll != 0.0);
757 if (notification.dragDetails != null) {
758 // We clamp the overscroll amount relative to the length of the viewport,
759 // which is the furthest distance a single pointer could pull on the
760 // screen. This is because more than one pointer will multiply the
761 // amount of overscroll - https://github.com/flutter/flutter/issues/11884
762
763 final double viewportDimension = notification.metrics.viewportDimension;
764 final double distanceForPull = _totalOverscroll.abs() / viewportDimension;
765 final double clampedOverscroll = clampDouble(distanceForPull, 0, 1.0);
766 _stretchController.pull(clampedOverscroll, _totalOverscroll);
767 }
768 }
769 }
770 } else if (notification is ScrollEndNotification || notification is ScrollUpdateNotification) {
771 // Since the overscrolling ended, we reset the total overscroll amount.
772 _totalOverscroll = 0;
773 _stretchController.scrollEnd();
774 }
775 _lastNotification = notification;
776 return false;
777 }
778
779 AlignmentGeometry _getAlignmentForAxisDirection(_StretchDirection stretchDirection) {
780 // Accounts for reversed scrollables by checking the AxisDirection
781 final AxisDirection direction = switch (stretchDirection) {
782 _StretchDirection.trailing => widget.axisDirection,
783 _StretchDirection.leading => flipAxisDirection(widget.axisDirection),
784 };
785 return switch (direction) {
786 AxisDirection.up => AlignmentDirectional.topCenter,
787 AxisDirection.down => AlignmentDirectional.bottomCenter,
788 AxisDirection.left => Alignment.centerLeft,
789 AxisDirection.right => Alignment.centerRight,
790 };
791 }
792
793 @override
794 void dispose() {
795 _stretchController.dispose();
796 super.dispose();
797 }
798
799 @override
800 Widget build(BuildContext context) {
801 final Size size = MediaQuery.sizeOf(context);
802 double mainAxisSize;
803 return NotificationListener<ScrollNotification>(
804 onNotification: _handleScrollNotification,
805 child: AnimatedBuilder(
806 animation: _stretchController,
807 builder: (BuildContext context, Widget? child) {
808 final double stretch = _stretchController.value;
809 double x = 1.0;
810 double y = 1.0;
811
812 switch (widget.axis) {
813 case Axis.horizontal:
814 x += stretch;
815 mainAxisSize = size.width;
816 case Axis.vertical:
817 y += stretch;
818 mainAxisSize = size.height;
819 }
820
821 final AlignmentGeometry alignment = _getAlignmentForAxisDirection(
822 _stretchController.stretchDirection,
823 );
824
825 final double viewportDimension =
826 _lastOverscrollNotification?.metrics.viewportDimension ?? mainAxisSize;
827 final Widget transform = Transform(
828 alignment: alignment,
829 transform: Matrix4.diagonal3Values(x, y, 1.0),
830 filterQuality: stretch == 0 ? null : FilterQuality.medium,
831 child: widget.child,
832 );
833
834 // Only clip if the viewport dimension is smaller than that of the
835 // screen size in the main axis. If the viewport takes up the whole
836 // screen, overflow from transforming the viewport is irrelevant.
837 return ClipRect(
838 clipBehavior:
839 stretch != 0.0 && viewportDimension != mainAxisSize
840 ? widget.clipBehavior
841 : Clip.none,
842 child: transform,
843 );
844 },
845 ),
846 );
847 }
848}
849
850enum _StretchState { idle, absorb, pull, recede }
851
852class _StretchController extends ChangeNotifier {
853 _StretchController({required TickerProvider vsync}) {
854 if (kFlutterMemoryAllocationsEnabled) {
855 ChangeNotifier.maybeDispatchObjectCreation(this);
856 }
857 _stretchController = AnimationController(vsync: vsync)..addStatusListener(_changePhase);
858 _decelerator = CurvedAnimation(parent: _stretchController, curve: Curves.decelerate)
859 ..addListener(notifyListeners);
860 _stretchSize = _decelerator.drive(_stretchSizeTween);
861 }
862
863 late final AnimationController _stretchController;
864 late final Animation<double> _stretchSize;
865 late final CurvedAnimation _decelerator;
866 final Tween<double> _stretchSizeTween = Tween<double>(begin: 0.0, end: 0.0);
867 _StretchState _state = _StretchState.idle;
868
869 double get pullDistance => _pullDistance;
870 double _pullDistance = 0.0;
871
872 _StretchDirection get stretchDirection => _stretchDirection;
873 _StretchDirection _stretchDirection = _StretchDirection.trailing;
874
875 // Constants from Android.
876 static const double _exponentialScalar = math.e / 0.33;
877 static const double _stretchIntensity = 0.016;
878 static const double _flingFriction = 1.01;
879 static const Duration _stretchDuration = Duration(milliseconds: 400);
880
881 double get value => _stretchSize.value;
882
883 // Constants for absorbImpact.
884 static const double _kMinVelocity = 1;
885 static const double _kMaxVelocity = 10000;
886 static const Duration _kMinStretchDuration = Duration(milliseconds: 50);
887
888 /// Handle a fling to the edge of the viewport at a particular velocity.
889 ///
890 /// The velocity must be positive.
891 void absorbImpact(double velocity, double totalOverscroll) {
892 assert(velocity >= 0.0);
893 velocity = clampDouble(velocity, _kMinVelocity, _kMaxVelocity);
894 _stretchSizeTween.begin = _stretchSize.value;
895 _stretchSizeTween.end = math.min(_stretchIntensity + (_flingFriction / velocity), 1.0);
896 _stretchController.duration = Duration(
897 milliseconds: math.max(velocity * 0.02, _kMinStretchDuration.inMilliseconds).round(),
898 );
899 _stretchController.forward(from: 0.0);
900 _state = _StretchState.absorb;
901 _stretchDirection =
902 totalOverscroll > 0 ? _StretchDirection.trailing : _StretchDirection.leading;
903 }
904
905 /// Handle a user-driven overscroll.
906 ///
907 /// The `normalizedOverscroll` argument should be the absolute value of the
908 /// scroll distance in logical pixels, divided by the extent of the viewport
909 /// in the main axis.
910 void pull(double normalizedOverscroll, double totalOverscroll) {
911 assert(normalizedOverscroll >= 0.0);
912
913 final _StretchDirection newStretchDirection =
914 totalOverscroll > 0 ? _StretchDirection.trailing : _StretchDirection.leading;
915 if (_stretchDirection != newStretchDirection && _state == _StretchState.recede) {
916 // When the stretch direction changes while we are in the recede state, we need to ignore the change.
917 // If we don't, the stretch will instantly jump to the new direction with the recede animation still playing, which causes
918 // a unwanted visual abnormality (https://github.com/flutter/flutter/pull/116548#issuecomment-1414872567).
919 // By ignoring the directional change until the recede state is finished, we can avoid this.
920 return;
921 }
922
923 _stretchDirection = newStretchDirection;
924 _pullDistance = normalizedOverscroll;
925 _stretchSizeTween.begin = _stretchSize.value;
926 final double linearIntensity = _stretchIntensity * _pullDistance;
927 final double exponentialIntensity =
928 _stretchIntensity * (1 - math.exp(-_pullDistance * _exponentialScalar));
929 _stretchSizeTween.end = linearIntensity + exponentialIntensity;
930 _stretchController.duration = _stretchDuration;
931 if (_state != _StretchState.pull) {
932 _stretchController.forward(from: 0.0);
933 _state = _StretchState.pull;
934 } else {
935 if (!_stretchController.isAnimating) {
936 assert(_stretchController.value == 1.0);
937 notifyListeners();
938 }
939 }
940 }
941
942 void scrollEnd() {
943 if (_state == _StretchState.pull) {
944 _recede(_stretchDuration);
945 }
946 }
947
948 void _changePhase(AnimationStatus status) {
949 if (!status.isCompleted) {
950 return;
951 }
952 switch (_state) {
953 case _StretchState.absorb:
954 _recede(_stretchDuration);
955 case _StretchState.recede:
956 _state = _StretchState.idle;
957 _pullDistance = 0.0;
958 case _StretchState.pull:
959 case _StretchState.idle:
960 break;
961 }
962 }
963
964 void _recede(Duration duration) {
965 if (_state == _StretchState.recede || _state == _StretchState.idle) {
966 return;
967 }
968 _stretchSizeTween.begin = _stretchSize.value;
969 _stretchSizeTween.end = 0.0;
970 _stretchController.duration = duration;
971 _stretchController.forward(from: 0.0);
972 _state = _StretchState.recede;
973 }
974
975 @override
976 void dispose() {
977 _stretchController.dispose();
978 _decelerator.dispose();
979 super.dispose();
980 }
981
982 @override
983 String toString() => '_StretchController()';
984}
985
986/// A notification that either a [GlowingOverscrollIndicator] or a
987/// [StretchingOverscrollIndicator] will start showing an overscroll indication.
988///
989/// To prevent the indicator from showing the indication, call
990/// [disallowIndicator] on the notification.
991///
992/// See also:
993///
994/// * [GlowingOverscrollIndicator], which generates this type of notification
995/// by painting an indicator over the child content.
996/// * [StretchingOverscrollIndicator], which generates this type of
997/// notification by applying a stretch transformation to the child content.
998class OverscrollIndicatorNotification extends Notification with ViewportNotificationMixin {
999 /// Creates a notification that an [GlowingOverscrollIndicator] or a
1000 /// [StretchingOverscrollIndicator] will start showing an overscroll indication.
1001 OverscrollIndicatorNotification({required this.leading});
1002
1003 /// Whether the indication will be shown on the leading edge of the scroll
1004 /// view.
1005 final bool leading;
1006
1007 /// Controls at which offset a [GlowingOverscrollIndicator] draws.
1008 ///
1009 /// A positive offset will move the glow away from its edge,
1010 /// i.e. for a vertical, [leading] indicator, a [paintOffset] of 100.0 will
1011 /// draw the indicator 100.0 pixels from the top of the edge.
1012 /// For a vertical indicator with [leading] set to `false`, a [paintOffset]
1013 /// of 100.0 will draw the indicator 100.0 pixels from the bottom instead.
1014 ///
1015 /// A negative [paintOffset] is generally not useful, since the glow will be
1016 /// clipped.
1017 ///
1018 /// This has no effect on a [StretchingOverscrollIndicator].
1019 double paintOffset = 0.0;
1020
1021 @protected
1022 @visibleForTesting
1023 /// Whether the current overscroll event will allow for the indicator to be
1024 /// shown.
1025 ///
1026 /// Calling [disallowIndicator] sets this to false, preventing the over scroll
1027 /// indicator from showing.
1028 ///
1029 /// Defaults to true.
1030 bool accepted = true;
1031
1032 /// Call this method if the overscroll indicator should be prevented.
1033 void disallowIndicator() {
1034 accepted = false;
1035 }
1036
1037 @override
1038 void debugFillDescription(List<String> description) {
1039 super.debugFillDescription(description);
1040 description.add('side: ${leading ? "leading edge" : "trailing edge"}');
1041 }
1042}
1043

Provided by KDAB

Privacy Policy
Learn more about Flutter for embedded and desktop on industrialflutter.com