Wes Matlock

🎸 Morph Into Flame: Building a Custom SwiftUI-to-UIKit Image Transition

Sometimes you need to throw down something more powerful than a boring push animation. You want movement. Emotion. The kind of slick…


🎸 Morph Into Flame: Building a Custom SwiftUI-to-UIKit Image Transition

Sometimes you need to throw down something more powerful than a boring push animation. You want movement. Emotion. The kind of slick experience that feels like it’s screaming down the fretboard — not plodding between screens.

This post is all about how I built a custom morphing transition from a SwiftUI grid of SF Symbols into a full-screen UIKit view using snapshots and scale animations. It’s smooth, expressive, and fully in control of your scene — just like Kirk’s solos.

Oh, and this actually came up in a recent iOS interview, so yeah, this stuff matters.


🤘 What We’re Building

  • A SwiftUI grid of 15 SF Symbols
  • Tap an image, and it grows into a full-screen UIKit view
  • Tap the big image, and it shrinks back to its original spot
  • Uses UIViewControllerAnimatedTransitioning, snapshots, and good old UIKit magic


🎛️ The Setup: SF Symbols Model

struct ImageItem: Identifiable, Equatable {  
    let id: UUID  
    let symbolName: String  
  
    init(symbolName: String) {  
        self.id = UUID()  
        self.symbolName = symbolName  
    }  
}

Key Riffs:

  • We use UUID to track taps in the grid
  • Equatable helps for comparisons later (e.g., for animation reversals)
  • Super lightweight — built for snapshot-based transitions

🧠 The ViewModel

@Observable  
class ImageGridViewModel {  
    var items: [ImageItem]  
  
    init() {  
        self.items = [  
            ImageItem(symbolName: "bolt.fill"),  
            ImageItem(symbolName: "flame.fill"),  
            ImageItem(symbolName: "hare.fill"),  
            ImageItem(symbolName: "tortoise.fill"),  
            ImageItem(symbolName: "guitars"),  
            ImageItem(symbolName: "music.note.list"),  
            ImageItem(symbolName: "bell"),  
            ImageItem(symbolName: "sun.max"),  
            ImageItem(symbolName: "moon"),  
            ImageItem(symbolName: "star.fill"),  
            ImageItem(symbolName: "heart.fill"),  
            ImageItem(symbolName: "bolt.circle.fill"),  
            ImageItem(symbolName: "airplane"),  
            ImageItem(symbolName: "car.fill"),  
            ImageItem(symbolName: "bus.fill")  
        ]  
    }  
}

Key Riffs:

  • Uses @Observable, the modern way to bind data in SwiftUI (iOS 17+)
  • Feeds the grid view directly
  • Includes a few symbols inspired by Metallica tours (we love a good flame and a bolt)

🖼️ The SwiftUI Grid View

struct ImageGridView: View {  
    @Bindable var viewModel: ImageGridViewModel  
    var onImageTap: (ImageItem, CGRect) -> Void  
  
    var body: some View {  
        GeometryReader { geo in  
            ScrollView {  
                LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 3), spacing: 16) {  
                    ForEach(viewModel.items) { item in  
                        GeometryReader { imageGeo in  
                            Image(systemName: item.symbolName)  
                                .resizable()  
                                .scaledToFit()  
                                .frame(width: 80, height: 80)  
                                .onTapGesture {  
                                    let globalFrame = imageGeo.frame(in: .global)  
                                    onImageTap(item, globalFrame)  
                                }  
                        }  
                        .frame(width: 80, height: 80)  
                    }  
                }  
                .padding()  
            }  
        }  
    }  
}

Key Riffs:

  • GeometryReader lets us grab the tapped image’s global frame
  • That frame gets passed to UIKit to set up the animation
  • This is the backbone for a “grow from origin” morph

🎤 The UIKit Image View (Where the Solo Happens)

class LargeImageViewController: UIViewController {  
    let symbolName: String  
    let image: UIImage  
  
    public private(set) lazy var imageView: UIImageView = {  
        let iv = UIImageView(image: image)  
        iv.contentMode = .scaleAspectFit  
        iv.translatesAutoresizingMaskIntoConstraints = false  
        iv.isUserInteractionEnabled = true  
        iv.accessibilityIdentifier = "largeImageView"  
        iv.accessibilityLabel = symbolName  
        return iv  
    }()  
  
    init(symbolName: String) {  
        self.symbolName = symbolName  
        guard let image = UIImage(systemName: symbolName) else {  
            fatalError("Invalid SF Symbol: \(symbolName)")  
        }  
        self.image = image  
        super.init(nibName: nil, bundle: nil)  
        modalPresentationStyle = .custom  
    }  
  
    required init?(coder: NSCoder) { fatalError() }  
  
    override func viewDidLoad() {  
        super.viewDidLoad()  
        view.backgroundColor = .systemBackground  
  
        view.addSubview(imageView)  
        NSLayoutConstraint.activate([  
            imageView.centerXAnchor.constraint(equalTo: view.centerXAnchor),  
            imageView.centerYAnchor.constraint(equalTo: view.centerYAnchor),  
            imageView.widthAnchor.constraint(equalToConstant: 200),  
            imageView.heightAnchor.constraint(equalToConstant: 200)  
        ])  
  
        let backgroundTap = UITapGestureRecognizer(target: self, action: #selector(dismissSelf))  
        backgroundTap.cancelsTouchesInView = false  
        view.addGestureRecognizer(backgroundTap)  
    }  
  
    @objc private func dismissSelf() {  
        dismiss(animated: true)  
    }  
}

Key Riffs:

  • imageView is lazy-loaded and safely public for transition access
  • We crash if the SF Symbol is bad — loud failures are good in dev
  • Full-screen presentation with .custom lets us own the transition

🔥 The CustomTransition (Where the Fire Meets the Fuel)

class CustomTransition: NSObject, UIViewControllerAnimatedTransitioning {  
    private let duration: TimeInterval = 0.5  
    let originFrame: CGRect  
    let isPresenting: Bool  
  
    init(originFrame: CGRect, isPresenting: Bool) {  
        self.originFrame = originFrame  
        self.isPresenting = isPresenting  
    }  
  
    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {  
        return duration  
    }  
  
    private func makeSnapshot(for symbolName: String) -> UIImageView? {  
        guard let image = UIImage(systemName: symbolName) else {  
            print("❌ Could not load symbol image for: \(symbolName)")  
            return nil  
        }  
        let imageView = UIImageView(image: image)  
        imageView.contentMode = .scaleAspectFit  
        return imageView  
    }  
  
    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {  
        let container = transitionContext.containerView  
  
        if isPresenting {  
            guard let toVC = transitionContext.viewController(forKey: .to) as? LargeImageViewController,  
                  let snapshot = makeSnapshot(for: toVC.symbolName) else {  
                transitionContext.completeTransition(false)  
                return  
            }  
  
            container.addSubview(toVC.view)  
            toVC.view.layoutIfNeeded()  
            let finalFrame = toVC.imageView.frame  
            let finalCenter = toVC.imageView.center  
  
            snapshot.frame = finalFrame  
            snapshot.center = CGPoint(x: originFrame.midX, y: originFrame.midY)  
            snapshot.transform = CGAffineTransform(scaleX: 0.1, y: 0.1)  
  
            container.addSubview(snapshot)  
            toVC.view.alpha = 0  
            toVC.imageView.alpha = 0  
  
            UIView.animate(withDuration: duration,  
                           delay: 0,  
                           usingSpringWithDamping: 0.8,  
                           initialSpringVelocity: 0.6,  
                           options: [.allowUserInteraction]) {  
                snapshot.transform = .identity  
                snapshot.center = finalCenter  
                toVC.view.alpha = 1  
            } completion: { _ in  
                snapshot.removeFromSuperview()  
                toVC.imageView.alpha = 1  
                transitionContext.completeTransition(true)  
            }  
  
        } else {  
            guard let fromVC = transitionContext.viewController(forKey: .from) as? LargeImageViewController,  
                  let snapshot = makeSnapshot(for: fromVC.symbolName) else {  
                transitionContext.completeTransition(false)  
                return  
            }  
  
            snapshot.frame = fromVC.imageView.frame  
            container.addSubview(snapshot)  
            fromVC.imageView.alpha = 0  
  
            UIView.animate(withDuration: duration, delay: 0, options: [.curveEaseInOut]) {  
                snapshot.transform = CGAffineTransform(scaleX: 0.1, y: 0.1)  
                snapshot.frame = self.originFrame  
                fromVC.view.alpha = 0  
            } completion: { _ in  
                snapshot.removeFromSuperview()  
                transitionContext.completeTransition(true)  
            }  
        }  
    }  
}

Key Riffs:

  • makeSnapshot avoids duplicated code and handles SF Symbol loading
  • .transform + .center avoids conflicts from frame animations
  • We use layoutIfNeeded() to ensure image frames are valid before animation
  • Springy present + clean shrink = buttery and snappy

🧪 Why This Rocks (and Why You’ll Be Asked About It)

Interviewers love these:

  • Custom transitions (UIViewControllerAnimatedTransitioning)
  • Mixing SwiftUI and UIKit
  • Capturing view geometry with GeometryReader
  • Using UIHostingController and custom modal logic

Also… it just feels rad.


🎸 Until It Sleeps…

This post was forged in UIKit and SwiftUI flames, riffing through layout quirks, symbol loading issues, and snapshot battles. But it’s now a fully playable UI experience that feels alive — and you’re in control of the tone.

Next time someone asks you about a custom transition or mixing SwiftUI with UIKit? Show them this. It’s the One.

🤘 Stay fast. Stay heavy.

If you’re hungry for more tips, tricks, and a few battle-tested stories from the trenches of native mobile development, swing by my collection of articles:https://medium.com/@wesleymatlock.

These posts are packed with real-world solutions, some laughs, and the kind of knowledge that’s saved me from a few late-night debugging sessions. Let’s keep building apps that rock — and if you’ve got questions or stories of your own, drop me a line. I’d love to hear from you.

— Wes

By Wesley Matlock on March 31, 2025.

Canonical link

Exported from Medium on May 10, 2025.

Written on March 31, 2025