Last week, I wrote a primer on how custom view controller transitions actually work. If you missed it and are relatively new to this stuff, or want a quick refresher, you might want to check it out here.
This week, Iām doing a set of 3 short articles that explore some more advanced, concrete ideas around custom transitions. This was originally intended to be one relatively straightforward article, but apparently Iāve got a lot to say about this stuff (or maybe Iām bad at editing š¤), and I decided that putting out a monolithic 8000 word piece probably wasnāt the most palatable choice. So, each article will revolve around one goal I had in mind as I built some nice custom transitions into my own app, and will detail the solutions I came up with in trying to achieve that goal.
To get us all on the same page, letās take a look at a couple of these transitions in action. Youāll see two different view controllers presented: the āAdd gameā view controller, and the āTagsā view controller. Both are presented using the same animation and presentation controllers, but they use different interaction controllers on dismissal ā āTagsā can be quickly and easily swiped away, while āAdd gameā is a more deliberate interaction that requires the user to drag a certain distance before releasing, and that includes some decoration views as part of the interaction.
There are some cool things going on behind the scenes here that all add up to what I think is a pretty nifty user experience. Letās talk about goal number one!
Goal 1: I want a dead-simple way to use my custom transitions
This first goal actually isnāt directly relevant to the end-user experience, but is VERY relevant to me, the developer, who doesnāt want a total mess on his hands as he starts using his custom transitions in different contexts throughout the app. And, letās be real: all technical debt seeps into the user experience eventually! So, letās figure out how to make sure our custom transitions are easy to use and reuse.
As I mentioned last week, the ātransitioning delegateā responsibility often falls on the presenting view controller by default. In the most naĆÆve case, this means that every time you want to present something using a custom transition, you need to implement a bunch of delegate methods in your presenting VC, giving it responsibilities it probably shouldnāt really have. Furthermore, if you want to support an interactive dismissal gesture, you might end up implementing a gesture handler inside your presented view controller ā its view is where the gesture is happening, after all ā and then figure out some way to hook up your gesture handling code to the interaction controller that your presenting view controller owns.
There may be some hypothetical cases where this approach is passable ā namely, if you know youāre building a very specific custom transition thatās only ever going to be used in one place. In my case, however, I knew I wanted to be able to present any view controller, from anywhere, using my custom transitions. In other words, I didnāt want to have to keep reimplementing gesture handlers and UIViewControllerTransitioningDelegate
methods every time I wanted to use my transitions.
My solution starts with the idea that we should have a dedicated class to serve as the transitioning delegate for our custom modal presentations ā weāll call it ModalTransitionManager
. As the transitioning delegate, itās simply responsible for vending presentation, animation and interaction controllers when asked. It might look something like this:
class ModalTransitionManager: NSObject {
private var interactionController: InteractionControlling?
init(interactionController: InteractionControlling?) {
self.interactionController = interactionController
}
}
extension ModalTransitionManager: UIViewControllerTransitioningDelegate {
func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {
return ModalPresentationController(presentedViewController: presented, presenting: presenting)
}
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return ModalTransitionAnimator(presenting: true)
}
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return ModalTransitionAnimator(presenting: false)
}
func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
guard let interactionController = interactionController, interactionController.interactionInProgress else {
return nil
}
return interactionController
}
}
The only tricky thing to notice here is the way the interaction controller is handled. Since UIKit uses the return value of interactionControllerForDismissal(using:)
as an indication of whether or not the dismissal should be performed interactively, our modal transition manager canāt simply create and return a new interaction controller here like it does with the animation and presentation controllers; if it did, UIKit would always assume a dismissal was happening interactively, and wait for interactive updates, even if the user tapped a button to dismiss the view non-interactively. This wouldnāt be an issue if the only way to dismiss our presented view controller was via an interaction, but in our case (and probably in most cases), itās important that our users can dismiss with a gesture or with the tap of a button.
So, our ModalTransitionManager
keeps a reference to an optional interaction controller, and the interaction controller itself knows if a dismissal has been triggered by a gesture via its interactionInProgress
property (more on that in a sec). In this way, our ModalTransitionManager
has the information it needs to either return an interaction controller, telling UIKit āyep, letās do an interactive dismissal,ā or return nil to say āno, just animate this dismissal non-interactively.ā
Of course, our ModalTransitionManager
needs to be owned by someone from the beginning of presentation until the end of dismissal so that it can communicate with UIKit as needed. To make sure of this, letās write a CustomPresentable
protocol that every custom-presented view controller will conform to, and that ensures that the presented view controller keeps a strong reference to the ModalTransitionManager
:
protocol CustomPresentable: AnyObject {
var transitionManager: UIViewControllerTransitioningDelegate? { get set }
// ...
}
With this in place, weāve taken the transitioning delegate responsibility out of our view controllers, and moved it into a separate object that the presented view controller keeps a reference to, but otherwise doesnāt care about.
The second thing to address is implementing a gesture handler on our presented view that can control the dismissal interaction. Instead of doing it inside the presented view controller, we can move this responsibility into the interaction controller itself. First, we create a simple protocol that inherits from UIViewControllerInteractiveTransitioning
:
protocol InteractionControlling: UIViewControllerInteractiveTransitioning {
var interactionInProgress: Bool { get }
}
This enforces that any interaction controller we build must be able to report when an interaction has started; we used this property in our ModalTransitionManager
above.
Now, we can build an interaction controller thatās responsible for handling gestures, and therefore knows when to set interactionInProgress
to true
:
class StandardInteractionController: NSObject, InteractionControlling {
var interactionInProgress = false
private weak var viewController: (UIViewController & CustomPresentable)!
init(viewController: UIViewController & CustomPresentable) {
self.viewController = viewController
super.init()
prepareGestureRecognizer(in: viewController.view)
}
private func prepareGestureRecognizer(in view: UIView) {
let gesture = UIPanGestureRecognizer(target: self, action: #selector(handleGesture(_:)))
view.addGestureRecognizer(gesture)
}
@objc func handleGesture(_ gestureRecognizer: UIPanGestureRecognizer) {
guard let superview = gestureRecognizer.view?.superview else { return }
let translation = gestureRecognizer.translation(in: superview).y
let velocity = gestureRecognizer.velocity(in: superview).y
switch gestureRecognizer.state {
case .began: gestureBegan()
case .changed: gestureChanged(translation: translation + interruptedTranslation, velocity: velocity)
case .cancelled: gestureCancelled(translation: translation + interruptedTranslation, velocity: velocity)
case .ended: gestureEnded(translation: translation + interruptedTranslation, velocity: velocity)
default: break
}
}
private func gestureBegan() {
if !interactionInProgress {
interactionInProgress = true
viewController.dismiss()
}
}
// ...
}
Of course, thereās a lot more than that going on in the full implementation, but this gives you the idea: our interaction controller is initialized with the presented view controller, and is responsible for installing and responding to the gesture recognizer, allowing us to set interactionInProgress
to true
before having UIKit kick off the dismissal, and making it trivial to get subsequent gesture updates through our interaction controller to UIKit.
Finally, this is all tied together with a simple UIViewController extension:
enum InteractiveDismissalType {
case none
case standard
case input
}
extension UIViewController {
func present(_ viewController: (UIViewController & CustomPresentable),
interactiveDismissalType: InteractiveDismissalType,
completion: (() -> Void)? = nil) {
let interactionController: InteractionControlling?
switch interactiveDismissalType {
case .none:
interactionController = nil
case .standard:
interactionController = StandardInteractionController(viewController: viewController)
case .input:
interactionController = InputInteractionController(viewController: viewController)
}
let transitionManager = ModalTransitionManager(interactionController: interactionController)
viewController.transitionManager = transitionManager
viewController.transitioningDelegate = transitionManager
viewController.modalPresentationStyle = .custom
present(viewController, animated: true, completion: completion)
}
}
Hereās where we actually kick off the custom presentation. The caller can specify the type of interactive dismissal, which dictates which interaction controller is created. The ModalTransitionManager
is created and assigned to the view controller we want to present ā we need to hold onto it strongly in the transitionManager
property, because transitioningDelegate
is a weak reference ā and then we present the view controller.
What does this all mean? It means that we can now present any view controller, from anywhere, and get all our desired animation and interaction behaviour, by simply making sure our presented view controller conforms to CustomPresentable:
final class CoolViewController: UIViewController, CustomPresentable {
var transitionManager: UIViewControllerTransitioningDelegate?
// ...
}
ā¦and then calling our presentation method:
let coolViewController = CoolViewController()
present(coolViewController, interactiveDismissalType: .standard)
ā¦and everything else works like magic. Thatās a whole lot of complexity we no longer have to think about. Pretty cool š
For a working example of the techniques described here and in the other two articles in this series on custom view controller transitions, check out this repo on GitHub.
Thanks for reading! This is a relatively quick look at a pretty tricky topic, so if you need some help or have any follow-up questions, Let me know! Find me on Twitter here š¦