Commit d9b9983e authored by Hans Muller's avatar Hans Muller Committed by GitHub

Refresh indicator (#4788)

parent 63eedb76
......@@ -18,6 +18,7 @@ class OverscrollDemo extends StatefulWidget {
}
class OverscrollDemoState extends State<OverscrollDemo> {
static final GlobalKey<ScrollableState> _scrollableKey = new GlobalKey<ScrollableState>();
static final List<String> _items = <String>[
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N'
];
......@@ -50,6 +51,7 @@ class OverscrollDemoState extends State<OverscrollDemo> {
child: new MaterialList(
type: MaterialListType.threeLine,
padding: const EdgeInsets.all(8.0),
scrollableKey: _scrollableKey,
children: _items.map((String item) {
return new ListItem(
isThreeLine: true,
......@@ -65,7 +67,11 @@ class OverscrollDemoState extends State<OverscrollDemo> {
body = new OverscrollIndicator(child: body);
break;
case IndicatorType.refresh:
body = new RefreshIndicator(child: body, refresh: refresh);
body = new RefreshIndicator(
child: body,
refresh: refresh,
scrollableKey: _scrollableKey
);
break;
}
......
......@@ -60,6 +60,8 @@ class SawTooth extends Curve {
@override
double transform(double t) {
if (t == 1.0)
return 1.0;
t *= count;
return t - t.truncateToDouble();
}
......
......@@ -96,7 +96,7 @@ class MaterialList extends StatelessWidget {
@override
Widget build(BuildContext context) {
return new ScrollableList(
key: scrollableKey,
scrollableKey: scrollableKey,
initialScrollOffset: initialScrollOffset,
scrollDirection: Axis.vertical,
onScrollStart: onScrollStart,
......
......@@ -317,9 +317,7 @@ class _CircularProgressIndicatorState extends State<CircularProgressIndicator> {
@override
void initState() {
super.initState();
_controller = new AnimationController(
duration: const Duration(milliseconds: 6666)
)..repeat();
_controller = _buildController();
}
@override
......@@ -328,6 +326,10 @@ class _CircularProgressIndicatorState extends State<CircularProgressIndicator> {
super.dispose();
}
AnimationController _buildController() {
return new AnimationController(duration: const Duration(milliseconds: 6666))..repeat();
}
Widget _buildIndicator(BuildContext context, double headValue, double tailValue, int stepValue, double rotationValue) {
return new Container(
constraints: new BoxConstraints(
......@@ -348,11 +350,7 @@ class _CircularProgressIndicatorState extends State<CircularProgressIndicator> {
);
}
@override
Widget build(BuildContext context) {
if (config.value != null)
return _buildIndicator(context, 0.0, 0.0, 0, 0.0);
Widget _buildAnimation() {
return new AnimatedBuilder(
animation: _controller,
builder: (BuildContext context, Widget child) {
......@@ -366,6 +364,13 @@ class _CircularProgressIndicatorState extends State<CircularProgressIndicator> {
}
);
}
@override
Widget build(BuildContext context) {
if (config.value != null)
return _buildIndicator(context, 0.0, 0.0, 0, 0.0);
return _buildAnimation();
}
}
class _RefreshProgressIndicatorPainter extends _CircularProgressIndicatorPainter {
......@@ -438,7 +443,12 @@ class RefreshProgressIndicator extends CircularProgressIndicator {
double value,
Color backgroundColor,
Animation<Color> valueColor
}) : super(key: key, value: value, backgroundColor: backgroundColor, valueColor: valueColor);
}) : super(
key: key,
value: value,
backgroundColor: backgroundColor,
valueColor: valueColor
);
@override
_RefreshProgressIndicatorState createState() => new _RefreshProgressIndicatorState();
......@@ -447,6 +457,19 @@ class RefreshProgressIndicator extends CircularProgressIndicator {
class _RefreshProgressIndicatorState extends _CircularProgressIndicatorState {
static double _kIndicatorSize = 40.0;
// Always show the indeterminate version of the circular progress indicator.
// When value is non-null the sweep of the progress indicator arrow's arc
// varies from 0 to about 270 degrees. When value is null the arrow animates
// starting from wherever we left it.
@override
Widget build(BuildContext context) {
if (config.value != null)
_controller.value = config.value / 10.0;
else
_controller.forward();
return _buildAnimation();
}
@override
Widget _buildIndicator(BuildContext context, double headValue, double tailValue, int stepValue, double rotationValue) {
return new Container(
......@@ -462,8 +485,8 @@ class _RefreshProgressIndicatorState extends _CircularProgressIndicatorState {
child: new CustomPaint(
painter: new _RefreshProgressIndicatorPainter(
valueColor: config._getValueColor(context),
value: config.value, // may be null
headValue: headValue, // remaining arguments are ignored if config.value is not null
value: null, // Draw the indeterminate progress indicator.
headValue: headValue,
tailValue: tailValue,
stepValue: stepValue,
rotationValue: rotationValue,
......
......@@ -44,6 +44,14 @@ enum RefreshIndicatorLocation {
bottom,
}
enum _RefreshIndicatorMode {
drag,
armed,
snap,
refresh,
dimiss
}
/// A widget that supports the Material "swipe to refresh" idiom.
///
/// When the child's vertical Scrollable descendant overscrolls, an
......@@ -76,7 +84,7 @@ class RefreshIndicator extends StatefulWidget {
/// Identifies the [Scrollable] descendant of child that will cause the
/// refresh indicator to appear. Can be null if there's only one
/// [Scrollable] descendant.
final Key scrollableKey;
final GlobalKey<ScrollableState> scrollableKey;
/// The distance from the child's top or bottom edge to where the refresh indicator
/// will settle. During the drag that exposes the refresh indicator, its actual
......@@ -101,6 +109,7 @@ class _RefreshIndicatorState extends State<RefreshIndicator> {
final AnimationController _scaleController = new AnimationController();
Animation<double> _sizeFactor;
Animation<double> _scaleFactor;
Animation<double> _value;
Animation<Color> _valueColor;
double _scrollOffset;
......@@ -108,6 +117,8 @@ class _RefreshIndicatorState extends State<RefreshIndicator> {
double _minScrollOffset;
double _maxScrollOffset;
RefreshIndicatorLocation _location = RefreshIndicatorLocation.top;
_RefreshIndicatorMode _mode;
Future<Null> _pendingRefreshFuture;
@override
void initState() {
......@@ -117,6 +128,13 @@ class _RefreshIndicatorState extends State<RefreshIndicator> {
final ThemeData theme = Theme.of(context);
// The "value" of the circular progress indicator during a drag.
_value = new Tween<double>(
begin: 0.0,
end: 0.75
)
.animate(_sizeController);
// Fully opaque when we've reached config.displacement.
_valueColor = new ColorTween(
begin: theme.primaryColor.withOpacity(0.0),
......@@ -126,6 +144,7 @@ class _RefreshIndicatorState extends State<RefreshIndicator> {
parent: _sizeController,
curve: new Interval(0.0, 1.0 / _kDragSizeFactorLimit)
));
}
@override
......@@ -146,73 +165,84 @@ class _RefreshIndicatorState extends State<RefreshIndicator> {
_maxScrollOffset = scrollBehavior.maxScrollOffset;
}
void _onScrollStarted(ScrollableState scrollable) {
_updateState(scrollable);
_scaleController.value = 0.0;
_sizeController.value = 0.0;
}
RefreshIndicatorLocation get _locationForScrollOffset {
return _scrollOffset < _minScrollOffset
? RefreshIndicatorLocation.top
: RefreshIndicatorLocation.bottom;
}
void _onScrollUpdated(ScrollableState scrollable) {
void _handlePointerDown(PointerDownEvent event) {
final ScrollableState scrollable = config.scrollableKey?.currentState;
if (scrollable == null)
return;
_updateState(scrollable);
_scaleController.value = 0.0;
_sizeController.value = 0.0;
_mode = _RefreshIndicatorMode.drag;
}
void _handlePointerMove(PointerMoveEvent event) {
final ScrollableState scrollable = config.scrollableKey?.currentState;
if (scrollable == null)
return;
final double value = scrollable.scrollOffset;
if ((value < _minScrollOffset || value > _maxScrollOffset) &&
((value - _scrollOffset).abs() > kPixelScrollTolerance.distance)) {
final double overScroll = value < _minScrollOffset ? _minScrollOffset - value : value - _maxScrollOffset;
final double newValue = overScroll / (_containerExtent * _kDragContainerExtentPercentage);
if (newValue > _sizeController.value) {
_sizeController.value = newValue;
if (_location != _locationForScrollOffset) {
setState(() {
_location = _locationForScrollOffset;
});
}
_sizeController.value = newValue.clamp(0.0, 1.0);
if (_location != _locationForScrollOffset) {
setState(() {
_location = _locationForScrollOffset;
});
}
}
_mode = _valueColor.value.alpha == 0xFF ? _RefreshIndicatorMode.armed : _RefreshIndicatorMode.drag;
_updateState(scrollable);
}
Future<Null> _doOnScrollEnded(ScrollableState scrollable) async {
if (_valueColor.value.alpha == 0xFF) {
Future<Null> _doHandlePointerUp(PointerUpEvent event) async {
if (_mode == _RefreshIndicatorMode.armed) {
_mode = _RefreshIndicatorMode.snap;
await _sizeController.animateTo(1.0 / _kDragSizeFactorLimit, duration: _kIndicatorSnapDuration);
await config.refresh();
}
return _scaleController.animateTo(1.0, duration: _kIndicatorScaleDuration);
}
if (mounted && _mode == _RefreshIndicatorMode.snap) {
setState(() {
_mode = _RefreshIndicatorMode.refresh; // Show the indeterminate progress indicator.
});
void _onScrollEnded(ScrollableState scrollable) {
_doOnScrollEnded(scrollable);
}
// Only one refresh callback is allowed to run at a time. If the user
// attempts to start a refresh while one is still running ("pending") we
// just continue to wait on the pending refresh.
if (_pendingRefreshFuture == null)
_pendingRefreshFuture = config.refresh();
await _pendingRefreshFuture;
bool completed = _pendingRefreshFuture != null;
_pendingRefreshFuture = null;
bool _handleScrollNotification(ScrollNotification notification) {
if (config.scrollableKey == null || config.scrollableKey == notification.scrollable.config.key) {
final ScrollableState scrollable = notification.scrollable;
if (scrollable.config.scrollDirection != Axis.vertical)
return false;
switch(notification.kind) {
case ScrollNotificationKind.started:
_onScrollStarted(scrollable);
break;
case ScrollNotificationKind.updated:
_onScrollUpdated(scrollable);
break;
case ScrollNotificationKind.ended:
_onScrollEnded(scrollable);
break;
if (mounted && completed && _mode == _RefreshIndicatorMode.refresh) {
setState(() {
_mode = null; // Stop showing the indeterminate progress indicator.
});
_scaleController.animateTo(1.0, duration: _kIndicatorScaleDuration);
}
}
} else {
_scaleController.animateTo(1.0, duration: _kIndicatorScaleDuration);
}
return false;
}
void _handlePointerUp(PointerEvent event) {
_doHandlePointerUp(event);
}
@override
Widget build(BuildContext context) {
final bool isAtTop = _location == RefreshIndicatorLocation.top;
return new NotificationListener<ScrollNotification>(
onNotification: _handleScrollNotification,
return new Listener(
onPointerDown: _handlePointerDown,
onPointerMove: _handlePointerMove,
onPointerUp: _handlePointerUp,
child: new Stack(
children: <Widget>[
new ClampOverscrolls(
......@@ -235,9 +265,14 @@ class _RefreshIndicatorState extends State<RefreshIndicator> {
alignment: isAtTop ? FractionalOffset.bottomCenter : FractionalOffset.topCenter,
child: new ScaleTransition(
scale: _scaleFactor,
child: new RefreshProgressIndicator(
value: null,
valueColor: _valueColor
child: new AnimatedBuilder(
animation: _sizeController,
builder: (BuildContext context, Widget child) {
return new RefreshProgressIndicator(
value: _mode == _RefreshIndicatorMode.refresh ? null : _value.value,
valueColor: _valueColor
);
}
)
)
)
......
......@@ -7,8 +7,9 @@ import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart';
void main() {
final GlobalKey<ScrollableState> scrollableKey = new GlobalKey<ScrollableState>();
void main() {
bool refreshCalled = false;
Future<Null> refresh() {
......@@ -19,8 +20,10 @@ void main() {
testWidgets('RefreshIndicator', (WidgetTester tester) async {
await tester.pumpWidget(
new RefreshIndicator(
scrollableKey: scrollableKey,
refresh: refresh,
child: new Block(
scrollableKey: scrollableKey,
children: <String>['A', 'B', 'C', 'D', 'E', 'F'].map((String item) {
return new SizedBox(
height: 200.0,
......@@ -31,7 +34,7 @@ void main() {
)
);
await tester.fling(find.text('A'), const Offset(0.0, 200.0), -1000.0);
await tester.fling(find.text('A'), const Offset(0.0, 300.0), -1000.0);
await tester.pump();
await tester.pump(const Duration(seconds: 1)); // finish the scroll animation
await tester.pump(const Duration(seconds: 1)); // finish the indicator settle animation
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment