Add SwiftUI Hero Transition

This commit is contained in:
Shadowfacts 2023-05-21 21:49:25 -07:00
parent 728ce659f6
commit 9ef07cd6e2
6 changed files with 887 additions and 2 deletions

View File

@ -358,10 +358,10 @@ article {
margin-top: -10px; margin-top: -10px;
margin-bottom: -10px; margin-bottom: -10px;
p:first-child { > p:first-child {
margin-top: 0; margin-top: 0;
} }
p:last-child { > p:last-child {
margin-bottom: 0; margin-bottom: 0;
} }
} }

View File

@ -0,0 +1,882 @@
```
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`](https://developer.apple.com/documentation/swiftui/view/matchedgeometryeffect(id:in:properties:anchor:issource:)) modifier that makes it relatively easy to build hero transitions. 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 internet[^1] 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.
[^1]: The SwiftUI Lab has a good [example](https://github.com/swiftui-lab/swiftui-hero-animations) of this technique.
<!-- excerpt-end -->
## 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.
```swift
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.
```swift
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:
1. `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).
2. 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 call `dismiss` 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.
```swift
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.
```swift
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:
```swift
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).
<style>
#layer-diagram {
height: 550px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
#layer-diagram p {
width: 100%;
margin: 10px 0;
text-align: center;
font-family: -apple-system, system-ui, BlinkMacSystemFont, sans-serif;
}
#layer-container {
transform: translate(30px, 30px) perspective(1000px) rotateX(-20deg) rotateY(30deg);
position: relative;
height: 550px;
width: 350px;
}
#layer-container > div {
width: 200px;
height: 400px;
position: absolute;
border: 1px dashed var(--ui-text-color);
}
#layer-container > #red {
background-color: rgba(255, 0, 0, 0.4);
top: 0;
left: 0;
}
#layer-container > #green {
top: 50px;
left: 50px;
}
#layer-container > #green > #background {
background-color: #50a14f;
width: 100%;
height: 100%;
animation: content-layer 5s infinite;
}
#layer-container > #green > p {
position: absolute;
top: 0;
animation: content-layer 5s infinite;
}
#layer-container > #blue {
top: 100px;
left: 100px;
}
#layer-container > #blue > #matched {
position: absolute;
background-color: blue;
animation: matched 5s infinite;
}
@keyframes content-layer {
0%, 80%, 100% { opacity: 0; }
30%, 50% { opacity: 1; }
}
@keyframes matched {
0%, 80%, 100% { width: 50px; height: 50px; top: calc(50% - 25px); left: calc(50% - 25px); }
30%, 50% { width: 100%; height: 100px; top: 50px; left: 0; }
}
</style>
<div id="layer-diagram" class="article-content-wide" aria-labelled="Diagram of the animation's layer structure. The presenter layer at the back is always visible. The presented layer in the middle fades in and out. The matched layer at the front changes shape between a small square while the presented layer is hidden and a larger rectangle when the presented layer is visible.">
<div id="layer-container">
<div id="red"><p>Presenter</p></div>
<div id="green"><div id="background"></div><p>Presented</p></div>
<div id="blue"><div id="matched"></div></div>
</div>
</div>
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.
<aside class="inline">
It's not clear at the moment why we need two separate hosting controllers, rather than having everything in the same SwiftUI view and host. There is a reason for this that will be made clear when we to get the actual custom presentation animation. It has to do with the details of how the view controller transition is implemented, so just bear with me for now.
</aside>
## 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 SwiftUI[^2], we're displaying the matched views outside of their original position in the view tree.
[^2]: 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` and `matchedGeometryEffect` modifiers on the same view.
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.
```swift
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.
```swift
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.
```swift
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.
```swift
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.
```swift
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`.
```swift
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:
1. We turn the `sources` dictionary into an array (and take the opportunity to flatten out the nested tuples) because we'll need a `RandomAccessCollection` to use with `ForEach` to display all the views.
2. 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.
```swift
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).
```swift
VStack {
Image("pranked")
.resizable()
.aspectRatio(contentMode: .fit)
.matchedGeometrySource(id: "image")
.frame(width: 100)
Button {
presented.toggle()
} label: {
Text("Present")
}
}
.matchedGeometryPresentation(isPresented: $presented)
```
<figure>
<div style="display: flex; flex-direction: row; align-items: center;">
<img src="/2023/swiftui-hero-transition/container-source.png" alt="" style="width: 50%;">
<img src="/2023/swiftui-hero-transition/container-presented.png" alt="" style="width: 50%;">
</div>
</figure>
## 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.
```swift
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.
```swift
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.
<aside>
Why not just use a `@State` property in the presentation view modifier, and pass a binding to that through to the destination? Because we'll need to observe values of the destinations property outside of the SwiftUI view tree—and using `@Published` gives us an easy way to do that.
</aside>
```swift
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:
```swift
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.
```swift
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 brevity[^3]).
[^3]: "Brevity," he says, at over 3000 words and counting.
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:
```swift
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.
```swift
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`:
```swift
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.
```swift
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
```swift
// ...
.matchedGeometryPresentation(isPresented: $presented) {
VStack {
Image("pranked")
.resizable()
.aspectRatio(contentMode: .fit)
.matchedGeometryDestination(id: "image")
Text("Hello!")
}
}
```
<figure>
<div style="display: flex; flex-direction: row; align-items: center;">
<img src="/2023/swiftui-hero-transition/content-presented.png" alt="" style="width: 50%;">
</div>
</figure>
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.
```swift
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.
```swift
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.
```swift
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.
```swift
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.
<aside class="inline">
And here is the answer from before, the reason why we have two separate hosting controllers: SwiftUI animations don't have completion handlers, but we need one in order to call the VC transition context's completion method. With a simple, time-based animation we could just enqueue something on the main runloop after the appropriate delay (though this would have the unfortunate side-effect of breaking the simulator's Slow Animations mode, which was a very useful tool when debugging this). But, I want to use a spring animation, since it feels much nicer, and calculating the appropriate amount of time is trickier.
The other piece of the puzzle is that `UIViewPropertyAnimator` will call the completion handler immediately if no animations are added. So, we do need to actually animate something. And we can't animate anything inside SwiftUI, since it uses it's own independent animation system. Thus, we split the content into two hosting controllers—one of which is animated by SwiftUI and the other by UIKit. So long as we take care to match the same spring configuration, they'll look perfectly good together.
</aside>
```swift
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.
```swift
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.
```swift
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.
```swift
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.
```swift
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 source[^4]. By keeping them at the same position, we can ensure it still appears to be a single view that's changing.
[^4]: 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 one `Color.blue` and see that the colors fades between the two during the animation.
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&rarr;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:
```swift
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
```swift
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.
```swift
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:
1. 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).
2. Prepare the initial state of all the properties that are involved in the animation.
3. Set the new values to start the animation.
This takes care of the first two points, and is fairly straightforward:
```swift
.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.
```swift
.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.
<figure>
<video controls style="max-width: 50%; margin: 0 auto; display: block;" title="">
<source src="/2023/swiftui-hero-transition/hero.mp4" type="video/mp4">
</video>
</figure>
(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:
```swift
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.<sup>(definitely not because I didn't feel like spending the time to work through them)</sup>
1. Recreating the `properties` and `anchor` parameters of `matchedGeometryEffect`
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.
2. 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.
3. 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

BIN
site/static/2023/swiftui-hero-transition/hero.mp4 (Stored with Git LFS) Normal file

Binary file not shown.