From c4ae13ed229348be2b81d4a179c541f553d13d6c Mon Sep 17 00:00:00 2001 From: Hans Muller Date: Fri, 15 Apr 2016 18:39:18 -0700 Subject: [PATCH] Refresh indicator (#3354) --- .../lib/demo/overscroll_demo.dart | 93 +++++++ .../material_gallery/lib/gallery/home.dart | 2 + packages/flutter/lib/material.dart | 1 + .../src/material/overscroll_indicator.dart | 1 + .../lib/src/material/progress_indicator.dart | 182 ++++++++++--- .../lib/src/material/refresh_indicator.dart | 242 ++++++++++++++++++ packages/flutter/lib/src/widgets/basic.dart | 2 +- .../flutter/lib/src/widgets/transitions.dart | 2 +- .../test/material/refresh_indicator_test.dart | 44 ++++ .../flutter_test/lib/src/widget_tester.dart | 8 +- 10 files changed, 537 insertions(+), 40 deletions(-) create mode 100644 examples/material_gallery/lib/demo/overscroll_demo.dart create mode 100644 packages/flutter/lib/src/material/refresh_indicator.dart create mode 100644 packages/flutter/test/material/refresh_indicator_test.dart diff --git a/examples/material_gallery/lib/demo/overscroll_demo.dart b/examples/material_gallery/lib/demo/overscroll_demo.dart new file mode 100644 index 0000000000..3dd532e250 --- /dev/null +++ b/examples/material_gallery/lib/demo/overscroll_demo.dart @@ -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 { + static final List _items = [ + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N' + ]; + + IndicatorType _type = IndicatorType.refresh; + + Future refresh() { + Completer completer = new Completer(); + 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: [ + 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 + ); + } + +} diff --git a/examples/material_gallery/lib/gallery/home.dart b/examples/material_gallery/lib/gallery/home.dart index cd4647dc84..ad577efcb4 100644 --- a/examples/material_gallery/lib/gallery/home.dart +++ b/examples/material_gallery/lib/gallery/home.dart @@ -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 { 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()), diff --git a/packages/flutter/lib/material.dart b/packages/flutter/lib/material.dart index 7cc5c5d183..b936002676 100644 --- a/packages/flutter/lib/material.dart +++ b/packages/flutter/lib/material.dart @@ -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'; diff --git a/packages/flutter/lib/src/material/overscroll_indicator.dart b/packages/flutter/lib/src/material/overscroll_indicator.dart index 4507c1ef25..840bd3cfea 100644 --- a/packages/flutter/lib/src/material/overscroll_indicator.dart +++ b/packages/flutter/lib/src/material/overscroll_indicator.dart @@ -174,6 +174,7 @@ class _OverscrollIndicatorState extends State { void dispose() { _hideTimer?.cancel(); _hideTimer = null; + _extentAnimation.dispose(); super.dispose(); } diff --git a/packages/flutter/lib/src/material/progress_indicator.dart b/packages/flutter/lib/src/material/progress_indicator.dart index c5c6d47a38..d0e8553d40 100644 --- a/packages/flutter/lib/src/material/progress_indicator.dart +++ b/packages/flutter/lib/src/material/progress_indicator.dart @@ -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)`. + /// + /// If null, the progress indicator is rendered with the current theme's primaryColor. + final Animation valueColor; + + Color _getBackgroundColor(BuildContext context) => backgroundColor ?? Theme.of(context).backgroundColor; + Color _getValueColor(BuildContext context) => valueColor?.value ?? Theme.of(context).primaryColor; @override void debugFillDescription(List description) { @@ -143,7 +156,7 @@ class _LinearProgressIndicatorState extends State { @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 valueColor + }) : super(key: key, value: value, backgroundColor: backgroundColor, valueColor: valueColor); @override _CircularProgressIndicatorState createState() => new _CircularProgressIndicatorState(); @@ -303,7 +320,7 @@ class _CircularProgressIndicatorState extends State { @override void dispose() { - _controller.stop(); + _controller.dispose(); super.dispose(); } @@ -320,7 +337,8 @@ class _CircularProgressIndicatorState extends State { 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 { ); } } + +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 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 + ) + ) + ) + ) + ); + } +} diff --git a/packages/flutter/lib/src/material/refresh_indicator.dart b/packages/flutter/lib/src/material/refresh_indicator.dart new file mode 100644 index 0000000000..fb6f7fefb9 --- /dev/null +++ b/packages/flutter/lib/src/material/refresh_indicator.dart @@ -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 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: +/// +/// * +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 { + final AnimationController _sizeController = new AnimationController(); + final AnimationController _scaleController = new AnimationController(); + Animation _sizeFactor; + Animation _scaleFactor; + Animation _valueColor; + + double _scrollOffset; + double _containerExtent; + double _minScrollOffset; + double _maxScrollOffset; + RefreshIndicatorLocation _location = RefreshIndicatorLocation.top; + + @override + void initState() { + super.initState(); + _sizeFactor = new Tween(begin: 0.0, end: _kDragSizeFactorLimit).animate(_sizeController); + _scaleFactor = new Tween(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 _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( + onNotification: _handleScrollNotification, + child: new Stack( + children: [ + 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 + ) + ) + ) + ) + ) + ) + ] + ) + ); + } +} diff --git a/packages/flutter/lib/src/widgets/basic.dart b/packages/flutter/lib/src/widgets/basic.dart index 178a182cb9..7f74ffdc54 100644 --- a/packages/flutter/lib/src/widgets/basic.dart +++ b/packages/flutter/lib/src/widgets/basic.dart @@ -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, diff --git a/packages/flutter/lib/src/widgets/transitions.dart b/packages/flutter/lib/src/widgets/transitions.dart index fe4995c015..171e0a77f6 100644 --- a/packages/flutter/lib/src/widgets/transitions.dart +++ b/packages/flutter/lib/src/widgets/transitions.dart @@ -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, diff --git a/packages/flutter/test/material/refresh_indicator_test.dart b/packages/flutter/test/material/refresh_indicator_test.dart new file mode 100644 index 0000000000..90f09887ba --- /dev/null +++ b/packages/flutter/test/material/refresh_indicator_test.dart @@ -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 refresh() { + refreshCalled = true; + return new Future.value(); + } + + test('RefreshIndicator', () { + testWidgets((WidgetTester tester) { + tester.pumpWidget( + new RefreshIndicator( + refresh: refresh, + child: new Block( + children: ['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); + }); + }); +} diff --git a/packages/flutter_test/lib/src/widget_tester.dart b/packages/flutter_test/lib/src/widget_tester.dart index 364c35d7c6..1df73befeb 100644 --- a/packages/flutter_test/lib/src/widget_tester.dart +++ b/packages/flutter_test/lib/src/widget_tester.dart @@ -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].