-
-
Save kentcb/d5afd520a0027d1d2698fa943eea4566 to your computer and use it in GitHub Desktop.
// Copyright 2014 The Chromium Authors. All rights reserved. | |
// Redistribution and use in source and binary forms, with or without | |
// modification, are permitted provided that the following conditions are | |
// met: | |
// * Redistributions of source code must retain the above copyright | |
// notice, this list of conditions and the following disclaimer. | |
// * Redistributions in binary form must reproduce the above | |
// copyright notice, this list of conditions and the following | |
// disclaimer in the documentation and/or other materials provided | |
// with the distribution. | |
// * Neither the name of Google Inc. nor the names of its | |
// contributors may be used to endorse or promote products derived | |
// from this software without specific prior written permission. | |
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
import 'dart:async'; | |
import 'dart:math' as math; | |
import 'package:flutter/foundation.dart'; | |
import 'package:flutter/material.dart'; | |
import 'package:flutter/widgets.dart'; | |
// The over-scroll distance that moves the indicator to its maximum displacement, as a percentage of the scrollable's container extent. | |
const double _kDragContainerExtentPercentage = 0.25; | |
// How much the scroll's drag gesture can overshoot the RefreshIndicator's displacement; max displacement = _kDragSizeFactorLimit * displacement. | |
const double _kDragSizeFactorLimit = 1.5; | |
// When the scroll ends, the duration of the refresh indicator's animation to the RefreshIndicator's displacement. | |
const Duration _kIndicatorSnapDuration = const Duration(milliseconds: 150); | |
// The duration of the ScaleTransition that starts when the refresh action has completed. | |
const Duration _kIndicatorScaleDuration = const Duration(milliseconds: 200); | |
/// The signature for a function that's called when the user has dragged a [ReactiveRefreshIndicator] far enough to demonstrate that they want to | |
/// instigate a refresh. | |
typedef void RefreshCallback(); | |
// The state machine moves through these modes only when the scrollable identified by scrollableKey has been scrolled to its min or max limit. | |
enum _RefreshIndicatorMode { | |
drag, // Pointer is down. | |
armed, // Dragged far enough that an up event will run the onRefresh callback. | |
snap, // Animating to the indicator's final "displacement". | |
refresh, // Running the refresh callback. | |
done, // Animating the indicator's fade-out after refreshing. | |
canceled, // Animating the indicator's fade-out after not arming. | |
} | |
/// This is a customization of the [RefreshIndicator] widget that is reactive in design. This makes it much easier to integrate into code | |
/// that has multiple avenues of refresh instigation. That is, refreshing in response to the user pulling down a [ListView], but also in | |
/// response to some other stimuli, like swiping a header left or right. | |
/// | |
/// Instead of [onRefresh] being asynchronous as it is in [RefreshIndicator], it is synchronous. Consequently, instead of determining the | |
/// visibility of the refresh indicator on your behalf, you must tell the control yourself via [isRefreshing]. The [onRefresh] callback is | |
/// only executed if the user instigates a refresh via a pull-to-refresh gesture. | |
class ReactiveRefreshIndicator extends StatefulWidget { | |
const ReactiveRefreshIndicator({ | |
Key key, | |
@required this.child, | |
this.displacement: 40.0, | |
@required this.isRefreshing, | |
@required this.onRefresh, | |
this.color, | |
this.backgroundColor, | |
this.notificationPredicate: defaultScrollNotificationPredicate, | |
}) : assert(child != null), | |
assert(onRefresh != null), | |
assert(notificationPredicate != null), | |
assert(isRefreshing != null), | |
super(key: key); | |
final Widget child; | |
final double displacement; | |
final bool isRefreshing; | |
final RefreshCallback onRefresh; | |
final Color color; | |
final Color backgroundColor; | |
final ScrollNotificationPredicate notificationPredicate; | |
@override | |
ReactiveRefreshIndicatorState createState() => new ReactiveRefreshIndicatorState(); | |
} | |
class ReactiveRefreshIndicatorState extends State<ReactiveRefreshIndicator> with TickerProviderStateMixin { | |
AnimationController _positionController; | |
AnimationController _scaleController; | |
Animation<double> _positionFactor; | |
Animation<double> _scaleFactor; | |
Animation<double> _value; | |
Animation<Color> _valueColor; | |
_RefreshIndicatorMode _mode; | |
bool _isIndicatorAtTop; | |
double _dragOffset; | |
@override | |
void initState() { | |
super.initState(); | |
_positionController = new AnimationController(vsync: this); | |
_positionFactor = new Tween<double>( | |
begin: 0.0, | |
end: _kDragSizeFactorLimit, | |
).animate(_positionController); | |
_value = new Tween<double>( // The "value" of the circular progress indicator during a drag. | |
begin: 0.0, | |
end: 0.75, | |
).animate(_positionController); | |
_scaleController = new AnimationController(vsync: this); | |
_scaleFactor = new Tween<double>( | |
begin: 1.0, | |
end: 0.0, | |
).animate(_scaleController); | |
_showOrDismissAccordingToIsRefreshing(); | |
} | |
@override | |
void didChangeDependencies() { | |
final ThemeData theme = Theme.of(context); | |
_valueColor = new ColorTween( | |
begin: (widget.color ?? theme.accentColor).withOpacity(0.0), | |
end: (widget.color ?? theme.accentColor).withOpacity(1.0) | |
).animate(new CurvedAnimation( | |
parent: _positionController, | |
curve: const Interval(0.0, 1.0 / _kDragSizeFactorLimit) | |
)); | |
super.didChangeDependencies(); | |
} | |
@override | |
void didUpdateWidget(ReactiveRefreshIndicator oldWidget) { | |
_showOrDismissAccordingToIsRefreshing(); | |
super.didUpdateWidget(oldWidget); | |
} | |
@override | |
void dispose() { | |
_positionController.dispose(); | |
_scaleController.dispose(); | |
super.dispose(); | |
} | |
void _showOrDismissAccordingToIsRefreshing() { | |
if (widget.isRefreshing) { | |
if (_mode != _RefreshIndicatorMode.refresh) { | |
// Doing this work immediately triggers an assertion failure in the case when the refresh indicator is visible | |
// upon first display: | |
// | |
// I/flutter (21441): The following assertion was thrown building SandvikRefreshIndicator(state: | |
// I/flutter (21441): SandvikRefreshIndicatorState#26328(tickers: tracking 2 tickers)): | |
// I/flutter (21441): 'package:flutter/src/rendering/object.dart': Failed assertion: line 1792 pos 12: '() { | |
// I/flutter (21441): final AbstractNode parent = this.parent; | |
// I/flutter (21441): if (parent is RenderObject) | |
// I/flutter (21441): return parent._needsCompositing; | |
// I/flutter (21441): return true; | |
// I/flutter (21441): }()': is not true. | |
// | |
// Therefore, we schedule it via a future instead. | |
new Future(() { | |
_start(AxisDirection.down); | |
_show(); | |
}); | |
} | |
} else { | |
if (_mode != null && _mode != _RefreshIndicatorMode.done) { | |
_dismiss(_RefreshIndicatorMode.done); | |
} | |
} | |
} | |
bool _handleScrollNotification(ScrollNotification notification) { | |
if (!widget.notificationPredicate(notification)) { | |
return false; | |
} | |
if (notification is ScrollStartNotification && notification.metrics.extentBefore == 0.0 && | |
_mode == null && _start(notification.metrics.axisDirection)) { | |
setState(() { | |
_mode = _RefreshIndicatorMode.drag; | |
}); | |
return false; | |
} | |
bool indicatorAtTopNow; | |
switch (notification.metrics.axisDirection) { | |
case AxisDirection.down: | |
indicatorAtTopNow = true; | |
break; | |
case AxisDirection.up: | |
indicatorAtTopNow = false; | |
break; | |
case AxisDirection.left: | |
case AxisDirection.right: | |
indicatorAtTopNow = null; | |
break; | |
} | |
if (indicatorAtTopNow != _isIndicatorAtTop) { | |
if (_mode == _RefreshIndicatorMode.drag || _mode == _RefreshIndicatorMode.armed) { | |
_dismiss(_RefreshIndicatorMode.canceled); | |
} | |
} else if (notification is ScrollUpdateNotification) { | |
if (_mode == _RefreshIndicatorMode.drag || _mode == _RefreshIndicatorMode.armed) { | |
if (notification.metrics.extentBefore > 0.0) { | |
_dismiss(_RefreshIndicatorMode.canceled); | |
} else { | |
_dragOffset -= notification.scrollDelta; | |
_checkDragOffset(notification.metrics.viewportDimension); | |
} | |
} | |
} else if (notification is OverscrollNotification) { | |
if (_mode == _RefreshIndicatorMode.drag || _mode == _RefreshIndicatorMode.armed) { | |
_dragOffset -= notification.overscroll / 2.0; | |
_checkDragOffset(notification.metrics.viewportDimension); | |
} | |
} else if (notification is ScrollEndNotification) { | |
switch (_mode) { | |
case _RefreshIndicatorMode.armed: | |
_show(userInstigated: true); | |
break; | |
case _RefreshIndicatorMode.drag: | |
_dismiss(_RefreshIndicatorMode.canceled); | |
break; | |
default: | |
// do nothing | |
break; | |
} | |
} | |
return false; | |
} | |
bool _handleGlowNotification(OverscrollIndicatorNotification notification) { | |
if (notification.depth != 0 || !notification.leading) { | |
return false; | |
} | |
if (_mode == _RefreshIndicatorMode.drag) { | |
notification.disallowGlow(); | |
return true; | |
} | |
return false; | |
} | |
bool _start(AxisDirection direction) { | |
assert(_mode == null); | |
assert(_isIndicatorAtTop == null); | |
assert(_dragOffset == null); | |
switch (direction) { | |
case AxisDirection.down: | |
_isIndicatorAtTop = true; | |
break; | |
case AxisDirection.up: | |
_isIndicatorAtTop = false; | |
break; | |
case AxisDirection.left: | |
case AxisDirection.right: | |
_isIndicatorAtTop = null; | |
// we do not support horizontal scroll views. | |
return false; | |
} | |
_dragOffset = 0.0; | |
_scaleController.value = 0.0; | |
_positionController.value = 0.0; | |
return true; | |
} | |
void _checkDragOffset(double containerExtent) { | |
assert(_mode == _RefreshIndicatorMode.drag || _mode == _RefreshIndicatorMode.armed); | |
double newValue = _dragOffset / (containerExtent * _kDragContainerExtentPercentage); | |
if (_mode == _RefreshIndicatorMode.armed) { | |
newValue = math.max(newValue, 1.0 / _kDragSizeFactorLimit); | |
} | |
_positionController.value = newValue.clamp(0.0, 1.0); // this triggers various rebuilds | |
if (_mode == _RefreshIndicatorMode.drag && _valueColor.value.alpha == 0xFF) { | |
_mode = _RefreshIndicatorMode.armed; | |
} | |
} | |
Future<Null> _dismiss(_RefreshIndicatorMode newMode) async { | |
// This can only be called from _show() when refreshing and | |
// _handleScrollNotification in response to a ScrollEndNotification or | |
// direction change. | |
assert(newMode == _RefreshIndicatorMode.canceled || newMode == _RefreshIndicatorMode.done); | |
setState(() { | |
_mode = newMode; | |
}); | |
switch (_mode) { | |
case _RefreshIndicatorMode.done: | |
await _scaleController.animateTo(1.0, duration: _kIndicatorScaleDuration); | |
break; | |
case _RefreshIndicatorMode.canceled: | |
await _positionController.animateTo(0.0, duration: _kIndicatorScaleDuration); | |
break; | |
default: | |
assert(false); | |
} | |
if (mounted && _mode == newMode) { | |
_dragOffset = null; | |
_isIndicatorAtTop = null; | |
setState(() => _mode = null); | |
} | |
} | |
void _show({bool userInstigated = false}) { | |
assert(_mode != _RefreshIndicatorMode.refresh); | |
assert(_mode != _RefreshIndicatorMode.snap); | |
_mode = _RefreshIndicatorMode.snap; | |
_positionController | |
.animateTo(1.0 / _kDragSizeFactorLimit, duration: _kIndicatorSnapDuration) | |
.then<void>((Null value) { | |
if (mounted && _mode == _RefreshIndicatorMode.snap) { | |
assert(widget.onRefresh != null); | |
setState(() => _mode = _RefreshIndicatorMode.refresh); | |
if (userInstigated) { | |
widget.onRefresh(); | |
} | |
} | |
}); | |
} | |
final GlobalKey _key = new GlobalKey(); | |
@override | |
Widget build(BuildContext context) { | |
final Widget child = new NotificationListener<ScrollNotification>( | |
key: _key, | |
onNotification: _handleScrollNotification, | |
child: new NotificationListener<OverscrollIndicatorNotification>( | |
onNotification: _handleGlowNotification, | |
child: widget.child, | |
), | |
); | |
if (_mode == null) { | |
assert(_dragOffset == null); | |
assert(_isIndicatorAtTop == null); | |
return child; | |
} | |
assert(_dragOffset != null); | |
assert(_isIndicatorAtTop != null); | |
final bool showIndeterminateIndicator = _mode == _RefreshIndicatorMode.refresh || _mode == _RefreshIndicatorMode.done; | |
return new Stack( | |
children: <Widget>[ | |
child, | |
new Positioned( | |
top: _isIndicatorAtTop ? 0.0 : null, | |
bottom: !_isIndicatorAtTop ? 0.0 : null, | |
left: 0.0, | |
right: 0.0, | |
child: new SizeTransition( | |
axisAlignment: _isIndicatorAtTop ? 1.0 : -1.0, | |
sizeFactor: _positionFactor, // this is what brings it down | |
child: new Container( | |
padding: _isIndicatorAtTop | |
? new EdgeInsets.only(top: widget.displacement) | |
: new EdgeInsets.only(bottom: widget.displacement), | |
alignment: _isIndicatorAtTop | |
? Alignment.topCenter | |
: Alignment.bottomCenter, | |
child: new ScaleTransition( | |
scale: _scaleFactor, | |
child: new AnimatedBuilder( | |
animation: _positionController, | |
builder: (BuildContext context, Widget child) { | |
return new RefreshProgressIndicator( | |
value: showIndeterminateIndicator ? null : _value.value, | |
valueColor: _valueColor, | |
backgroundColor: widget.backgroundColor, | |
); | |
}, | |
), | |
), | |
), | |
), | |
), | |
], | |
); | |
} | |
} |
Hello,
i am getting The argument type '(Null) → Null' can't be assigned to the parameter type '(void) → dynamic'
on https://gist.github.com/kentcb/d5afd520a0027d1d2698fa943eea4566#file-reactive_refresh_indicator-dart-L349
for some strange reasons, this works in development/normal but doesn't work in Release mode. Any thoughts? Haven't really had a chance to debug.
Here it is,
[ERROR:flutter/lib/ui/ui_dart_state.cc(148)] Unhandled Exception: 'package:connect/widgets/refreshing_widget.dart': Failed assertion: line 275 pos 12: '_mode == null': is not true.
Hey, @hiteshjoshi I'm dealing with the same problem. Did you know how to solve the problem ?
I actually ended up not using it and creating my own Reactive refresh indicator.
@hiteshjoshi Can you help me out. I'm stuck.
it worked perfectly. thanks @kentcb
side note, if you wrap this with LayoutBuilder the manual pull to refresh won't work because didUpdateWidget is resetting the state
for those who are getting this error
reactive_refresh_indicator.dart:349:19: Error: The argument type 'Null Function(Null)' can't be assigned to the parameter type 'dynamic Function(void)'.
.then<void>((Null value) {
replace this line with
.then<void>((void value) {
So what's actually different? 🤔