mirror of
https://github.com/flutter/flutter.git
synced 2025-06-03 00:51:18 +00:00
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
This commit is contained in:
parent
fcf0941417
commit
feadfd2e40
@ -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';
|
||||
|
@ -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
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -327,6 +327,13 @@ List<GalleryItem> _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',
|
||||
|
@ -103,9 +103,7 @@ Future<Null> 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.
|
||||
|
@ -72,6 +72,7 @@ const List<Demo> demos = const <Demo>[
|
||||
const Demo('Dialogs'),
|
||||
const Demo('Navigation'),
|
||||
const Demo('Pickers'),
|
||||
const Demo('Pull to refresh'),
|
||||
const Demo('Sliders'),
|
||||
const Demo('Switches'),
|
||||
|
||||
|
17
packages/flutter/.vscode/tasks.json
vendored
17
packages/flutter/.vscode/tasks.json
vendored
@ -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}"]
|
||||
}
|
||||
]
|
||||
}
|
@ -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';
|
||||
|
@ -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<CupertinoActivityIndicator> with SingleTickerProviderStateMixin {
|
||||
AnimationController _controller;
|
||||
@ -68,11 +76,12 @@ class _CupertinoActivityIndicatorState extends State<CupertinoActivityIndicator>
|
||||
@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<double> 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);
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
|
521
packages/flutter/lib/src/cupertino/refresh.dart
Normal file
521
packages/flutter/lib/src/cupertino/refresh.dart
Normal file
@ -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();
|
||||
}
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
1260
packages/flutter/test/cupertino/refresh_test.dart
Normal file
1260
packages/flutter/test/cupertino/refresh_test.dart
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user