-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathoverscroll_indicator.dart
562 lines (509 loc) · 20.2 KB
/
overscroll_indicator.dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
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
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async' show Timer;
import 'dart:math' as math;
import 'package:flutter/animation.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/physics.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'basic.dart';
import 'framework.dart';
import 'notification_listener.dart';
import 'scroll_notification.dart';
import 'ticker_provider.dart';
/// 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
/// generated by a [ScrollView], such as a [ListView] or a [GridView].
///
/// [GlowingOverscrollIndicator] generates [OverscrollIndicatorNotification]
/// before showing an overscroll indication. To prevent the indicator from
/// showing the indication, call [OverscrollIndicatorNotification.disallowGlow]
/// on the notification.
///
/// Created automatically by [ScrollBehavior.buildViewportChrome] on platforms
/// (e.g., Android) that commonly use this type of overscroll indication.
///
/// In a [MaterialApp], the edge glow color is the [ThemeData.accentColor].
class GlowingOverscrollIndicator extends StatefulWidget {
/// 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].
///
/// The [showLeading], [showTrailing], [axisDirection], [color], and
/// [notificationPredicate] arguments must not be null.
const GlowingOverscrollIndicator({
Key key,
this.showLeading = true,
this.showTrailing = true,
@required this.axisDirection,
@required this.color,
this.notificationPredicate = defaultScrollNotificationPredicate,
this.child,
}) : assert(showLeading != null),
assert(showTrailing != null),
assert(axisDirection != null),
assert(color != null),
assert(notificationPredicate != null),
super(key: key);
/// 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;
/// The direction of positive scroll offsets in the [Scrollable] whose
/// overscrolls are to be visualized.
final AxisDirection axisDirection;
/// The axis along which scrolling occurs in the [Scrollable] whose
/// overscrolls are to be visualized.
Axis get axis => axisDirectionToAxis(axisDirection);
/// The color of the glow. The alpha channel is ignored.
final Color color;
/// A check that specifies whether a [ScrollNotification] should be
/// handled by this widget.
///
/// By default, checks whether `notification.depth == 0`. Set it to something
/// else for more complicated layouts.
final ScrollNotificationPredicate notificationPredicate;
/// 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.
///
/// Typically a [GlowingOverscrollIndicator] is created by a
/// [ScrollBehavior.buildViewportChrome] method, in which case
/// the child is usually the one provided as an argument to that method.
final Widget child;
@override
_GlowingOverscrollIndicatorState createState() => _GlowingOverscrollIndicatorState();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(EnumProperty<AxisDirection>('axisDirection', axisDirection));
String showDescription;
if (showLeading && showTrailing) {
showDescription = 'both sides';
} else if (showLeading) {
showDescription = 'leading side only';
} else if (showTrailing) {
showDescription = 'trailing side only';
} else {
showDescription = 'neither side (!)';
}
properties.add(MessageProperty('show', showDescription));
properties.add(ColorProperty('color', color, showName: false));
}
}
class _GlowingOverscrollIndicatorState extends State<GlowingOverscrollIndicator> with TickerProviderStateMixin {
_GlowController _leadingController;
_GlowController _trailingController;
Listenable _leadingAndTrailingListener;
@override
void initState() {
super.initState();
_leadingController = _GlowController(vsync: this, color: widget.color, axis: widget.axis);
_trailingController = _GlowController(vsync: this, color: widget.color, axis: widget.axis);
_leadingAndTrailingListener = Listenable.merge(<Listenable>[_leadingController, _trailingController]);
}
@override
void didUpdateWidget(GlowingOverscrollIndicator oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.color != widget.color || oldWidget.axis != widget.axis) {
_leadingController.color = widget.color;
_leadingController.axis = widget.axis;
_trailingController.color = widget.color;
_trailingController.axis = widget.axis;
}
}
Type _lastNotificationType;
final Map<bool, bool> _accepted = <bool, bool>{false: true, true: true};
bool _handleScrollNotification(ScrollNotification notification) {
if (!widget.notificationPredicate(notification))
return false;
if (notification is OverscrollNotification) {
_GlowController controller;
if (notification.overscroll < 0.0) {
controller = _leadingController;
} else if (notification.overscroll > 0.0) {
controller = _trailingController;
} else {
assert(false);
}
final bool isLeading = controller == _leadingController;
if (_lastNotificationType != OverscrollNotification) {
final OverscrollIndicatorNotification confirmationNotification = OverscrollIndicatorNotification(leading: isLeading);
confirmationNotification.dispatch(context);
_accepted[isLeading] = confirmationNotification._accepted;
}
assert(controller != null);
assert(notification.metrics.axis == widget.axis);
if (_accepted[isLeading]) {
if (notification.velocity != 0.0) {
assert(notification.dragDetails == null);
controller.absorbImpact(notification.velocity.abs());
} else {
assert(notification.overscroll != 0.0);
if (notification.dragDetails != null) {
assert(notification.dragDetails.globalPosition != null);
final RenderBox renderer = notification.context.findRenderObject() as RenderBox;
assert(renderer != null);
assert(renderer.hasSize);
final Size size = renderer.size;
final Offset position = renderer.globalToLocal(notification.dragDetails.globalPosition);
switch (notification.metrics.axis) {
case Axis.horizontal:
controller.pull(notification.overscroll.abs(), size.width, position.dy.clamp(0.0, size.height) as double, size.height);
break;
case Axis.vertical:
controller.pull(notification.overscroll.abs(), size.height, position.dx.clamp(0.0, size.width) as double, size.width);
break;
}
}
}
}
} else if (notification is ScrollEndNotification || notification is ScrollUpdateNotification) {
if ((notification as dynamic).dragDetails != null) {
_leadingController.scrollEnd();
_trailingController.scrollEnd();
}
}
_lastNotificationType = notification.runtimeType;
return false;
}
@override
void dispose() {
_leadingController.dispose();
_trailingController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return NotificationListener<ScrollNotification>(
onNotification: _handleScrollNotification,
child: RepaintBoundary(
child: CustomPaint(
foregroundPainter: _GlowingOverscrollIndicatorPainter(
leadingController: widget.showLeading ? _leadingController : null,
trailingController: widget.showTrailing ? _trailingController : null,
axisDirection: widget.axisDirection,
repaint: _leadingAndTrailingListener,
),
child: RepaintBoundary(
child: widget.child,
),
),
),
);
}
}
// 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({
@required TickerProvider vsync,
@required Color color,
@required Axis axis,
}) : assert(vsync != null),
assert(color != null),
assert(axis != null),
_color = color,
_axis = axis {
_glowController = AnimationController(vsync: vsync)
..addStatusListener(_changePhase);
final Animation<double> decelerator = CurvedAnimation(
parent: _glowController,
curve: Curves.decelerate,
)..addListener(notifyListeners);
_glowOpacity = decelerator.drive(_glowOpacityTween);
_glowSize = decelerator.drive(_glowSizeTween);
_displacementTicker = vsync.createTicker(_tickDisplacement);
}
// animation of the main axis direction
_GlowState _state = _GlowState.idle;
AnimationController _glowController;
Timer _pullRecedeTimer;
// animation values
final Tween<double> _glowOpacityTween = Tween<double>(begin: 0.0, end: 0.0);
Animation<double> _glowOpacity;
final Tween<double> _glowSizeTween = Tween<double>(begin: 0.0, end: 0.0);
Animation<double> _glowSize;
// animation of the cross axis position
Ticker _displacementTicker;
Duration _displacementTickerLastElapsed;
double _displacementTarget = 0.5;
double _displacement = 0.5;
// tracking the pull distance
double _pullDistance = 0.0;
Color get color => _color;
Color _color;
set color(Color value) {
assert(color != null);
if (color == value)
return;
_color = value;
notifyListeners();
}
Axis get axis => _axis;
Axis _axis;
set axis(Axis value) {
assert(axis != null);
if (axis == value)
return;
_axis = value;
notifyListeners();
}
static const Duration _recedeTime = Duration(milliseconds: 600);
static const Duration _pullTime = Duration(milliseconds: 167);
static const Duration _pullHoldTime = Duration(milliseconds: 167);
static const Duration _pullDecayTime = Duration(milliseconds: 2000);
static final Duration _crossAxisHalfTime = Duration(microseconds: (Duration.microsecondsPerSecond / 60.0).round());
static const double _maxOpacity = 0.5;
static const double _pullOpacityGlowFactor = 0.8;
static const double _velocityGlowFactor = 0.00006;
static const double _sqrt3 = 1.73205080757; // const math.sqrt(3)
static const double _widthToHeightFactor = (3.0 / 4.0) * (2.0 - _sqrt3);
// absorbed velocities are clamped to the range _minVelocity.._maxVelocity
static const double _minVelocity = 100.0; // logical pixels per second
static const double _maxVelocity = 10000.0; // logical pixels per second
@override
void dispose() {
_glowController.dispose();
_displacementTicker.dispose();
_pullRecedeTimer?.cancel();
super.dispose();
}
/// Handle a scroll slamming into the edge at a particular velocity.
///
/// The velocity must be positive.
void absorbImpact(double velocity) {
assert(velocity >= 0.0);
_pullRecedeTimer?.cancel();
_pullRecedeTimer = null;
velocity = velocity.clamp(_minVelocity, _maxVelocity) as double;
_glowOpacityTween.begin = _state == _GlowState.idle ? 0.3 : _glowOpacity.value;
_glowOpacityTween.end = (velocity * _velocityGlowFactor).clamp(_glowOpacityTween.begin, _maxOpacity) as double;
_glowSizeTween.begin = _glowSize.value;
_glowSizeTween.end = math.min(0.025 + 7.5e-7 * velocity * velocity, 1.0);
_glowController.duration = Duration(milliseconds: (0.15 + velocity * 0.02).round());
_glowController.forward(from: 0.0);
_displacement = 0.5;
_state = _GlowState.absorb;
}
/// Handle a user-driven overscroll.
///
/// The `overscroll` argument should be the scroll distance in logical pixels,
/// the `extent` argument should be the total dimension of the viewport in the
/// main axis in logical pixels, the `crossAxisOffset` argument should be the
/// distance from the leading (left or top) edge of the cross axis of the
/// viewport, and the `crossExtent` should be the size of the cross axis. For
/// example, a pull of 50 pixels up the middle of a 200 pixel high and 100
/// pixel wide vertical viewport should result in a call of `pull(50.0, 200.0,
/// 50.0, 100.0)`. The `overscroll` value should be positive regardless of the
/// direction.
void pull(double overscroll, double extent, double crossAxisOffset, double crossExtent) {
_pullRecedeTimer?.cancel();
_pullDistance += overscroll / 200.0; // This factor is magic. Not clear why we need it to match Android.
_glowOpacityTween.begin = _glowOpacity.value;
_glowOpacityTween.end = math.min(_glowOpacity.value + overscroll / extent * _pullOpacityGlowFactor, _maxOpacity);
final double height = math.min(extent, crossExtent * _widthToHeightFactor);
_glowSizeTween.begin = _glowSize.value;
_glowSizeTween.end = math.max(1.0 - 1.0 / (0.7 * math.sqrt(_pullDistance * height)), _glowSize.value);
_displacementTarget = crossAxisOffset / crossExtent;
if (_displacementTarget != _displacement) {
if (!_displacementTicker.isTicking) {
assert(_displacementTickerLastElapsed == null);
_displacementTicker.start();
}
} else {
_displacementTicker.stop();
_displacementTickerLastElapsed = null;
}
_glowController.duration = _pullTime;
if (_state != _GlowState.pull) {
_glowController.forward(from: 0.0);
_state = _GlowState.pull;
} else {
if (!_glowController.isAnimating) {
assert(_glowController.value == 1.0);
notifyListeners();
}
}
_pullRecedeTimer = Timer(_pullHoldTime, () => _recede(_pullDecayTime));
}
void scrollEnd() {
if (_state == _GlowState.pull)
_recede(_recedeTime);
}
void _changePhase(AnimationStatus status) {
if (status != AnimationStatus.completed)
return;
switch (_state) {
case _GlowState.absorb:
_recede(_recedeTime);
break;
case _GlowState.recede:
_state = _GlowState.idle;
_pullDistance = 0.0;
break;
case _GlowState.pull:
case _GlowState.idle:
break;
}
}
void _recede(Duration duration) {
if (_state == _GlowState.recede || _state == _GlowState.idle)
return;
_pullRecedeTimer?.cancel();
_pullRecedeTimer = null;
_glowOpacityTween.begin = _glowOpacity.value;
_glowOpacityTween.end = 0.0;
_glowSizeTween.begin = _glowSize.value;
_glowSizeTween.end = 0.0;
_glowController.duration = duration;
_glowController.forward(from: 0.0);
_state = _GlowState.recede;
}
void _tickDisplacement(Duration elapsed) {
if (_displacementTickerLastElapsed != null) {
final double t = (elapsed.inMicroseconds - _displacementTickerLastElapsed.inMicroseconds).toDouble();
_displacement = _displacementTarget - (_displacementTarget - _displacement) * math.pow(2.0, -t / _crossAxisHalfTime.inMicroseconds);
notifyListeners();
}
if (nearEqual(_displacementTarget, _displacement, Tolerance.defaultTolerance.distance)) {
_displacementTicker.stop();
_displacementTickerLastElapsed = null;
} else {
_displacementTickerLastElapsed = elapsed;
}
}
void paint(Canvas canvas, Size size) {
if (_glowOpacity.value == 0.0)
return;
final double baseGlowScale = size.width > size.height ? size.height / size.width : 1.0;
final double radius = size.width * 3.0 / 2.0;
final double height = math.min(size.height, size.width * _widthToHeightFactor);
final double scaleY = _glowSize.value * baseGlowScale;
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);
canvas.save();
canvas.scale(1.0, scaleY);
canvas.clipRect(rect);
canvas.drawCircle(center, radius, paint);
canvas.restore();
}
}
class _GlowingOverscrollIndicatorPainter extends CustomPainter {
_GlowingOverscrollIndicatorPainter({
this.leadingController,
this.trailingController,
this.axisDirection,
Listenable repaint,
}) : super(
repaint: repaint,
);
/// The controller for the overscroll glow on the side with negative scroll offsets.
///
/// For a vertical downwards viewport, this is the top side.
final _GlowController leadingController;
/// The controller for the overscroll glow on the side with positive scroll offsets.
///
/// For a vertical downwards viewport, this is the bottom side.
final _GlowController trailingController;
/// The direction of the viewport.
final AxisDirection axisDirection;
static const double piOver2 = math.pi / 2.0;
void _paintSide(Canvas canvas, Size size, _GlowController controller, AxisDirection axisDirection, GrowthDirection growthDirection) {
if (controller == null)
return;
switch (applyGrowthDirectionToAxisDirection(axisDirection, growthDirection)) {
case AxisDirection.up:
controller.paint(canvas, size);
break;
case AxisDirection.down:
canvas.save();
canvas.translate(0.0, size.height);
canvas.scale(1.0, -1.0);
controller.paint(canvas, size);
canvas.restore();
break;
case AxisDirection.left:
canvas.save();
canvas.rotate(piOver2);
canvas.scale(1.0, -1.0);
controller.paint(canvas, Size(size.height, size.width));
canvas.restore();
break;
case AxisDirection.right:
canvas.save();
canvas.translate(size.width, 0.0);
canvas.rotate(piOver2);
controller.paint(canvas, Size(size.height, size.width));
canvas.restore();
break;
}
}
@override
void paint(Canvas canvas, Size size) {
_paintSide(canvas, size, leadingController, axisDirection, GrowthDirection.reverse);
_paintSide(canvas, size, trailingController, axisDirection, GrowthDirection.forward);
}
@override
bool shouldRepaint(_GlowingOverscrollIndicatorPainter oldDelegate) {
return oldDelegate.leadingController != leadingController
|| oldDelegate.trailingController != trailingController;
}
}
/// A notification that an [GlowingOverscrollIndicator] will start showing an
/// overscroll indication.
///
/// To prevent the indicator from showing the indication, call [disallowGlow] on
/// the notification.
///
/// See also:
///
/// * [GlowingOverscrollIndicator], which generates this type of notification.
class OverscrollIndicatorNotification extends Notification with ViewportNotificationMixin {
/// Creates a notification that an [GlowingOverscrollIndicator] will start
/// showing an overscroll indication.
///
/// The [leading] argument must not be null.
OverscrollIndicatorNotification({
@required this.leading,
});
/// Whether the indication will be shown on the leading edge of the scroll
/// view.
final bool leading;
bool _accepted = true;
/// Call this method if the glow should be prevented.
void disallowGlow() {
_accepted = false;
}
@override
void debugFillDescription(List<String> description) {
super.debugFillDescription(description);
description.add('side: ${leading ? "leading edge" : "trailing edge"}');
}
}