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 { ...@@ -31,7 +31,26 @@ class CupertinoActivityIndicator extends StatefulWidget {
this.radius = _kDefaultIndicatorRadius, this.radius = _kDefaultIndicatorRadius,
}) : assert(animating != null), }) : assert(animating != null),
assert(radius != 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); super(key: key);
/// Whether the activity indicator is running its animation. /// Whether the activity indicator is running its animation.
...@@ -44,6 +63,14 @@ class CupertinoActivityIndicator extends StatefulWidget { ...@@ -44,6 +63,14 @@ class CupertinoActivityIndicator extends StatefulWidget {
/// Defaults to 10px. Must be positive and cannot be null. /// Defaults to 10px. Must be positive and cannot be null.
final double radius; 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 @override
_CupertinoActivityIndicatorState createState() => _CupertinoActivityIndicatorState(); _CupertinoActivityIndicatorState createState() => _CupertinoActivityIndicatorState();
} }
...@@ -91,6 +118,7 @@ class _CupertinoActivityIndicatorState extends State<CupertinoActivityIndicator> ...@@ -91,6 +118,7 @@ class _CupertinoActivityIndicatorState extends State<CupertinoActivityIndicator>
position: _controller, position: _controller,
activeColor: CupertinoDynamicColor.resolve(_kActiveTickColor, context), activeColor: CupertinoDynamicColor.resolve(_kActiveTickColor, context),
radius: widget.radius, radius: widget.radius,
progress: widget.progress,
), ),
), ),
); );
...@@ -100,20 +128,26 @@ class _CupertinoActivityIndicatorState extends State<CupertinoActivityIndicator> ...@@ -100,20 +128,26 @@ class _CupertinoActivityIndicatorState extends State<CupertinoActivityIndicator>
const double _kTwoPI = math.pi * 2.0; const double _kTwoPI = math.pi * 2.0;
const int _kTickCount = 12; const int _kTickCount = 12;
// Alpha values extracted from the native component (for both dark and light mode). // Alpha values extracted from the native component (for both dark and light mode) to
// The list has a length of 12. // draw the spinning ticks. The list must have a length of _kTickCount. The order of
const List<int> _alphaValues = <int>[147, 131, 114, 97, 81, 64, 47, 47, 47, 47, 47, 47]; // 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 { class _CupertinoActivityIndicatorPainter extends CustomPainter {
_CupertinoActivityIndicatorPainter({ _CupertinoActivityIndicatorPainter({
@required this.position, @required this.position,
@required this.activeColor, @required this.activeColor,
double radius, @required this.radius,
@required this.progress,
}) : tickFundamentalRRect = RRect.fromLTRBXY( }) : tickFundamentalRRect = RRect.fromLTRBXY(
-radius,
radius / _kDefaultIndicatorRadius,
-radius / 2.0,
-radius / _kDefaultIndicatorRadius, -radius / _kDefaultIndicatorRadius,
-radius / 2.0,
radius / _kDefaultIndicatorRadius,
-radius,
radius / _kDefaultIndicatorRadius, radius / _kDefaultIndicatorRadius,
radius / _kDefaultIndicatorRadius, radius / _kDefaultIndicatorRadius,
), ),
...@@ -122,6 +156,8 @@ class _CupertinoActivityIndicatorPainter extends CustomPainter { ...@@ -122,6 +156,8 @@ class _CupertinoActivityIndicatorPainter extends CustomPainter {
final Animation<double> position; final Animation<double> position;
final RRect tickFundamentalRRect; final RRect tickFundamentalRRect;
final Color activeColor; final Color activeColor;
final double radius;
final double progress;
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
...@@ -132,11 +168,11 @@ class _CupertinoActivityIndicatorPainter extends CustomPainter { ...@@ -132,11 +168,11 @@ class _CupertinoActivityIndicatorPainter extends CustomPainter {
final int activeTick = (_kTickCount * position.value).floor(); final int activeTick = (_kTickCount * position.value).floor();
for (int i = 0; i < _kTickCount; ++ i) { for (int i = 0; i < _kTickCount * progress; ++i) {
final int t = (i + activeTick) % _kTickCount; final int t = (i - activeTick) % _kTickCount;
paint.color = activeColor.withAlpha(_alphaValues[t]); paint.color = activeColor.withAlpha(progress < 1 ? _partiallyRevealedAlpha : _alphaValues[t]);
canvas.drawRRect(tickFundamentalRRect, paint); canvas.drawRRect(tickFundamentalRRect, paint);
canvas.rotate(-_kTwoPI / _kTickCount); canvas.rotate(_kTwoPI / _kTickCount);
} }
canvas.restore(); canvas.restore();
...@@ -144,6 +180,6 @@ class _CupertinoActivityIndicatorPainter extends CustomPainter { ...@@ -144,6 +180,6 @@ class _CupertinoActivityIndicatorPainter extends CustomPainter {
@override @override
bool shouldRepaint(_CupertinoActivityIndicatorPainter oldPainter) { 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'; ...@@ -13,8 +13,9 @@ import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'activity_indicator.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 { class _CupertinoSliverRefresh extends SingleChildRenderObjectWidget {
const _CupertinoSliverRefresh({ const _CupertinoSliverRefresh({
...@@ -294,7 +295,7 @@ class CupertinoSliverRefreshControl extends StatefulWidget { ...@@ -294,7 +295,7 @@ class CupertinoSliverRefreshControl extends StatefulWidget {
Key key, Key key,
this.refreshTriggerPullDistance = _defaultRefreshTriggerPullDistance, this.refreshTriggerPullDistance = _defaultRefreshTriggerPullDistance,
this.refreshIndicatorExtent = _defaultRefreshIndicatorExtent, this.refreshIndicatorExtent = _defaultRefreshIndicatorExtent,
this.builder = buildSimpleRefreshIndicator, this.builder = buildRefreshIndicator,
this.onRefresh, this.onRefresh,
}) : assert(refreshTriggerPullDistance != null), }) : assert(refreshTriggerPullDistance != null),
assert(refreshTriggerPullDistance > 0.0), assert(refreshTriggerPullDistance > 0.0),
...@@ -330,9 +331,6 @@ class CupertinoSliverRefreshControl extends StatefulWidget { ...@@ -330,9 +331,6 @@ class CupertinoSliverRefreshControl extends StatefulWidget {
/// A builder that's called as this sliver's size changes, and as the state /// A builder that's called as this sliver's size changes, and as the state
/// changes. /// 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 /// Can be set to null, in which case nothing will be drawn in the overscrolled
/// space. /// space.
/// ///
...@@ -361,43 +359,68 @@ class CupertinoSliverRefreshControl extends StatefulWidget { ...@@ -361,43 +359,68 @@ class CupertinoSliverRefreshControl extends StatefulWidget {
return state.refreshState; return state.refreshState;
} }
/// Builds a simple refresh indicator that fades in a bottom aligned down /// Builds a refresh indicator that reflects the standard iOS pull-to-refresh
/// arrow before the refresh is triggered, a [CupertinoActivityIndicator] /// behavior. Specifically, this entails presenting an activity indicator that
/// during the refresh and fades the [CupertinoActivityIndicator] away when /// changes depending on the current refreshState. As the user initially drags
/// the refresh is done. /// down, the indicator will gradually reveal individual ticks until the refresh
static Widget buildSimpleRefreshIndicator( /// 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, BuildContext context,
RefreshIndicatorMode refreshState, RefreshIndicatorMode refreshState,
double pulledExtent, double pulledExtent,
double refreshTriggerPullDistance, double refreshTriggerPullDistance,
double refreshIndicatorExtent, double refreshIndicatorExtent,
) { ) {
const Curve opacityCurve = Interval(0.4, 0.8, curve: Curves.easeInOut); final double percentageComplete = pulledExtent / refreshTriggerPullDistance;
return Align(
alignment: Alignment.bottomCenter, // Place the indicator at the top of the sliver that opens up. Note that we're using
child: Padding( // a Stack/Positioned widget because the CupertinoActivityIndicator does some internal
padding: const EdgeInsets.only(bottom: 16.0), // translations based on the current size (which grows as the user drags) that makes
child: refreshState == RefreshIndicatorMode.drag // Padding calculations difficult. Rather than be reliant on the internal implementation
? Opacity( // of the activity indicator, the Positioned widget allows us to be explicit where the
opacity: opacityCurve.transform( // widget gets placed. Also note that the indicator should appear over the top of the
min(pulledExtent / refreshTriggerPullDistance, 1.0) // dragged widget, hence the use of Overflow.visible.
), return Center(
child: Icon( child: Stack(
CupertinoIcons.down_arrow, overflow: Overflow.visible,
color: CupertinoDynamicColor.resolve(CupertinoColors.inactiveGray, context), children: <Widget>[
size: 36.0, Positioned(
), top: _kActivityIndicatorMargin,
) left: 0.0,
: Opacity( right: 0.0,
opacity: opacityCurve.transform( child: _buildIndicatorForRefreshState(refreshState, _kActivityIndicatorRadius, percentageComplete),
min(pulledExtent / refreshIndicatorExtent, 1.0) ),
), ],
child: const CupertinoActivityIndicator(radius: 14.0),
),
), ),
); );
} }
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 @override
_CupertinoSliverRefreshControlState createState() => _CupertinoSliverRefreshControlState(); _CupertinoSliverRefreshControlState createState() => _CupertinoSliverRefreshControlState();
} }
......
...@@ -71,15 +71,79 @@ void main() { ...@@ -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. // Regression test for https://github.com/flutter/flutter/issues/41345.
testWidgets('has the correct corner radius', (WidgetTester tester) async { testWidgets('has the correct corner radius', (WidgetTester tester) async {
await tester.pumpWidget( await tester.pumpWidget(
const CupertinoActivityIndicator(animating: false, radius: 100), 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( expect(
find.byType(CupertinoActivityIndicator), 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() { ...@@ -1329,48 +1329,54 @@ void main() {
); );
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
testWidgets('buildSimpleRefreshIndicator dark mode', (WidgetTester tester) async { testWidgets('buildRefreshIndicator progress', (WidgetTester tester) async {
const CupertinoDynamicColor color = CupertinoColors.inactiveGray;
await tester.pumpWidget( await tester.pumpWidget(
MediaQuery( Directionality(
data: const MediaQueryData(platformBrightness: Brightness.light), textDirection: TextDirection.ltr,
child: Directionality( child: Builder(
textDirection: TextDirection.ltr, builder: (BuildContext context) {
child: Builder( return CupertinoSliverRefreshControl.buildRefreshIndicator(
builder: (BuildContext context) { context,
return CupertinoSliverRefreshControl.buildSimpleRefreshIndicator( RefreshIndicatorMode.drag,
context, 10, 100, 10,
RefreshIndicatorMode.drag, );
10, 10, 10, },
);
},
),
), ),
), ),
); );
expect(tester.widget<CupertinoActivityIndicator>(find.byType(CupertinoActivityIndicator)).progress, 10.0 / 100.0);
expect(tester.widget<Icon>(find.byType(Icon)).color.value, color.color.value);
await tester.pumpWidget( await tester.pumpWidget(
MediaQuery( Directionality(
data: const MediaQueryData(platformBrightness: Brightness.dark), textDirection: TextDirection.ltr,
child: Directionality( child: Builder(
textDirection: TextDirection.ltr, builder: (BuildContext context) {
child: Builder( return CupertinoSliverRefreshControl.buildRefreshIndicator(
builder: (BuildContext context) { context,
return CupertinoSliverRefreshControl.buildSimpleRefreshIndicator( RefreshIndicatorMode.drag,
context, 26, 100, 10,
RefreshIndicatorMode.drag, );
10, 10, 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