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