Bogdan P.

Hi there! I'm Bogdan's digital clone - nice to meet you! I can answer questions about Bogdan and his work, and put you in touch with him if need be. What's on your mind?

Zooming a Liquid Glass pane into a sheet, properly

SwiftUIUIKitiOSAnimationLiquid GlassZoom Transitionlldb

My app has a floating Liquid Glass pane at the bottom of the screen, and tapping it opens a sheet. The zoom transition Apple shipped in iOS 18 and extended to Liquid Glass toolbar items in iOS 26 connects the two: .matchedTransitionSource(id:in:) on the source, .navigationTransition(.zoom(sourceID:in:)) on the sheet content. The generally suggested approach online is extremely buggy; this article explores why, and how to get it working right.

There are two working end states: a pure SwiftUI setup that gets close, and a UIKit presentation path that gets within a hair of native. This post documents both, and the mechanism that explains the gap between them.

The private class names, hierarchy shapes, and participant counts below are observations from the iOS 27 simulator build used for this debugging session, not API contracts. They may change between OS releases.

The broken, generally suggested approach

This is what the generally suggested wiring looks like, and how it behaves:

Swift
1// The floating pane, wrapped in glass
2GlassEffectContainer {
3    PaneContent { showsSheet = true }
4        .padding(20)
5        .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 28))
6}
7.transition(.move(edge: .bottom).combined(with: .opacity))
8.matchedTransitionSource(id: "pane", in: transition)
9.zIndex(10)
10
11// The presentation site
12.sheet(isPresented: $showsSheet) {
13    SheetView()
14        .navigationTransition(.zoom(sourceID: "pane", in: transition))
15}
Preview

Understanding why comes from one method: a minimal app with the same sheet opened two ways, from a system toolbar button and from the custom pane, with lldb attached to dump the window at the transition lifecycle:

Shell
1# Attach to the running app, dump the window when the transition lands
2lldb -b -p <pid> \
3  -o 'breakpoint set -r "presentationTransitionDidEnd"' \
4  -o 'continue' \
5  -o 'expr -l objc -O -- [[[UIApplication sharedApplication] keyWindow] recursiveDescription]' \
6  -o detach

Break on presentationTransitionWillBegin and presentationTransitionDidEnd (use regex breakpoints, the sheet's presentation controller overrides both) and you see everything UIKit builds for the animation. For the native toolbar case it is not a SwiftUI animation at all. UIKit inserts a morph container at the window level and runs it through a private framework called AnimationKit:

Text
1<UIWindow>
2  <UIKit._UIMorphAnimationContainerView>
3    <_UIMorphAnimationContainerView.TransformView>
4      <UIKit.MagicMorphView; frame = (140 798; 122.3 48)>  <- exactly the platter
5        <AnimationKit.MagicMorphLayer>
6          <AnimationKit.InProcessAnimatableLayer>
7            <AnimationKit.MagicMorphTransformLayer>        <- the glass
8              <InProcessAnimatablePortalLayer>
9            <AnimationKit.MagicMorphTransformLayer>        <- the button content
10              <InProcessAnimatablePortalLayer>
11            <AnimationKit.MagicMorphTransformLayer>        <- the sheet
12              <InProcessAnimatablePortalLayer>
13  ...
14  <UIKit.NavigationBarPlatterContainer_v2>
15    <UIPlatformGlassInteractionView>                       <- the source platter
16      ...
17      <UIKit._UIReparentingView>
18        <_UIPortalView>                                    <- mirrors it into the morph

In that capture, the InProcessAnimatablePortalLayers are portals, live mirrors of existing layer trees rather than snapshots, fed by _UIReparentingView / _UIPortalView pairs injected at the source and the sheet. There are three morph participants: the button's glass platter, the button's content, and the sheet. UIKit owns the toolbar platter, so it animates the glass independently of the label, which is why the native version dissolves so cleanly. And the geometry is exact: the morph rect collapses to precisely the platter's frame.

The same dump for a custom SwiftUI glass pane as the source:

Text
1<UIWindow>
2  <UIKit._UIMorphAnimationContainerView>
3    <_UIMorphAnimationContainerView.TransformView>
4      <UIKit.MagicMorphView; frame = (0 544; 402 296)>     <- pane bounds PLUS margins
5        <AnimationKit.MagicMorphLayer>
6          <AnimationKit.InProcessAnimatableLayer>
7            <AnimationKit.MagicMorphTransformLayer>        <- pane, glass and content welded
8              <InProcessAnimatablePortalLayer>
9            <AnimationKit.MagicMorphTransformLayer>        <- the sheet
10              <InProcessAnimatablePortalLayer>

In the same simulator build, a custom SwiftUI source registered as one flat portal, glass welded to content. The morph rect was the modified view's full bounds, so padding applied before (inside) the source modifier was included, and the sheet collapsed toward a rect bigger than the visible pane. In this setup, every visual artifact traced back to one of those two observations.

As far as pure SwiftUI gets you

Anchor the source on the glass itself so the geometry is exact, and use the configuration closure to give the morph card the treatment a platter would have had, a matching clip and its own shadow:

Swift
1.overlay(alignment: .bottom) {
2    PaneContent { showsSheet = true }
3        .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 28))
4        .matchedTransitionSource(id: "pane", in: transition) { source in
5            source
6                .clipShape(.rect(cornerRadius: 28))
7                .shadow(color: .black.opacity(0.25), radius: 24, y: 8)
8        }
9        .padding([.horizontal, .bottom], 16)
10}
11.sheet(isPresented: $showsSheet) {
12    SheetView()
13        .presentationDetents([.large])
14        .navigationTransition(.zoom(sourceID: "pane", in: transition))
15}
Preview

You may be able to get this into a workable state; for me it still looked quite broken. Its limits are structural, not fixable by rearranging modifiers:

A closer UIKit implementation

preferredTransition = .zoom(sourceViewProvider:) lets you return the exact UIView the morph should start from, and SwiftUI's .glassEffect() is itself backed by a UIKit view of the same UIPlatformGlassInteractionView class that toolbar platters use. Resolve the nearest such view from a bridge installed alongside the pane and hand it to the transition:

Swift
1@MainActor
2final class GlassZoomPresenter {
3    weak var anchor: UIView?
4
5    func present() {
6        guard let anchor, let window = anchor.window,
7              var top = window.rootViewController else { return }
8        while let presented = top.presentedViewController { top = presented }
9
10        let host = UIHostingController(rootView: SheetView())
11        if let sheet = host.sheetPresentationController {
12            sheet.detents = [.large()]
13        }
14        let options = UIViewController.Transition.ZoomOptions()
15        options.alignmentRectProvider = { context in
16            let sheetWidth = context.zoomedViewController.view.bounds.width
17            let paneSize = context.sourceView.bounds.size
18            guard paneSize.width > 0 else { return nil }
19            let height = sheetWidth * paneSize.height / paneSize.width
20            return CGRect(x: 0, y: 0, width: sheetWidth, height: height)
21        }
22        // UIKit asks for the source again on dismissal. Re-resolve it each
23        // time instead of capturing the glass UIView used during presentation.
24        host.preferredTransition = .zoom(options: options) { [weak self] _ in
25            guard let anchor = self?.anchor else { return nil }
26            return Self.glassView(near: anchor)
27        }
28        top.present(host, animated: true)
29    }
30
31    // SwiftUI renders .glassEffect through a UIKit view whose class name
32    // contains "GlassInteractionView". Search successively wider ancestors
33    // of the local bridge so unrelated toolbar glass elsewhere in the window
34    // cannot win the lookup first.
35    static func glassView(near anchor: UIView) -> UIView? {
36        var scope: UIView? = anchor
37        while let current = scope {
38            var queue: [UIView] = [current]
39            while let view = queue.popLast() {
40                if String(describing: type(of: view))
41                    .contains("GlassInteractionView") {
42                    return view
43                }
44                queue.append(contentsOf: view.subviews)
45            }
46            scope = current.superview
47        }
48        return nil
49    }
50}
51
52// A bridge that hands us a stable location inside the pane's hierarchy.
53struct AnchorView: UIViewRepresentable {
54    let presenter: GlassZoomPresenter
55
56    func makeUIView(context: Context) -> UIView {
57        let view = UIView()
58        view.isUserInteractionEnabled = false
59        presenter.anchor = view
60        return view
61    }
62
63    func updateUIView(_ uiView: UIView, context: Context) {}
64}
65
66struct ContentView: View {
67    @State private var presenter = GlassZoomPresenter()
68
69    var body: some View {
70        NavigationStack { Color.clear }
71            .overlay(alignment: .bottom) {
72                PaneContent { presenter.present() }
73                    .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 28))
74                    .background(AnchorView(presenter: presenter))
75                    .padding([.horizontal, .bottom], 16)
76            }
77    }
78}
Preview

Dump the hierarchy mid transition with this setup and the morph has three transform layers, matching the native capture. That is consistent with the glass view being enrolled as its own morph participant. Geometry is exact, shadows are drawn by the morph itself, and the interactive swipe to dismiss lands precisely back on the glass. The ZoomOptions.alignmentRectProvider in the code above matters: source and destination aspect ratios disagree in this shape, and returning a rect in the sheet with the pane's aspect anchored the open morph and removed most of the content drift. Intermittent glitches remain on close.

Caveats:

Conclusion

The zoom transition is not one animation with two entry points. From a toolbar it is a three way portal morph where UIKit animates your glass separately from your content. In the tested custom SwiftUI setup, it was a two way morph of a flattened card, and modifier shuffling did not change that. One way to get closer to the native feel from a custom Liquid Glass surface is to go through UIKit's sourceViewProvider and resolve the glass view SwiftUI already made for you, accepting the internal-class-name dependency.