Unverified Commit 159557eb authored by Craig Edwards's avatar Craig Edwards Committed by GitHub

iOS mid-drag activity indicator (#58392)

parent 2a1c078b
......@@ -31,7 +31,26 @@ class CupertinoActivityIndicator extends StatefulWidget {
this.radius = _kDefaultIndicatorRadius,
}) : assert(animating != null),
assert(radius != null),
assert(radius > 0),
assert(radius > 0.0),
progress = 1.0,
super(key: key);
/// Creates a non-animated iOS-style activity indicator that displays
/// a partial count of ticks based on the value of [progress].
///
/// When provided, the value of [progress] must be between 0.0 (zero ticks
/// will be shown) and 1.0 (all ticks will be shown) inclusive. Defaults
/// to 1.0.
const CupertinoActivityIndicator.partiallyRevealed({
Key key,
this.radius = _kDefaultIndicatorRadius,
this.progress = 1.0,
}) : assert(radius != null),
assert(radius > 0.0),
assert(progress != null),
assert(progress >= 0.0),
assert(progress <= 1.0),
animating = false,
super(key: key);
/// Whether the activity indicator is running its animation.
......@@ -44,6 +63,14 @@ class CupertinoActivityIndicator extends StatefulWidget {
/// Defaults to 10px. Must be positive and cannot be null.
final double radius;
/// Determines the percentage of spinner ticks that will be shown. Typical usage would
/// display all ticks, however, this allows for more fine-grained control such as
/// during pull-to-refresh when the drag-down action shows one tick at a time as
/// the user continues to drag down.
///
/// Defaults to 1.0. Must be between 0.0 and 1.0 inclusive, and cannot be null.
final double progress;
@override
_CupertinoActivityIndicatorState createState() => _CupertinoActivityIndicatorState();
}
......@@ -91,6 +118,7 @@ class _CupertinoActivityIndicatorState extends State<CupertinoActivityIndicator>
position: _controller,
activeColor: CupertinoDynamicColor.resolve(_kActiveTickColor, context),
radius: widget.radius,
progress: widget.progress,
),
),
);
......@@ -100,20 +128,26 @@ class _CupertinoActivityIndicatorState extends State<CupertinoActivityIndicator>
const double _kTwoPI = math.pi * 2.0;
const int _kTickCount = 12;
// Alpha values extracted from the native component (for both dark and light mode).
// The list has a length of 12.
const List<int> _alphaValues = <int>[147, 131, 114, 97, 81, 64, 47, 47, 47, 47, 47, 47];
// Alpha values extracted from the native component (for both dark and light mode) to
// draw the spinning ticks. The list must have a length of _kTickCount. The order of
// these values is designed to match the first frame of the iOS activity indicator which
// has the most prominent tick at 9 o'clock.
const List<int> _alphaValues = <int>[47, 47, 47, 47, 64, 81, 97, 114, 131, 147, 47, 47];
// The alpha value that is used to draw the partially revealed ticks.
const int _partiallyRevealedAlpha = 147;
class _CupertinoActivityIndicatorPainter extends CustomPainter {
_CupertinoActivityIndicatorPainter({
@required this.position,
@required this.activeColor,
double radius,
@required this.radius,
@required this.progress,
}) : tickFundamentalRRect = RRect.fromLTRBXY(
-radius,
radius / _kDefaultIndicatorRadius,
-radius / 2.0,
-radius / _kDefaultIndicatorRadius,
-radius / 2.0,
radius / _kDefaultIndicatorRadius,
-radius,
radius / _kDefaultIndicatorRadius,
radius / _kDefaultIndicatorRadius,
),
......@@ -122,6 +156,8 @@ class _CupertinoActivityIndicatorPainter extends CustomPainter {
final Animation<double> position;
final RRect tickFundamentalRRect;
final Color activeColor;
final double radius;
final double progress;
@override
void paint(Canvas canvas, Size size) {
......@@ -132,11 +168,11 @@ class _CupertinoActivityIndicatorPainter extends CustomPainter {
final int activeTick = (_kTickCount * position.value).floor();
for (int i = 0; i < _kTickCount; ++ i) {
final int t = (i + activeTick) % _kTickCount;
paint.color = activeColor.withAlpha(_alphaValues[t]);
for (int i = 0; i < _kTickCount * progress; ++i) {
final int t = (i - activeTick) % _kTickCount;
paint.color = activeColor.withAlpha(progress < 1 ? _partiallyRevealedAlpha : _alphaValues[t]);
canvas.drawRRect(tickFundamentalRRect, paint);
canvas.rotate(-_kTwoPI / _kTickCount);
canvas.rotate(_kTwoPI / _kTickCount);
}
canvas.restore();
......@@ -144,6 +180,6 @@ class _CupertinoActivityIndicatorPainter extends CustomPainter {
@override
bool shouldRepaint(_CupertinoActivityIndicatorPainter oldPainter) {
return oldPainter.position != position || oldPainter.activeColor != activeColor;
return oldPainter.position != position || oldPainter.activeColor != activeColor || oldPainter.progress != progress;
}
}
......@@ -13,8 +13,9 @@ import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'activity_indicator.dart';
import 'colors.dart';
import 'icons.dart';
const double _kActivityIndicatorRadius = 14.0;
const double _kActivityIndicatorMargin = 16.0;
class _CupertinoSliverRefresh extends SingleChildRenderObjectWidget {
const _CupertinoSliverRefresh({
......@@ -294,7 +295,7 @@ class CupertinoSliverRefreshControl extends StatefulWidget {
Key key,
this.refreshTriggerPullDistance = _defaultRefreshTriggerPullDistance,
this.refreshIndicatorExtent = _defaultRefreshIndicatorExtent,
this.builder = buildSimpleRefreshIndicator,
this.builder = buildRefreshIndicator,
this.onRefresh,
}) : assert(refreshTriggerPullDistance != null),
assert(refreshTriggerPullDistance > 0.0),
......@@ -330,9 +331,6 @@ class CupertinoSliverRefreshControl extends StatefulWidget {
/// 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.
///
......@@ -361,43 +359,68 @@ class CupertinoSliverRefreshControl extends StatefulWidget {
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(
/// Builds a refresh indicator that reflects the standard iOS pull-to-refresh
/// behavior. Specifically, this entails presenting an activity indicator that
/// changes depending on the current refreshState. As the user initially drags
/// down, the indicator will gradually reveal individual ticks until the refresh
/// becomes armed. At this point, the animated activity indicator will begin rotating.
/// Once the refresh has completed, the activity indicator shrinks away as the
/// space allocation animates back to closed.
static Widget buildRefreshIndicator(
BuildContext context,
RefreshIndicatorMode refreshState,
double pulledExtent,
double refreshTriggerPullDistance,
double refreshIndicatorExtent,
) {
const Curve opacityCurve = Interval(0.4, 0.8, curve: Curves.easeInOut);
return Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: refreshState == RefreshIndicatorMode.drag
? Opacity(
opacity: opacityCurve.transform(
min(pulledExtent / refreshTriggerPullDistance, 1.0)
),
child: Icon(
CupertinoIcons.down_arrow,
color: CupertinoDynamicColor.resolve(CupertinoColors.inactiveGray, context),
size: 36.0,
),
)
: Opacity(
opacity: opacityCurve.transform(
min(pulledExtent / refreshIndicatorExtent, 1.0)
),
child: const CupertinoActivityIndicator(radius: 14.0),
),
final double percentageComplete = pulledExtent / refreshTriggerPullDistance;
// Place the indicator at the top of the sliver that opens up. Note that we're using
// a Stack/Positioned widget because the CupertinoActivityIndicator does some internal
// translations based on the current size (which grows as the user drags) that makes
// Padding calculations difficult. Rather than be reliant on the internal implementation
// of the activity indicator, the Positioned widget allows us to be explicit where the
// widget gets placed. Also note that the indicator should appear over the top of the
// dragged widget, hence the use of Overflow.visible.
return Center(
child: Stack(
overflow: Overflow.visible,
children: <Widget>[
Positioned(
top: _kActivityIndicatorMargin,
left: 0.0,
right: 0.0,
child: _buildIndicatorForRefreshState(refreshState, _kActivityIndicatorRadius, percentageComplete),
),
],
),
);
}
static Widget _buildIndicatorForRefreshState(RefreshIndicatorMode refreshState, double radius, double percentageComplete) {
switch (refreshState) {
case RefreshIndicatorMode.drag:
// While we're dragging, we draw individual ticks of the spinner while simultaneously
// easing the opacity in. Note that the opacity curve values here were derived using
// Xcode through inspecting a native app running on iOS 13.5.
const Curve opacityCurve = Interval(0.0, 0.35, curve: Curves.easeInOut);
return Opacity(
opacity: opacityCurve.transform(percentageComplete),
child: CupertinoActivityIndicator.partiallyRevealed(radius: radius, progress: percentageComplete),
);
case RefreshIndicatorMode.armed:
case RefreshIndicatorMode.refresh:
// Once we're armed or performing the refresh, we just show the normal spinner.
return CupertinoActivityIndicator(radius: radius);
case RefreshIndicatorMode.done:
// When the user lets go, the standard transition is to shrink the spinner.
return CupertinoActivityIndicator(radius: radius * percentageComplete);
default:
// Anything else doesn't show anything.
return Container();
}
}
@override
_CupertinoSliverRefreshControlState createState() => _CupertinoSliverRefreshControlState();
}
......
......@@ -71,15 +71,79 @@ void main() {
);
});
testWidgets('Activity indicator 0% in progress', (WidgetTester tester) async {
final Key key = UniqueKey();
await tester.pumpWidget(
Center(
child: RepaintBoundary(
key: key,
child: Container(
color: CupertinoColors.white,
child: const CupertinoActivityIndicator.partiallyRevealed(progress: 0),
),
),
),
);
await expectLater(
find.byKey(key),
matchesGoldenFile('activityIndicator.inprogress.0.0.png'),
);
});
testWidgets('Activity indicator 30% in progress', (WidgetTester tester) async {
final Key key = UniqueKey();
await tester.pumpWidget(
Center(
child: RepaintBoundary(
key: key,
child: Container(
color: CupertinoColors.white,
child: const CupertinoActivityIndicator.partiallyRevealed(progress: 0.5),
),
),
),
);
await expectLater(
find.byKey(key),
matchesGoldenFile('activityIndicator.inprogress.0.3.png'),
);
});
testWidgets('Activity indicator 100% in progress', (WidgetTester tester) async {
final Key key = UniqueKey();
await tester.pumpWidget(
Center(
child: RepaintBoundary(
key: key,
child: Container(
color: CupertinoColors.white,
child: const CupertinoActivityIndicator.partiallyRevealed(progress: 1),
),
),
),
);
await expectLater(
find.byKey(key),
matchesGoldenFile('activityIndicator.inprogress.1.0.png'),
);
});
// Regression test for https://github.com/flutter/flutter/issues/41345.
testWidgets('has the correct corner radius', (WidgetTester tester) async {
await tester.pumpWidget(
const CupertinoActivityIndicator(animating: false, radius: 100),
);
// An earlier implementation for the activity indicator started drawing
// the ticks at 9 o'clock, however, in order to support partially revealed
// indicator (https://github.com/flutter/flutter/issues/29159), the
// first tick was changed to be at 12 o'clock.
expect(
find.byType(CupertinoActivityIndicator),
paints..rrect(rrect: const RRect.fromLTRBXY(-100, 10, -50, -10, 10, 10)),
paints..rrect(rrect: const RRect.fromLTRBXY(-10, -50, 10, -100, 10, 10)),
);
});
}
......
......@@ -1329,48 +1329,54 @@ void main() {
);
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
testWidgets('buildSimpleRefreshIndicator dark mode', (WidgetTester tester) async {
const CupertinoDynamicColor color = CupertinoColors.inactiveGray;
testWidgets('buildRefreshIndicator progress', (WidgetTester tester) async {
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(platformBrightness: Brightness.light),
child: Directionality(
textDirection: TextDirection.ltr,
child: Builder(
builder: (BuildContext context) {
return CupertinoSliverRefreshControl.buildSimpleRefreshIndicator(
context,
RefreshIndicatorMode.drag,
10, 10, 10,
);
},
),
Directionality(
textDirection: TextDirection.ltr,
child: Builder(
builder: (BuildContext context) {
return CupertinoSliverRefreshControl.buildRefreshIndicator(
context,
RefreshIndicatorMode.drag,
10, 100, 10,
);
},
),
),
);
expect(tester.widget<Icon>(find.byType(Icon)).color.value, color.color.value);
expect(tester.widget<CupertinoActivityIndicator>(find.byType(CupertinoActivityIndicator)).progress, 10.0 / 100.0);
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(platformBrightness: Brightness.dark),
child: Directionality(
textDirection: TextDirection.ltr,
child: Builder(
builder: (BuildContext context) {
return CupertinoSliverRefreshControl.buildSimpleRefreshIndicator(
context,
RefreshIndicatorMode.drag,
10, 10, 10,
);
},
),
Directionality(
textDirection: TextDirection.ltr,
child: Builder(
builder: (BuildContext context) {
return CupertinoSliverRefreshControl.buildRefreshIndicator(
context,
RefreshIndicatorMode.drag,
26, 100, 10,
);
},
),
),
);
expect(tester.widget<CupertinoActivityIndicator>(find.byType(CupertinoActivityIndicator)).progress, 26.0 / 100.0);
expect(tester.widget<Icon>(find.byType(Icon)).color.value, color.darkColor.value);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Builder(
builder: (BuildContext context) {
return CupertinoSliverRefreshControl.buildRefreshIndicator(
context,
RefreshIndicatorMode.drag,
100, 100, 10,
);
},
),
),
);
expect(tester.widget<CupertinoActivityIndicator>(find.byType(CupertinoActivityIndicator)).progress, 100.0 / 100.0);
});
};
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment