diff --git a/examples/flutter_gallery/lib/demo/cupertino/cupertino.dart b/examples/flutter_gallery/lib/demo/cupertino/cupertino.dart index 4e32ff2ca4b..da10d713428 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_buttons_demo.dart'; 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 00000000000..5c34206dfed --- /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 { + List> randomizedContacts; + + @override + void initState() { + super.initState(); + repopulateList(); + } + + void repopulateList() { + final Random random = new Random(); + randomizedContacts = new List>.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: [ + const CupertinoSliverNavigationBar( + largeTitle: const Text('Cupertino Refresh'), + ), + new CupertinoRefreshControl( + onRefresh: () { + return new Future.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> contacts = >[ + ['George Washington', 'Westmoreland County', ' 4/30/1789'], + ['John Adams', 'Braintree', ' 3/4/1797'], + ['Thomas Jefferson', 'Shadwell', ' 3/4/1801'], + ['James Madison', 'Port Conway', ' 3/4/1809'], + ['James Monroe', 'Monroe Hall', ' 3/4/1817'], + ['Andrew Jackson', 'Waxhaws Region South/North', ' 3/4/1829'], + ['John Quincy Adams', 'Braintree', ' 3/4/1825'], + ['William Henry Harrison', 'Charles City County', ' 3/4/1841'], + ['Martin Van Buren', 'Kinderhook New', ' 3/4/1837'], + ['Zachary Taylor', 'Barboursville', ' 3/4/1849'], + ['John Tyler', 'Charles City County', ' 4/4/1841'], + ['James Buchanan', 'Cove Gap', ' 3/4/1857'], + ['James K. Polk', 'Pineville North', ' 3/4/1845'], + ['Millard Fillmore', 'Summerhill New', '7/9/1850'], + ['Franklin Pierce', 'Hillsborough New', ' 3/4/1853'], + ['Andrew Johnson', 'Raleigh North', ' 4/15/1865'], + ['Abraham Lincoln', 'Sinking Spring', ' 3/4/1861'], + ['Ulysses S. Grant', 'Point Pleasant', ' 3/4/1869'], + ['Rutherford B. Hayes', 'Delaware', ' 3/4/1877'], + ['Chester A. Arthur', 'Fairfield', ' 9/19/1881'], + ['James A. Garfield', 'Moreland Hills', ' 3/4/1881'], + ['Benjamin Harrison', 'North Bend', ' 3/4/1889'], + ['Grover Cleveland', 'Caldwell New', ' 3/4/1885'], + ['William McKinley', 'Niles', ' 3/4/1897'], + ['Woodrow Wilson', 'Staunton', ' 3/4/1913'], + ['William H. Taft', 'Cincinnati', ' 3/4/1909'], + ['Theodore Roosevelt', 'New York City New', ' 9/14/1901'], + ['Warren G. Harding', 'Blooming Grove', ' 3/4/1921'], + ['Calvin Coolidge', 'Plymouth', '8/2/1923'], + ['Herbert Hoover', 'West Branch', ' 3/4/1929'], + ['Franklin D. Roosevelt', 'Hyde Park New', ' 3/4/1933'], + ['Harry S. Truman', 'Lamar', ' 4/12/1945'], + ['Dwight D. Eisenhower', 'Denison', ' 1/20/1953'], + ['Lyndon B. Johnson', 'Stonewall', '11/22/1963'], + ['Ronald Reagan', 'Tampico', ' 1/20/1981'], + ['Richard Nixon', 'Yorba Linda', ' 1/20/1969'], + ['Gerald Ford', 'Omaha', 'August 9/1974'], + ['John F. Kennedy', 'Brookline', ' 1/20/1961'], + ['George H. W. Bush', 'Milton', ' 1/20/1989'], + ['Jimmy Carter', 'Plains', ' 1/20/1977'], + ['George W. Bush', 'New Haven', ' 1/20, 2001'], + ['Bill Clinton', 'Hope', ' 1/20/1993'], + ['Barack Obama', 'Honolulu', ' 1/20/2009'], + ['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: [ + 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: [ + new Expanded( + child: new Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + 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 44b7120bae3..e4247a83117 100644 --- a/examples/flutter_gallery/lib/gallery/item.dart +++ b/examples/flutter_gallery/lib/gallery/item.dart @@ -327,6 +327,13 @@ List _buildGalleryItems() { routeName: CupertinoPickerDemo.routeName, 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', diff --git a/examples/flutter_gallery/test/smoke_test.dart b/examples/flutter_gallery/test/smoke_test.dart index 2a4d2062e6e..c550cac2ff5 100644 --- a/examples/flutter_gallery/test/smoke_test.dart +++ b/examples/flutter_gallery/test/smoke_test.dart @@ -103,9 +103,7 @@ Future smokeDemo(WidgetTester tester, String routeName) async { 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 0605380e679..2b50646df11 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 List demos = const [ 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 144fbd7a35c..00000000000 --- 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 3920b4870b2..89317edaaa2 100644 --- a/packages/flutter/lib/cupertino.dart +++ b/packages/flutter/lib/cupertino.dart @@ -16,6 +16,7 @@ export 'src/cupertino/icons.dart'; 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 1109de5250b..0922c31b551 100644 --- a/packages/flutter/lib/src/cupertino/activity_indicator.dart +++ b/packages/flutter/lib/src/cupertino/activity_indicator.dart @@ -8,6 +8,8 @@ import 'package:flutter/widgets.dart'; import 'colors.dart'; +const double _kDefaultIndicatorRadius = 10.0; + /// An iOS-style activity indicator. /// /// See also: @@ -18,7 +20,10 @@ class CupertinoActivityIndicator extends StatefulWidget { 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 @@ class CupertinoActivityIndicator extends StatefulWidget { /// 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 with SingleTickerProviderStateMixin { AnimationController _controller; @@ -68,11 +76,12 @@ class _CupertinoActivityIndicatorState extends State @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 _kTickCount = 12; 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 position; + final RRect tickFundamentalRRect; @override void paint(Canvas canvas, Size size) { @@ -105,7 +123,7 @@ class _CupertinoActivityIndicatorPainter extends CustomPainter { 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 5e94d6a27dc..75f8d9803a8 100644 --- a/packages/flutter/lib/src/cupertino/icons.dart +++ b/packages/flutter/lib/src/cupertino/icons.dart @@ -90,4 +90,13 @@ class CupertinoIcons { /// 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 00000000000..9664865d3d2 --- /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 { + _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 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. +/// * +/// * [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 { + /// 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 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 00000000000..de4143179b6 --- /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 refreshCompleter; + + /// The widget that the indicator builder given to the CupertinoRefreshControl + /// returns. + Widget refreshIndicator; + + setUp(() { + mockHelper = new MockHelper(); + refreshCompleter = new Completer.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: [ + 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: [ + 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: [ + 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: [ + 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([ + 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 platformCallLog = []; + + SystemChannels.platform.setMockMethodCallHandler((MethodCall methodCall) async { + platformCallLog.add(methodCall); + }); + + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new CustomScrollView( + slivers: [ + 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([ + 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: [ + 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([ + 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: [ + 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: [ + 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: [ + 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: [ + 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: [ + 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: [ + 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: [ + 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: [ + 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: [ + 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: [ + 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: [ + 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: [ + 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: [ + 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: [ + 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: [ + 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 refreshTask(); +} \ No newline at end of file