Glitch of iOS Interactive Animation

Introduction

In iOS development, there is an advanced animation trick - offering an interactive animation by setting the corresponded CAMediaTiming conformed object's speed property to 0 and manipulating the value of its timeOffset property with user interaction (whatever the root layer of the layer tree the animation happens to or the animation happens to the layer tree, because both CALayer and CAAnimation is conformed to CAMediaTiming, and CAAnimation inherits the time space from the layer which it installed.).

func installAnimation() {  
    // Create animation
    let animation = CABasicAnimation()
    // Set the animation up...

    // Add the animation to the layer
    layer.addAnimation(animation, forKey: animation.keyPath)
    // Set the layer's speed to 0
    layer.speed = 0
}

func manipulateAnimation(input: Double) {  
     // Manipulate layer's timeOffset
     layer.timeOffset = CFTimeInterval(input)
}

If you don’t know the technique enough, you can learn it with following materials:

  1. Technical Q&A QA1673, How to pause the animation of a layer tree
  2. WWDC 2013 Session 228 Hidden Gems in Cocoa and Cocoa Touch

The trick works great when you don’t have long-existing interactive animations on screen. But once you have one, say, an interactively dissolved blur background for your navigation bar which connects the table view header and body seamlessly, the glitch comes with it.

Glitch in Practice

Goes through a view controller’s life time, the view controller’s root view object may be moved on a screen, off a screen, to a window, out of a window over and over again — which caused by application activity changes and the containing view controller’s behaviors(such as UITabBarController’s selecting tabs or UINavigationController’s pushing and popping view controllers). This not only fires system notifications like UIApplicationWillResignActivity and UIApplicationDidBecomeActive, or system APIs like UIView's instance function: willMoveTo(window:) and didMoveToWindow(), but also causes the CoreAnimation framework swipes all the animations away from anywhere on the root view’s layer tree. This mechanism breaks the expectation on long-existing interactive animations: they are removed when the app goes to the background or the view controller gets blown away.

img

You might think: That’s easy. We can set all things up over again when the view was moved to a window or the application became active.

Yeah. Easy like that. But there is still a glitch: you would find it is impossible to get the animation restored to the state what right before it was removed.

I just have built an example project to simulate the situation. Clone the example project, and switch to interactive-animation-cannot-restore branch, you will go to the same dire strait — the navigation bar’s blur effects cannot be restored when you switched to another tab and then come back or put the app to the background and then bring it to the foreground.

Let’s analyze the example project:

  1. The navigation bar’s internal visual effect view’s blurring animation is setup when the navigation bar was moved to a window

  2. The navigation bar’s internal visual effect view’s blur effect is set to nil when the navigation bar was moved out of a window

  3. The table view controller sends its scroll view’s did-scroll message to the delegate

  4. The navigation controller is the table view controller’s delegate which receives its scroll view’s did-scroll message and translate to the navigation bar’s backgroundAlpha.

  5. The navigation bar translates backgroundAlpha to the internal visual effect view’s blurring animation’s time-offset and separator view’s alpha.

Since the visual effect view’s blurring animation is controlled by the layer’s speed and timeOffset properties. By reflecting them in the code, they are:

backgroundView.layer.speed = 0  

in NavigationBar’s setup() function;

backgroundView.layer.timeOffset = CFTimeInterval(_clamppedBackgroundAlpha)  

in NavigationBar’s _installVisualEffectsAnimation() function;

and

backgroundView.layer.timeOffset = timeOffset  

in NavigationBar’s backgroundAlpha’s didSet observer.

The speed = 0 makes the animation being not played, it seems works correctly. So the rest is timeOffset. Since CAMediaTiming is a mysterious protocol — even not well documented nowadays nor being used widely, you might think that there might be other properties made the animation cannot be restored. But even it was true, we still should check timeOffset firstly.

Subtleness in the Framework

According to the document, timeOffset in CAMediaTiming specifies an additional time offset in active local time. With it, we can conjecture that if the timeOffset property works correctly, the code shall work. But the truth conflicts with it. Is timeOffset property not correctly documented? Or firstly, we shall create some examples to help us understand how exactly timeOffset works. I have also built a playground page, which you can download it here.

img

With this playground page, you can adjust the animation’s timeOffset with the slider. And you also can adjust the initialTimeOffset. By setting the initialTimeOffset to 0 and 0.5, you would find an interesting fact: by moving the grabber of the slider to the most right side, you are getting two different results.

What can we conjecture from this fact? Maybe, I mean maybe, maybe the CoreAnimation doesn’t get the animations played as soon as they were installed on a layer(when the UIView animation block was executed which reflected in UIKit), and setting the layer’s timeOffset as soon as the animations were installed makes time-space of the installed animations offset alongside the layer. Another evidence supports this conjecture is that CoreAnimation is a transactional animation system — which requires a little time slice to organize the transactions — and there do be such a native little time slice — Run Loop, and what I talked about above might happen in this kind of little time slice.

Since there is no convenient way to dispatch tasks to a Run Loop’s next loop(though I have a framework can do this, but let’s just assume that we don’t have such a thing), we can just left the backgroundView.layer.timeOffset = 0 in NavigationBar’s _installVisualEffectsAnimation() function, and use NSObject’s perform(_:with:afterDelay:) function to fire the time-offset fixing selector by settings the delay to 0.001 — which is hard to notice by human eyes but must be longer than a Run Loop’s loop. You can check the actual code by checking out the example project’s interactive-animation-can-restore-naive-approach branch.

The interactive animation now can be restored correctly now.

Moreover

But with the delay-by-time selector performer, the result is theoretically not guaranteed. Is there a deterministic way to do the same thing?

YES. As I said before, I’ve build a framework could conveniently dispatch tasks to a Run Loop’s next loop. By checking out the example project’s interactive-animation-can-restore-systematic-approach branch and running git submodule update --init --recursive to initialize all the submodules, you can now check out the code.

You can find the naive implementation fires delay-by-time selector performer were replaced with:

RunLoop.main.schedule(in: .defaultRunLoopMode, when: .nextLoopBegan) {  
    let timeOffset = CFTimeInterval(self._clamppedBackgroundAlpha)
     self.backgroundView.layer.timeOffset = timeOffset
}

If you are interested in how it works, you can go to the Nest project and check the framework out.

WeZZard

Independent iOS developer, World of Warcraft add-on developer. Interested in Computer Graphics and Machine Learning.

People's Republic of China https://github.com/WeZZard