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?

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.

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

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

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.

Screenshot showing a gap in the list during a zoom transition Screenshot showing just the list text being removed during a zoom transition
Using the full cell as the source view (top) versus just the content view (bottom). Note that in general a zoom transition from a skinny row like this won’t look good. I could have made nicer example screenshots.

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:

Screenshot during zoom animation showing image ‘ghosting’ effect on ferry.

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:

Screenshot during zoom animation showing only one ferry.

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:

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:

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: