Commit c4ae13ed authored by Hans Muller's avatar Hans Muller

Refresh indicator (#3354)

parent 9ce995f6
// Copyright 2016 The Chromium 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';
import 'package:flutter/material.dart';
enum IndicatorType { overscroll, refresh }
class OverscrollDemo extends StatefulWidget {
OverscrollDemo({ Key key }) : super(key: key);
@override
OverscrollDemoState createState() => new OverscrollDemoState();
}
class OverscrollDemoState extends State<OverscrollDemo> {
static final List<String> _items = <String>[
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N'
];
IndicatorType _type = IndicatorType.refresh;
Future<Null> refresh() {
Completer<Null> completer = new Completer<Null>();
new Timer(new Duration(seconds: 3), () { completer.complete(null); });
return completer.future;
}
@override
Widget build(BuildContext context) {
String indicatorTypeText;
switch(_type) {
case IndicatorType.overscroll:
indicatorTypeText = 'Over-scroll indicator';
break;
case IndicatorType.refresh:
indicatorTypeText = 'Refresh indicator';
break;
}
Widget body = new MaterialList(
type: MaterialListType.threeLine,
padding: const EdgeInsets.all(8.0),
children: _items.map((String item) {
return new ListItem(
isThreeLine: true,
leading: new CircleAvatar(child: new Text(item)),
title: new Text('This item represents $item.'),
subtitle: new Text('Even more additional list item information appears on line three.')
);
})
);
switch(_type) {
case IndicatorType.overscroll:
body = new OverscrollIndicator(child: body);
break;
case IndicatorType.refresh:
body = new RefreshIndicator(child: body, refresh: refresh);
break;
}
return new Scaffold(
appBar: new AppBar(
title: new Text('$indicatorTypeText'),
actions: <Widget>[
new IconButton(
icon: Icons.refresh,
tooltip: 'Pull to refresh',
onPressed: () {
setState(() {
_type = IndicatorType.refresh;
});
}
),
new IconButton(
icon: Icons.play_for_work,
tooltip: 'Over-scroll indicator',
onPressed: () {
setState(() {
_type = IndicatorType.overscroll;
});
}
)
]
),
body: body
);
}
}
......@@ -26,6 +26,7 @@ import '../demo/leave_behind_demo.dart';
import '../demo/list_demo.dart';
import '../demo/modal_bottom_sheet_demo.dart';
import '../demo/menu_demo.dart';
import '../demo/overscroll_demo.dart';
import '../demo/page_selector_demo.dart';
import '../demo/persistent_bottom_sheet_demo.dart';
import '../demo/progress_indicator_demo.dart';
......@@ -133,6 +134,7 @@ class GalleryHomeState extends State<GalleryHome> {
new GalleryItem(title: 'List', builder: () => new ListDemo()),
new GalleryItem(title: 'Menus', builder: () => new MenuDemo()),
new GalleryItem(title: 'Modal bottom sheet', builder: () => new ModalBottomSheetDemo()),
new GalleryItem(title: 'Over-scroll', builder: () => new OverscrollDemo()),
new GalleryItem(title: 'Page selector', builder: () => new PageSelectorDemo()),
new GalleryItem(title: 'Persistent bottom sheet', builder: () => new PersistentBottomSheetDemo()),
new GalleryItem(title: 'Progress indicators', builder: () => new ProgressIndicatorDemo()),
......
......@@ -49,6 +49,7 @@ export 'src/material/popup_menu.dart';
export 'src/material/progress_indicator.dart';
export 'src/material/radio.dart';
export 'src/material/raised_button.dart';
export 'src/material/refresh_indicator.dart';
export 'src/material/scaffold.dart';
export 'src/material/scrollbar.dart';
export 'src/material/shadows.dart';
......
......@@ -174,6 +174,7 @@ class _OverscrollIndicatorState extends State<OverscrollIndicator> {
void dispose() {
_hideTimer?.cancel();
_hideTimer = null;
_extentAnimation.dispose();
super.dispose();
}
......
......@@ -6,6 +6,7 @@ import 'dart:math' as math;
import 'package:flutter/widgets.dart';
import 'material.dart';
import 'theme.dart';
const double _kLinearProgressIndicatorHeight = 6.0;
......@@ -31,7 +32,9 @@ abstract class ProgressIndicator extends StatefulWidget {
/// indicator). See [value] for details.
ProgressIndicator({
Key key,
this.value
this.value,
this.backgroundColor,
this.valueColor
}) : super(key: key);
/// If non-null, the value of this progress indicator with 0.0 corresponding
......@@ -43,8 +46,18 @@ abstract class ProgressIndicator extends StatefulWidget {
/// much actual progress is being made.
final double value;
Color _getBackgroundColor(BuildContext context) => Theme.of(context).backgroundColor;
Color _getValueColor(BuildContext context) => Theme.of(context).primaryColor;
/// The progress indicator's background color. If null, the background color is
/// the current theme's backgroundColor.
final Color backgroundColor;
/// The indicator's color is the animation's value. To specify a constant
/// color use: `new AlwaysStoppedAnimation<Color>(color)`.
///
/// If null, the progress indicator is rendered with the current theme's primaryColor.
final Animation<Color> valueColor;
Color _getBackgroundColor(BuildContext context) => backgroundColor ?? Theme.of(context).backgroundColor;
Color _getValueColor(BuildContext context) => valueColor?.value ?? Theme.of(context).primaryColor;
@override
void debugFillDescription(List<String> description) {
......@@ -143,7 +156,7 @@ class _LinearProgressIndicatorState extends State<LinearProgressIndicator> {
@override
void dispose() {
_controller.stop();
_controller.dispose();
super.dispose();
}
......@@ -185,14 +198,25 @@ class _CircularProgressIndicatorPainter extends CustomPainter {
static const double _kSweep = _kTwoPI - _kEpsilon;
static const double _kStartAngle = -math.PI / 2.0;
const _CircularProgressIndicatorPainter({
_CircularProgressIndicatorPainter({
this.valueColor,
this.value,
this.headValue,
this.tailValue,
this.stepValue,
this.rotationValue
});
double value,
double headValue,
double tailValue,
int stepValue,
double rotationValue,
this.strokeWidth
}) : this.value = value,
this.headValue = headValue,
this.tailValue = tailValue,
this.stepValue = stepValue,
this.rotationValue = rotationValue,
arcStart = value != null
? _kStartAngle
: _kStartAngle + tailValue * 3 / 2 * math.PI + rotationValue * math.PI * 1.7 - stepValue * 0.8 * math.PI,
arcSweep = value != null
? value.clamp(0.0, 1.0) * _kSweep
: math.max(headValue * 3 / 2 * math.PI - tailValue * 3 / 2 * math.PI, _kEpsilon);
final Color valueColor;
final double value;
......@@ -200,33 +224,23 @@ class _CircularProgressIndicatorPainter extends CustomPainter {
final double tailValue;
final int stepValue;
final double rotationValue;
final double strokeWidth;
final double arcStart;
final double arcSweep;
@override
void paint(Canvas canvas, Size size) {
Paint paint = new Paint()
..color = valueColor
..strokeWidth = _kCircularProgressIndicatorStrokeWidth
..strokeWidth = strokeWidth
..style = PaintingStyle.stroke;
if (value != null) {
// Determinate
double angle = value.clamp(0.0, 1.0) * _kSweep;
Path path = new Path()
..arcTo(Point.origin & size, _kStartAngle, angle, false);
canvas.drawPath(path, paint);
} else {
// Indeterminate
if (value == null) // Indeterminate
paint.strokeCap = StrokeCap.square;
double arcSweep = math.max(headValue * 3 / 2 * math.PI - tailValue * 3 / 2 * math.PI, _kEpsilon);
Path path = new Path()
..arcTo(Point.origin & size,
_kStartAngle + tailValue * 3 / 2 * math.PI + rotationValue * math.PI * 1.7 - stepValue * 0.8 * math.PI,
arcSweep,
false);
canvas.drawPath(path, paint);
}
Path path = new Path()
..arcTo(Point.origin & size, arcStart, arcSweep, false);
canvas.drawPath(path, paint);
}
@override
......@@ -236,7 +250,8 @@ class _CircularProgressIndicatorPainter extends CustomPainter {
|| oldPainter.headValue != headValue
|| oldPainter.tailValue != tailValue
|| oldPainter.stepValue != stepValue
|| oldPainter.rotationValue != rotationValue;
|| oldPainter.rotationValue != rotationValue
|| oldPainter.strokeWidth != strokeWidth;
}
}
......@@ -266,8 +281,10 @@ class CircularProgressIndicator extends ProgressIndicator {
/// indicator). See [value] for details.
CircularProgressIndicator({
Key key,
double value
}) : super(key: key, value: value);
double value,
Color backgroundColor,
Animation<Color> valueColor
}) : super(key: key, value: value, backgroundColor: backgroundColor, valueColor: valueColor);
@override
_CircularProgressIndicatorState createState() => new _CircularProgressIndicatorState();
......@@ -303,7 +320,7 @@ class _CircularProgressIndicatorState extends State<CircularProgressIndicator> {
@override
void dispose() {
_controller.stop();
_controller.dispose();
super.dispose();
}
......@@ -320,7 +337,8 @@ class _CircularProgressIndicatorState extends State<CircularProgressIndicator> {
headValue: headValue, // remaining arguments are ignored if config.value is not null
tailValue: tailValue,
stepValue: stepValue,
rotationValue: rotationValue
rotationValue: rotationValue,
strokeWidth: _kCircularProgressIndicatorStrokeWidth
)
)
);
......@@ -345,3 +363,99 @@ class _CircularProgressIndicatorState extends State<CircularProgressIndicator> {
);
}
}
class _RefreshProgressIndicatorPainter extends _CircularProgressIndicatorPainter {
_RefreshProgressIndicatorPainter({
Color valueColor,
double value,
double headValue,
double tailValue,
int stepValue,
double rotationValue,
double strokeWidth
}) : super(
valueColor: valueColor,
value: value,
headValue: headValue,
tailValue: tailValue,
stepValue: stepValue,
rotationValue: rotationValue,
strokeWidth: strokeWidth
);
void paintArrowhead(Canvas canvas, Size size) {
// ux, uy: a unit vector whose direction parallels the base of the arrowhead.
// Note that -ux, uy points in the direction the arrowhead points.
final double arcEnd = arcStart + arcSweep;
final double ux = math.cos(arcEnd);
final double uy = math.sin(arcEnd);
assert(size.width == size.height);
final double radius = size.width / 2.0;
final double arrowHeadRadius = strokeWidth * 1.5;
final double innerRadius = radius - arrowHeadRadius;
final double outerRadius = radius + arrowHeadRadius;
Path path = new Path()
..moveTo(radius + ux * innerRadius, radius + uy * innerRadius)
..lineTo(radius + ux * outerRadius, radius + uy * outerRadius)
..lineTo(radius + ux * radius + -uy * strokeWidth * 2.0, radius + uy * radius + ux * strokeWidth * 2.0)
..close();
Paint paint = new Paint()
..color = valueColor
..strokeWidth = strokeWidth
..style = PaintingStyle.fill;
canvas.drawPath(path, paint);
}
@override
void paint(Canvas canvas, Size size) {
super.paint(canvas, size);
paintArrowhead(canvas, size);
}
}
class RefreshProgressIndicator extends CircularProgressIndicator {
RefreshProgressIndicator({
Key key,
double value,
Color backgroundColor,
Animation<Color> valueColor
}) : super(key: key, value: value, backgroundColor: backgroundColor, valueColor: valueColor);
@override
_RefreshProgressIndicatorState createState() => new _RefreshProgressIndicatorState();
}
class _RefreshProgressIndicatorState extends _CircularProgressIndicatorState {
static double _kIndicatorSize = 40.0;
@override
Widget _buildIndicator(BuildContext context, double headValue, double tailValue, int stepValue, double rotationValue) {
return new Container(
width: _kIndicatorSize,
height: _kIndicatorSize,
margin: const EdgeInsets.all(4.0), // acommodate the shadow
child: new Material(
type: MaterialType.circle,
color: Theme.of(context).canvasColor,
elevation: 2,
child: new Padding(
padding: const EdgeInsets.all(12.0),
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
tailValue: tailValue,
stepValue: stepValue,
rotationValue: rotationValue,
strokeWidth: 2.0
)
)
)
)
);
}
}
// Copyright 2016 The Chromium 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';
import 'package:flutter/widgets.dart';
import 'theme.dart';
import 'progress_indicator.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;
// How far the indicator must be dragged to trigger the refresh callback.
const double _kDragThresholdFactor = 0.75;
// When the scroll ends, the duration of the refresh indicator's animation
// to the RefreshIndicator's displacment.
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 the
/// refresh indicator far enough to demonstrate that they want the app to
/// refresh. The returned Future must complete when the refresh operation
/// is finished.
typedef Future<Null> RefreshCallback();
/// Where the refresh indicator appears: top for over-scrolls at the
/// start of the scrollable, bottom for over-scrolls at the end.
enum RefreshIndicatorLocation { top, bottom }
/// A widget that supports the Material "swipe to refresh" idiom.
///
/// When the child's vertical Scrollable descendant overscrolls, an
/// animated circular progress indicator is faded into view. When the scroll
/// ends, if the indicator has been dragged far enough for it to become
/// completely opaque, the refresh callback is called. The callback is
/// expected to udpate the scrollback and then complete the Future it
/// returns. The refresh indicator disappears after the callback's
/// Future has completed.
///
/// See also:
///
/// * <https://www.google.com/design/spec/patterns/swipe-to-refresh.html>
class RefreshIndicator extends StatefulWidget {
RefreshIndicator({
Key key,
this.scrollableKey,
this.child,
this.displacement: 40.0,
this.refresh
}) : super(key: key) {
assert(child != null);
assert(refresh != null);
}
/// 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;
/// 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
/// displacement may significantly exceed this value.
final double displacement;
/// A function that's called when the user has dragged the refresh indicator
/// far enough to demonstrate that they want the app to refresh. The returned
/// Future must complete when the refresh operation is finished.
final RefreshCallback refresh;
/// The refresh indicator will be stacked on top of this child. The indicator
/// will appear when child's Scrollable descendant is over-scrolled.
final Widget child;
@override
_RefreshIndicatorState createState() => new _RefreshIndicatorState();
}
class _RefreshIndicatorState extends State<RefreshIndicator> {
final AnimationController _sizeController = new AnimationController();
final AnimationController _scaleController = new AnimationController();
Animation<double> _sizeFactor;
Animation<double> _scaleFactor;
Animation<Color> _valueColor;
double _scrollOffset;
double _containerExtent;
double _minScrollOffset;
double _maxScrollOffset;
RefreshIndicatorLocation _location = RefreshIndicatorLocation.top;
@override
void initState() {
super.initState();
_sizeFactor = new Tween<double>(begin: 0.0, end: _kDragSizeFactorLimit).animate(_sizeController);
_scaleFactor = new Tween<double>(begin: 1.0, end: 0.0).animate(_scaleController);
final ThemeData theme = Theme.of(context);
// Fully opaque when we've reached config.displacement.
_valueColor = new ColorTween(
begin: theme.primaryColor.withOpacity(0.0),
end: theme.primaryColor.withOpacity(1.0)
)
.animate(new CurvedAnimation(
parent: _sizeController,
curve: new Interval(0.0, 1.0 / _kDragSizeFactorLimit)
));
}
@override
void dispose() {
_sizeController.dispose();
_scaleController.dispose();
super.dispose();
}
void _updateState(ScrollableState scrollable) {
final Axis axis = scrollable.config.scrollDirection;
if (axis != Axis.vertical || scrollable.scrollBehavior is! ExtentScrollBehavior)
return;
final ExtentScrollBehavior scrollBehavior = scrollable.scrollBehavior;
_scrollOffset = scrollable.scrollOffset;
_containerExtent = scrollBehavior.containerExtent;
_minScrollOffset = scrollBehavior.minScrollOffset;
_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) {
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;
});
}
}
}
_updateState(scrollable);
}
Future<Null> _doOnScrollEnded(ScrollableState scrollable) async {
if (_valueColor.value.alpha == 0xFF) {
await _sizeController.animateTo(1.0 / _kDragSizeFactorLimit, duration: _kIndicatorSnapDuration);
await config.refresh();
}
return _scaleController.animateTo(1.0, duration: _kIndicatorScaleDuration);
}
void _onScrollEnded(ScrollableState scrollable) {
_doOnScrollEnded(scrollable);
}
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;
}
}
return false;
}
@override
Widget build(BuildContext context) {
final bool isAtTop = _location == RefreshIndicatorLocation.top;
return new NotificationListener<ScrollNotification>(
onNotification: _handleScrollNotification,
child: new Stack(
children: <Widget>[
new ClampOverscrolls(
child: config.child,
value: true
),
new Positioned(
top: isAtTop ? 0.0 : null,
bottom: isAtTop ? null : 0.0,
left: 0.0,
right: 0.0,
child: new SizeTransition(
axisAlignment: isAtTop ? 1.0 : 0.0,
sizeFactor: _sizeFactor,
child: new Container(
padding: isAtTop
? new EdgeInsets.only(top: config.displacement)
: new EdgeInsets.only(bottom: config.displacement),
child: new Align(
alignment: isAtTop ? FractionalOffset.bottomCenter : FractionalOffset.topCenter,
child: new ScaleTransition(
scale: _scaleFactor,
child: new RefreshProgressIndicator(
value: null,
valueColor: _valueColor
)
)
)
)
)
)
]
)
);
}
}
......@@ -1242,7 +1242,7 @@ class Stack extends StackRenderObjectWidgetBase {
}
}
/// A [Stack] that shows a single child at once.
/// A [Stack] that shows a single child from a list of children.
class IndexedStack extends StackRenderObjectWidgetBase {
IndexedStack({
Key key,
......
......@@ -183,7 +183,7 @@ class RotationTransition extends AnimatedWidget {
}
}
/// Animates a widget's width or height.
/// Animates its own size and clips and aligns the child.
class SizeTransition extends AnimatedWidget {
SizeTransition({
Key key,
......
// Copyright 2015 The Chromium 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';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart';
import 'package:test/test.dart';
void main() {
bool refreshCalled = false;
Future<Null> refresh() {
refreshCalled = true;
return new Future<Null>.value();
}
test('RefreshIndicator', () {
testWidgets((WidgetTester tester) {
tester.pumpWidget(
new RefreshIndicator(
refresh: refresh,
child: new Block(
children: <String>['A', 'B', 'C', 'D', 'E', 'F'].map((String item) {
return new SizedBox(
height: 200.0,
child: new Text(item)
);
}).toList()
)
)
);
tester.fling(find.text('A'), const Offset(0.0, 200.0), -1000.0);
tester.pump();
tester.pump(const Duration(seconds: 1)); // finish the scroll animation
tester.pump(const Duration(seconds: 1)); // finish the indicator settle animation
tester.pump(const Duration(seconds: 1)); // finish the indicator hide animation
expect(refreshCalled, true);
});
});
}
......@@ -20,8 +20,8 @@ import 'instrumentation.dart';
/// test('MyWidget', () {
/// testWidgets((WidgetTester tester) {
/// tester.pumpWidget(new MyWidget());
/// tester.tap(find.byText('Save'));
/// expect(tester, hasWidget(find.byText('Success')));
/// tester.tap(find.text('Save'));
/// expect(tester, hasWidget(find.text('Success')));
/// });
/// });
void testWidgets(void callback(WidgetTester widgetTester)) {
......@@ -34,7 +34,7 @@ void testWidgets(void callback(WidgetTester widgetTester)) {
///
/// Examples:
///
/// tester.tap(find.byText('Save'));
/// tester.tap(find.text('Save'));
/// tester.widget(find.byType(MyWidget));
/// tester.stateOf(find.byConfig(config));
/// tester.getSize(find.byKey(new ValueKey('save-button')));
......@@ -44,7 +44,7 @@ const CommonFinders find = const CommonFinders._();
///
/// Example:
///
/// expect(tester, hasWidget(find.byText('Save')));
/// expect(tester, hasWidget(find.text('Save')));
Matcher hasWidget(Finder finder) => new _HasWidgetMatcher(finder);
/// Opposite of [hasWidget].
......
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