🎸 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.
Exported from Medium on May 10, 2025.