Unverified Commit feadfd2e authored by xster's avatar xster Committed by GitHub

Cupertino pull to refresh part 1: sliver and a simple indicator widget builder (#15324)

* Gallery scaffolding

* Started RenderSliver

* demo and initial hookup

* Cleaned up demo more and scaffolding basic sliver->widget communication structure.

* works

* states and default indicator building works

* start adding docs

* added an alignment setting optimized the sliver relayout mechanism

* tested a default bottom aligned sized indicator

* Added a bunch of tests

* more fixes and more tests

* Finished the tests

* Add docs

* Add more doc diffing wrt material pull to refresh

* Mention nav bar synergy

* add more asserts

* review 1

* Fix mockito 2 / dart 2 / strong typed tests

* review

* Remove the vscode config

* review
parent fcf09414
...@@ -7,5 +7,6 @@ export 'cupertino_buttons_demo.dart'; ...@@ -7,5 +7,6 @@ export 'cupertino_buttons_demo.dart';
export 'cupertino_dialog_demo.dart'; export 'cupertino_dialog_demo.dart';
export 'cupertino_navigation_demo.dart'; export 'cupertino_navigation_demo.dart';
export 'cupertino_picker_demo.dart'; export 'cupertino_picker_demo.dart';
export 'cupertino_refresh_demo.dart';
export 'cupertino_slider_demo.dart'; export 'cupertino_slider_demo.dart';
export 'cupertino_switch_demo.dart'; export 'cupertino_switch_demo.dart';
// Copyright 2018 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 'dart:math' show Random;
import 'package:flutter/cupertino.dart';
class CupertinoRefreshControlDemo extends StatefulWidget {
static const String routeName = '/cupertino/refresh';
@override
_CupertinoRefreshControlDemoState createState() => new _CupertinoRefreshControlDemoState();
}
class _CupertinoRefreshControlDemoState extends State<CupertinoRefreshControlDemo> {
List<List<String>> randomizedContacts;
@override
void initState() {
super.initState();
repopulateList();
}
void repopulateList() {
final Random random = new Random();
randomizedContacts = new List<List<String>>.generate(
100,
(int index) {
return contacts[random.nextInt(contacts.length)]
// Randomly adds a telephone icon next to the contact or not.
..add(random.nextBool().toString());
}
);
}
@override
Widget build(BuildContext context) {
return new DefaultTextStyle(
style: const TextStyle(
fontFamily: '.SF UI Text',
inherit: false,
fontSize: 17.0,
color: CupertinoColors.black,
),
child: new CupertinoPageScaffold(
child: new DecoratedBox(
decoration: const BoxDecoration(color: const Color(0xFFEFEFF4)),
child: new CustomScrollView(
slivers: <Widget>[
const CupertinoSliverNavigationBar(
largeTitle: const Text('Cupertino Refresh'),
),
new CupertinoRefreshControl(
onRefresh: () {
return new Future<void>.delayed(const Duration(seconds: 2))
..then((_) => setState(() => repopulateList()));
},
),
new SliverSafeArea(
top: false, // Top safe area is consumed by the navigation bar.
sliver: new SliverList(
delegate: new SliverChildBuilderDelegate(
(BuildContext context, int index) {
return new _ListItem(
name: randomizedContacts[index][0],
place: randomizedContacts[index][1],
date: randomizedContacts[index][2],
called: randomizedContacts[index][3] == 'true',
);
},
childCount: 20,
),
),
),
],
),
),
),
);
}
}
List<List<String>> contacts = <List<String>>[
<String>['George Washington', 'Westmoreland County', ' 4/30/1789'],
<String>['John Adams', 'Braintree', ' 3/4/1797'],
<String>['Thomas Jefferson', 'Shadwell', ' 3/4/1801'],
<String>['James Madison', 'Port Conway', ' 3/4/1809'],
<String>['James Monroe', 'Monroe Hall', ' 3/4/1817'],
<String>['Andrew Jackson', 'Waxhaws Region South/North', ' 3/4/1829'],
<String>['John Quincy Adams', 'Braintree', ' 3/4/1825'],
<String>['William Henry Harrison', 'Charles City County', ' 3/4/1841'],
<String>['Martin Van Buren', 'Kinderhook New', ' 3/4/1837'],
<String>['Zachary Taylor', 'Barboursville', ' 3/4/1849'],
<String>['John Tyler', 'Charles City County', ' 4/4/1841'],
<String>['James Buchanan', 'Cove Gap', ' 3/4/1857'],
<String>['James K. Polk', 'Pineville North', ' 3/4/1845'],
<String>['Millard Fillmore', 'Summerhill New', '7/9/1850'],
<String>['Franklin Pierce', 'Hillsborough New', ' 3/4/1853'],
<String>['Andrew Johnson', 'Raleigh North', ' 4/15/1865'],
<String>['Abraham Lincoln', 'Sinking Spring', ' 3/4/1861'],
<String>['Ulysses S. Grant', 'Point Pleasant', ' 3/4/1869'],
<String>['Rutherford B. Hayes', 'Delaware', ' 3/4/1877'],
<String>['Chester A. Arthur', 'Fairfield', ' 9/19/1881'],
<String>['James A. Garfield', 'Moreland Hills', ' 3/4/1881'],
<String>['Benjamin Harrison', 'North Bend', ' 3/4/1889'],
<String>['Grover Cleveland', 'Caldwell New', ' 3/4/1885'],
<String>['William McKinley', 'Niles', ' 3/4/1897'],
<String>['Woodrow Wilson', 'Staunton', ' 3/4/1913'],
<String>['William H. Taft', 'Cincinnati', ' 3/4/1909'],
<String>['Theodore Roosevelt', 'New York City New', ' 9/14/1901'],
<String>['Warren G. Harding', 'Blooming Grove', ' 3/4/1921'],
<String>['Calvin Coolidge', 'Plymouth', '8/2/1923'],
<String>['Herbert Hoover', 'West Branch', ' 3/4/1929'],
<String>['Franklin D. Roosevelt', 'Hyde Park New', ' 3/4/1933'],
<String>['Harry S. Truman', 'Lamar', ' 4/12/1945'],
<String>['Dwight D. Eisenhower', 'Denison', ' 1/20/1953'],
<String>['Lyndon B. Johnson', 'Stonewall', '11/22/1963'],
<String>['Ronald Reagan', 'Tampico', ' 1/20/1981'],
<String>['Richard Nixon', 'Yorba Linda', ' 1/20/1969'],
<String>['Gerald Ford', 'Omaha', 'August 9/1974'],
<String>['John F. Kennedy', 'Brookline', ' 1/20/1961'],
<String>['George H. W. Bush', 'Milton', ' 1/20/1989'],
<String>['Jimmy Carter', 'Plains', ' 1/20/1977'],
<String>['George W. Bush', 'New Haven', ' 1/20, 2001'],
<String>['Bill Clinton', 'Hope', ' 1/20/1993'],
<String>['Barack Obama', 'Honolulu', ' 1/20/2009'],
<String>['Donald J. Trump', 'New York City', ' 1/20/2017'],
];
class _ListItem extends StatelessWidget {
const _ListItem({
this.name,
this.place,
this.date,
this.called,
});
final String name;
final String place;
final String date;
final bool called;
@override
Widget build(BuildContext context) {
return new Container(
color: CupertinoColors.white,
height: 60.0,
padding: const EdgeInsets.only(top: 9.0),
child: new Row(
children: <Widget>[
new Container(
width: 38.0,
child: called
? new Align(
alignment: Alignment.topCenter,
child: new Icon(
CupertinoIcons.phone_solid,
color: CupertinoColors.inactiveGray,
size: 18.0,
),
)
: null,
),
new Expanded(
child: new Container(
decoration: const BoxDecoration(
border: const Border(
bottom: const BorderSide(color: const Color(0xFFBCBBC1), width: 0.0),
),
),
padding: const EdgeInsets.only(left: 1.0, bottom: 9.0, right: 10.0),
child: new Row(
children: <Widget>[
new Expanded(
child: new Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
new Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.w600,
letterSpacing: -0.41,
),
),
new Text(
place,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15.0,
letterSpacing: -0.24,
color: CupertinoColors.inactiveGray,
),
),
],
),
),
new Text(
date,
style: const TextStyle(
color: CupertinoColors.inactiveGray,
fontSize: 15.0,
letterSpacing: -0.41,
),
),
new Padding(
padding: const EdgeInsets.only(left: 9.0),
child: new Icon(
CupertinoIcons.info,
color: CupertinoColors.activeBlue
),
),
],
),
),
),
],
),
);
}
}
...@@ -327,6 +327,13 @@ List<GalleryItem> _buildGalleryItems() { ...@@ -327,6 +327,13 @@ List<GalleryItem> _buildGalleryItems() {
routeName: CupertinoPickerDemo.routeName, routeName: CupertinoPickerDemo.routeName,
buildRoute: (BuildContext context) => new CupertinoPickerDemo(), buildRoute: (BuildContext context) => new CupertinoPickerDemo(),
), ),
new GalleryItem(
title: 'Pull to refresh',
subtitle: 'Cupertino styled refresh controls',
category: 'Cupertino Components',
routeName: CupertinoRefreshControlDemo.routeName,
buildRoute: (BuildContext context) => new CupertinoRefreshControlDemo(),
),
new GalleryItem( new GalleryItem(
title: 'Sliders', title: 'Sliders',
subtitle: 'Cupertino styled sliders', subtitle: 'Cupertino styled sliders',
......
...@@ -103,9 +103,7 @@ Future<Null> smokeDemo(WidgetTester tester, String routeName) async { ...@@ -103,9 +103,7 @@ Future<Null> smokeDemo(WidgetTester tester, String routeName) async {
await tester.pump(const Duration(milliseconds: 400)); await tester.pump(const Duration(milliseconds: 400));
// Go back // Go back
final Finder backButton = find.byTooltip('Back'); await tester.pageBack();
expect(backButton, findsOneWidget);
await tester.tap(backButton);
await tester.pump(); // Start the pop "back" operation. await tester.pump(); // Start the pop "back" operation.
await tester.pump(); // Complete the willPop() Future. await tester.pump(); // Complete the willPop() Future.
await tester.pump(const Duration(milliseconds: 400)); // Wait until it has finished. await tester.pump(const Duration(milliseconds: 400)); // Wait until it has finished.
......
...@@ -72,6 +72,7 @@ const List<Demo> demos = const <Demo>[ ...@@ -72,6 +72,7 @@ const List<Demo> demos = const <Demo>[
const Demo('Dialogs'), const Demo('Dialogs'),
const Demo('Navigation'), const Demo('Navigation'),
const Demo('Pickers'), const Demo('Pickers'),
const Demo('Pull to refresh'),
const Demo('Sliders'), const Demo('Sliders'),
const Demo('Switches'), const Demo('Switches'),
......
{
"version": "0.1.0",
"command": "flutter",
"args": [],
"showOutput": "always",
"echoCommand": true,
"tasks": [
{
// Assign key binding to workbench.action.tasks.test to quickly run
// the currently open test.
"taskName": "test",
"isTestCommand": true,
"isShellCommand": true,
"args": ["${file}"]
}
]
}
...@@ -16,6 +16,7 @@ export 'src/cupertino/icons.dart'; ...@@ -16,6 +16,7 @@ export 'src/cupertino/icons.dart';
export 'src/cupertino/nav_bar.dart'; export 'src/cupertino/nav_bar.dart';
export 'src/cupertino/page_scaffold.dart'; export 'src/cupertino/page_scaffold.dart';
export 'src/cupertino/picker.dart'; export 'src/cupertino/picker.dart';
export 'src/cupertino/refresh.dart';
export 'src/cupertino/route.dart'; export 'src/cupertino/route.dart';
export 'src/cupertino/scrollbar.dart'; export 'src/cupertino/scrollbar.dart';
export 'src/cupertino/slider.dart'; export 'src/cupertino/slider.dart';
......
...@@ -8,6 +8,8 @@ import 'package:flutter/widgets.dart'; ...@@ -8,6 +8,8 @@ import 'package:flutter/widgets.dart';
import 'colors.dart'; import 'colors.dart';
const double _kDefaultIndicatorRadius = 10.0;
/// An iOS-style activity indicator. /// An iOS-style activity indicator.
/// ///
/// See also: /// See also:
...@@ -18,7 +20,10 @@ class CupertinoActivityIndicator extends StatefulWidget { ...@@ -18,7 +20,10 @@ class CupertinoActivityIndicator extends StatefulWidget {
const CupertinoActivityIndicator({ const CupertinoActivityIndicator({
Key key, Key key,
this.animating: true, this.animating: true,
this.radius: _kDefaultIndicatorRadius,
}) : assert(animating != null), }) : assert(animating != null),
assert(radius != null),
assert(radius > 0),
super(key: key); super(key: key);
/// Whether the activity indicator is running its animation. /// Whether the activity indicator is running its animation.
...@@ -26,12 +31,15 @@ class CupertinoActivityIndicator extends StatefulWidget { ...@@ -26,12 +31,15 @@ class CupertinoActivityIndicator extends StatefulWidget {
/// Defaults to true. /// Defaults to true.
final bool animating; final bool animating;
/// Radius of the spinner widget.
///
/// Defaults to 10px. Must be positive and cannot be null.
final double radius;
@override @override
_CupertinoActivityIndicatorState createState() => new _CupertinoActivityIndicatorState(); _CupertinoActivityIndicatorState createState() => new _CupertinoActivityIndicatorState();
} }
const double _kIndicatorWidth = 20.0;
const double _kIndicatorHeight = 20.0;
class _CupertinoActivityIndicatorState extends State<CupertinoActivityIndicator> with SingleTickerProviderStateMixin { class _CupertinoActivityIndicatorState extends State<CupertinoActivityIndicator> with SingleTickerProviderStateMixin {
AnimationController _controller; AnimationController _controller;
...@@ -68,11 +76,12 @@ class _CupertinoActivityIndicatorState extends State<CupertinoActivityIndicator> ...@@ -68,11 +76,12 @@ class _CupertinoActivityIndicatorState extends State<CupertinoActivityIndicator>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return new SizedBox( return new SizedBox(
width: _kIndicatorWidth, height: widget.radius * 2,
height: _kIndicatorHeight, width: widget.radius * 2,
child: new CustomPaint( child: new CustomPaint(
painter: new _CupertinoActivityIndicatorPainter( painter: new _CupertinoActivityIndicatorPainter(
position: _controller, position: _controller,
radius: widget.radius,
), ),
), ),
); );
...@@ -84,14 +93,23 @@ const int _kTickCount = 12; ...@@ -84,14 +93,23 @@ const int _kTickCount = 12;
const int _kHalfTickCount = _kTickCount ~/ 2; const int _kHalfTickCount = _kTickCount ~/ 2;
const Color _kTickColor = CupertinoColors.lightBackgroundGray; const Color _kTickColor = CupertinoColors.lightBackgroundGray;
const Color _kActiveTickColor = const Color(0xFF9D9D9D); const Color _kActiveTickColor = const Color(0xFF9D9D9D);
final RRect _kTickFundamentalRRect = new RRect.fromLTRBXY(-10.0, 1.0, -5.0, -1.0, 1.0, 1.0);
class _CupertinoActivityIndicatorPainter extends CustomPainter { class _CupertinoActivityIndicatorPainter extends CustomPainter {
_CupertinoActivityIndicatorPainter({ _CupertinoActivityIndicatorPainter({
this.position, this.position,
}) : super(repaint: position); double radius,
}) : tickFundamentalRRect = new RRect.fromLTRBXY(
-radius,
1.0 * radius / _kDefaultIndicatorRadius,
-radius / 2.0,
-1.0 * radius / _kDefaultIndicatorRadius,
1.0,
1.0
),
super(repaint: position);
final Animation<double> position; final Animation<double> position;
final RRect tickFundamentalRRect;
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
...@@ -105,7 +123,7 @@ class _CupertinoActivityIndicatorPainter extends CustomPainter { ...@@ -105,7 +123,7 @@ class _CupertinoActivityIndicatorPainter extends CustomPainter {
for (int i = 0; i < _kTickCount; ++ i) { for (int i = 0; i < _kTickCount; ++ i) {
final double t = (((i + activeTick) % _kTickCount) / _kHalfTickCount).clamp(0.0, 1.0); final double t = (((i + activeTick) % _kTickCount) / _kHalfTickCount).clamp(0.0, 1.0);
paint.color = Color.lerp(_kActiveTickColor, _kTickColor, t); paint.color = Color.lerp(_kActiveTickColor, _kTickColor, t);
canvas.drawRRect(_kTickFundamentalRRect, paint); canvas.drawRRect(tickFundamentalRRect, paint);
canvas.rotate(-_kTwoPI / _kTickCount); canvas.rotate(-_kTwoPI / _kTickCount);
} }
......
...@@ -90,4 +90,13 @@ class CupertinoIcons { ...@@ -90,4 +90,13 @@ class CupertinoIcons {
/// Three solid dots. /// Three solid dots.
static const IconData ellipsis = const IconData(0xf46a, fontFamily: iconFont, fontPackage: iconFontPackage); static const IconData ellipsis = const IconData(0xf46a, fontFamily: iconFont, fontPackage: iconFontPackage);
/// A phone handset outline.
static const IconData phone = const IconData(0xf4b8, fontFamily: iconFont, fontPackage: iconFontPackage);
/// A phone handset.
static const IconData phone_solid = const IconData(0xf4b9, fontFamily: iconFont, fontPackage: iconFontPackage);
/// A solid down arrow.
static const IconData down_arrow = const IconData(0xf35d, fontFamily: iconFont, fontPackage: iconFontPackage);
} }
// Copyright 2018 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 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'activity_indicator.dart';
import 'colors.dart';
import 'icons.dart';
class _CupertinoRefreshSliver extends SingleChildRenderObjectWidget {
const _CupertinoRefreshSliver({
this.refreshIndicatorLayoutExtent: 0.0,
this.hasLayoutExtent: false,
Widget child,
}) : assert(refreshIndicatorLayoutExtent != null),
assert(refreshIndicatorLayoutExtent >= 0.0),
assert(hasLayoutExtent != null),
super(child: child);
// The amount of space the indicator should occupy in the sliver in a
// resting state when in the refreshing mode.
final double refreshIndicatorLayoutExtent;
// _RenderCupertinoRefreshSliver will paint the child in the available
// space either way but this instructs the _RenderCupertinoRefreshSliver
// on whether to also occupy any layoutExtent space or not.
final bool hasLayoutExtent;
@override
_RenderCupertinoRefreshSliver createRenderObject(BuildContext context) {
return new _RenderCupertinoRefreshSliver(
refreshIndicatorExtent: refreshIndicatorLayoutExtent,
hasLayoutExtent: hasLayoutExtent,
);
}
@override
void updateRenderObject(BuildContext context, covariant _RenderCupertinoRefreshSliver renderObject) {
renderObject
..refreshIndicatorLayoutExtent = refreshIndicatorLayoutExtent
..hasLayoutExtent = hasLayoutExtent;
}
}
// RenderSliver object that gives its child RenderBox object space to paint
// in the overscrolled gap and may or may not hold that overscrolled gap
// around the RenderBox depending on whether [layoutExtent] is set.
//
// The [layoutExtentOffsetCompensation] field keeps internal accounting to
// prevent scroll position jumps as the [layoutExtent] is set and unset.
class _RenderCupertinoRefreshSliver
extends RenderSliver
with RenderObjectWithChildMixin<RenderBox> {
_RenderCupertinoRefreshSliver({
@required double refreshIndicatorExtent,
@required bool hasLayoutExtent,
RenderBox child,
}) : assert(refreshIndicatorExtent != null),
assert(refreshIndicatorExtent >= 0.0),
assert(hasLayoutExtent != null),
_refreshIndicatorExtent = refreshIndicatorExtent,
_hasLayoutExtent = hasLayoutExtent {
this.child = child;
}
// The amount of layout space the indicator should occupy in the sliver in a
// resting state when in the refreshing mode.
double get refreshIndicatorLayoutExtent => _refreshIndicatorExtent;
double _refreshIndicatorExtent;
set refreshIndicatorLayoutExtent(double value) {
assert(value != null);
assert(value >= 0.0);
if (value == _refreshIndicatorExtent)
return;
_refreshIndicatorExtent = value;
markNeedsLayout();
}
// The child box will be laid out and painted in the available space either
// way but this determines whether to also occupy any layoutExtent space or
// not.
bool get hasLayoutExtent => _hasLayoutExtent;
bool _hasLayoutExtent;
set hasLayoutExtent(bool value) {
assert(value != null);
if (value == _hasLayoutExtent)
return;
_hasLayoutExtent = value;
markNeedsLayout();
}
// This keeps track of the previously applied scroll offsets to the scrollable
// so that when [refreshIndicatorLayoutExtent] or [hasLayoutExtent] changes,
// the appropriate delta can be applied to keep everything in the same place
// visually.
double layoutExtentOffsetCompensation = 0.0;
@override
void performLayout() {
// Only pulling to refresh from the top is currently supported.
assert(constraints.axisDirection == AxisDirection.down);
assert(constraints.growthDirection == GrowthDirection.forward);
// The new layout extent this sliver should now have.
final double layoutExtent =
(_hasLayoutExtent ? 1.0 : 0.0) * _refreshIndicatorExtent;
// If the new layoutExtent instructive changed, the SliverGeometry's
// layoutExtent will take that value (on the next performLayout run). Shift
// the scroll offset first so it doesn't make the scroll position suddenly jump.
if (layoutExtent != layoutExtentOffsetCompensation) {
geometry = new SliverGeometry(
scrollOffsetCorrection: layoutExtent - layoutExtentOffsetCompensation,
);
layoutExtentOffsetCompensation = layoutExtent;
// Return so we don't have to do temporary accounting and adjusting the
// child's constraints accounting for this one transient frame using a
// combination of existing layout extent, new layout extent change and
// the overlap.
return;
}
final bool active = constraints.overlap < 0.0 || layoutExtent > 0.0;
final double overscrolledExtent =
constraints.overlap < 0.0 ? constraints.overlap.abs() : 0.0;
// Layout the child giving it the space of the currently dragged overscroll
// which may or may not include a sliver layout extent space that it will
// keep after the user lets go during the refresh process.
child.layout(
constraints.asBoxConstraints(
maxExtent: layoutExtent
// Plus only the overscrolled portion immediately preceding this
// sliver.
+ overscrolledExtent,
),
parentUsesSize: true,
);
if (active) {
geometry = new SliverGeometry(
scrollExtent: layoutExtent,
paintOrigin: -overscrolledExtent - constraints.scrollOffset,
paintExtent: max(
// Check child size (which can come from overscroll) because
// layoutExtent may be zero. Check layoutExtent also since even
// with a layoutExtent, the indicator builder may decide to not
// build anything.
max(child.size.height, layoutExtent) - constraints.scrollOffset,
0.0,
),
maxPaintExtent: max(
max(child.size.height, layoutExtent) - constraints.scrollOffset,
0.0,
),
layoutExtent: max(layoutExtent - constraints.scrollOffset, 0.0),
);
} else {
// If we never started overscrolling, return no geometry.
geometry = SliverGeometry.zero;
}
}
@override
void paint(PaintingContext paintContext, Offset offset) {
if (constraints.overlap < 0.0 ||
constraints.scrollOffset + child.size.height > 0) {
paintContext.paintChild(child, offset);
}
}
// Nothing special done here because this sliver always paints its child
// exactly between paintOrigin and paintExtent.
@override
void applyPaintTransform(RenderObject child, Matrix4 transform) {}
}
/// The current state of the refresh control.
///
/// Passed into the [RefreshControlIndicatorBuilder] builder function so
/// users can show different UI in different modes.
enum RefreshIndicatorMode {
/// Initial state, when not being overscrolled into, or after the overscroll
/// is canceled or after done and the sliver retracted away.
inactive,
/// While being overscrolled but not far enough yet to trigger the refresh.
drag,
/// Dragged far enough that the onRefresh callback will run and the dragged
/// displacement is not yet at the final refresh resting state.
armed,
/// While the onRefresh task is running.
refresh,
/// While the indicator is animating away after refreshing.
done,
}
/// Signature for a builder that can create a different widget to show in the
/// refresh indicator space depending on the current state of the refresh
/// control and the space available.
///
/// The `refreshTriggerPullDistance` and `refreshIndicatorExtent` parameters are
/// the same values passed into the [CupertinoRefreshControl].
///
/// The `pulledExtent` parameter is the currently available space either from
/// overscrolling or as held by the sliver during refresh.
typedef Widget RefreshControlIndicatorBuilder(
BuildContext context,
RefreshIndicatorMode refreshState,
double pulledExtent,
double refreshTriggerPullDistance,
double refreshIndicatorExtent,
);
/// A callback function that's invoked when the [CupertinoRefreshControl] is
/// pulled a `refreshTriggerPullDistance`. Must return a [Future]. Upon
/// completion of the [Future], the [CupertinoRefreshControl] enters the
/// [RefreshIndicatorMode.done] state and will start to go away.
typedef Future<void> RefreshCallback();
/// A sliver widget implementing the iOS-style pull to refresh content control.
///
/// When inserted as the first sliver in a scroll view or behind other slivers
/// that still lets the scrollable overscroll in front of this sliver (such as
/// the [CupertinoSliverNavigationBar], this widget will:
///
/// * Let the user draw inside the overscrolled area via the passed in [builder].
/// * Trigger the provided [onRefresh] function when overscrolled far enough to
/// pass [refreshTriggerPullDistance].
/// * Continue to hold [refreshIndicatorExtent] amount of space for the [builder]
/// to keep drawing inside of as the [Future] returned by [onRefresh] processes.
/// * Scroll away once the [onRefresh] [Future] completes.
///
/// The [builder] function will be informed of the current [RefreshIndicatorMode]
/// when invoking it, except in the [RefreshIndicatorMode.inactive] state when
/// no space is available and nothing needs to be built. The [builder] function
/// will otherwise be continuously invoked as the amount of space available
/// changes from overscroll, as the sliver scrolls away after the [onRefresh]
/// task is done, etc.
///
/// Only one refresh can be triggered until the previous refresh has completed
/// and the indicator sliver has retracted at least 90% of the way back.
///
/// Can only be used in downward scrolling vertical lists.
///
/// See also:
///
/// * [CustomScrollView], a typical sliver holding scroll view this control
/// should go into.
/// * <https://developer.apple.com/ios/human-interface-guidelines/controls/refresh-content-controls/>
/// * [RefreshIndicator], a Material Design version of the pull-to-refresh
/// paradigm. This widget works differently than [RefreshIndicator] because
/// instead of being an overlay on top of the scrollable, the
/// [CupertinoRefreshControl] is part of the scrollable and actively occupies
/// scrollable space.
class CupertinoRefreshControl extends StatefulWidget {
/// Create a new [CupertinoRefreshControl] for inserting into a list of slivers.
///
/// [refreshTriggerPullDistance], [refreshIndicatorExtent] both have reasonable
/// defaults and cannot be null.
///
/// [builder] has a default indicator builder but can be null, in which case
/// no indicator UI will be shown but the [onRefresh] will still be invoked.
///
/// [onRefresh] will be called when pulled far enough to trigger a refresh.
const CupertinoRefreshControl({
this.refreshTriggerPullDistance: _kDefaultRefreshTriggerPullDistance,
this.refreshIndicatorExtent: _kDefaultRefreshIndicatorExtent,
this.builder: buildSimpleRefreshIndicator,
this.onRefresh,
}) : assert(refreshTriggerPullDistance != null),
assert(refreshTriggerPullDistance > 0.0),
assert(refreshIndicatorExtent != null),
assert(refreshIndicatorExtent >= 0.0),
assert(
refreshTriggerPullDistance >= refreshIndicatorExtent,
'The refresh indicator cannot take more space in its final state '
'than the amount initially created by overscrolling.'
);
/// The amount of overscroll the scrollable must be dragged to trigger a reload.
///
/// Must not be null, must be larger than 0.0 and larger than [refreshIndicatorExtent].
///
/// When overscrolled past this distance, [onRefresh] will be called if not
/// null and the [builder] will build in the [RefreshIndicatorMode.armed] state.
final double refreshTriggerPullDistance;
/// The amount of space the refresh indicator sliver will keep holding while
/// [onRefresh]'s [Future] is still running.
///
/// Must not be null and must be positive, but can be 0.0, in which case the
/// sliver will start retracting back to 0.0 as soon as the refresh is started.
///
/// Must be smaller than [refreshTriggerPullDistance], since the sliver
/// shouldn't grow further after triggering the refresh.
final double refreshIndicatorExtent;
/// A builder that's called as this sliver's size changes, and as the state
/// changes.
///
/// A default simple Twitter-style pull-to-refresh indicator is provided if
/// not specified.
///
/// Can be set to null, in which case nothing will be drawn in the overscrolled
/// space.
///
/// Will not be called when the available space is zero such as before any
/// overscroll.
final RefreshControlIndicatorBuilder builder;
/// Callback invoked when pulled by [refreshTriggerPullDistance].
///
/// If provided, must return a [Future] which will keep the indicator in the
/// [RefreshIndicatorMode.refresh] state until the [Future] completes.
///
/// Can be null, in which case a single frame of [RefreshIndicatorMode.armed]
/// state will be drawn before going immediately to the [RefreshIndicatorMode.done]
/// where the sliver will start retracting.
final RefreshCallback onRefresh;
static const double _kDefaultRefreshTriggerPullDistance = 100.0;
static const double _kDefaultRefreshIndicatorExtent = 60.0;
/// Retrieve the current state of the CupertinoRefreshControl. The same as the
/// state that gets passed into the [builder] function. Used for testing.
@visibleForTesting
static RefreshIndicatorMode state(BuildContext context) {
final _CupertinoRefreshControlState state
= context.ancestorStateOfType(const TypeMatcher<_CupertinoRefreshControlState>());
return state.refreshState;
}
/// Builds a simple refresh indicator that fades in a bottom aligned down
/// arrow before the refresh is triggered, a [CupertinoActivityIndicator]
/// during the refresh and fades the [CupertinoActivityIndicator] away when
/// the refresh is done.
static Widget buildSimpleRefreshIndicator(BuildContext context,
RefreshIndicatorMode refreshState,
double pulledExtent,
double refreshTriggerPullDistance,
double refreshIndicatorExtent,
) {
const Curve opacityCurve = const Interval(0.4, 0.8, curve: Curves.easeInOut);
return new Align(
alignment: Alignment.bottomCenter,
child: new Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: refreshState == RefreshIndicatorMode.drag
? new Opacity(
opacity: opacityCurve.transform(
min(pulledExtent / refreshTriggerPullDistance, 1.0)
),
child: const Icon(
CupertinoIcons.down_arrow,
color: CupertinoColors.inactiveGray,
size: 36.0,
),
)
: new Opacity(
opacity: opacityCurve.transform(
min(pulledExtent / refreshIndicatorExtent, 1.0)
),
child: const CupertinoActivityIndicator(radius: 14.0),
),
),
);
}
@override
_CupertinoRefreshControlState createState() => new _CupertinoRefreshControlState();
}
class _CupertinoRefreshControlState extends State<CupertinoRefreshControl> {
/// Reset the state from done to inactive when only this fraction of the
/// original `refreshTriggerPullDistance` is left.
static const double _kInactiveResetOverscrollFraction = 0.1;
RefreshIndicatorMode refreshState;
// [Future] returned by the widget's `onRefresh`.
Future<void> refreshTask;
// The amount of space available from the inner indicator box's perspective.
//
// The value is the sum of the sliver's layout extent and the overscroll
// (which partially gets transfered into the layout extent when the refresh
// triggers).
//
// The value of lastIndicatorExtent doesn't change when the sliver scrolls
// away without retracting; it is independent from the sliver's scrollOffset.
double lastIndicatorExtent = 0.0;
bool hasSliverLayoutExtent = false;
@override
void initState() {
super.initState();
refreshState = RefreshIndicatorMode.inactive;
}
// A state machine transition calculator. Multiple states can be transitioned
// through per single call.
RefreshIndicatorMode transitionNextState() {
RefreshIndicatorMode nextState;
void goToDone() {
nextState = RefreshIndicatorMode.done;
// Either schedule the RenderSliver to re-layout on the next frame
// when not currently in a frame or schedule it on the next frame.
if (SchedulerBinding.instance.schedulerPhase == SchedulerPhase.idle) {
setState(() => hasSliverLayoutExtent = false);
} else {
SchedulerBinding.instance.addPostFrameCallback((Duration timestamp){
setState(() => hasSliverLayoutExtent = false);
});
}
}
switch (refreshState) {
case RefreshIndicatorMode.inactive:
if (lastIndicatorExtent <= 0) {
return RefreshIndicatorMode.inactive;
} else {
nextState = RefreshIndicatorMode.drag;
}
continue drag;
drag:
case RefreshIndicatorMode.drag:
if (lastIndicatorExtent == 0) {
return RefreshIndicatorMode.inactive;
} else if (lastIndicatorExtent < widget.refreshTriggerPullDistance) {
return RefreshIndicatorMode.drag;
} else {
if (widget.onRefresh != null) {
HapticFeedback.mediumImpact();
// Call onRefresh after this frame finished since the function is
// user supplied and we're always here in the middle of the sliver's
// performLayout.
SchedulerBinding.instance.addPostFrameCallback((Duration timestamp) {
refreshTask = widget.onRefresh()..then((_) {
if (mounted) {
setState(() => refreshTask = null);
// Trigger one more transition because by this time, BoxConstraint's
// maxHeight might already be resting at 0 in which case no
// calls to [transitionNextState] will occur anymore and the
// state may be stuck in a non-inactive state.
refreshState = transitionNextState();
}
});
setState(() => hasSliverLayoutExtent = true);
});
}
return RefreshIndicatorMode.armed;
}
// Don't continue here. We can never possibly call onRefresh and
// progress to the next state in one [computeNextState] call.
break;
case RefreshIndicatorMode.armed:
if (refreshState == RefreshIndicatorMode.armed && refreshTask == null) {
goToDone();
continue done;
}
if (lastIndicatorExtent > widget.refreshIndicatorExtent) {
return RefreshIndicatorMode.armed;
} else {
nextState = RefreshIndicatorMode.refresh;
}
continue refresh;
refresh:
case RefreshIndicatorMode.refresh:
if (refreshTask != null) {
return RefreshIndicatorMode.refresh;
} else {
goToDone();
}
continue done;
done:
case RefreshIndicatorMode.done:
// Let the transition back to inactive trigger before strictly going
// to 0.0 since the last bit of the animation can take some time and
// can feel sluggish if not going all the way back to 0.0 prevented
// a subsequent pull-to-refresh from starting.
if (lastIndicatorExtent >
widget.refreshTriggerPullDistance * _kInactiveResetOverscrollFraction) {
return RefreshIndicatorMode.done;
} else {
nextState = RefreshIndicatorMode.inactive;
}
break;
}
return nextState;
}
@override
Widget build(BuildContext context) {
return new _CupertinoRefreshSliver(
refreshIndicatorLayoutExtent: widget.refreshIndicatorExtent,
hasLayoutExtent: hasSliverLayoutExtent,
// A LayoutBuilder lets the sliver's layout changes be fed back out to
// its owner to trigger state changes.
child: new LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
lastIndicatorExtent = constraints.maxHeight;
refreshState = transitionNextState();
if (widget.builder != null && refreshState != RefreshIndicatorMode.inactive) {
return widget.builder(
context,
refreshState,
lastIndicatorExtent,
widget.refreshTriggerPullDistance,
widget.refreshIndicatorExtent,
);
} else {
return new Container();
}
},
)
);
}
}
// Copyright 2018 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/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
void main() {
MockHelper mockHelper;
/// Completer that holds the future given to the CupertinoRefreshControl.
Completer<void> refreshCompleter;
/// The widget that the indicator builder given to the CupertinoRefreshControl
/// returns.
Widget refreshIndicator;
setUp(() {
mockHelper = new MockHelper();
refreshCompleter = new Completer<void>.sync();
refreshIndicator = new Container();
when(mockHelper.builder).thenReturn(
(
BuildContext context,
RefreshIndicatorMode refreshState,
double pulledExtent,
double refreshTriggerPullDistance,
double refreshIndicatorExtent,
) {
if (refreshState == RefreshIndicatorMode.inactive) {
throw new TestFailure(
'RefreshControlIndicatorBuilder should never be called with the '
"inactive state because there's nothing to build in that case"
);
}
if (pulledExtent < 0.0) {
throw new TestFailure('The pulledExtent should never be less than 0.0');
}
if (refreshTriggerPullDistance < 0.0) {
throw new TestFailure('The refreshTriggerPullDistance should never be less than 0.0');
}
if (refreshIndicatorExtent < 0.0) {
throw new TestFailure('The refreshIndicatorExtent should never be less than 0.0');
}
// This closure is now shadowing the mock implementation which logs.
// Pass the call to the mock to log.
mockHelper.builder(
context,
refreshState,
pulledExtent,
refreshTriggerPullDistance,
refreshIndicatorExtent,
);
return refreshIndicator;
},
);
// Make the function reference itself concrete.
when(mockHelper.refreshTask).thenReturn(() => mockHelper.refreshTask());
when(mockHelper.refreshTask()).thenReturn(refreshCompleter.future);
});
SliverList buildAListOfStuff() {
return new SliverList(
delegate: new SliverChildBuilderDelegate(
(BuildContext context, int index) {
return new Container(
height: 200.0,
child: new Center(child: new Text(index.toString())),
);
},
childCount: 10,
),
);
}
group('UI tests', () {
testWidgets("doesn't invoke anything without user interaction", (WidgetTester tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
await tester.pumpWidget(
new Directionality(
textDirection: TextDirection.ltr,
child: new CustomScrollView(
slivers: <Widget>[
new CupertinoRefreshControl(
builder: mockHelper.builder,
),
buildAListOfStuff(),
],
),
),
);
// The function is referenced once while passing into CupertinoRefreshControl
// but never called.
verify(mockHelper.builder);
verifyNoMoreInteractions(mockHelper);
expect(
tester.getTopLeft(find.widgetWithText(Container, '0')),
const Offset(0.0, 0.0),
);
debugDefaultTargetPlatformOverride = null;
});
testWidgets('calls the indicator builder when starting to overscroll', (WidgetTester tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
await tester.pumpWidget(
new Directionality(
textDirection: TextDirection.ltr,
child: new CustomScrollView(
slivers: <Widget>[
new CupertinoRefreshControl(
builder: mockHelper.builder,
),
buildAListOfStuff(),
],
),
),
);
// Drag down but not enough to trigger the refresh.
await tester.drag(find.text('0'), const Offset(0.0, 50.0));
await tester.pump();
// The function is referenced once while passing into CupertinoRefreshControl
// but never called.
verify(mockHelper.builder);
verify(mockHelper.builder(
typed(any),
RefreshIndicatorMode.drag,
50.0,
100.0, // Default value.
60.0, // Default value.
));
verifyNoMoreInteractions(mockHelper);
expect(
tester.getTopLeft(find.widgetWithText(Container, '0')),
const Offset(0.0, 50.0),
);
debugDefaultTargetPlatformOverride = null;
});
testWidgets(
"don't call the builder if overscroll doesn't move slivers like on Android",
(WidgetTester tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
await tester.pumpWidget(
new Directionality(
textDirection: TextDirection.ltr,
child: new CustomScrollView(
slivers: <Widget>[
new CupertinoRefreshControl(
builder: mockHelper.builder,
),
buildAListOfStuff(),
],
),
),
);
// Drag down but not enough to trigger the refresh.
await tester.drag(find.text('0'), const Offset(0.0, 50.0));
await tester.pump();
// The function is referenced once while passing into CupertinoRefreshControl
// but never called.
verify(mockHelper.builder);
verifyNoMoreInteractions(mockHelper);
expect(
tester.getTopLeft(find.widgetWithText(Container, '0')),
const Offset(0.0, 0.0),
);
debugDefaultTargetPlatformOverride = null;
},
);
testWidgets('let the builder update as cancelled drag scrolls away', (WidgetTester tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
await tester.pumpWidget(
new Directionality(
textDirection: TextDirection.ltr,
child: new CustomScrollView(
slivers: <Widget>[
new CupertinoRefreshControl(
builder: mockHelper.builder,
),
buildAListOfStuff(),
],
),
),
);
// Drag down but not enough to trigger the refresh.
await tester.drag(find.text('0'), const Offset(0.0, 50.0));
await tester.pump();
await tester.pump(const Duration(milliseconds: 20));
await tester.pump(const Duration(milliseconds: 20));
await tester.pump(const Duration(seconds: 3));
verifyInOrder(<void>[
mockHelper.builder,
mockHelper.builder(
typed(any),
RefreshIndicatorMode.drag,
50.0,
100.0, // Default value.
60.0, // Default value.
),
mockHelper.builder(
typed(any),
RefreshIndicatorMode.drag,
typed(argThat(moreOrLessEquals(48.36801747187993))),
100.0, // Default value.
60.0, // Default value.
),
mockHelper.builder(
typed(any),
RefreshIndicatorMode.drag,
typed(argThat(moreOrLessEquals(44.63031931875867))),
100.0, // Default value.
60.0, // Default value.
),
// The builder isn't called again when the sliver completely goes away.
]);
verifyNoMoreInteractions(mockHelper);
expect(
tester.getTopLeft(find.widgetWithText(Container, '0')),
const Offset(0.0, 0.0),
);
debugDefaultTargetPlatformOverride = null;
});
testWidgets('drag past threshold triggers refresh task', (WidgetTester tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
final List<MethodCall> platformCallLog = <MethodCall>[];
SystemChannels.platform.setMockMethodCallHandler((MethodCall methodCall) async {
platformCallLog.add(methodCall);
});
await tester.pumpWidget(
new Directionality(
textDirection: TextDirection.ltr,
child: new CustomScrollView(
slivers: <Widget>[
new CupertinoRefreshControl(
builder: mockHelper.builder,
onRefresh: mockHelper.refreshTask,
),
buildAListOfStuff(),
],
),
),
);
final TestGesture gesture = await tester.startGesture(const Offset(0.0, 0.0));
await gesture.moveBy(const Offset(0.0, 99.0));
await tester.pump();
await gesture.moveBy(const Offset(0.0, -30.0));
await tester.pump();
await gesture.moveBy(const Offset(0.0, 50.0));
await tester.pump();
verifyInOrder(<void>[
mockHelper.builder,
mockHelper.refreshTask,
mockHelper.builder(
typed(any),
RefreshIndicatorMode.drag,
99.0,
100.0, // Default value.
60.0, // Default value.
),
mockHelper.builder(
typed(any),
RefreshIndicatorMode.drag,
typed(argThat(moreOrLessEquals(86.78169))),
100.0, // Default value.
60.0, // Default value.
),
mockHelper.builder(
typed(any),
RefreshIndicatorMode.armed,
typed(argThat(moreOrLessEquals(105.80452021305739))),
100.0, // Default value.
60.0, // Default value.
),
// The refresh callback is triggered after the frame.
mockHelper.refreshTask(),
]);
verifyNoMoreInteractions(mockHelper);
expect(
platformCallLog.last,
isMethodCall('HapticFeedback.vibrate', arguments: 'HapticFeedbackType.mediumImpact'),
);
debugDefaultTargetPlatformOverride = null;
});
testWidgets(
'refreshing task keeps the sliver expanded forever until done',
(WidgetTester tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
await tester.pumpWidget(
new Directionality(
textDirection: TextDirection.ltr,
child: new CustomScrollView(
slivers: <Widget>[
new CupertinoRefreshControl(
builder: mockHelper.builder,
onRefresh: mockHelper.refreshTask,
),
buildAListOfStuff(),
],
),
),
);
await tester.drag(find.text('0'), const Offset(0.0, 150.0));
await tester.pump();
// Let it start snapping back.
await tester.pump(const Duration(milliseconds: 50));
verifyInOrder(<void>[
mockHelper.builder,
mockHelper.refreshTask,
mockHelper.builder(
typed(any),
RefreshIndicatorMode.armed,
150.0,
100.0, // Default value.
60.0, // Default value.
),
mockHelper.refreshTask(),
mockHelper.builder(
typed(any),
RefreshIndicatorMode.armed,
typed(argThat(moreOrLessEquals(127.10396988577114))),
100.0, // Default value.
60.0, // Default value.
),
]);
// Reaches refresh state and sliver's at 60.0 in height after a while.
await tester.pump(const Duration(seconds: 1));
verify(mockHelper.builder(
typed(any),
RefreshIndicatorMode.refresh,
60.0,
100.0, // Default value.
60.0, // Default value.
));
// Stays in that state forever until future completes.
await tester.pump(const Duration(seconds: 1000));
verifyNoMoreInteractions(mockHelper);
expect(
tester.getTopLeft(find.widgetWithText(Container, '0')),
const Offset(0.0, 60.0),
);
refreshCompleter.complete(null);
await tester.pump();
verify(mockHelper.builder(
typed(any),
RefreshIndicatorMode.done,
60.0,
100.0, // Default value.
60.0, // Default value.
));
verifyNoMoreInteractions(mockHelper);
debugDefaultTargetPlatformOverride = null;
},
);
testWidgets('expanded refreshing sliver scrolls normally', (WidgetTester tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
refreshIndicator = const Center(child: const Text('-1'));
await tester.pumpWidget(
new Directionality(
textDirection: TextDirection.ltr,
child: new CustomScrollView(
slivers: <Widget>[
new CupertinoRefreshControl(
builder: mockHelper.builder,
onRefresh: mockHelper.refreshTask,
),
buildAListOfStuff(),
],
),
),
);
await tester.drag(find.text('0'), const Offset(0.0, 150.0));
await tester.pump();
verify(mockHelper.builder(
typed(any),
RefreshIndicatorMode.armed,
150.0,
100.0, // Default value.
60.0, // Default value.
));
// Given a box constraint of 150, the Center will occupy all that height.
expect(
tester.getRect(find.widgetWithText(Center, '-1')),
new Rect.fromLTRB(0.0, 0.0, 800.0, 150.0),
);
await tester.drag(find.text('0'), const Offset(0.0, -300.0));
await tester.pump();
// Refresh indicator still being told to layout the same way.
verify(mockHelper.builder(
typed(any),
RefreshIndicatorMode.refresh,
60.0,
100.0, // Default value.
60.0, // Default value.
));
// Now the sliver is scrolled off screen.
expect(
tester.getTopLeft(find.widgetWithText(Center, '-1')).dy,
moreOrLessEquals(-175.38461538461536),
);
expect(
tester.getBottomLeft(find.widgetWithText(Center, '-1')).dy,
moreOrLessEquals(-115.38461538461536),
);
expect(
tester.getTopLeft(find.widgetWithText(Center, '0')).dy,
moreOrLessEquals(-115.38461538461536),
);
// Scroll the top of the refresh indicator back to overscroll, it will
// snap to the size of the refresh indicator and stay there.
await tester.drag(find.text('1'), const Offset(0.0, 200.0));
await tester.pump();
await tester.pump(const Duration(seconds: 2));
expect(
tester.getRect(find.widgetWithText(Center, '-1')),
new Rect.fromLTRB(0.0, 0.0, 800.0, 60.0),
);
expect(
tester.getRect(find.widgetWithText(Center, '0')),
new Rect.fromLTRB(0.0, 60.0, 800.0, 260.0),
);
debugDefaultTargetPlatformOverride = null;
});
testWidgets('expanded refreshing sliver goes away when done', (WidgetTester tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
refreshIndicator = const Center(child: const Text('-1'));
await tester.pumpWidget(
new Directionality(
textDirection: TextDirection.ltr,
child: new CustomScrollView(
slivers: <Widget>[
new CupertinoRefreshControl(
builder: mockHelper.builder,
onRefresh: mockHelper.refreshTask,
),
buildAListOfStuff(),
],
),
),
);
await tester.drag(find.text('0'), const Offset(0.0, 150.0));
await tester.pump();
verify(mockHelper.builder(
typed(any),
RefreshIndicatorMode.armed,
150.0,
100.0, // Default value.
60.0, // Default value.
));
expect(
tester.getRect(find.widgetWithText(Center, '-1')),
new Rect.fromLTRB(0.0, 0.0, 800.0, 150.0),
);
verify(mockHelper.refreshTask());
// Rebuilds the sliver with a layout extent now.
await tester.pump();
// Let it snap back to occupy the indicator's final sliver space only.
await tester.pump(const Duration(seconds: 2));
verify(mockHelper.builder(
typed(any),
RefreshIndicatorMode.refresh,
60.0,
100.0, // Default value.
60.0, // Default value.
));
expect(
tester.getRect(find.widgetWithText(Center, '-1')),
new Rect.fromLTRB(0.0, 0.0, 800.0, 60.0),
);
expect(
tester.getRect(find.widgetWithText(Center, '0')),
new Rect.fromLTRB(0.0, 60.0, 800.0, 260.0),
);
refreshCompleter.complete(null);
await tester.pump();
verify(mockHelper.builder(
typed(any),
RefreshIndicatorMode.done,
60.0,
100.0, // Default value.
60.0, // Default value.
));
await tester.pump(const Duration(seconds: 5));
expect(find.text('-1'), findsNothing);
expect(
tester.getRect(find.widgetWithText(Center, '0')),
new Rect.fromLTRB(0.0, 0.0, 800.0, 200.0),
);
debugDefaultTargetPlatformOverride = null;
});
testWidgets(
'retracting sliver during done cannot be pulled to refresh again until fully retracted',
(WidgetTester tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
refreshIndicator = const Center(child: const Text('-1'));
await tester.pumpWidget(
new Directionality(
textDirection: TextDirection.ltr,
child: new CustomScrollView(
slivers: <Widget>[
new CupertinoRefreshControl(
builder: mockHelper.builder,
onRefresh: mockHelper.refreshTask,
),
buildAListOfStuff(),
],
),
),
);
await tester.drag(find.text('0'), const Offset(0.0, 150.0));
await tester.pump();
verify(mockHelper.refreshTask());
refreshCompleter.complete(null);
await tester.pump();
verify(mockHelper.builder(
typed(any),
RefreshIndicatorMode.done,
150.0, // Still overscrolled here.
100.0, // Default value.
60.0, // Default value.
));
// Let it start going away but not fully.
await tester.pump(const Duration(milliseconds: 100));
// The refresh indicator is still building.
verify(mockHelper.builder(
typed(any),
RefreshIndicatorMode.done,
91.31180913199277,
100.0, // Default value.
60.0, // Default value.
));
expect(
tester.getBottomLeft(find.widgetWithText(Center, '-1')).dy,
moreOrLessEquals(91.311809131992776),
);
// Start another drag by an amount that would have been enough to
// trigger another refresh if it were in the right state.
await tester.drag(find.text('0'), const Offset(0.0, 150.0));
await tester.pump();
// Instead, it's still in the done state because the sliver never
// fully retracted.
verify(mockHelper.builder(
typed(any),
RefreshIndicatorMode.done,
147.3772721631821,
100.0, // Default value.
60.0, // Default value.
));
// Now let it fully go away.
await tester.pump(const Duration(seconds: 5));
expect(find.text('-1'), findsNothing);
expect(
tester.getRect(find.widgetWithText(Center, '0')),
new Rect.fromLTRB(0.0, 0.0, 800.0, 200.0),
);
// Start another drag. It's now in drag mode.
await tester.drag(find.text('0'), const Offset(0.0, 40.0));
await tester.pump();
verify(mockHelper.builder(
typed(any),
RefreshIndicatorMode.drag,
40.0,
100.0, // Default value.
60.0, // Default value.
));
debugDefaultTargetPlatformOverride = null;
},
);
testWidgets(
'sliver held in overscroll when task finishes completes normally',
(WidgetTester tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
refreshIndicator = const Center(child: const Text('-1'));
await tester.pumpWidget(
new Directionality(
textDirection: TextDirection.ltr,
child: new CustomScrollView(
slivers: <Widget>[
new CupertinoRefreshControl(
builder: mockHelper.builder,
onRefresh: mockHelper.refreshTask,
),
buildAListOfStuff(),
],
),
),
);
final TestGesture gesture = await tester.startGesture(const Offset(0.0, 0.0));
// Start a refresh.
await gesture.moveBy(const Offset(0.0, 150.0));
await tester.pump();
verify(mockHelper.refreshTask());
// Complete the task while held down.
refreshCompleter.complete(null);
await tester.pump();
verify(mockHelper.builder(
typed(any),
RefreshIndicatorMode.done,
150.0, // Still overscrolled here.
100.0, // Default value.
60.0, // Default value.
));
expect(
tester.getRect(find.widgetWithText(Center, '0')),
new Rect.fromLTRB(0.0, 150.0, 800.0, 350.0),
);
await gesture.up();
await tester.pump();
await tester.pump(const Duration(seconds: 5));
expect(find.text('-1'), findsNothing);
expect(
tester.getRect(find.widgetWithText(Center, '0')),
new Rect.fromLTRB(0.0, 0.0, 800.0, 200.0),
);
debugDefaultTargetPlatformOverride = null;
},
);
testWidgets(
'sliver scrolled away when task completes properly removes itself',
(WidgetTester tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
refreshIndicator = const Center(child: const Text('-1'));
await tester.pumpWidget(
new Directionality(
textDirection: TextDirection.ltr,
child: new CustomScrollView(
slivers: <Widget>[
new CupertinoRefreshControl(
builder: mockHelper.builder,
onRefresh: mockHelper.refreshTask,
),
buildAListOfStuff(),
],
),
),
);
// Start a refresh.
await tester.drag(find.text('0'), const Offset(0.0, 150.0));
await tester.pump();
verify(mockHelper.refreshTask());
await tester.drag(find.text('0'), const Offset(0.0, -300.0));
await tester.pump();
// Refresh indicator still being told to layout the same way.
verify(mockHelper.builder(
typed(any),
RefreshIndicatorMode.refresh,
60.0,
100.0, // Default value.
60.0, // Default value.
));
// Now the sliver is scrolled off screen.
expect(
tester.getTopLeft(find.widgetWithText(Center, '-1')).dy,
moreOrLessEquals(-175.38461538461536),
);
expect(
tester.getBottomLeft(find.widgetWithText(Center, '-1')).dy,
moreOrLessEquals(-115.38461538461536),
);
// Complete the task while scrolled away.
refreshCompleter.complete(null);
// The sliver is instantly gone since there is no overscroll physics
// simulation.
await tester.pump();
// The next item's position is not disturbed.
expect(
tester.getTopLeft(find.widgetWithText(Center, '0')).dy,
moreOrLessEquals(-115.38461538461536),
);
// Scrolling past the first item still results in a new overscroll.
// The layout extent is gone.
await tester.drag(find.text('1'), const Offset(0.0, 120.0));
await tester.pump();
verify(mockHelper.builder(
typed(any),
RefreshIndicatorMode.drag,
4.615384615384642,
100.0, // Default value.
60.0, // Default value.
));
// Snaps away normally.
await tester.pump();
await tester.pump(const Duration(seconds: 2));
expect(find.text('-1'), findsNothing);
expect(
tester.getRect(find.widgetWithText(Center, '0')),
new Rect.fromLTRB(0.0, 0.0, 800.0, 200.0),
);
debugDefaultTargetPlatformOverride = null;
},
);
testWidgets(
"don't do anything unless it can be overscrolled at the start of the list",
(WidgetTester tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
refreshIndicator = const Center(child: const Text('-1'));
await tester.pumpWidget(
new Directionality(
textDirection: TextDirection.ltr,
child: new CustomScrollView(
slivers: <Widget>[
buildAListOfStuff(),
new CupertinoRefreshControl( // it's in the middle now.
builder: mockHelper.builder,
onRefresh: mockHelper.refreshTask,
),
buildAListOfStuff(),
],
),
),
);
await tester.fling(find.byType(Container).first, const Offset(0.0, 200.0), 2000.0);
await tester.fling(find.byType(Container).first, const Offset(0.0, -200.0), 3000.0);
verify(mockHelper.builder);
verify(mockHelper.refreshTask);
verifyNoMoreInteractions(mockHelper);
debugDefaultTargetPlatformOverride = null;
},
);
testWidgets(
'without an onRefresh, builder is called with arm for one frame then sliver goes away',
(WidgetTester tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
refreshIndicator = const Center(child: const Text('-1'));
await tester.pumpWidget(
new Directionality(
textDirection: TextDirection.ltr,
child: new CustomScrollView(
slivers: <Widget>[
new CupertinoRefreshControl(
builder: mockHelper.builder,
),
buildAListOfStuff(),
],
),
),
);
await tester.drag(find.text('0'), const Offset(0.0, 150.0));
await tester.pump();
verify(mockHelper.builder(
typed(any),
RefreshIndicatorMode.armed,
150.0,
100.0, // Default value.
60.0, // Default value.
));
await tester.pump(const Duration(milliseconds: 10));
verify(mockHelper.builder(
typed(any),
RefreshIndicatorMode.done, // Goes to done on the next frame.
148.6463892921364,
100.0, // Default value.
60.0, // Default value.
));
await tester.pump(const Duration(seconds: 5));
expect(find.text('-1'), findsNothing);
expect(
tester.getRect(find.widgetWithText(Center, '0')),
new Rect.fromLTRB(0.0, 0.0, 800.0, 200.0),
);
debugDefaultTargetPlatformOverride = null;
}
);
});
// Test the internal state machine directly to make sure the UI aren't just
// correct by coincidence.
group('state machine test', () {
testWidgets('starts in inactive state', (WidgetTester tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
await tester.pumpWidget(
new Directionality(
textDirection: TextDirection.ltr,
child: new CustomScrollView(
slivers: <Widget>[
new CupertinoRefreshControl(
builder: mockHelper.builder,
),
buildAListOfStuff(),
],
),
),
);
expect(
CupertinoRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
RefreshIndicatorMode.inactive,
);
debugDefaultTargetPlatformOverride = null;
});
testWidgets('goes to drag and returns to inactive in a small drag', (WidgetTester tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
await tester.pumpWidget(
new Directionality(
textDirection: TextDirection.ltr,
child: new CustomScrollView(
slivers: <Widget>[
new CupertinoRefreshControl(
builder: mockHelper.builder,
),
buildAListOfStuff(),
],
),
),
);
await tester.drag(find.text('0'), const Offset(0.0, 20.0));
await tester.pump();
expect(
CupertinoRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
RefreshIndicatorMode.drag,
);
await tester.pump(const Duration(seconds: 2));
expect(
CupertinoRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
RefreshIndicatorMode.inactive,
);
debugDefaultTargetPlatformOverride = null;
});
testWidgets('goes to armed the frame it passes the threshold', (WidgetTester tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
await tester.pumpWidget(
new Directionality(
textDirection: TextDirection.ltr,
child: new CustomScrollView(
slivers: <Widget>[
new CupertinoRefreshControl(
builder: mockHelper.builder,
refreshTriggerPullDistance: 80.0,
),
buildAListOfStuff(),
],
),
),
);
final TestGesture gesture = await tester.startGesture(const Offset(0.0, 0.0));
await gesture.moveBy(const Offset(0.0, 79.0));
await tester.pump();
expect(
CupertinoRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
RefreshIndicatorMode.drag,
);
await gesture.moveBy(const Offset(0.0, 3.0)); // Overscrolling, need to move more than 1px.
await tester.pump();
expect(
CupertinoRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
RefreshIndicatorMode.armed,
);
debugDefaultTargetPlatformOverride = null;
});
testWidgets(
'goes to refresh the frame it crossed back the refresh threshold',
(WidgetTester tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
await tester.pumpWidget(
new Directionality(
textDirection: TextDirection.ltr,
child: new CustomScrollView(
slivers: <Widget>[
new CupertinoRefreshControl(
builder: mockHelper.builder,
onRefresh: mockHelper.refreshTask,
refreshTriggerPullDistance: 90.0,
refreshIndicatorExtent: 50.0,
),
buildAListOfStuff(),
],
),
),
);
final TestGesture gesture = await tester.startGesture(const Offset(0.0, 0.0));
await gesture.moveBy(const Offset(0.0, 90.0)); // Arm it.
await tester.pump();
expect(
CupertinoRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
RefreshIndicatorMode.armed,
);
await gesture.moveBy(const Offset(0.0, -80.0)); // Overscrolling, need to move more than -40.
await tester.pump();
expect(
tester.getTopLeft(find.widgetWithText(Container, '0')).dy,
moreOrLessEquals(49.775111111111116), // Below 50 now.
);
expect(
CupertinoRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
RefreshIndicatorMode.refresh,
);
debugDefaultTargetPlatformOverride = null;
},
);
testWidgets(
'goes to done internally as soon as the task finishes',
(WidgetTester tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
await tester.pumpWidget(
new Directionality(
textDirection: TextDirection.ltr,
child: new CustomScrollView(
slivers: <Widget>[
new CupertinoRefreshControl(
builder: mockHelper.builder,
onRefresh: mockHelper.refreshTask,
),
buildAListOfStuff(),
],
),
),
);
await tester.drag(find.text('0'), const Offset(0.0, 100.0));
await tester.pump();
expect(
CupertinoRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
RefreshIndicatorMode.armed,
);
// The sliver scroll offset correction is applied on the next frame.
await tester.pump();
await tester.pump(const Duration(seconds: 2));
expect(
CupertinoRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
RefreshIndicatorMode.refresh,
);
expect(
tester.getRect(find.widgetWithText(Container, '0')),
new Rect.fromLTRB(0.0, 60.0, 800.0, 260.0),
);
refreshCompleter.complete(null);
// The task completed between frames. The internal state goes to done
// right away even though the sliver gets a new offset correction the
// next frame.
expect(
CupertinoRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
RefreshIndicatorMode.done,
);
debugDefaultTargetPlatformOverride = null;
},
);
testWidgets(
'goes back to inactive when retracting back past 10% of arming distance',
(WidgetTester tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
await tester.pumpWidget(
new Directionality(
textDirection: TextDirection.ltr,
child: new CustomScrollView(
slivers: <Widget>[
new CupertinoRefreshControl(
builder: mockHelper.builder,
onRefresh: mockHelper.refreshTask,
),
buildAListOfStuff(),
],
),
),
);
final TestGesture gesture = await tester.startGesture(const Offset(0.0, 0.0));
await gesture.moveBy(const Offset(0.0, 150.0));
await tester.pump();
expect(
CupertinoRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
RefreshIndicatorMode.armed,
);
refreshCompleter.complete(null);
expect(
CupertinoRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
RefreshIndicatorMode.done,
);
await tester.pump();
// Now back in overscroll mode.
await gesture.moveBy(const Offset(0.0, -200.0));
await tester.pump();
expect(
tester.getTopLeft(find.widgetWithText(Container, '0')).dy,
moreOrLessEquals(27.944444444444457),
);
// Need to bring it to 100 * 0.1 to reset to inactive.
expect(
CupertinoRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
RefreshIndicatorMode.done,
);
await gesture.moveBy(const Offset(0.0, -35.0));
await tester.pump();
expect(
tester.getTopLeft(find.widgetWithText(Container, '0')).dy,
moreOrLessEquals(9.313890708161875),
);
expect(
CupertinoRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
RefreshIndicatorMode.inactive,
);
debugDefaultTargetPlatformOverride = null;
},
);
testWidgets(
'goes back to inactive if already scrolled away when task completes',
(WidgetTester tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
await tester.pumpWidget(
new Directionality(
textDirection: TextDirection.ltr,
child: new CustomScrollView(
slivers: <Widget>[
new CupertinoRefreshControl(
builder: mockHelper.builder,
onRefresh: mockHelper.refreshTask,
),
buildAListOfStuff(),
],
),
),
);
final TestGesture gesture = await tester.startGesture(const Offset(0.0, 0.0));
await gesture.moveBy(const Offset(0.0, 150.0));
await tester.pump();
expect(
CupertinoRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
RefreshIndicatorMode.armed,
);
await tester.pump(); // Sliver scroll offset correction is applied one frame later.
await gesture.moveBy(const Offset(0.0, -300.0));
await tester.pump();
// The refresh indicator is offscreen now.
expect(
tester.getTopLeft(find.widgetWithText(Container, '0')).dy,
moreOrLessEquals(-145.0332383665717),
);
expect(
CupertinoRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
RefreshIndicatorMode.refresh,
);
refreshCompleter.complete(null);
// The sliver layout extent is removed on next frame.
await tester.pump();
expect(
CupertinoRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
RefreshIndicatorMode.inactive,
);
// Nothing moved.
expect(
tester.getTopLeft(find.widgetWithText(Container, '0')).dy,
moreOrLessEquals(-145.0332383665717),
);
await tester.pump(const Duration(seconds: 2));
// Everything stayed as is.
expect(
tester.getTopLeft(find.widgetWithText(Container, '0')).dy,
moreOrLessEquals(-145.0332383665717),
);
debugDefaultTargetPlatformOverride = null;
},
);
testWidgets(
"don't have to build any indicators or occupy space during refresh",
(WidgetTester tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
refreshIndicator = const Center(child: const Text('-1'));
await tester.pumpWidget(
new Directionality(
textDirection: TextDirection.ltr,
child: new CustomScrollView(
slivers: <Widget>[
new CupertinoRefreshControl(
builder: null,
onRefresh: mockHelper.refreshTask,
refreshIndicatorExtent: 0.0,
),
buildAListOfStuff(),
],
),
),
);
await tester.drag(find.text('0'), const Offset(0.0, 150.0));
await tester.pump();
expect(
CupertinoRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
RefreshIndicatorMode.armed,
);
await tester.pump();
await tester.pump(const Duration(seconds: 5));
// In refresh mode but has no UI.
expect(
CupertinoRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
RefreshIndicatorMode.refresh,
);
expect(
tester.getRect(find.widgetWithText(Center, '0')),
new Rect.fromLTRB(0.0, 0.0, 800.0, 200.0),
);
verify(mockHelper.refreshTask()); // The refresh function still called.
refreshCompleter.complete(null);
await tester.pump();
// Goes to inactive right away since the sliver is already collapsed.
expect(
CupertinoRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
RefreshIndicatorMode.inactive,
);
debugDefaultTargetPlatformOverride = null;
}
);
});
}
class MockHelper extends Mock {
Widget builder(
BuildContext context,
RefreshIndicatorMode refreshState,
double pulledExtent,
double refreshTriggerPullDistance,
double refreshIndicatorExtent,
);
Future<void> refreshTask();
}
\ No newline at end of file
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