Premature Completion: An Embarrassing Problem

Working on a project recently, Jerry and I came across an odd bug. We have a two-level UI that allows the user to navigate between several different scroll views. For the sake of keeping things pretty, we want to reset a given scroll view before going back to the navigation interface paradigm. No problem, right? Something like this should do the trick:

[UIView animateWithDuration:2.f animations:^{
		[scrollView scrollRectToVisible:CGRectMake(0, 0, 10, 10) animated:YES];
	}completion:^(BOOL finished){
		[self.delegate resumeNavigation];
	}];

Turns out, nope! Somehow this runs the completion block in parallel with the animation block. The solution? Simply changing the YES to a NO in the scrollRectToVisible call.

But Why?

It seems that Core Animation (and therefore UIView animation) does some unexpected, not entirely welcome magic behind the scenes. If there is nothing to do in the animation block, the framework shrugs and runs the completion block immediately, rather than waiting the specified duration.

My guess, and this is just a guess, is that internally Core Animation depends on the animationDidStop:finished: delegate callback from a CAAnimationGroup or similar it sets up to handle everything that happens inside an animation block. When that callback fires, CA knows it’s time to kick off the completion block. If there are no animations created, there is nothing to send a callback. Rather than set a timer to wait for nothing to happen, the completion block runs right away, because why not?

This is seductive reasoning, and has the advantage of being easy to code. Unfortunately, it’s not always what the user of the API expects. (I would venture to say never!) In our case, it means the naive code fails because (again, guessing) asking the scroll view to animate its scrolling sets up another animation context. Asking it not to animate, by contrast, allows Core Animation to create an implicit animation and work its magic.

What’s the Solution?

Unfortunately, for us API clients there really isn’t one. This is more of an informational post than anything: once you’re aware this can be a problem, you may save yourself hours you otherwise would’ve spent in fruitless debugging. Believe me, I’ve been there on this issue.

The best thing you can do is play around and see where exactly unexpected things happen.  As long as you keep the “are there any animations created?” question in mind, whatever behavior you see will be easy to explain. But reasoning a priori and figuring out what to expect is impossible without inside knowledge of the implementation of UIKit. Which brings us to…

Toys

To see just what was going on, I put together a tiny sample project. You can find it here. It has a UIProgressView and a UITextView, both set up to perform some transition either animated or not. The important part of the code looks like this:

- (void)party:(BOOL)animated
{
	[UIView animateWithDuration:2.f animations:^{
		[self.partyProgress setProgress:1 animated:animated];
	}completion:^(BOOL finished){
		self.partyLabel.text = @"PARTY TIME!";
	}];
}

- (void)study:(BOOL)animated
{
	[UIView animateWithDuration:2.f animations:^{
		[self.studyView scrollRectToVisible:CGRectMake(0, 0, 10, 10) animated:animated];
	}completion:^(BOOL finished){
		self.studyLabel.text = @"STUDY TIME!";
	}];
}

Try scrolling the text view all the way to the bottom, then compare the difference between animating and not animating inside of an animation block. Then do the same with the progress view. Why would they be different?

There are plenty of other classes in UIKit with methods that take an animated: argument. I encourage you to plug them into this test app to see just what happens. Protect yourself from being surprised next time. Take any precaution you can against the embarrassment and social shunning that inevitably accompany premature completion.

Joel Kraut

About Joel Kraut

Developing on Apple platforms for, holy shit, over ten years now. Find me on linkedin and twitter. My personal website is foon.us.
This entry was posted in Explanation and tagged , , , , , , , , . Bookmark the permalink.

10 Responses to Premature Completion: An Embarrassing Problem

  1. Johann Fradj says:

    Hi,

    I think you misunderstood a key concept of UIView animation.
    When you use UIView animation you should only set animatable properties in the animation block and not call any function especially function that handle the animation themselves.

    [self.studyView scrollRectToVisible:CGRectMake(0, 0, 10, 10) animated:animated]; is a convenient method that compute for you the good contentOffset that will show the part of the scrollView you want (i.e. defined by the CGRect). And Apple add a parameter to animate that or not.
    Unfortunately they don’t provide any completion callback mechanism.

    So if you want a completion callback you just have to compute the good contentOffset and animate that property. And in your case the contentOffset will probably be really simple.

    So just use that code, and everything will be ok:

    [UIView animateWithDuration:2.f animations:^{
    scrollView.contentOffset = CGPointMake(0, 0);
    }completion:^(BOOL finished){
    [self.delegate resumeNavigation];
    }];

    • foon says:

      Indeed, this is a key concept. I do understand it, despite the fact that it isn’t documented. But it’s not the point. This blog post is meant to demonstrate how UIView animation blocks that don’t actually create any animations will call their completion blocks immediately. The example is contrived, and as you point out, easily fixed. Apparently I should have worked harder to make this clear.

      • Johann Fradj says:

        Ok so you’re agree that UIView animation is just fine because it doesn’t see anything to animate in your animation block (no value set to animatable properties = no animation to do) so it’s just like the animation is already finished and it calls your completion block directly.
        It can do anything else. Not calling the completion block immediately will be weird.

        Sorry I supposed you didn’t understand that key concept because it’s a common error (many of my students make that kind of errors).

        • foon says:

          The other option–and I would argue, the more reasonable option–is to fire the completion block after the duration passed to the UIView method, whether or not an animation has been created.
          Consider a situation where the user animates a view that may or may not be onscreen. Is it better to do what the user expects, and run the completion after the animation duration regardless? Or should he have to special case and set up his own dispatch_after only if the view won’t animate due to implementation details in CA?

          • Johann Fradj says:

            Hahaha,

            I’ll argue that creating software making synchronisation based on timer is really bad ;-)

            Also I want to say that it is a completion block and as it’s name supposed it’s a block that has to be called when the animation complete. And for me – and apparently for Apple too -, when there is nothing to animate, the job is already done ;-) so the animation duration is useless. That parameter as a good name too: animationDuration, so if there is nothing to animate it should take no time to do it ;-)
            (Just a note: if you use the delay parameter, you’ll see that the animation block is called after the delay, which is completely logic.)

          • ultrajoke says:

            Not synchronization, just UI timing. I disagree that the word “completion” should be read so strictly, but I see your point. For me it’s just about what the user of the API will expect. I think people won’t necessarily think through all the implications of “completion”, but I could certainly be wrong.

          • foon says:

            Whoops, I seem to be switching between disqus users. Foon and ultrajoke are both the author :)

  2. Josh Converse says:

    I’m late to this party, but there’s a pretty reasonable workaround to this behavior:

    Explicit CATransactions

    Since some of the Apple APIs don’t provide a completion block for animated methods (the ideal solution), you can cheat with CA a bit. Most/all of these calls will themselves create either an implicit or explicit transaction under the hood. Because transactions are naturally nestable, we can inject a bit of order into the chaos by creating our own explicit CATransaction and adding a completion block. Whatever transactions occur during the Apple-animated method will be a part of our transaction, and we can take advantage of that.

    Obviously your mileage may vary, and I haven’t tested this on anything but a UIProgressView. Also, in animation-heavy contexts, this probably breaks down. It ought to work for simple enough cases though. I’m using it to wait until my progress view finishes filling up before dismissing the view controller that it lives in.

    For example:

    #import

    [CATransaction begin];
    [CATransaction setCompletionBlock:^{
    NSLog(@”Got completion block”);
    }];
    [myUIProgressView setProgress:progress animated:YES];
    [CATransaction commit];

  3. Brian Nickel says:

    I’m a bit late but I believe the problem you were facing is that animated scrolling does not use CAAnimations, or if it does, not in the sense we’re used to.

    When a scroll view scrolls, animated or by touch, it fires off a bunch of scrollViewDidScroll: messages. This is critical in cases where you want an element fixed to the top of the scroll view, or in table views where they are constantly shuffling cells around. To do this, scroll views must be doing a sequence of scroll, evaluate, scroll, evaluate…

    In the case of animated:NO, the scroll view is saying “scroll X pixels instantly”, in the case of animated:YES, it is saying something like scroll 10px, in 0.02 seconds. This is what’s tripping you up.

    A general rule you could use is: if you don’t layout in a scrollview delegate, use animated:NO. If you do, use animated:YES and listen for the appropriate scroll view didFinish events for your next action.

  4. Bro says:

    I’m the latest of all to the party.

    The solution is to use a CATransaction to determine when an animation which you do not own (i.e. a progress view animation) is actually completed.

    [CATransaction setCompletionBlock:^{
    self.partyLabel.text = @”PARTY TIME!”;
    }];

    [UIView animateWithDuration:2.f animations:^{
    [self.partyProgress setProgress:1 animated:animated];
    }completion:^(BOOL finished){
    self.partyLabel.text = @”PARTY TIME!”;
    }];

    I found the solution on Stack Overflow:

    http://stackoverflow.com/questions/16367997/wait-for-animation-to-finish-in-ios/16368679#16368679

Leave a Reply

Your email address will not be published. Required fields are marked *