Refresh indicator (#3354)
This commit is contained in:
parent
9ce995f65e
commit
c4ae13ed22
93
examples/material_gallery/lib/demo/overscroll_demo.dart
Normal file
93
examples/material_gallery/lib/demo/overscroll_demo.dart
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
// 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/list_demo.dart';
|
||||||
import '../demo/modal_bottom_sheet_demo.dart';
|
import '../demo/modal_bottom_sheet_demo.dart';
|
||||||
import '../demo/menu_demo.dart';
|
import '../demo/menu_demo.dart';
|
||||||
|
import '../demo/overscroll_demo.dart';
|
||||||
import '../demo/page_selector_demo.dart';
|
import '../demo/page_selector_demo.dart';
|
||||||
import '../demo/persistent_bottom_sheet_demo.dart';
|
import '../demo/persistent_bottom_sheet_demo.dart';
|
||||||
import '../demo/progress_indicator_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: 'List', builder: () => new ListDemo()),
|
||||||
new GalleryItem(title: 'Menus', builder: () => new MenuDemo()),
|
new GalleryItem(title: 'Menus', builder: () => new MenuDemo()),
|
||||||
new GalleryItem(title: 'Modal bottom sheet', builder: () => new ModalBottomSheetDemo()),
|
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: 'Page selector', builder: () => new PageSelectorDemo()),
|
||||||
new GalleryItem(title: 'Persistent bottom sheet', builder: () => new PersistentBottomSheetDemo()),
|
new GalleryItem(title: 'Persistent bottom sheet', builder: () => new PersistentBottomSheetDemo()),
|
||||||
new GalleryItem(title: 'Progress indicators', builder: () => new ProgressIndicatorDemo()),
|
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/progress_indicator.dart';
|
||||||
export 'src/material/radio.dart';
|
export 'src/material/radio.dart';
|
||||||
export 'src/material/raised_button.dart';
|
export 'src/material/raised_button.dart';
|
||||||
|
export 'src/material/refresh_indicator.dart';
|
||||||
export 'src/material/scaffold.dart';
|
export 'src/material/scaffold.dart';
|
||||||
export 'src/material/scrollbar.dart';
|
export 'src/material/scrollbar.dart';
|
||||||
export 'src/material/shadows.dart';
|
export 'src/material/shadows.dart';
|
||||||
|
@ -174,6 +174,7 @@ class _OverscrollIndicatorState extends State<OverscrollIndicator> {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_hideTimer?.cancel();
|
_hideTimer?.cancel();
|
||||||
_hideTimer = null;
|
_hideTimer = null;
|
||||||
|
_extentAnimation.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,6 +6,7 @@ import 'dart:math' as math;
|
|||||||
|
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
import 'material.dart';
|
||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
|
|
||||||
const double _kLinearProgressIndicatorHeight = 6.0;
|
const double _kLinearProgressIndicatorHeight = 6.0;
|
||||||
@ -31,7 +32,9 @@ abstract class ProgressIndicator extends StatefulWidget {
|
|||||||
/// indicator). See [value] for details.
|
/// indicator). See [value] for details.
|
||||||
ProgressIndicator({
|
ProgressIndicator({
|
||||||
Key key,
|
Key key,
|
||||||
this.value
|
this.value,
|
||||||
|
this.backgroundColor,
|
||||||
|
this.valueColor
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
/// If non-null, the value of this progress indicator with 0.0 corresponding
|
/// 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.
|
/// much actual progress is being made.
|
||||||
final double value;
|
final double value;
|
||||||
|
|
||||||
Color _getBackgroundColor(BuildContext context) => Theme.of(context).backgroundColor;
|
/// The progress indicator's background color. If null, the background color is
|
||||||
Color _getValueColor(BuildContext context) => Theme.of(context).primaryColor;
|
/// 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
|
@override
|
||||||
void debugFillDescription(List<String> description) {
|
void debugFillDescription(List<String> description) {
|
||||||
@ -143,7 +156,7 @@ class _LinearProgressIndicatorState extends State<LinearProgressIndicator> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_controller.stop();
|
_controller.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -185,14 +198,25 @@ class _CircularProgressIndicatorPainter extends CustomPainter {
|
|||||||
static const double _kSweep = _kTwoPI - _kEpsilon;
|
static const double _kSweep = _kTwoPI - _kEpsilon;
|
||||||
static const double _kStartAngle = -math.PI / 2.0;
|
static const double _kStartAngle = -math.PI / 2.0;
|
||||||
|
|
||||||
const _CircularProgressIndicatorPainter({
|
_CircularProgressIndicatorPainter({
|
||||||
this.valueColor,
|
this.valueColor,
|
||||||
this.value,
|
double value,
|
||||||
this.headValue,
|
double headValue,
|
||||||
this.tailValue,
|
double tailValue,
|
||||||
this.stepValue,
|
int stepValue,
|
||||||
this.rotationValue
|
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 Color valueColor;
|
||||||
final double value;
|
final double value;
|
||||||
@ -200,33 +224,23 @@ class _CircularProgressIndicatorPainter extends CustomPainter {
|
|||||||
final double tailValue;
|
final double tailValue;
|
||||||
final int stepValue;
|
final int stepValue;
|
||||||
final double rotationValue;
|
final double rotationValue;
|
||||||
|
final double strokeWidth;
|
||||||
|
final double arcStart;
|
||||||
|
final double arcSweep;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void paint(Canvas canvas, Size size) {
|
void paint(Canvas canvas, Size size) {
|
||||||
Paint paint = new Paint()
|
Paint paint = new Paint()
|
||||||
..color = valueColor
|
..color = valueColor
|
||||||
..strokeWidth = _kCircularProgressIndicatorStrokeWidth
|
..strokeWidth = strokeWidth
|
||||||
..style = PaintingStyle.stroke;
|
..style = PaintingStyle.stroke;
|
||||||
|
|
||||||
if (value != null) {
|
if (value == null) // Indeterminate
|
||||||
// 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
|
|
||||||
paint.strokeCap = StrokeCap.square;
|
paint.strokeCap = StrokeCap.square;
|
||||||
|
|
||||||
double arcSweep = math.max(headValue * 3 / 2 * math.PI - tailValue * 3 / 2 * math.PI, _kEpsilon);
|
Path path = new Path()
|
||||||
Path path = new Path()
|
..arcTo(Point.origin & size, arcStart, arcSweep, false);
|
||||||
..arcTo(Point.origin & size,
|
canvas.drawPath(path, paint);
|
||||||
_kStartAngle + tailValue * 3 / 2 * math.PI + rotationValue * math.PI * 1.7 - stepValue * 0.8 * math.PI,
|
|
||||||
arcSweep,
|
|
||||||
false);
|
|
||||||
canvas.drawPath(path, paint);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -236,7 +250,8 @@ class _CircularProgressIndicatorPainter extends CustomPainter {
|
|||||||
|| oldPainter.headValue != headValue
|
|| oldPainter.headValue != headValue
|
||||||
|| oldPainter.tailValue != tailValue
|
|| oldPainter.tailValue != tailValue
|
||||||
|| oldPainter.stepValue != stepValue
|
|| 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.
|
/// indicator). See [value] for details.
|
||||||
CircularProgressIndicator({
|
CircularProgressIndicator({
|
||||||
Key key,
|
Key key,
|
||||||
double value
|
double value,
|
||||||
}) : super(key: key, value: value);
|
Color backgroundColor,
|
||||||
|
Animation<Color> valueColor
|
||||||
|
}) : super(key: key, value: value, backgroundColor: backgroundColor, valueColor: valueColor);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_CircularProgressIndicatorState createState() => new _CircularProgressIndicatorState();
|
_CircularProgressIndicatorState createState() => new _CircularProgressIndicatorState();
|
||||||
@ -303,7 +320,7 @@ class _CircularProgressIndicatorState extends State<CircularProgressIndicator> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_controller.stop();
|
_controller.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -320,7 +337,8 @@ class _CircularProgressIndicatorState extends State<CircularProgressIndicator> {
|
|||||||
headValue: headValue, // remaining arguments are ignored if config.value is not null
|
headValue: headValue, // remaining arguments are ignored if config.value is not null
|
||||||
tailValue: tailValue,
|
tailValue: tailValue,
|
||||||
stepValue: stepValue,
|
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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
242
packages/flutter/lib/src/material/refresh_indicator.dart
Normal file
242
packages/flutter/lib/src/material/refresh_indicator.dart
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
// 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 {
|
class IndexedStack extends StackRenderObjectWidgetBase {
|
||||||
IndexedStack({
|
IndexedStack({
|
||||||
Key key,
|
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 {
|
class SizeTransition extends AnimatedWidget {
|
||||||
SizeTransition({
|
SizeTransition({
|
||||||
Key key,
|
Key key,
|
||||||
|
44
packages/flutter/test/material/refresh_indicator_test.dart
Normal file
44
packages/flutter/test/material/refresh_indicator_test.dart
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
// 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', () {
|
/// test('MyWidget', () {
|
||||||
/// testWidgets((WidgetTester tester) {
|
/// testWidgets((WidgetTester tester) {
|
||||||
/// tester.pumpWidget(new MyWidget());
|
/// tester.pumpWidget(new MyWidget());
|
||||||
/// tester.tap(find.byText('Save'));
|
/// tester.tap(find.text('Save'));
|
||||||
/// expect(tester, hasWidget(find.byText('Success')));
|
/// expect(tester, hasWidget(find.text('Success')));
|
||||||
/// });
|
/// });
|
||||||
/// });
|
/// });
|
||||||
void testWidgets(void callback(WidgetTester widgetTester)) {
|
void testWidgets(void callback(WidgetTester widgetTester)) {
|
||||||
@ -34,7 +34,7 @@ void testWidgets(void callback(WidgetTester widgetTester)) {
|
|||||||
///
|
///
|
||||||
/// Examples:
|
/// Examples:
|
||||||
///
|
///
|
||||||
/// tester.tap(find.byText('Save'));
|
/// tester.tap(find.text('Save'));
|
||||||
/// tester.widget(find.byType(MyWidget));
|
/// tester.widget(find.byType(MyWidget));
|
||||||
/// tester.stateOf(find.byConfig(config));
|
/// tester.stateOf(find.byConfig(config));
|
||||||
/// tester.getSize(find.byKey(new ValueKey('save-button')));
|
/// tester.getSize(find.byKey(new ValueKey('save-button')));
|
||||||
@ -44,7 +44,7 @@ const CommonFinders find = const CommonFinders._();
|
|||||||
///
|
///
|
||||||
/// Example:
|
/// Example:
|
||||||
///
|
///
|
||||||
/// expect(tester, hasWidget(find.byText('Save')));
|
/// expect(tester, hasWidget(find.text('Save')));
|
||||||
Matcher hasWidget(Finder finder) => new _HasWidgetMatcher(finder);
|
Matcher hasWidget(Finder finder) => new _HasWidgetMatcher(finder);
|
||||||
|
|
||||||
/// Opposite of [hasWidget].
|
/// Opposite of [hasWidget].
|
||||||
|
Loading…
x
Reference in New Issue
Block a user