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
diff --git a/examples/flutter_gallery/lib/demo/cupertino/cupertino.dart b/examples/flutter_gallery/lib/demo/cupertino/cupertino.dart
index 4e32ff2..da10d71 100644
--- a/examples/flutter_gallery/lib/demo/cupertino/cupertino.dart
+++ b/examples/flutter_gallery/lib/demo/cupertino/cupertino.dart
@@ -7,5 +7,6 @@
export 'cupertino_dialog_demo.dart';
export 'cupertino_navigation_demo.dart';
export 'cupertino_picker_demo.dart';
+export 'cupertino_refresh_demo.dart';
export 'cupertino_slider_demo.dart';
export 'cupertino_switch_demo.dart';
diff --git a/examples/flutter_gallery/lib/demo/cupertino/cupertino_refresh_demo.dart b/examples/flutter_gallery/lib/demo/cupertino/cupertino_refresh_demo.dart
new file mode 100644
index 0000000..5c34206
--- /dev/null
+++ b/examples/flutter_gallery/lib/demo/cupertino/cupertino_refresh_demo.dart
@@ -0,0 +1,225 @@
+// 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
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/examples/flutter_gallery/lib/gallery/item.dart b/examples/flutter_gallery/lib/gallery/item.dart
index 44b7120..e4247a8 100644
--- a/examples/flutter_gallery/lib/gallery/item.dart
+++ b/examples/flutter_gallery/lib/gallery/item.dart
@@ -328,6 +328,13 @@
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(
title: 'Sliders',
subtitle: 'Cupertino styled sliders',
category: 'Cupertino Components',
diff --git a/examples/flutter_gallery/test/smoke_test.dart b/examples/flutter_gallery/test/smoke_test.dart
index 2a4d206..c550cac 100644
--- a/examples/flutter_gallery/test/smoke_test.dart
+++ b/examples/flutter_gallery/test/smoke_test.dart
@@ -103,9 +103,7 @@
await tester.pump(const Duration(milliseconds: 400));
// Go back
- final Finder backButton = find.byTooltip('Back');
- expect(backButton, findsOneWidget);
- await tester.tap(backButton);
+ await tester.pageBack();
await tester.pump(); // Start the pop "back" operation.
await tester.pump(); // Complete the willPop() Future.
await tester.pump(const Duration(milliseconds: 400)); // Wait until it has finished.
diff --git a/examples/flutter_gallery/test_driver/transitions_perf_test.dart b/examples/flutter_gallery/test_driver/transitions_perf_test.dart
index 0605380..2b50646 100644
--- a/examples/flutter_gallery/test_driver/transitions_perf_test.dart
+++ b/examples/flutter_gallery/test_driver/transitions_perf_test.dart
@@ -72,6 +72,7 @@
const Demo('Dialogs'),
const Demo('Navigation'),
const Demo('Pickers'),
+ const Demo('Pull to refresh'),
const Demo('Sliders'),
const Demo('Switches'),
diff --git a/packages/flutter/.vscode/tasks.json b/packages/flutter/.vscode/tasks.json
deleted file mode 100644
index 144fbd7..0000000
--- a/packages/flutter/.vscode/tasks.json
+++ /dev/null
@@ -1,17 +0,0 @@
-{
- "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}"]
- }
- ]
-}
diff --git a/packages/flutter/lib/cupertino.dart b/packages/flutter/lib/cupertino.dart
index 3920b48..89317ed 100644
--- a/packages/flutter/lib/cupertino.dart
+++ b/packages/flutter/lib/cupertino.dart
@@ -16,6 +16,7 @@
export 'src/cupertino/nav_bar.dart';
export 'src/cupertino/page_scaffold.dart';
export 'src/cupertino/picker.dart';
+export 'src/cupertino/refresh.dart';
export 'src/cupertino/route.dart';
export 'src/cupertino/scrollbar.dart';
export 'src/cupertino/slider.dart';
diff --git a/packages/flutter/lib/src/cupertino/activity_indicator.dart b/packages/flutter/lib/src/cupertino/activity_indicator.dart
index 1109de5..0922c31 100644
--- a/packages/flutter/lib/src/cupertino/activity_indicator.dart
+++ b/packages/flutter/lib/src/cupertino/activity_indicator.dart
@@ -8,6 +8,8 @@
import 'colors.dart';
+const double _kDefaultIndicatorRadius = 10.0;
+
/// An iOS-style activity indicator.
///
/// See also:
@@ -18,7 +20,10 @@
const CupertinoActivityIndicator({
Key key,
this.animating: true,
+ this.radius: _kDefaultIndicatorRadius,
}) : assert(animating != null),
+ assert(radius != null),
+ assert(radius > 0),
super(key: key);
/// Whether the activity indicator is running its animation.
@@ -26,12 +31,15 @@
/// Defaults to true.
final bool animating;
+ /// Radius of the spinner widget.
+ ///
+ /// Defaults to 10px. Must be positive and cannot be null.
+ final double radius;
+
@override
_CupertinoActivityIndicatorState createState() => new _CupertinoActivityIndicatorState();
}
-const double _kIndicatorWidth = 20.0;
-const double _kIndicatorHeight = 20.0;
class _CupertinoActivityIndicatorState extends State<CupertinoActivityIndicator> with SingleTickerProviderStateMixin {
AnimationController _controller;
@@ -68,11 +76,12 @@
@override
Widget build(BuildContext context) {
return new SizedBox(
- width: _kIndicatorWidth,
- height: _kIndicatorHeight,
+ height: widget.radius * 2,
+ width: widget.radius * 2,
child: new CustomPaint(
painter: new _CupertinoActivityIndicatorPainter(
position: _controller,
+ radius: widget.radius,
),
),
);
@@ -84,14 +93,23 @@
const int _kHalfTickCount = _kTickCount ~/ 2;
const Color _kTickColor = CupertinoColors.lightBackgroundGray;
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 {
_CupertinoActivityIndicatorPainter({
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 RRect tickFundamentalRRect;
@override
void paint(Canvas canvas, Size size) {
@@ -105,7 +123,7 @@
for (int i = 0; i < _kTickCount; ++ i) {
final double t = (((i + activeTick) % _kTickCount) / _kHalfTickCount).clamp(0.0, 1.0);
paint.color = Color.lerp(_kActiveTickColor, _kTickColor, t);
- canvas.drawRRect(_kTickFundamentalRRect, paint);
+ canvas.drawRRect(tickFundamentalRRect, paint);
canvas.rotate(-_kTwoPI / _kTickCount);
}
diff --git a/packages/flutter/lib/src/cupertino/icons.dart b/packages/flutter/lib/src/cupertino/icons.dart
index 5e94d6a..75f8d98 100644
--- a/packages/flutter/lib/src/cupertino/icons.dart
+++ b/packages/flutter/lib/src/cupertino/icons.dart
@@ -90,4 +90,13 @@
/// Three solid dots.
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);
}
diff --git a/packages/flutter/lib/src/cupertino/refresh.dart b/packages/flutter/lib/src/cupertino/refresh.dart
new file mode 100644
index 0000000..9664865
--- /dev/null
+++ b/packages/flutter/lib/src/cupertino/refresh.dart
@@ -0,0 +1,521 @@
+// 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();
+ }
+ },
+ )
+ );
+ }
+}
diff --git a/packages/flutter/test/cupertino/refresh_test.dart b/packages/flutter/test/cupertino/refresh_test.dart
new file mode 100644
index 0000000..de41431
--- /dev/null
+++ b/packages/flutter/test/cupertino/refresh_test.dart
@@ -0,0 +1,1260 @@
+// 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