Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

iOS mid-drag activity indicator #58392

Merged
merged 25 commits into from Jun 23, 2020

Conversation

edwardaux
Copy link
Contributor

@edwardaux edwardaux commented May 31, 2020

Description

The current iOS pull-to-refresh action renders a down-arrow as the user is pulling downwards, but this is not how iOS behaves. Rather, it should progressively reveal the activity ticks one-by-one until the refresh action is triggered, at which point the activity indicator starts animating.

pull-to-refresh

This PR modifies CupertinoActivityIndicator to add the ability to pass a progress value so that the indicator ticks can be revealed one-by-one.

I'm currently asserting that the value of progress is non-null and between 0.0 and 1.0 (inclusive), however I'm not sure whether a better approach could be to just ensure that it is non-null and clamp internally.

It also modifies the default builder in CupertinoSliverRefreshControl to selectively render a different version of the activity indicator depending on the current pull-to-refresh state.

Note that there is a subtle change in the way the activity indicator is drawn that results in a breaking golden image. In the old code, the first frame of the animation used to draw the brightest tick at 9 o'clock. However, the iOS in-progress tick reveal starts at 12 o'clock, so I added an initial 90 degree rotation to make the maths cleaner in the loop. See the different golden images below:

Original first frame New first frame
cupertino activityIndicator paused light_masterImage_466c7c9de2328f439a349dfe957ce4f6 cupertino activityIndicator paused light_testImage_466c7c9de2328f439a349dfe957ce4f6

As part of my tests, I also added three new golden images:

One Two Three
activityIndicator inprogress 0 0 activityIndicator inprogress 0 3 activityIndicator inprogress 1 0

One last comment... the original code use to render all 12 ticks anti-clockwise, however, the iOS in-progress ticks are revealed clockwise. This is why I switched the loop from ascending to descending.

Related Issues

There is (at least) one issue tracking this at #29159

Tests

I added the following tests:

  • Added golden file tests for several cases to catch future regressions.

Checklist

Before you create this PR confirm that it meets all requirements listed below by checking the relevant checkboxes ([x]). This will ensure a smooth and quick review process.

  • I read the [Contributor Guide] and followed the process outlined there for submitting PRs.
  • I signed the [CLA].
  • I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement].
  • I read the [Tree Hygiene] wiki page, which explains my responsibilities.
  • I updated/added relevant documentation (doc comments with ///).
  • All existing and new tests are passing.
  • The analyzer (flutter analyze --flutter-repo) does not report any problems on my PR.
  • I am willing to follow-up on review comments in a timely manner.

Breaking Change

Did any tests fail when you ran them? Please read [Handling breaking changes].

I've ticked that it is breaking, because of the golden image change. However, given this image is part of a rotating animation, I don't believe that it is truly "breaking".

@Piinks This is my first contribution using golden files, so looping you in per guidance in the Golden Test page. Apologies if I have missed any steps or done them incorrectly.

@fluttergithubbot fluttergithubbot added f: cupertino flutter/packages/flutter/cupertino repository framework flutter/packages/flutter repository. See also f: labels. labels May 31, 2020
@edwardaux
Copy link
Contributor Author

I'm not sure why customer_testing-windows is failing. The errors appear to be coming from somewhere totally unrelated to this PR:

Golden "test-maps/sorting/isometric_windows.png": Pixel test failed, 0.13% diff detected.

@Piinks Piinks self-assigned this Jun 2, 2020
Copy link
Contributor

@Piinks Piinks left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @edwardaux welcome! Thank you for the contribution!

The failing golden file test in the customer_testing-windows shard is unrelated to your change. I believe if you update your branch with the latest from master it will pass.

I am happy to help land the golden files you are adding in this PR. I think @xster or @LongCatIsLooong may have some thoughts here on this change.

This is a breaking change since a pre-existing test is altered here and the default behavior is changing. Please see our wiki on making breaking changes. I recommend creating a design document (the template is linked there) so you can gather feedback from other contributors in successfully making this change. :)

Related issues for reference: #29159 and #16069

testWidgets('buildSimpleRefreshIndicator dark mode', (WidgetTester tester) async {
const CupertinoDynamicColor color = CupertinoColors.inactiveGray;

testWidgets('buildAppleRefreshIndicator progress', (WidgetTester tester) async {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why has this test changed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The old test used to check for the presence of an icon (the down-arrow) and make sure that the colour of the icon was correct. The change introduced in this PR (which matches iOS implementation) doesn't have an icon any more, so now the test makes sure that the in-progress calculations are correct.

/// 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 animated back to closed.
static Widget buildAppleRefreshIndicator(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the name change? I think we avoid using 'Apple' explicitly in the framework.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The old name was buildSimpleRefreshIndicator which, to me, was accurate in that it was a simple implementation that didn't deal with the complexities of how iOS renders its indicator. Having the full implementation labelled "simple" didn't feel quite right. Your point is taken about referencing Apple, though, so I've renamed it to simply buildRefreshIndicator

@Piinks Piinks removed their assignment Jun 2, 2020
@Piinks Piinks added f: scrolling Viewports, list views, slivers, etc. c: API break Backwards-incompatible API changes labels Jun 2, 2020
@edwardaux edwardaux force-pushed the ios-in-progress-activity-indicator branch from 1f05d3c to 24852d3 Compare June 2, 2020 22:54
Copy link
Contributor

@Piinks Piinks left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be made non-breaking by introducing a flag on the refresh indicator. This flag could specify which refresh indicator to use. It would use the previous behavior by default and the new behavior as an opt-in.

@Piinks Piinks added a: fidelity Matching the OEM platforms better platform-ios iOS applications specifically a: quality A truly polished experience labels Jun 3, 2020
@edwardaux
Copy link
Contributor Author

... It would use the previous behavior by default and the new behavior as an opt-in.

I did consider adding a flag, however I guess my concern with this is that by having the existing behaviour as the default, we're actively promoting the incorrect user experience. I feel like all developers who have explicitly chosen to use CupertinoActivityIndicator would want the default behaviour to match the iOS behaviour.

Perhaps it comes down to the definition of "breaking". To me, it feels like we're fixing a defect in the current implementation.

@edwardaux
Copy link
Contributor Author

FYI, design doc is located here: http://flutter.dev/go/ios-pull-to-refresh

Copy link
Contributor

@justinmc justinmc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the high quality contribution! Most of my comments are just small code style changes. The design doc looks good.

}) : assert(animating != null),
assert(radius != null),
assert(radius > 0),
assert(progress != null),
assert(progress >= 0),
assert(progress <= 1),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: We usually include the .0 for doubles, just to clarify that it is a double.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Also updated the radius variable above.

@@ -128,13 +143,17 @@ class _CupertinoActivityIndicatorPainter extends CustomPainter {
canvas.save();
canvas.translate(size.width / 2.0, size.height / 2.0);

// The standard iOS implementation has the top tick appearing first,
// so need to rotate so that that is the first one that gets drawn
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs a period at the end of the sentence. If you want you could probably even simplify this to:

// Rotate so that the top tick is the first to be drawn.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

/// the refresh is done.
static Widget buildSimpleRefreshIndicator(
/// Builds a refresh indicator that reflects the standard iOS pull-to-refresh
/// behaviour. Specifically, this entails presenting an activity indicator that
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

/// 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 animated back to closed.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"animated" => "animates"

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

/// behaviour. 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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Would it help to include references to the states like [RefreshIndicatorMode.armed] here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had a couple of goes at trying this, but each attempt resulted in an unwieldy mouthful.

At the moment, the function description is more like an overview, rather than an explanation of the internal mechanics. As a consumer of this function, I'm not sure if I would really need the gory details, which seem reasonably clear in the switch statement further down, but I'm happy to try further if you feel it is warranted.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No worries, I'm happy with leaving it as-is. Thanks for taking a look.

),
);
}

static Widget _buildIndicatorForRefreshState(RefreshIndicatorMode refreshState, double radius, double percentageComplete) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I've been called out on my PRs before for using static methods that return widgets and told to just make a new StatelessWidget instead. In this case I realize that buildSimpleRefreshIndicator was already guilty of this though :) Just something to keep in mind if you see a better way to do this, otherwise it's fine as-is.

);
case RefreshIndicatorMode.armed:
case RefreshIndicatorMode.refresh:
// Once we're armed or performing the refresh, we just show the normal spinner
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Period at the end of the sentence here and two more comments below.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

// Once we're armed or performing the refresh, we just show the normal spinner
return CupertinoActivityIndicator(radius: radius);
case RefreshIndicatorMode.done:
// When the user let's go, the standard transition is to shrink the spinner
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"let's" => "lets"

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

@@ -1327,48 +1327,40 @@ void main() {
);
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));

testWidgets('buildSimpleRefreshIndicator dark mode', (WidgetTester tester) async {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this still be tested in dark mode one way or another?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excellent question. We don't do anything specific depending on what mode we're in... not sure if the standard practice is to always do a light and dark mode test, or only if the widget actually does something different. Do you know if there's any specific guidance one way or another?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, it's not necessary if the change doesn't have any special dark mode behavior. But why was this test replaced? Could you keep the original test and add your new one, or does the original test conflict with the new behavior?

Copy link
Contributor

@justinmc justinmc Jun 8, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I see your response to @Piinks above asking about the same thing. This is fine as-is since the dark mode specific behavior was removed. 👍

@justinmc
Copy link
Contributor

justinmc commented Jun 5, 2020

I'm on board with this being a breaking change rather than opt-in. It seems worth the effort to make this look correct, and hopefully it's just a couple of visual diff tests that need to be updated.

@Piinks
Copy link
Contributor

Piinks commented Jun 5, 2020

I'm on board with this being a breaking change rather than opt-in. It seems worth the effort to make this look correct, and hopefully it's just a couple of visual diff tests that need to be updated.

I concur! :)

Copy link
Contributor

@LongCatIsLooong LongCatIsLooong left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is awesome, thank you!

Is the golden change the only breaking change? If that's the case since the const List<int> _alphaValues = <int>[147, 131, 114, 97, 81, 64, 47, 47, 47, 47, 47, 47]; array is not used when progress < 1, can we just shift this array so when progress == 1 the first frame looks the same?

@@ -107,6 +120,7 @@ class _CupertinoActivityIndicatorPainter extends CustomPainter {
@required this.position,
@required this.activeColor,
double radius,
@required this.progress,
}) : tickFundamentalRRect = RRect.fromLTRBXY(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: it would make the paint logic slightly easier to understand if tickFundamentalRRect starts off from a vertical position, instead of making the adjustment later using canvas.rotate(math.pi / 2);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

/// 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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems when progress is under 1, setting animating to true or false makes no difference visually? Maybe we should assert(!animating) if progress < 1 , since progress basically takes control from the state's animation controller and it doesn't really make too much sense to have the animation running when progress is manually set?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1. This is implicitly controlling whether it's static or animating. It should definitely be pointed out in the docs. Perhaps allow null and make it mutually exclusive with animating? Since it's a bit harder to explain that 1 specific value of this double has a secondary meaning.

I also strongly encourage consistency with https://api.flutter.dev/flutter/material/ProgressIndicator/value.html

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if it is worth creating an explicit constructor for the "progressive release" version? That might help with trying to keep the mutually exclusive values separate... not sure if there's any specific guidelines around when we should/shouldn't create a separate constructor?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that's a great idea! Many widgets have more than one constructor so it doesn't look like a problem to me.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have added a new constructor called CupertinoActivityIndicator.partiallyRevealed() but am open to suggestions about better naming (I'm not super-thrilled about what I have now). Also updated some of the internal comments to be more accurate.

@@ -42,6 +46,14 @@ class CupertinoActivityIndicator extends StatefulWidget {
/// Defaults to 10px. Must be positive and cannot be null.
final double radius;

/// Determines the number of spinner segments that will be show. Typical usage would
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe the percentage of spinner segments? The value ranges from 0 to 1. Also I think most people would expect the segments to distribute evenly when progress < 1, or even mistake this parameter for something that can be used to configure the total number of segments.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

@@ -120,6 +134,7 @@ class _CupertinoActivityIndicatorPainter extends CustomPainter {
final Animation<double> position;
final RRect tickFundamentalRRect;
final Color activeColor;
final double progress;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldRepaint should return true when progress changes. Not sure why it's repainting, maybe it's because the indicator is inside a LayoutBuilder.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

canvas.drawRRect(tickFundamentalRRect, paint);
canvas.rotate(-_kTwoPI / _kTickCount);
canvas.rotate(_kTwoPI / _kTickCount);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you change the rotate direction here, doesn't it counteract the iteration direction change?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's three things all going on here at once: 1) looping through rendering each tick when spinning, 2) pulling out an opacity for each tick, and 3) incrementally revealing each tick when progress < 1.

The first two don't really matter what sequence you do them in (or rotate) as long as you get the indexes correct for the colours (this is because in the finish, all 12 of them need to be drawn). However, the last case /must/ reveal the ticks in specific order (clockwise).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank for cleaning this up! Now the _alphaValues array is a lot easier to reason about.

final int activeTick = (_kTickCount * position.value).floor();

for (int i = 0; i < _kTickCount; ++ i) {
for (int i = ((_kTickCount - 1) * progress).toInt(); i >= 0; --i) {
final int t = (i + activeTick) % _kTickCount;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Should this be
final int t = (i - activeTick) % _kTickCount;
so the segment sitting at i == activeTick becomes the most visible one, when progress == 1 (But watch out this changes the direction of the opacity gradient too)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've adjusted most of the maths to be simpler (well, as simple as it can be)

final int t = (i + activeTick) % _kTickCount;
paint.color = activeColor.withAlpha(_alphaValues[t]);
paint.color = activeColor.withAlpha(progress < 1 ? _alphaValues[0] : _alphaValues[t]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe mention this color change in the API documentation.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've extracted that out into a private constant to be clearer

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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: native UIRefreshIndicators dismiss with a 0.3s shrink + fadeout animation (independent of percentageComplete)

@@ -128,13 +143,16 @@ class _CupertinoActivityIndicatorPainter extends CustomPainter {
canvas.save();
canvas.translate(size.width / 2.0, size.height / 2.0);

// Rotate so that the top tick is the first to be drawn.
canvas.rotate(math.pi / 2);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

optional: but this function would look cleaner if we just drew from the top first (adjust the tickFundamentalRRect if needed) instead of having to rotate twice in the paint.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that I had to update a testcase to reflect this change because the testcase assumes that the 9 o'clock tick is the one that gets drawn first. I added a comment in the testcase for clarity.

@edwardaux
Copy link
Contributor Author

I think this is ready for review again now.

There are still a couple of minor gaps in fidelity that I'm aware of:

  • As the indicator goes into armed state, iOS has a slight scale pop of a couple of pixels but we don't.
  • As the indicator transitions from static to spinning, iOS begins spinning with all ticks having the same opacity and fades in the different opacities for the ticks, whereas we change the opacities instantly from all being the same to being varied.

How vital is it that we implement these?

Copy link
Contributor

@justinmc justinmc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, just some nits below.

I checked out the code and tried it and it looks and feels good to me.

I also tried some native iOS apps to see the differences mentioned above and I do see what you mean. In my opinion it's ok for us to do these kinds of things incrementally. Could you create a new issue (or two) for those?

case RefreshIndicatorMode.drag:
// While we're dragging, we draw individual segments of the spinner while simultaneously
// easing the opacity in.
const Curve opacityCurve = Interval(0.0, 0.2, curve: Curves.easeInOut);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't want to answer for @xster, but I would say it's definitely valuable to document that. "These values were obtained by stepping through a screen recording of <native widget> on <device> running <iOS version>."

I have lots of comments floating around stating that I just loosely eyeballed some value. It's valuable because if someone comes along with a value they got with a more precise method, they can be confident that their value is better than mine and they should update it.

// 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 early implementation for the activity indicator starting drawing
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// An early implementation for the activity indicator starting drawing
// An earlier implementation for the activity indicator started drawing

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With

const List<int> _alphaValues = <int>[47, 47, 47, 47, 64, 81, 97, 114, 131, 147, 47, 47];

the starting position of the activity indicator remains the same (as the current behavior on master, the most visible segment appears at 9 o'clock initially). Since the native activity indicator also starts at 9 o'clock, I think we should keep it as is to avoid introducing breaking changes.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've modified the order of the values in the list so that the most prominent tick starts at 9 o'clock, and have added some comments explaining the meaning of the values.

Regarding the "breaking" test case... there's a bit of subtlety here. This test case is designed to confirm that the rounded rectangle has been generated correctly and it happens to do so by inspecting the /first/ rrect that is painted.

My implementation draws the first rrect at 12 o'clock, whereas the old implementation used to be at 9 o'clock. However, both implementations still draw all 12 ticks... the only thing that has changed is the sequence that they get drawn in.

There are no user-facing breakages at all. This test case just happens to be very much a white-box test, and we've now changed the internal implementation details.

Hope that makes sense.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I see. I don't think having to change this specific test makes the PR a breaking change, it was meant to ensure the corner radius scales with the activity indicator.

What I meant by "breaking change" is the angle of the most prominent segment when animating is false. It used to be 9 o'clock and now its 11 o'clock. If we shift the alpha array to [47, 47, 47, 47, 64, 81, 97, 114, 131, 147, 47, 47] then it can go back to 9 o'clock. I doubt it will break anyone for real but it would be best if the appearance can remain exactly the same imo.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 I think I shuffled those entries in an earlier commit to reflect the original behaviour. All good.

Copy link
Contributor

@LongCatIsLooong LongCatIsLooong left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is already a good enough fidelity improvement to the existing refresh indicator as-is. I can open a new issue to keep track of the further improvements we wish to make in the future once the PR is merged.

case RefreshIndicatorMode.drag:
// While we're dragging, we draw individual segments of the spinner while simultaneously
// easing the opacity in.
const Curve opacityCurve = Interval(0.0, 0.2, curve: Curves.easeInOut);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the end value (0.2) correct? From what I'm seeing in Xcode the opacity seems to go up to 1.0 based on the drag distance.


@override
void paint(Canvas canvas, Size size) {
final Paint paint = Paint();

canvas.save();
canvas.translate(size.width / 2.0, size.height / 2.0);
canvas.translate(size.width / 2.0, radius);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This moves the center of the spinner to the top center from the center of the canvas, if the canvas has a big enough size. Is it because the activity indicator needs to be pinned to the top when it's in done state?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, precisely. Once the drag is fully complete, size.height / 2.0 should be the same as radius, however where we were coming unstuck was that during the initial stages of the drag, size.height is less than the diameter of the spinner and changes as the user drags. This has the undesirable effect of moving the spinner as the user drags down.

By using radius, we ensure that the translation is constant and the spinner doesn't move. I've added some comments to this effect.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Activity indicator is a public widget so we probably shouldn't change its behavior just to suit our needs. I think we might want to change the SliverGeometry the _RenderCupertinoSliverRefresh reports to its viewport (or we can add it to the list of things we want to improve later).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, of course ... that makes total sense. I've reverted that change and pushed a change that manipulates the sliver geometry.

While I was in there, I realised that the standard iOS pull-to-refresh interaction has a super-subtle effect where the first few pixels of the top tick get drawn out of the clipping bounds over the top of the content being dragged down. By adding the margin to the paintOrigin I achieve the same effect... however, it has the knock-on effect of impacting a couple of testcases.

At the time those test cases were written, the widget bounds that it was checking were correct, but I've had to adjust them slightly to accommodate these sliver geometry changes.

Let me know if I'm on the wrong path, or if there's a better way.

canvas.drawRRect(tickFundamentalRRect, paint);
canvas.rotate(-_kTwoPI / _kTickCount);
canvas.rotate(_kTwoPI / _kTickCount);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank for cleaning this up! Now the _alphaValues array is a lot easier to reason about.

// 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 early implementation for the activity indicator starting drawing
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With

const List<int> _alphaValues = <int>[47, 47, 47, 47, 64, 81, 97, 114, 131, 147, 47, 47];

the starting position of the activity indicator remains the same (as the current behavior on master, the most visible segment appears at 9 o'clock initially). Since the native activity indicator also starts at 9 o'clock, I think we should keep it as is to avoid introducing breaking changes.

geometry = SliverGeometry(
scrollExtent: layoutExtent,
paintOrigin: -overscrolledExtent - constraints.scrollOffset,
paintOrigin: topMargin + internalPainterDelta - overscrolledExtent - constraints.scrollOffset,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the delay. Thank you for putting detailed comments in the code!

Is it possible to add a padding to the activity indicator, instead of applying the paintOrigin change, and achieve the same thing? My concern is it might hurt the customizability of this widget, if the sliver has a hard-coded top margin.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, here's a gif showing the native behavior:
refresh-control

The native refresh control seems to become fully visible on screen (in terms of extent) as soon as the drag begins, and fades in as the user keeps dragging the content down.

When the refresh control becomes active (armed or refresh), it stays at the top unless the user drags the content up far enough, at which point it simply disappears. It reappears when the content goes back down.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can remove the height constraint from the BoxConstriants we apply one the child:

child.layout(
constraints.asBoxConstraints(
maxExtent: layoutExtent
// Plus only the overscrolled portion immediately preceding this
// sliver.
+ overscrolledExtent,
),
parentUsesSize: true,
, but that will be a hard breaking change, as it breaks every custom refresh indicator that doesn't have an intrinsic height.

@edwardaux
Copy link
Contributor Author

OK, this is looking much nicer. I've pulled the margin calculations out of the sliver into the widget that wraps the spinner.

These last changes also simplify a lot of the maths by removing the dependency on the internal implementation of the activity indicator (the padding calculations were previously a real pain to try and cater for the internal translation that the activity indicator does). It also means I can revert the test case changes which were, again, dependent on those calculations. Much cleaner now.

pull


// If the user has dragged less than the threshold, there is no activity indicator
// returned. Instead, an empty container is returned until the threshold is passed.
expect(find.byType(Container), findsOneWidget);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One test is failing because the Container is now gone. Could you fix it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm... that's weird. Somehow I didn't push that change up. Sorry... changes incoming.

// dragged widget, hence the use of Overflow.visible.
return Center(
child: Stack(
overflow: Overflow.visible,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh wow this is brilliant 👍

@LongCatIsLooong
Copy link
Contributor

👍 LGTM modulo the test failure.

Copy link
Contributor

@LongCatIsLooong LongCatIsLooong left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Thank you for your contribution!

I'll leave it open for another day in case @Piinks @justinmc @xster have something to add.

Copy link
Contributor

@Piinks Piinks left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an amazing contribution! 🎉 Thank you @edwardaux for your work on this. I had a few questions below about constants, otherwise LGTM!
I've prepped Gold for the updated files and will make sure all the goldens (new and updated) get checked in once this lands

import 'colors.dart';
import 'icons.dart';

const double _activityIndicatorRadius = 14.0;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit for constant:

Suggested change
const double _activityIndicatorRadius = 14.0;
const double _kActivityIndicatorRadius = 14.0;

I also noticed that in packages/flutter/lib/src/cupertino/activity_indicator.dart above, _kDefaultIndicatorRadius = 10.0, is this expected? I wasn't sure if they were intentionally different.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah, thanks. Have updated this constant, plus also the margin one you mentioned further down.

The default radius for the activity indicator has been set to 10 since the very initial commit (and it used as a default in the constructor, so I guess forms part of the public API)... any apps that just use the activity indicator (outside of the pull-to-refresh context) would expect for that to remain the same.

In the pull-to-refresh context, though, the default radius is set to 14 (since the original commit).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool! Thanks for checking!

overflow: Overflow.visible,
children: <Widget>[
Positioned(
top: 16.0,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this also a constant?

Copy link
Contributor

@justinmc justinmc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM after a re-review 👍

@Piinks Piinks merged commit 159557e into flutter:master Jun 23, 2020
@fluttergithubbot
Copy link
Contributor

Nice merge! 🎉
It looks like this PR made changes to golden files. If these changes have not been triaged as a tryjob, be sure to visit Flutter Gold to triage the results when post-submit testing has completed. The status of these tests can be seen on the Flutter Dashboard.
Also, be sure to include this change in the Changelog.

For more information about working with golden files, see the wiki page Writing a Golden File Test for package:flutter/flutter.

vasilich6107 added a commit to artflutter/whatsup_flutter_june2020 that referenced this pull request Jul 4, 2020
iOS mid-drag activity indicator
@BytesZero
Copy link

@edwardaux Thank you very much for your contribution, I really like this animation.

@ammarhemani
Copy link

I just noticed this difference in behavior, a couple of days ago when I had to use it in my ongoing-app. Thank you so much for this contribution!

mingwandroid pushed a commit to mingwandroid/flutter that referenced this pull request Sep 6, 2020
@github-actions github-actions bot locked as resolved and limited conversation to collaborators Aug 12, 2021
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
a: fidelity Matching the OEM platforms better a: quality A truly polished experience c: API break Backwards-incompatible API changes f: cupertino flutter/packages/flutter/cupertino repository f: scrolling Viewports, list views, slivers, etc. framework flutter/packages/flutter repository. See also f: labels. platform-ios iOS applications specifically
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

9 participants