44 KiB
title = "A Hero View Controller Transition in SwiftUI"
tags = ["swift"]
date = "2023-05-21 23:40:42 -0400"
short_desc = ""
slug = "swiftui-hero-transition"
Out of the box, SwiftUI has a matchedGeometryEffect
modifier that makes it relatively easy to build hero transitions (a style of transition where a new screen is presented and part of the source screen changes position and size to reach it's place on the new screen). It's cool that SwiftUI includes this out of the box, but unfortunately it has a few limitations that make it unsuitable for certain use cases. Particularly for me, that it doesn't work with presenting another view. Most examples on the internet1 work around this by faking a custom presented view: just slap a full-screen background color down and show your content on top of it. That's essentially the same as a presenting a full-screen view, with the one major caveat that the pseudo-presented view can only cover the containing hosting controller. And if that controller isn't full-screen (say, if it's presented as a sheet), you can't present anything that's truly full-screen. So, let's build a custom hero transition that actually presents it's content across the entire screen.
Presenting a UIViewController
The first problem we need to contend with is how to bust out of the containing UIHostingController
from within the SwiftUI view tree. In UIKit-land, we'd "escape" the current view controller by presenting another one. SwiftUI has presentation modifiers like sheet
and fullScreenCover
, which are analogous to the UIKit present(_:animated:)
and modalPresentationStyle
APIs, but the builtin SwiftUI API doesn't work for us since the whole point of this is controlling the presentation animation, which SwiftUI doesn't expose.
So, to get at the presentation animation APIs, we need to present the VC ourselves. Which means that from inside SwiftUI, we need to have access to a view controller whose present
method we can call. Rather than trying to walk up the view tree to find the UIHostingController
that contains the SwiftUI view tree, we can use UIViewControllerRepresentable
to create our own VC and let the framework manage the child VC relationship. We can still call present
, because UIKit will handle forwarding the presentation up the hierarchy until it finds one that can actually handle it.
The representable will take a function that creates a view controller—so we can defer its creation until it's actually used—as well as a Binding<Bool>
representing whether the VC is currently presented, following the SwiftUI pattern for presentations.
The actual view controller that the representable, uh, represents will be completely empty and unconfigured: we only need it to exist so that we can call present
, we don't need it to display anything.
struct ViewControllerPresenter: UIViewControllerRepresentable {
let makeVC: () -> UIViewController
@Binding var isPresented: Bool
func makeUIViewController(context: Context) -> UIViewController {
return UIViewController()
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
}
}
In the update method, we can read the value of the binding and present or dismiss the VC as necessary.
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
if isPresented {
if uiViewController.presentedViewController == nil {
let presented = makeVC()
uiViewController.present(presented, animated: true)
}
} else {
if let presentedViewController = uiViewController.presentedViewController,
!presentedViewController.isBeingDismissed {
uiViewController.dismiss(animated: true)
}
}
}
A couple things of note:
makeVC
is called only once, when the VC is first presented. This means that, while it's okay to access SwiftUI state in when constructing the VC, updates to the state will not be reflected in the VC, so we have to take care to either pass bindings or store mutable state inside of an immutable container (i.e., a class).- We make sure to check
isBeingDismissed
before dismissing the presented VC. Otherwise, if there's another view update during the dismissal—which is entirely possible—we'll double calldismiss
and potentially dismiss the VC containing the presenter representable.
Just this already works pretty well: you can present and dismiss a view controller using a SwiftUI binding. But the view controller also needs to be able to dismiss itself, without having any knowledge of the binding, and still keep the binding value up-to-date. To accomplish that, we'll make the coordinator the delegate of the presentation controller, so it can use the will-dismiss method to update the binding state.
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
if isPresented {
if uiViewController.presentedViewController == nil {
let presented = makeVC()
presented.presentationController!.delegate = context.coordinator
uiViewController.present(presented, animated: true)
}
} else {
// ...
}
}
func makeCoordinator() -> Coordinator {
return Coordinator(isPresented: $isPresented)
}
class Coordinator: NSObject, UIAdaptivePresentationControllerDelegate {
@Binding var isPresented: Bool
init(isPresented: Binding<Bool>) {
self._isPresented = isPresented
}
func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
isPresented = false
}
}
Lastly, if you try this combined with another presented VC, you'll notice a rather annoying problem: a view update of the presenter representable when another VC is presented results in that VC getting dismissed. Because the presentedViewController
doesn't just look at the target's presented VC, but walks up the VC hierarchy until it finds the actual VC responsible for presentation. So if another VC was presented, it will be returned and, since isPresented
is false, be dismissed prematurely. To solve this, we can keep track of when the representable itself actually triggered the presentation.
class Coordinator: NSObject, UIAdaptivePresentationControllerDelegate {
@Binding var isPresented: Bool
var didPresent = false
// ...
func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
isPresented = false
didPresent = false
}
}
This doesn't use a SwiftUI state property, since we're deliberately going to change it during view updates. When the representable presents the VC, it can set this flag to true and later verify that it's true before dismissing the VC:
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
if isPresented {
if uiViewController.presentedViewController == nil {
// ...
context.coordinator.didPresent = true
}
} else {
if context.coordinator.didPresent,
let presentedViewController = uiViewController.presentedViewController,
// ...
}
}
The Approach
Now that we have the ability to present a custom view controller, let's think about what that VC actually needs to contain for the matched geometry effect. The transition we want to achieve is split into two parts: the matched geometry and everything else. The frames of the views being matched should animate smoothly from source to destination, and the views themselves should be visible the entire time. The rest of the presented content, however, is not matched and should appear with some other animation. There are multiple options, but I'm going to go with a simple fade-in.
So, when put all together, there will be three layers which are (back to front): the the source view, the non-matched part of the presented view, and finally the matched view(s).
Presenter
Presented
The presented and matched layers will each be their own UIHostingController
, containing the respective parts of the SwiftUI view tree that we want to display. They'll be grouped into a single container view controller, which is the VC that will actually be presented.
Collecting Matched Geometry Sources
The first step in building the actual effect we're after is collecting all of the views we want to use as sources as well as their geometries. The views themselves are necessary in addition to the frames because, unlike SwiftUI2, we're displaying the matched views outside of their original position in the view tree.
To send this information up through the view tree, we'll use a custom preference. The value of the preference will be a dictionary which maps the matched geometry's ID to a tuple of an AnyView
and a CGRect
. The view is the type-erased view that's being matched, and the rect is the frame of the source view. The important part of the preference key is the reducer which, rather than simply overwriting the current value, merges it with the new one. This means that, if there are multiple matched geometry sources in the view tree, reading the preference from higher up in the tree will give us access to all of the sources.
struct MatchedGeometrySourcesKey: PreferenceKey {
static let defaultValue: [AnyHashable: (AnyView, CGRect)] = [:]
static func reduce(value: inout Value, nextValue: () -> Value) {
value.merge(nextValue(), uniquingKeysWith: { _, new in new })
}
}
The modifier then uses a GeometryReader
to get the frame. It resolves the frame in the global coordinate space, which is what we want, since we'll present a view controller that fills the window. The geometry reader is placed in a background modifier so that it doesn't affect the layout of the wrapped view.
struct MatchedGeometrySourceModifier<Matched: View>: ViewModifier {
let id: AnyHashable
let matched: Matched
func body(content: Content) -> some View {
content
.background(GeometryReader {
Color.clear
.preference(key: MatchedGeometrySourcesKey.self, value: [
id: (AnyView(matched), proxy.frame(in: .global))
])
})
}
}
Also of note here is why the matched
property is necessary, rather than just using the method's content
parameter. When applying the modifier, SwiftUI doesn't invoke the body
method with the actual wrapped view. Rather, it receives an instance of _ViewModifier_Content
, which seems to be a placeholder for the real content. And it can't be used outside of the modifier (trying to do so will result in nothing rendering), so our source modifier needs to store a copy of the actual wrapped view.
We can then add a little extension to View
to make the API nice and SwiftUI-y.
extension View {
func matchedGeometrySource<ID: Hashable>(id: ID) -> some View {
self.modifier(MatchedGeometrySourceModifier(id: AnyHashable(id), matched: self))
}
}
Next, let's read the source data that's collected by the preference and pass it off to the presented view controller. This too will be a modifier, like the SwiftUI presentation ones.
struct MatchedGeometryPresentationModifier: ViewModifier {
@Binding var isPresented: Bool
func body(content: Content) -> some View {
content
.backgroundPreferenceValue(MatchedGeometrySourcesKey.self) { sources in
Color.clear
.presentViewController(makeVC(sources: sources), isPresented: $isPresented)
}
}
private func makeVC(sources: [AnyHashable: (AnyView, CGRect)]) -> () -> UIViewController {
return {
return MatchedGeometryViewController(sources: sources)
}
}
}
The backgroundPreferenceValue
gives us access to the value of the preference and let's us use it to build part of the view tree. We can't use the onPreferenceChange
modifier since it requires the preference value conform to Equatable
, which isn't possible for the source since it uses AnyView
. So instead, we use the background preference modifier which always gives us access to the current value of the preference, without having to check whether it's changed. But we don't actually want to show anything in the background, so we attached the VC presentation modifier to the clear color.
The Container View Controller
The container VC is where the bulk of the work is happening, so we'll start with a simple version that just displays all the sources in the right positions—without any animations or other content—to validate our approach.
class MatchedGeometryViewController: UIViewController {
let sources: [AnyHashable: (AnyView, CGRect)]
var matchedHost: UIHostingController<MatchedContainerView>!
init(sources: [AnyHashable: (AnyView, CGRect)]) {
self.sources = sources
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
The container VC will have a hosting controller that's dedicated to the views that we're matching between the source and the destination. We'll set it up in viewDidLoad
.
override func viewDidLoad() {
super.viewDidLoad()
let sources = self.sources.map { (id: $0.key, view: $0.value.0, frame: $0.value.1) }
let matchedContainer = MatchedContainerView(sources: sources)
matchedHost = UIHostingController(rootView: matchedContainer)
matchedHost.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
matchedHost.view.frame = view.bounds
matchedHost.view.backgroundColor = .clear
matchedHost.view.layer.zPosition = 100
addChild(matchedHost)
view.addSubview(matchedHost.view)
matchedHost.didMove(toParent: self)
}
A couple notes on this:
- We turn the
sources
dictionary into an array (and take the opportunity to flatten out the nested tuples) because we'll need aRandomAccessCollection
to use withForEach
to display all the views. - We make the background color clear and raise the z-index so that when we add the content, the matched views appear above it and don't completely obscure it.
The container view for the matched views will, for now, just display all of the views in a ZStack
and fix them at their source frames.
struct MatchedContainerView: View {
let sources: [(id: AnyHashable, view: AnyView, frame: CGRect)]
var body: some View {
ZStack {
ForEach(sources, id: \.id) { (id, view, frame) in
view
.frame(width: frame.width, height: frame.height)
.position(x: frame.midX, y: frame.midY)
.ignoresSafeArea()
}
}
}
}
Note that we use the middle x/y coordinates of the source frame, since the position
modifier is anchored at the center of the view. We also make the matched view ignore the safe area, since the coordinates we're using for the position are in the global coordinate space, which extends past the safe area.
Testing this out, we can see that the matched views are indeed displayed at the same position (roughly: the container VC is still actually being presented as a sheet, which is slightly offsetting everything relative to the global coordinate space).
VStack {
Image("pranked")
.resizable()
.aspectRatio(contentMode: .fit)
.matchedGeometrySource(id: "image")
.frame(width: 100)
Button {
presented.toggle()
} label: {
Text("Present")
}
}
.matchedGeometryPresentation(isPresented: $presented)
Non-Matched Content
Next, let's build the destination side of the setup and actually display the real content we want to present, not just the matched views. We'll use a modifier like the source one to collect the geometry of the destination views, with much the same geometry reader technique.
struct MatchedGeometryDestinationModifier<Matched: View>: ViewModifier {
let id: AnyHashable
let matched: Matched
func body(content: Content) -> some View {
content
.background(GeometryReader { proxy in
Color.clear
.preference(key: MatchedGeometryDestinationFrameKey.self, value: proxy.frame(in: .global))
.onPreferenceChange(MatchedGeometryDestinationFrameKey.self) { newValue in
// TODO
}
})
}
}
extension View {
func matchedGeometryDestination<ID: Hashable>(id: ID) -> some View {
self.modifier(MatchedGeometryDestinationModifier(id: AnyHashable(id), matched: self))
}
}
The difference here is that the preference is only going to contain the frame, so that we can use the onPreferenceChange
modifier to listen for changes and update the container VC's state. Unlike the source, we're not going to listen for this preference anywhere higher in the view tree. The reason for this is that we need the value of the preference to update state, not to construct another part of the view tree. And onPreferenceChange
is the only modifier we have available for that—and it requires the preference's value be Equatable
which it can't be if we put the AnyView
in it.
The preference key itself is very simple: it just holds an optional rect.
struct MatchedGeometryDestinationFrameKey: PreferenceKey {
static let defaultValue: CGRect? = nil
static func reduce(value: inout CGRect?, nextValue: () -> CGRect?) {
value = nextValue()
}
}
Before we can fill in the todo comment, we need somewhere to put the destination state. We're going to make a separate ObservableObject
which will contain several pieces of state we need in various places for the animation. But for now we just need somewhere to stick the destinations.
class MatchedGeometryState: ObservableObject {
@Published var destinations: [AnyHashable: (AnyView, CGRect)] = [:]
}
Back in the destination view modifier, we'll get the state object from the environment, and update the destinations dictionary in an on-change modifier:
struct MatchedGeometryDestinationModifier<Matched: View>: ViewModifier {
let id: AnyHashable
let matched: Matched
@EnvironmentObject private var state: MatchedGeometryState
func body(content: Content) -> some View {
content
.background(GeometryReader { proxy in
Color.clear
.preference(key: MatchedGeometryDestinationFrameKey.self, value: proxy.frame(in: .global))
.onPreferenceChange(MatchedGeometryDestinationFrameKey.self) { newValue in
if let newValue {
state.destinations[id] = (AnyView(matched), newValue)
}
}
})
}
}
Getting the object from the environment is all well and good, but we still need to create it and inject it. For the animation, we'll want access to some of the state properties in the source modifier, so the object needs to be created somewhere on the source side, rather than being owned by the container view controller. Since the presentation modifier needs to be at a higher level in the view tree than the source modifiers (which means it serves as the "namespace" for the transition, like the Namespace.ID
parameter of SwiftUI's effect), this is a natural place to put it.
struct MatchedGeometryPresentationModifier: ViewModifier {
@Binding var isPresented: Bool
@StateObject private var state = MatchedGeometryState()
func body(content: Content) -> some View {
content
.environmentObject(state)
// ...
}
// ...
}
We also pass it into the VC's constructor in the makeVC
function, and then in the constructore it's stored in a state
property on the VC (code omitted for brevity3).
But we're not done yet. We still need actual content to present, which is where the destination modifier will be used, so we'll add another property to the presentation modifier and update our View extension function:
extension View {
func matchedGeometryPresentation<Presented: View>(isPresented: Binding<Bool>, @ViewBuilder presenting: () -> Presented) -> some View {
self.modifier(MatchedGeometryPresentationModifier(isPresented: isPresented, presented: presenting()))
}
}
struct MatchedGeometryPresentationModifier<Presented: View>: ViewModifier {
@Binding var isPresented: Bool
let presented: Presented
@StateObject private var state = MatchedGeometryState()
// ...
}
The presented
view also gets passed into the VC, and we'll make it generic over the view type—to avoid any more type-erasing then we strictly need. We'll also add another hosting controller which will display the presented content.
class MatchedGeometryViewController<Content: View>: UIViewController {
let sources: [AnyHashable: (AnyView, CGRect)]
let content: Content
let state: MatchedGeometryState
var contentHost: UIHostingController<ContentContainerView<Content>>!
var matchedHost: UIHostingController<MatchedContainerView>!
// ...
}
And we'll setup the new hosting controller in viewDidLoad
much the same as the other one, but this time providing the content
:
class MatchedGeometryViewController<Content: View>: UIViewController {
// ...
override func viewDidLoad() {
// ...
let contentContainer = ContentContainerView(content: content, state: state)
contentHost = UIHostingController(rootView: contentContainer)
contentHost.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
contentHost.view.frame = view.bounds
addChild(contentHost)
view.addSubview(contentHost.view)
contentHost.didMove(toParent: self)
}
}
The ContentContainerView
is very simple: it just displays the content and provides access to our state object through the environment—finally bringing us full-circle to how the destination modifier will access it.
struct ContentContainerView<Content: View>: View {
let content: Content
let state: MatchedGeometryState
var body: some View {
content
.environmentObject(state)
}
}
Now, if we tweak our test code to add some content to the presentation, we can see that
// ...
.matchedGeometryPresentation(isPresented: $presented) {
VStack {
Image("pranked")
.resizable()
.aspectRatio(contentMode: .fit)
.matchedGeometryDestination(id: "image")
Text("Hello!")
}
}
But it doesn't look quite right yet. We are displaying all the layers, but we're always displaying all the layers—which is why we've got two copies of the image visible. To fix that, we'll need to build:
The Animation
With everything in place, let's actually put the pieces together and build the animation. First off, we'll set the modal presentation style and transitioning delegate so we can completely control the presentation animation.
class MatchedGeometryViewController<Content: View>: UIViewController, UIViewControllerTransitioningDelegate {
init(sources: [AnyHashable: (AnyView, CGRect)], content: Content, state: MatchedGeometryState) {
// ...
modalPresentationStyle = .custom
transitioningDelegate = self
}
}
We'll also make the VC conform to the transitioning delegate protocol. Ordinarily, I'd declare this conformance in an extension, but Swift classes with generic types cannot declare @objc
protocol conformances in extensions, only on the class itself.
The methods we'll implement for this protocol are all simple: the actual work is offloaded to other classes. For the presenting animation controller, we'll just instantiate another class.
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return MatchedGeometryPresentationAnimationController<Content>()
}
Note that the animation controller class is generic over the same content view type as the container VC. This is so that, in the implementation of the animation, we can cast the UIViewController
we're given to the concrete type of the VC that we know is being presented.
In the animation controller class, the transitionDuration
method will just return 1 second. We're actually going to use a spring animation, so this duration isn't quite accurate, but we need something to go here, and this is the duration we'll use when configuring the animator, even if the timing is ultimately driven by the spring.
class MatchedGeometryPresentationAnimationController<Content: View>: NSObject, UIViewControllerAnimatedTransitioning {
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 1
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
// TODO
}
}
All the actual work will happen inside the animateTransition
method. The first thing we need to do is pull out our view controller (using the .to
key, because it's the destination of the presentation animation) and add it to the transition container view.
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
let matchedGeomVC = transitionContext.viewController(forKey: .to) as! MatchedGeometryViewController<Content>
let container = transitionContext.containerView
container.addSubview(matchedGeomVC.view)
}
Next, let's get the fade-in working for the non-matched content. We start of by setting that layer's opacity to 0, making it completely transparent. Then we set up a UIViewPropertyAnimator
with the same spring configuration we're going to ultimately use for the matched geometry effect. That animator will set the layer's opacity to 1, bringing it fully opaque. And when the animation completes, we need to inform the transition context.
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
// ...
matchedGeomVC.view.layer.opacity = 0
let spring = UISpringTimingParameters(mass: 1, stiffness: 150, damping: 15, initialVelocity: .zero)
let animator = UIViewPropertyAnimator(duration: self.transitionDuration(using: transitionContext), timingParameters: spring)
animator.addAnimations {
matchedGeomVC.contentHost.view.layer.opacity = 1
}
animator.addCompletion { _ in
transitionContext.completeTransition(true)
}
}
We can ignore the position parameter the animation completion handler receives, because we're never going to stop or cancel the animation.
Testing the animation now, you can see that the non-matched content does indeed fade in. But there's still an issue, even aside from the fact that we're not handling the matched geometry effect yet. Specifically, the source and destination views are visible during the animation, which breaks the illusion that a single view is moving seamless from one place to another.
To take care of this, we'll add another property to the state object. It will store whether the animation is currently taken place.
class MatchedGeometryState: ObservableObject {
// ...
@Published var animating: Bool = false
}
The source and destination modifiers can then use this to make their respective wrapped views entirely transparent during the animation.
struct MatchedGeometrySourceModifier<Matched: View>: ViewModifier {
// ...
@EnvironmentObject private var state: MatchedGeometryState
func body(content: Content) -> some View {
// ...
.opacity(state.animating ? 0 : 1)
}
}
struct MatchedGeometryDestinationModifier<Matched: View>: ViewModifier {
// ...
func body(content: Content) -> some View {
// ...
.opacity(state.animating ? 0 : 1)
}
}
Then the animation controller can set the animating
state to true
when the animation starts and back to false
when it ends.
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
// ...
matchedGeomVC.state.animating = false
// ...
animator.addCompletion { _ in
transitionContext.completeTransition(true)
matchedGeomVC.state.animating = false
}
animator.startAnimating()
}
Alright, now we can move on to the meat and potatoes of actually matching the views.
The first step is adding another property to the state object. currentFrames
will store, well, the current frames of each matched view. Initially, it will contain the source frames, and then setting it to the destination frames will trigger the SwiftUI side of the animation.
class MatchedGeometryState: ObservableObject {
// ...
@Published var currentFrames: [AnyHashable: CGRect] = [:]
@Published var mode: Mode = .presenting
enum Mode {
case presenting, dismissing
}
}
Then we can update the matched container view to use the current frame, rather than always using the source one. The other change we're going to make to the matched views is to blend between the source and destination matched views.
This handles the possibility that there are visual differences between the two, beyond their sizes and positions. We'll perform this blending by fading in the matched view from the destination over top of the matched view from the source4. By keeping them at the same position, we can ensure it still appears to be a single view that's changing.
We also need a property for the mode—whether we're presenting or dismissing—since this fade depends on the direction of the animation. When presenting, the destination view's opacity should go from 0→1, going from transparent to fully opaque, but when dismissing it should go the other way around.
In the MatchedContainerView
, we'll split creating the actual matched view into a separate function, since there's a fair bit of logic that goes into it:
struct MatchedContainerView: View {
// ...
var body: some View {
ZStack {
ForEach(sources, id: \.id) { (id, view, _) in
matchedView(id: id, source: view)
}
}
}
}
The matchedView
function will acually generate this view
struct MatchedContainerView: View {
// ...
func matchedView(id: AnyHashable, source: AnyView) -> some View {
let frame = state.currentFrames[id]!
let dest = state.destinations[id]!.0
let destOpacity: Double
if case .presenting = state.mode {
destOpacity = state.animating ? 1 : 0
} else {
destOpacity = state.animating ? 0 : 1
}
return ZStack {
source
dest
.opacity(destOpacity)
}
.frame(width: frame.width, height: frame.height)
.position(x: frame.midX, y: frame.midY)
.ignoresSafeArea()
.animation(.interpolatingSpring(mass: 1, stiffness: 150, damping: 15, initialVelocity: 0), value: frame)
}
}
This function gets the current frame for the view from the state object based on its ID. It also looks up the corresponding destination view. They're layered together in a ZStack
with an opacity modifier applied to the destination, so that it fades in on top of the source.
Note that we only apply the opacity to the destination view. If the opacity animation also applied to the source view (albeit in reverse), the animation would pass through intermediate states where both views are partially transparent, resulting in the background content being visible through the matched view, which can look strange.
The frame and position modifiers also move to the stack, so that they apply to both the source and destination views. We also add an animation modifier to the stack, using a spring animation and making sure to match the configuration to the UISpringTimingParameters
we used for the view controller animation.
Next, we need to return to the UIKit animation controller, since we have yet to actually kick off the matched geometry animation.
This was the trickiest part to figure out when I was building this, since we need to wait for the destination SwiftUI view tree to update and layout in order actually know where all the destinations are. If we were to just kick off the animation immediately, there would be no destination views/frames, and the force unwraps above would fail. What's more, providing a default for the animation and swapping out the real destination frame while the animation is in-flight ends up looking rather janky, since the spring animation has a fair bit of momentum. So, the destination needs to fully laid out before we can even start the SwiftUI side of the animation.
Since SwiftUI doesn't seem to provide any way of forcing the view tree to update and fully layout immediately, we have to set up some listener that will fire once the destination is ready. Since we already have an ObservableObject
containing the destination info in a @Published
property, we can subscribe to that with Combine.
func animationTransition(using transitionContext: UIViewControllerContextTransitioning) {
// ...
container.addSubview(matchedGeomVC.view)
let cancellable = matchedGeomVC.state.$destinations
.filter { destinations in
matchedGeomVC.sources.allSatisfy { source in
destinations.keys.contains(source.key)
}
}
.first()
.sink { _ in
}
matchedGeomVC.view.layer.opacity = 0
// ...
animator.addCompletion { _ in
// ...
cancellable.cancel()
matchedGeomVC.matchedHost?.view.isHidden = true
}
animator.startAnimation()
}
We use the .filter
to wait until the state's destinations dictionary contains entries for all of the sources. We also use the .first()
operator, since we only want the sink closure to fire once—triggering the animation multiple times would mess up the animation and the state tracking we're doing.
Subscribing to the publisher creates an AnyCancellable
which we cancel in the animator's completion handler—both because, if the destinations never became available, we don't want to keep listening, and because we need to keep the cancellable alive for the duration of the animation (otherwise it would be deinitialized when the animateTransition
method returns, cancelling our subscription).
When the publisher fires and all the state is present, we need to do a few things:
- Add the matched hosting controller (as noted before, we can no longer do this immediately, since all the requisite state for the
MatchedContainerView
isn't available initially). - Prepare the initial state of all the properties that are involved in the animation.
- Set the new values to start the animation.
This takes care of the first two points, and is fairly straightforward:
.sink { _ in
matchedGeomVC.addMatchedHostingController()
matchedGeomVC.state.mode = .presenting
matchedGeomVC.state.currentFrames = matchedGeomVC.sources.mapValues(\.1)
}
Just make sure to factor the code for adding the matched hosting controller out of viewDidLoad
and into a separate method.
The third point is slightly trickier. We can't just immediately set the new values, even in a withAnimation
closure. Setting the new values needs to take place in a new transaction, once the view has already been updated with the intiial values. So, we wait one runloop iteration and then set the new values to kick off the animation.
.sink { _ in
// ...
DispatchQueue.main.async {
matchedGeomVC.state.animating = true
matchedGeomVC.state.currentFrames = matchedGeomVC.state.destinations.mapValues(\.1)
}
}
And with that, the presentation animation is finally complete! We can present a SwiftUI view fullscreen, and have certain parts of the source view smoothly animate to their positions in the destination view, with everything else fading in in between the source and destination views.
(The hitch towards the end is an artifact of the simulator screen recording, it's not actually present in the simulator or on-device.)
Dismiss Animation
The dismiss animation is implemented in a very similar manner, so I won't go over it in detail.
There's another animation controller, which is returned from animationController(forDismissed:)
which does pretty much the same thing as the presentation animation but in reverse. It sets the state to .dismissing
, and the current frames to the destination frames. Then, one runloop iteration later, it sets animating = true
and switches the current frames to the source frames.
Unlike the presentation animation, the dismiss one doesn't need the workaround with the $destinations
publisher, since when the dismiss is happening, we know that the presented view must already be all setup and laid-out.
The only slight wrinkle the dismiss animation needs is that, in order to work with the UIViewController
presentation abstraction we built, there also needs to be a presentation controller that notifies its delegate when the dismiss transition begins:
class MatchedGeometryViewController<Content: View>: UIViewController, UIViewControllerTransitioningDelegate {
// ...
func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {
return MatchedGeometryPresentationController(presentedViewController: presented, presenting: presenting)
}
}
class MatchedGeometryPresentationController: UIPresentationController {
override func dismissalTransitionWillBegin() {
super.dismissalTransitionWillBegin()
delegate?.presentationControllerWillDismiss?(self)
}
}
Future Work
There are a few loose ends I have deliberately left as an exercise for you, dear reader.(definitely not because I didn't feel like spending the time to work through them)
- Recreating the
properties
andanchor
parameters ofmatchedGeometryEffect
I chose not to implement these because I don't have any need for them, but given how we're displaying and positioning the matched views, it should be pretty clear how you could go about adding similar functionality.
- Handling device rotation
If the user's device is rotated while the matched VC is presented, the source frames will become invalid. There are a couple ways you could approach this. One is figuring out why the source frames aren't updated while the VC is presented. Or, if that doesn't have an easy fix, detecting when the window size changes while the VC is presented and then using a different animation for the dismissal.
- Conditional matched views
Because of the way we've implemented the $destinations
publisher workaround, if there are source views that never end up getting a destination view, the entire matched part of the animation will just never run. This is rather less than ideal, particularly if there are views that you want to match that may not always be present in the destination.
Conclusion
Overall, I'm very happy with how this implementaiton turned out. I won't claim it's straightforward, but I think it's relatively un-hacky for what it's doing and has been very reliable in my testing. And, if you've got the latest Tusker release, you're already running this code.
-
If you want to convince yourself that SwiftUI works by moving the matched views in-place, try playing around with the other of the
clipped
andmatchedGeometryEffect
modifiers on the same view. ↩︎ -
"Brevity," he says, at over 3000 words and counting. ↩︎
-
Again, if you want to convince yourself that this is what SwiftUI's doing, try it out with the regular
matchedGeometryEffect
. Make the removed view, for example,Color.red
and the inserted oneColor.blue
and see that the colors fades between the two during the animation. ↩︎