Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mpiannucci/975766460766f3e5188ddd1744bce90e to your computer and use it in GitHub Desktop.
Save mpiannucci/975766460766f3e5188ddd1744bce90e to your computer and use it in GitHub Desktop.
A better RefreshIndicator for flutter
// 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);
}
@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) {
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 ReactiveRefreshIndicator(state:
// I/flutter (21441): ReactiveRefreshIndicatorState#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);
}
}
super.didUpdateWidget(oldWidget);
}
@override
void dispose() {
_positionController.dispose();
_scaleController.dispose();
super.dispose();
}
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,
);
},
),
),
),
),
),
],
);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment