Zoom transitions
Zoom transitions are my favourite addition in the iOS 18 SDK. They’re easy to implement, look great, and are fun!
I ended up trying out almost everything you can do with the zoom transition API, both in test projects and in my reading app. In this post, I’ll share what I learned, starting with the most basic setup in both SwiftUI and UIKit, then looking at some more advanced topics further down.
Contents
- When should the zoom transition be used?
- Zoom transitions in SwiftUI
- Zoom transitions in UIKit
- Platform availability
- Fine-tuning the source view
- Fine-tuning the destination view
- Gestures
- Can you spin items around as you pinch to close?
- Resources
When should the zoom transition be used?
A zoom transition animates from a source view to a destination view and back again.
The best fit for zooming is when the destination view shows a larger version of the source view, like going from an image thumbnail to an image that fills the window. Additionally, this transition can look decent when the source view is large and has an aspect ratio similar to the destination view. Zooming doesn’t look good when the source view is a skinny row in a list that fills the entire width of the window — although if the row shows a thumbnail image then that might be a more suitable source view.
Zoom transitions are available both for navigation push/pop and full screen present/dismiss transitions. If your destination view should have a navigation bar with a back button, use the former. If you want a close/done button instead, use the latter.
Zoom transitions in SwiftUI
Zoom transitions are easy to implement in SwiftUI. Apply the navigationTransition
modifier with zoom(sourceID:in:)
to the destination view and the matchedTransitionSource
modifier to the source view. To connect the source view and destination view, provide a (possibly non-unique) identifier and a namespace, which together create a unique identifier for the transition.
The examples below show exactly where to apply these modifiers. I’m using a placeholder Item
type to represent the data being shown.
Using NavigationLink
- Source: Either the
NavigationLink
itself (shown below) or a child view built by theNavigationLink
’slabel
closure. - Destination: The view built by the
NavigationLink
’sdestination
closure.
struct ContentView: View {
let items: [Item]
@Namespace var transitionNamespace
var body: some View {
NavigationStack {
List(items) { item in
NavigationLink {
DetailView(item: item)
.navigationTransition(.zoom(sourceID: item, in: transitionNamespace))
} label: {
ItemRow(item: item)
}
.matchedTransitionSource(id: item, in: transitionNamespace)
}
}
}
}
Using fullScreenCover
- Source: If, for example, the transition is to be triggered by tapping a
Button
, the source view would be either theButton
itself (shown below) or a child view built by theButton
’slabel
closure. - Destination: The view built by the
fullScreenCover
modifier’scontent
closure.
struct ContentView: View {
let items: [Item]
@State var presentedItem: Item?
@Namespace var transitionNamespace
var body: some View {
NavigationStack {
List(items) { item in
Button {
presentedItem = item
} label: {
ItemRow(item: item)
}
.matchedTransitionSource(id: item, in: transitionNamespace)
}
.fullScreenCover(item: $presentedItem) { item in
NavigationStack {
DetailView(item: item)
.toolbar {
Button {
presentedItem = nil
} label: {
Text("Done")
}
}
}
.navigationTransition(.zoom(sourceID: item, in: transitionNamespace))
}
}
}
}
Zoom transitions in UIKit
Since UIKit views are reference types, the destination and source can be established in one place by setting the preferredTransition
property of the destination view controller to zoom(options:sourceViewProvider:)
.
Using navigation push
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
collectionView.deselectItem(at: indexPath, animated: true)
let item = dataSource.itemIdentifier(for: indexPath)
let detailViewController = DetailViewController(item: item)
detailViewController.preferredTransition = .zoom(sourceViewProvider: { context in
// Use the context instead of capturing to avoid needing to make a weak reference.
let detailViewController = context.zoomedViewController as! DetailViewController
// Fetch this instead of capturing in case the item shown by the destination view can change while the destination view is visible.
let item = detailViewController.item
// Look up the index path in case the items in the collection view can change.
guard let indexPath = self.dataSource.indexPath(for: item) else {
return nil
}
// Always fetch the cell again because even if the data never changes, cell reuse might occur. E.g if the device rotates.
guard let cell = self.collectionView.cellForItem(at: indexPath) else {
return nil
}
return cell.contentView
})
navigationController!.pushViewController(detailViewController, animated: true)
}
Using full screen presentation
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
collectionView.deselectItem(at: indexPath, animated: true)
let detailNavigationController = UINavigationController(rootViewController: DetailViewController())
detailNavigationController.preferredTransition = .zoom(sourceViewProvider: { context in
// Use the context instead of capturing to avoid needing to make a weak reference.
let detailNavigationController = context.zoomedViewController as! UINavigationController
let detailViewController = detailNavigationController.viewControllers[0] as! DetailViewController
// Fetch this instead of capturing in case the item shown by the destination view can change while the destination view is visible.
let item = detailViewController.item
// Look up the index path in case the items in the collection view can change.
guard let indexPath = self.dataSource.indexPath(for: item) else {
return nil
}
// Always fetch the cell again because even if the data never changes, cell reuse might occur. E.g if the device rotates.
guard let cell = self.collectionView.cellForItem(at: indexPath) else {
return nil
}
return cell.contentView
})
present(detailNavigationController, animated: true)
}
Platform availability
iOS: Zoom transitions are implemented.
visionOS, tvOS and watchOS: The zoom transition APIs are available but have no effect.
Mac Catalyst: The zoom transition APIs are available, but only have an effect when using the Scaled to Match iPad setting. When using Optimize for Mac, the traditional iOS-style navigation push and full screen cover vertical transitions will be used instead, which is ironic because a zoom transition would be more Mac-like.
macOS: The SwiftUI zoom transition APIs are unavailable. It’s unclear why we need to write conditionally compiled SwiftUI code to accommodate macOS, while on all other platforms the API exists even if it’s ignored. I presume this is related to how SwiftUI is internally backed by UIKit on all other platforms, but this feels like an implementation detail that shouldn’t leak into the API.
Fine-tuning the source view
Filling an entire row/cell
SwiftUI views may hug their content. I found when using zoom transition with fullScreenCover
from the row of a List
, the animation started from an area tightly hugging the visible row content. To animate the zoom from the full width of the row, I added a Spacer
so the source view fills the available space. I’d like to hear if there is a neater solution.
struct ItemRow: View {
var body: some View {
HStack() {
Image(...)
Text(...)
// Without this, the source for full screen cover
// doesn’t fill the whole width of the cell.
Spacer(minLength: 0)
}
}
}
Avoiding gaps in grouped lists in UIKit
In UIKit, a slight improvement over what’s shown in Russell Ladd’s WWDC session is to pass the cell‘s contentView
as the source rather than the cell itself. This matches how the transition looks when set up in SwiftUI and avoids showing gaps in grouped style lists, which I think doesn’t look good.
Can the source view be missing?
Yes. If your source view is not available, you can omit the matchedTransitionSource
(SwiftUI) or return nil
from the sourceViewProvider
(UIKit). In this case, the destination view will zoom from, or back to, the centre of the container view. Note that because cellForItem(at:)
may or may not return a view for cells scrolled off the edge of the window because of prefetching, you might inconsistently see the zoom animation going either off the top/bottom of the window or to the centre of the window.
Does the source view need to be the same as the view that triggers the transition?
No. The source view can be whatever you like so technically can be unrelated to the view that triggers the transition. However this might create a confusing user experience.
A good way to make use of this flexibility is for the source view to be a thumbnail image that’s a subview of the cell being tapped.
Fine-tuning the destination view
Zooming to only part of the destination view
To make the animation look great, special care needs taking if the source view is a visual miniature of only part of the destination view. A common situation where this occurs is when zooming into an image with an aspect ratio that doesn’t match the container view. Here’s the animation in slow motion when setting up the transition without any options:
Notice how the ferry shifts because the aspect ratios of the source and destination don’t match:
To fix this shifting image using UIKit, specify where the image is in the destination view using an alignmentRectProvider
:
let options = UIViewController.Transition.ZoomOptions()
options.alignmentRectProvider = { context in
let detailViewController = context.zoomedViewController as! DetailViewController
let imageView = detailViewController.imageView
return imageView.convert(imageView.bounds, to: detailViewController.view)
}
detailViewController.preferredTransition = .zoom(options: options, sourceViewProvider: { context in
...
})
I couldn’t find equivalent API in SwiftUI. I tried applying the navigationTransition
modifier to a child view of the NavigationLink
’s destination
or the fullScreenCover
’s content
, but this didn’t make any difference. I’d love to know if I missing something here.
Here’s the animation in slow motion with the alignmentRectProvider
specified:
This looks great:
The transition might look even better if the black background didn’t zoom at all and instead cross-faded in place while only the image was zoomed. The same applies to any UI elements like toolbars in the destination view. We want to give the image a sense of physical presence, while the background is supposed to be empty space rather than a shape with concrete edges. Unfortunately this isn’t what Apple implemented. To achieve this, during the transition the destination view would need removing from its regular position in the view hierarchy, similar to what happens to the source view. Therefore I don’t see how this improvement could be made without altering the API to receive a view instead of an alignment rectangle.
Can the destination view be partially transparent?
Not really. If you set the modalPresentationStyle
of the destination view controller to overFullScreen
, transparent regions in the destination view will show the views underneath once the transition ends, but during a zoom transition the system adds an opaque view in the system background colour behind the destination view.
Can the zoom transition be used with sheets and popovers?
No. Zoom transitions are only available for full screen presentations. If you try to show the destination view another way, either the modal presentation style or the transition will be ignored:
Sheet: The presentation will be treated as full screen instead.
Popover: This will show a popover, but the presentation and dismissal animations won’t be different to usual. Interestingly, the dismiss gestures (pinch and swipe down, described below) are installed on the popover and trigger a non-interactive dismissal; I presume this is a bug, but I wouldn’t say it’s at all important as this is not exactly a valid setup.
Gestures
The forwards transition (push/present) is always triggered by our code in response to a discrete action (a tap). I can’t see a way to let users start this transition interactively with a ‘spread’ gesture on the source view. This was possible in Photos on iPhone OS 3.2 on the original iPad but not in more recent versions of Photos.
Interactive gestures for the reverse transition (pop/dismiss) are available automatically:
Pinch: Users can use a two finger pinch gesture to interactively reverse the zoom transition. This is intuitive but can be inconvenient because it requires two fingers. If the destination view is itself zoomable using a scroll view, the system will give that scroll view precedence, so the scroll view must be fully zoomed out before the user can pinch to pop/dismiss.
Scroll vertically: Users can interactively reverse the zoom transition by swiping down with one finger anywhere in the destination view. If the destination view scrolls vertically, dismissal is only possible when scrolled to the top. In iOS 18.0 beta 1, this was only available for full screen present/dismiss, but beta 2 enabled this for navigation push/pop as well.
Scroll horizontally: Users can go back interactively by swiping with a direct touch (a finger) from near the leading edge of the container. (This is the left edge with English.) If the destination view scrolls horizontally, users must scroll from away from the leading edge of the container. With a trackpad or mouse, users can go back by scrolling in the leading direction from anywhere in the destination view. If the destination view scrolls vertically, dismissal with trackpad/mouse scrolling is only possible when fully scrolled to the start. In some iOS 18.0 betas, these gestures were only available for navigation push/pop, but they were later enabled for full screen present/dismiss as well.
I don’t know any API to access the gesture recognisers driving all these interactive transitions. This might be useful if you wanted to set up custom failure requirements with your own gesture recognisers, although I didn’t see any situation so far where the behaviour wasn’t already as I would expect.
With a full screen presentation, while the gestures mean that a user will be able to ‘escape’ from the destination view, you should also add a close/done button for better discoverability and accessibility. I’ve shown this in the basic SwiftUI code sample near the top of this article. Adding a button isn’t necessary for a navigation push/pop because there will be a back button in the navigation bar by default.
Can you spin items around as you pinch to close?
Yes! It’s so fun:
I’m delighted with this new API. In my reading app (used for the screen recordings in this article) I’m definitely going to use the zoom transition to go between seeing an image inline in an article and viewing that image filling the window. I’ll probably also use this transition to go from the list of articles to reading an article because the cells are quite large and this would let me delete code implementing custom interactive dismissal on swiping down.
Resources
To learn more about zoom transitions on iOS, check out:
- Russell Ladd’s WWDC 2024 session: Enhance your UI animations and transitions
- Enhancing your app with fluid transitions in the UIKit documentation