Interactive Custom Container View Controller Transitions
In issue #12 of the excellent objc.io publication, Joachim Bondo wrote an article about using the view controller transitions API introduced in iOS 7, for supporting animated transitions in a custom container view controller. He concluded with the following words:
Note that we only support non-interactive transitions so far. The next step is supporting interactive transitions as well.
I will leave that as an exercise for you. It is somewhat more complex because we are basically mimicking the framework behavior, which is all guesswork, really.
In this article we will do that exercise, based on the same container view controller Joachim left for us at GitHub.
If you are not familiar with the APIs for view controller transitions, I would suggest you start by reading Joachims article and its many, linked resources. Also, note that even though all the API protocols seem to be safe to implement on our own, we will during this exercise find out that is not really the case. No need to worry though, there is always a way.
I spent most of my weekend trying to swizzle alloc, but then giving up and just solving it in raw assembly – Peter Steinberger
Setting the stage
Starting off from Joachims article, we are left with a ContainerViewController
catering three child view controllers. The current UI for switching between the child view controllers consists of a range of numbered buttons.
When tapping one of the buttons, a PrivateTransitionContext
object, conforming to the UIViewControllerContextTransitioning
protocol is created. It holds state for the transition to the new child view controller.
Through the ContainerViewControllerDelegate
protocol, a UIViewControllerAnimatedTransitioning
animator object is fed. It is responsible for executing a pretty animation, conveying the transition to the user. If the delegate does not return an animator, the default PrivateAnimatedTransition
is instanciated.
Finally, the ContainerViewController
itself cleans up child view controller relations when the animation is over.
The challenge
Our mission, should we choose to accept it; is to provide an interactive transitioning between the child view controllers in the container.
Since the default transition animation is a horizontal panning, a sensible interaction would be a panning touch gesture across the screen. Users should also be able to cancel an ongoing interactive transition if they wish.
In addition to the default interaction, we should extend the ContainerViewControllerDelegate
protocol so that other interactive transitions can be used. While different types of panning or pinching gestures are the most common inputs for interactive transitions, one could with a bit of creativity drive transitions using accelerometer data, button mashing, sound or even camera feeds.
Let's code
Just as in Joachims article, we will implement these new interactions in a few stages, namely four.
The XCode project, with tags marking each stage, can be found on GitHub.
Stage 4: Adding a gesture recognizer
First, we prepare the interaction by adding a new pan gesture recognizer to the container view. This is done by the new PanGestureInteractiveTransition
object, which at this stage only setups the gesture recognizer and lets the creator issue a block when a pan gesture begins recognizing.
__weak typeof(self) wself = self; self.defaultInteractionController = [[PanGestureInteractiveTransition alloc] initWithGestureRecognizerInView:self.privateContainerView recognizedBlock:^(UIPanGestureRecognizer *recognizer) { BOOL leftToRight = [recognizer velocityInView:recognizer.view].x > 0; NSUInteger currentVCIndex = [self.viewControllers indexOfObject:self.selectedViewController]; if (!leftToRight && currentVCIndex != self.viewControllers.count-1) { NSUInteger newIndex = currentVCIndex+1; [wself setSelectedViewController:self.viewControllers[newIndex]]; } else if (leftToRight && currentVCIndex > 0) { NSUInteger newIndex = currentVCIndex-1; [wself setSelectedViewController:self.viewControllers[newIndex]]; } }];
With these few lines of code, we can trigger a transition to go to the next or previous child view controller when the user starts panning. At this point the transition itself is still non-interactive though.
Check out the stage-4 tag and its diff to see the changes compared to the original project.
Stage 5: Making it interactive
In order to make the transition interactive, we need to have an object conforming to the UIViewControllerInteractiveTransitioning
protocol. It is a simple protocol containing only three methods:
- (void)startInteractiveTransition:(id<UIViewControllerContextTransitioning>)transitionContext; - (UIViewAnimationCurve)completionCurve; - (CGFloat)completionSpeed;
At the start of a transition, the ContainerViewController
checks if the transition should be interactive and either calls startInteractiveTransition:
on the interaction controller, or animateTransition:
on the animation controller.
id<UIViewControllerInteractiveTransitioning> interactionController = [self _interactionControllerForAnimator:animator]; transitionContext.interactive = (interactionController != nil); if ([transitionContext isInteractive]) { [interactionController startInteractiveTransition:transitionContext]; } else { [animator animateTransition:transitionContext]; }
In this case, we want the animation executed by the animation controller to be drived by the interaction controller. Now you might wonder how that is done. The answer is (usually) UIPercentDrivenInteractionController
. This is what we would have used to achieve the same effect on a UINavigationController
or a UITabBarController
. Let's try!
Tying animation and interaction together
UIPercentDrivenInteractionController
is an object conforming to the UIViewControllerInteractiveTransitioning
protocol, providing an easy way to control whichever animated transition that gets thrown at it, as long as it animates through the UIView
or CoreAnimation
APIs.
In addition to the the protocol implementation, it has only a couple more methods which are used to report progress on the transition.
- (void)updateInteractiveTransition:(CGFloat)percentComplete;
- (void)cancelInteractiveTransition;
- (void)finishInteractiveTransition;
With these methods and some Core Animation magic, the interaction controller controls the flow of the animated transition.
Now we already have our PanGestureInteractiveTransition
that holds a pan gesture recognizer. By making it a subclass of UIPercentDrivenInteractionController
, we can use that object as our interaction controller and let it report progress up the class hierarchy when we're panning.
- (void)pan:(UIPanGestureRecognizer*)recognizer {
if (recognizer.state == UIGestureRecognizerStateBegan) {
self.gestureRecognizedBlock(recognizer);
} else if (recognizer.state == UIGestureRecognizerStateChanged) {
CGPoint translation = [recognizer translationInView:recognizer.view];
CGFloat d = fabs(translation.x / CGRectGetWidth(recognizer.view.bounds));
[self updateInteractiveTransition:d*0.5];
} else if (recognizer.state >= UIGestureRecognizerStateEnded) {
[self finishInteractiveTransition];
}
}
Once we have the PanGestureInteractiveTransition
set up, we can use it as the interaction controller for the transition.
- (id<UIViewControllerInteractiveTransitioning>) _interactionControllerForAnimator: (id<UIViewControllerAnimatedTransitioning>)animationController { if (self.defaultInteractionController.recognizer.state == UIGestureRecognizerStateBegan) { return self.defaultInteractionController; } else { return nil; } }
If you've been paying attention up to this point, you might still wonder how and when the actual animation controller gets fired. Because, when we have an interaction controller, we no longer call the animateTransition:
method that initiates the animation.
Firing the simulator, we actually find out that Apple is cheating a bit.
[PrivateTransitionContext _animator]: unrecognized selector sent to instance 0x8e27f10'
// Call stack
...
5 UIKit 0x0098bcae -[UIPercentDrivenInteractiveTransition startInteractiveTransition:] + 56
6 Transitions 0x0000b76c -[PanGestureInteractiveTransition startInteractiveTransition:] + 108
...
As soon as we start panning the app crashes. Looking down the call stack, we can see that UIPercentDrivenInteractiveTransition
looks for the animator in our context, calling an undocumented method. Conveniently enough, all Apple-made transition contexts implement this method and that's how the percent driven transition can fire the animation. Unfortunately, that means we cannot use the class for our own custom container view controllers.
Enter AWPercentDrivenInteractionController
– a drop-in replacement which is fully API-compliant unlike Apple's own implementation. By changing the superclass of our interaction controller to AWPercentDrivenInteractionController
and adding one extra line in the setup, we have a working, interactive transition.
self.defaultInteractionController.animator = animationController;
At this stage, we have interactive transitions that work but without cancel support. To see the complete code for this stage, checkout the stage-5 tag or view the diff to see the changes.
If you are interested in the inner workings of AWPercentDrivenInteractionController
, look at Appendix A below, or in its separate project on GitHub. The class is also released as a cocoapod with the same name.
Stage 6: Abort!
When users start an interactive transition, they expect being able to cancel it. Just as they can with the pop transition in a navigation controller, we will now make sure they can with ours too.
To enable cancellation, we need to do a bit of work both with our context and the interaction controller. Since all of this already is supported through UIKits protocols, it is not too hard to figure out how to implement. We start off with the changes in PanGestureInteractionController
.
At the moment, the interaction controller always calls finishInteractiveTransition
when the pan gesture is ended. We can use different criterias, including velocity of the touch, to take the decision on either canceling or finishing the transition. For this example, we just use a simple progress check.
} else if (recognizer.state >= UIGestureRecognizerStateEnded) {
if (self.percentComplete > 0.2) {
[self finishInteractiveTransition];
} else {
[self cancelInteractiveTransition];
}
}
When we run cancelInteractiveTransition
, AWPercentDrivenInteractionController
forwards the message to our transition context. So let's implement the callback in PrivateTransitionContext
.
First of all, we add a new Bool
property to replace the hardcoded transitionWasCancelled
method which always returns NO
. We do this in the class extension, since the public getter is already declared by the UIViewControllerContextTransitioning
protocol.
@property (nonatomic, assign) BOOL transitionWasCancelled;
Then we implement the two callback stubs that get called by the interactive transition.
- (void)finishInteractiveTransition {self.transitionWasCancelled = NO;}
- (void)cancelInteractiveTransition {self.transitionWasCancelled = YES;}
Remember that the transition context is just a holder of state, it does not actually do much of its own. We now have to check the state in the completion block ContainerViewController
defines and react accordingly.
transitionContext.completionBlock = ^(BOOL didComplete) {
if (didComplete) {
[fromViewController.view removeFromSuperview];
[fromViewController removeFromParentViewController];
[toViewController didMoveToParentViewController:self];
[self _finishTransitionToChildViewController:toViewController];
} else {
[toViewController.view removeFromSuperview];
}
if ([animator respondsToSelector:@selector (animationEnded:)]) {
[animator animationEnded:didComplete];
}
self.privateButtonsView.userInteractionEnabled = YES;
};
Another small detail is that we now need to hold on updating the selection states of our navigational buttons before an interactive transition actually has completed. You will find that this change is implemented in the code with the stage-6 tag. To see what change since stage 5, check the diff.
In the best of worlds, we would let the buttons animate along with the transition. That will be left as the final exercise for the reader. In essense, one would use the updateInteractiveTransition:
callback on the context to drive the animation forward.
Stage 7: Delegation
In this final stage, we will enable delegation of interaction controllers through the ContainerViewControllerDelegate
protocol. Just as we already do with animation controllers. We add a third delegate method to the protocol:
- (id <UIViewControllerInteractiveTransitioning>)containerViewController:(ContainerViewController *)containerViewController interactionControllerForAnimationController:(id <UIViewControllerAnimatedTransitioning>)animationController;
The common pattern among Apple's implementations is that the interaction controller is dependent on the animation controller, but not the other way around. If the delegate returns an animation controller, it gets the chance to provide an interaction controller too. But it cannot ever control the default animation, since that may change at any notice. Here is a table that shows the possible combinations:
Let's implement this pattern in our ContainerViewController
:
- (id<UIViewControllerInteractiveTransitioning>) _interactionControllerForAnimator:(id<UIViewControllerAnimatedTransitioning>)animationController animatorIsDefault:(BOOL)animatorIsDefault { if (self.defaultInteractionController.recognizer.state == UIGestureRecognizerStateBegan) { self.defaultInteractionController.animator = animationController; return self.defaultInteractionController; } else if (!animatorIsDefault && [self.delegate respondsToSelector:@selector(containerViewController:interactionControllerForAnimationController:)]) { return [self.delegate containerViewController:self interactionControllerForAnimationController:animationController]; } else { return nil; } }
There is only one step left until our API is complete for delegating interactive transitions. If someone uses our ContainerViewController
and wants to vend their entire own interactive transitions, they probably want to disable the interaction controller already built in. We look at UINavigationController
for inspiration:
@property (nonatomic, readonly) UIGestureRecognizer *interactiveTransitionGestureRecognizer;
It is a simple solution to expose the gesture recognizer that triggers the default interaction transition through a readonly-property. Since UIGestureRecognizer
has an enabled
property, anyone using our class can just disable the recognizer to disable the default interactive transition.
Since UIGestureRecognizer
has an enabled
property, anyone using our class can just disable the recognizer to disable the default interactive transition. Note also that we in the public interface cast it as a plain UIGestureRecognizer
without specifying that it recognizes a pan gesture. This is the highest, useful level of abstraction. Remember: whenever possible, code against an interface, not an implementation.
Now our ContainerViewController
is fully catering all needs for both custom animated AND interactive transitions. With this separation of concerns, the container view becomes incredibly powerful as transitions can change dynamically while the app is running, just from our public API.
The final code for this project can be found at the stage-7 tag. Just as before, there is also a diff against the previous stage that you might be interested in.
To test the delegation of interaction controllers, there is a small, fake interaction class that drives the interaction entirely by itself inside the app delegate. By un-commenting the lines setting the delegate and disabling the default interaction, you can see it in action by pressing any of the transition buttons.
Conclusion
In this article we have learned how to implement the iOS 7 view controller transitions API for adding interactive transitions to a custom container view controller. In iOS 7-land, this is expected of all general containers and now we have done our part.
The newly introduced view controller transitions API has its rough edges, something that becomes apparent when trying to implement interactive transitions with the framework-provided UIPercentDrivenInteractiveTransition
. Perhaps this is something that will be remedied in the next major release of iOS. With just days away from Apple's World Wide Developer Conference, we may soon find out.
Even though we worked long and hard, there is still one piece of the transitions API still missing: the UIViewControllerTransitionCoordinator
. This is a protocol that enables other animations alongside a view controller transition.
As mentioned earlier, a nice thing would be to animate the state of the buttons as a transition progresses. This could be done by using the transition coordinator. I invite you to take that challenge :)
Appendix A – AWPercentDrivenInteractionController
AWPercentDrivenInteractiveTransition
is a drop-in replacement for UIPercentDrivenInteractiveTransition
for use in custom container view controllers.
Why do you need it? Because Apples own UIPercentDrivenInteractiveTransition
calls undocumented methods on your custom UIViewControllerContextTransitioning
objects.
Note that this class can also be used with UIKits standard container view controllers such as UINavigationController
, UITabBarController
and for presenting modal view controllers.
Inner mechanics
The percent driven interaction controller works by taking advantage of that CALayer
(which drives UIView
) implement the CAMediaTiming
protocol.
- (void)startInteractiveTransition:(id<UIViewControllerContextTransitioning>)transitionContext { _transitionContext = transitionContext; [_transitionContext containerView].layer.speed = 0; [_animator animateTransition:_transitionContext]; }
When an interactive transition starts, AWPercentDrivenInteractionController
freezes all animations inside the transition contexts container view by setting its layers speed
to 0. It then calls the animation controllers animateTransition:
method, which adds the transition animations to the layer.
The interactive transition object is now in full control of all the animations residing in the container view.
- (void)updateInteractiveTransition:(CGFloat)percentComplete {
self.percentComplete = fmaxf(fminf(percentComplete, 1), 0); // Input validation
}
- (void)setPercentComplete:(CGFloat)percentComplete {
_percentComplete = percentComplete;
[self _setTimeOffset:percentComplete*[self duration]];
[_transitionContext updateInteractiveTransition:percentComplete];
}
- (void)_setTimeOffset:(NSTimeInterval)timeOffset {
[_transitionContext containerView].layer.timeOffset = timeOffset;
}
To make interactions drive the animation forward, the timeOffset
property on the container view layer is adjusted during interaction callbacks. This renders the layer as if it was in mid-animation even though all animations are technically paused.
Since animation is controlled by timing, the animation curve used is very important for the resulting feel of the interaction. When aiming for direct manipulation, use UIViewAnimationCurveLinear
in your animated transition.
Finishing
The mechanics of finishing an interaction is very simple. Basically, the layers speed
is reset to 1.0
, beginTime
is set to the old timeOffset
and timeOffset
is reset to 0. This makes the animation complete just as it usually would, beginning at the same point in time as we rendered before the interaction was completed.
- (void)finishInteractiveTransition {
CALayer *layer = [_transitionContext containerView].layer;
layer.speed = 1.0;
CFTimeInterval pausedTime = [layer timeOffset];
layer.timeOffset = 0;
layer.beginTime = 0;
CFTimeInterval timeSincePause = [layer convertTime:CACurrentMediaTime() fromLayer:nil] - pausedTime;
layer.beginTime = timeSincePause;
[_transitionContext finishInteractiveTransition];
}
We are also relying on the animator object to call completeTransition:
on the transition context once the animation is completed.
Cancelling
Cancelling transitions turned out to be somewhat more complicated. Even though it should be possible to set a layer's speed
property to -1.0
, I never got this to work. Instead it is done the old-fashioned way by using a CADisplayLink
:
- (void)cancelInteractiveTransition {
_displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(_tickCancelAnimation)];
[_displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
[_transitionContext cancelInteractiveTransition];
}
As the display link ticks, the layer's timeOffset
gets adjusted negatively until it finally reaches 0
.
- (void)_tickCancelAnimation {
NSTimeInterval timeOffset = [self _timeOffset]-[_displayLink duration];
if (timeOffset < 0) {
[self _transitionFinishedCanceling];
} else {
[self _setTimeOffset:timeOffset];
}
}
When that happens, we invalidate the display link and reset the layer's speed. This also triggers the completion block inside the animator, which should tell the context that the transition is over.
- (void)_transitionFinishedCanceling {
[_displayLink invalidate];
CALayer *layer = [_transitionContext containerView].layer;
layer.speed = 1;
}
For an in-depth view of what can be done with the CAMediaTiming
protocol, check out this blogpost by my fellow Swede, the animations expert David Rönnqvist.
How to get
Check out the source at GitHub or install as a CocoaPod by adding pod 'AWPercentDrivenInteractiveTransition'
to your podfile.