Simple UIViewController Transitions
When it comes to UIViewController
transitions there are many articles and videos out there that talk more in depth about the sophisticated solutions provided by UIKit and that is probably for the good reason that those UIKit APIs are very complicated to understand. But UIKit also provides a simpler API for simpler needs. And I think nobody talks enough about it either because it is so simple that everyone assumes that everyone knows how it works or that it “just” works somehow that nobody cares about the details of how it’s intended to be used.
What API am I talking about? I’m talking about this one liner:
func transition(
from fromViewController: UIViewController,
to toViewController: UIViewController,
duration: TimeInterval,
options: UIView.AnimationOptions = [],
animations: (() -> Void)?,
completion: ((Bool) -> Void)? = nil
)
If used incorrectly it might either just emit a warning on the console:
Unbalanced calls to begin/end appearance transitions for <MyViewController>
Or throw an exception at runtime and crash your app:
Terminating app due to uncaught exception 'NSInvalidArgumentException',
reason: 'Children view controllers <ViewController> and <ViewController>
must have a common parent view controller when calling
-[UIViewController transitionFromViewController:toViewController:duration:options:animations:completion:]'
Let’s build a demo!
What we want to make is a simple container view controller that has a single child view controller that can be replaced with some basic transition. Let’s begin.
First let’s see how the ContainerViewController
should be used. So if we have a so called ContainerViewController
then we should be able to init
it and assign the content to it.
For example, we could use a Timer
to periodically cycle through a bunch of children.
let vwCtrls = (0..<5).map { SomeViewController(desc: "Child \($0)") }
let containerVwCtrl = ContainerViewController(contentViewController: vwCtrls[selectedIndex])
let timer = Timer.scheduledTimer(
timeInterval: 3,
target: self, selector: #selector(displayNext),
userInfo: nil, repeats: true
)
@objc func displayNext() {
selectedIndex = (selectedIndex + 1) % vwCtrls.count
containerVwCtrl.contentViewController = vwCtrls[selectedIndex]
}
Or call the animated variant, one we are more interested in
@objc func displayNext() {
selectedIndex = (selectedIndex + 1) % vwCtrls.count
containerVwCtrl.set(contentViewController: vwCtrls[selectedIndex], animationDuration: 1.0)
}
The docs on creating our ContainerViewController
are pretty clear. Comes even with a snippet, we can simply copy-paste them:
class ContainerViewController: UIViewController {
private func add(viewController: UIViewController) {
addChild(viewController)
view.addSubview(viewController.view)
view.addConstraints(to: viewController.view, insets: contentEdgeInsets)
viewController.didMove(toParent: self)
}
private func remove(viewController: UIViewController) {
viewController.willMove(toParent: nil)
viewController.view.removeFromSuperview()
viewController.removeFromParent()
}
}
Now since we want our container to always start with a child what better place than to ask than in the init
class ContainerViewController: UIViewController {
private var childVwCtrl: UIViewController
init(contentViewController: UIViewController) {
childVwCtrl = contentViewController
super.init(nibName: nil, bundle: nil)
add(viewController: contentViewController)
}
// ...
}
So far so good. Next we want to expose methods to be able to reset the child
class ContainerViewController: UIViewController {
var contentViewController: UIViewController {
get { childVwCtrl }
set { set(contentViewController: newValue, animationDuration: nil) }
}
func set(contentViewController toVwCtrl: UIViewController, animationDuration: Double?) {
// TODO: stuff goes here ...
childVwCtrl = toVwCtrl
}
}
Since we want to be able to change the child with or without animation. If the animationDuration
is provided we assume the change is animated otherwise not. Filling the non-animated part is easy, we just remove the last child and add the new child
class ContainerViewController: UIViewController {
func set(contentViewController toVwCtrl: UIViewController, animationDuration: Double?) {
let fromVwCtrl = childVwCtrl
guard let duration = animationDuration else {
remove(viewController: fromVwCtrl)
add(viewController: toVwCtrl)
childVwCtrl = toVwCtrl
return
}
// ...
}
}
Coming to animated transition, we could use the API simply as:
class ContainerViewController: UIViewController {
func set(contentViewController toVwCtrl: UIViewController, animationDuration: Double?) {
let fromVwCtrl = childVwCtrl
guard let duration = animationDuration else {
remove(viewController: fromVwCtrl)
add(viewController: toVwCtrl)
childVwCtrl = toVwCtrl
return
}
add(viewController: toVwCtrl)
fromVwCtrl.view.alpha = 1.0
toVwCtrl.view.alpha = 0.0
transition(
from: fromVwCtrl,
to: toVwCtrl,
duration: duration,
options: [.curveEaseOut],
animations: {
fromVwCtrl.view.alpha = 0.0
toVwCtrl.view.alpha = 1.0
},
completion: { _ in
self.remove(viewController: fromVwCtrl)
self.childVwCtrl = toVwCtrl
}
)
}
}
But this doesn’t work. When run it emits a warning to the console.
Unbalanced calls to begin/end appearance transitions for Child 1.
Let’s see what the docs have to say about it.
This method adds the second view controller’s view to the view hierarchy and then performs the animations defined in your animations block. After the animation completes, it removes the first view controller’s view from the view hierarchy.
Hmm, doesn’t seems to add much. Let’s also read the docs for NSViewController
because why not
This method adds the view in the toViewController view controller to the superview of the view in the fromViewController view controller. Likewise, this method removes the fromViewController view from the parent view controller’s view hierarchy at the appropriate time. It is important to allow this method to add and remove these views.
Okay turns out this doc gives a bit more implementation detail. One interesting extra fact we learn here is that the view add and remove operations are called internally at the appropriate time (wonder when that might be). But now at least we know that we just need to add and remove the view controllers and let the API handle the add-remove of the views.
addChild(toVwCtrl)
toVwCtrl.didMove(toParent: self)
fromVwCtrl.view.alpha = 1.0
toVwCtrl.view.alpha = 0.0
transition(
from: fromVwCtrl,
to: toVwCtrl,
duration: duration,
options: [.curveEaseOut],
animations: {
fromVwCtrl.view.alpha = 0.0
toVwCtrl.view.alpha = 1.0
},
completion: { _ in
fromVwCtrl.willMove(toParent: nil)
fromVwCtrl.removeFromParent()
self.childVwCtrl = toVwCtrl
}
)
So our warning is gone, but now the constraints are gone too! So the next child becomes full screen, or more specifically the size of the ContainerViewController
We can try adding the constraints back in the animation block.
addChild(toVwCtrl)
toVwCtrl.didMove(toParent: self)
fromVwCtrl.view.alpha = 1.0
toVwCtrl.view.alpha = 0.0
transition(
from: fromVwCtrl,
to: toVwCtrl,
duration: duration,
options: [.curveEaseOut],
animations: {
self.view.addConstraints( to: toVwCtrl.view, insets: self.contentEdgeInsets)
fromVwCtrl.view.alpha = 0.0
toVwCtrl.view.alpha = 1.0
},
completion: { _ in
fromVwCtrl.willMove(toParent: nil)
fromVwCtrl.removeFromParent()
self.childVwCtrl = toVwCtrl
}
)
This seems to work as expected.
But since we don’t have control over adding the view, we can’t be sure if this would work all the time. Let’s read up the more docs to see if we can find any more information.
addChild(_ childController: UIViewController)
This method creates a parent-child relationship between the current view controller and the object in the
childController
parameter. This relationship is necessary when embedding the child view controller’s view into the current view controller’s content. If the new child view controller is already the child of a container view controller, it is removed from that container before being added.
removeFromParent()
This method is only intended to be called by an implementation of a custom container view controller. If you override this method, you must call super in your implementation.
willMove(toParent parent: UIViewController?)
If you are implementing your own container view controller, it must call the
willMove(toParent:)
method of the child view controller before calling theremoveFromParent()
method, passing in a parent value of nil.
didMove(toParent parent: UIViewController?)
If you are implementing your own container view controller, it must call the
didMove(toParent:)
method of the child view controller after the transition to the new controller is complete or, if there is no transition, immediately after calling theaddChild(_:)
method.
It seems the only new thing here is that didMove(toParent:)
has to be called after the transition. So the correct implementation should be following, although I don’t find any visual difference
addChild(toVwCtrl)
fromVwCtrl.willMove(toParent: nil)
fromVwCtrl.view.alpha = 1.0
toVwCtrl.view.alpha = 0.0
transition(
from: fromVwCtrl,
to: toVwCtrl,
duration: duration,
options: [.curveEaseOut],
animations: {
self.view.addConstraints( to: toVwCtrl.view, insets: self.contentEdgeInsets)
fromVwCtrl.view.alpha = 0.0
toVwCtrl.view.alpha = 1.0
},
completion: { _ in
toVwCtrl.didMove(toParent: self)
fromVwCtrl.removeFromParent()
self.childVwCtrl = toVwCtrl
}
)
Let’s get rid of AutoLayout
This API is pretty nice for simpler transitions and maybe excellent if you don’t have to bother with auto layout. Personally I find animating constraints always not so much fun.
For example making a slide in/out transition is pretty easy to build using just frame
func set(contentViewController toVwCtrl: UIViewController, animationDuration: Double?) {
let fromVwCtrl = childVwCtrl
guard let duration = animationDuration else {
remove(viewController: fromVwCtrl)
add(viewController: toVwCtrl)
childVwCtrl = toVwCtrl
return
}
addChild(toVwCtrl)
toVwCtrl.view.frame = contentFrame
fromVwCtrl.willMove(toParent: nil)
beginAnimation(fromView: fromVwCtrl.view, toView: toVwCtrl.view)
transition(
from: fromVwCtrl,
to: toVwCtrl,
duration: duration,
options: [.curveEaseOut],
animations: {
self.endAnimation(
fromView: fromVwCtrl.view,
toView: toVwCtrl.view
)
},
completion: { _ in
toVwCtrl.didMove(toParent: self)
fromVwCtrl.removeFromParent()
self.childVwCtrl = toVwCtrl
}
)
}
private func beginAnimation(fromView: UIView, toView: UIView) {
fromView.transform = CGAffineTransform.identity
toView.transform = CGAffineTransform(translationX: 500, y: 0)
}
private func endAnimation(fromView: UIView, toView: UIView) {
fromView.transform = CGAffineTransform(translationX: -500, y: 0)
toView.transform = CGAffineTransform.identity
}
Or even go crazy with some mind blowing 3D transformations
private func transform(angle: Double) -> CATransform3D {
let angleRads = angle * .pi / 180.0
var transform = CATransform3DIdentity
transform.m34 = 1.0 / -100
transform = CATransform3DRotate(transform, angleRads, 1, 0, 0)
transform = CATransform3DTranslate(transform, 0, 0, -10)
return transform
}
private func beginAnimation(fromView: UIView, toView: UIView) {
fromView.layer.transform = CATransform3DIdentity
toView.layer.transform = transform(angle: -90)
}
private func endAnimation(fromView: UIView, toView: UIView) {
fromView.layer.transform = transform(angle: 90)
toView.layer.transform = CATransform3DIdentity
}
If this seems interesting to you I wrote about 3D transformation in more details some time back, go check it out.
Source
The code for this experiment is available at: https://gist.github.com/chunkyguy/39a8e0c2151e0b13955141544627ec46