Recreating Apple Music's Native Mini Player & Now Playing Morph Animation Using SwiftUI & UIKit
- Tolga Sağsöz
- 22 Haz
- 14 dakikada okunur
Apple Music now playing transition
In Apple Music, you tap the little playback bar at the bottom and the small artwork swells until it fills the screen. The card seems to unfold. I wanted that exact feeling in a music app I was building, and the stock tools couldn't give it to me. Here is how the transition actually works, and the things that tripped me up along the way.
What we're after
Most music apps keep a slim playback bar pinned to the bottom of the screen. A small piece of album art, the track name, a play button. I'll call this the mini player. Tapping it brings up the full now playing screen, with the big artwork, the scrubber, and the rest of the controls. In a typical app that switch is just a cut. The small view disappears and the big one takes its place. Apple Music does something nicer: the small cover looks like it physically grows and slides into where the big cover sits. Your eye reads it as one cover getting bigger rather than two separate things, and that continuity is most of what makes the app feel expensive.

"Animate the cover from small to big" sounds like a one liner. A few of the details turn out to be genuinely awkward on iOS, though. Before getting into them, let me set up some shared words.
A bit of vocabulary
New to SwiftUI? Read this part. Comfortable already? Skip to section 3.
The view tree
Everything on screen is a tree of nested boxes. The window on the outside, the tab bar inside it, screens inside that, then covers and labels inside those. When we animate, we tend to say "move this cover over to that spot." The catch is that if the cover and the target spot sit on different branches of the tree, the system has no way to know they're meant to be the same thing. That's the exact wall we're about to run into.
SwiftUI and UIKit
iOS gives you two ways to build interfaces. UIKit is the older, lower level one, where you control more or less every pixel yourself. SwiftUI is the newer, higher level one, where you describe what you want and let the framework figure out the how. Most new apps are mostly SwiftUI. Some delicate animations need more control than SwiftUI hands you, though, so you drop down into UIKit for those bits. Our morph is one of them: the surrounding app is SwiftUI, but the transition itself runs in UIKit.
Shared element transitions
When a transition carries an element that looks the same across two screens, like our album cover, people call it a shared element transition. Android has it built in. On iOS, matchedGeometryEffect and .navigationTransition(.zoom) cover some of these cases. The word doing the work there is some. They didn't cover ours, and the reason why is worth understanding.
Why the stock tools fall short
The obvious first move is to lean on Apple's built in transitions. I tried both of the likely candidates, and both broke for this particular layout. It's worth walking through, because you'll probably reach for them first too.
.navigationTransition(.zoom)
SwiftUI's zoom transition really does scale the cover up, which is promising. It comes with two constraints, though. It only works inside SwiftUI's own navigation machinery, like a sheet or a navigation stack. And it grows the cover to the bounds of its source container rather than to the full screen, so the source effectively gets cropped. What I wanted was the cover flying to full size while the surrounding controls came in on their own separate schedule. Zoom couldn't split those two timelines. Either the cover lands correctly or the controls do, and the target keeps drifting between them.
matchedGeometryEffect
This one links the "same" view living in two different places and animates cleanly between them. It looks like the perfect fit, right up until you find out where the mini player actually lives.
The actual blocker
The mini player that sits above the tab bar lives in its own separate hosting area, a little isolated view tree of its own. matchedGeometryEffect needs both of the views it links to be in the same tree. Since the mini player is fenced off in its own, there's no way to bridge it across to the full screen.
So the stock tools are closed to us here. What's left is to do the whole thing by hand, which sounds like a loss but is actually freeing. Once you're doing it by hand, you get full control over every frame.
The trick: a stand-in cover
The technique is an old standby in animation work, and simpler than you might expect.
The core idea
We don't move the real mini cover or the real big cover at all. Instead we spin up a temporary copy, a stand-in, that exists only while the transition runs. It's born at the mini player's spot, grows toward the full screen spot, and the moment the animation ends we throw it away. At that same instant the real big cover becomes visible underneath. The eye never catches two covers on screen at once, so the join is invisible.

What makes this pleasant to work with is that we can drop the stand-in on the very top layer, in front of everything else. The "separate tree" problem just evaporates. The stand-in belongs to no tree. It floats above the whole screen for the length of the transition and doesn't care about view hierarchy boundaries in the slightest. Two things are still missing: where the stand-in should start, which is the mini cover's position on screen, and where it should end up, which is the big cover's position. Both of those live in separate view trees, so we need a spot where the two positions can meet. That's the next piece.
Sharing positions across the gap
The mini player knows its own position. The full screen now playing view knows where its cover sits. Neither can see the other, since they're in separate trees. The fix is a small shared object that both of them can write to and read from, a meeting point. It's basically a box. It holds a couple of rectangles and a flag or two. When the mini player draws, it writes its own position in. When the now playing view draws, it writes its cover position in. And the animation reads both positions back out. That's the whole job.
FrameBridge.swift
// The shared meeting point between the SwiftUI side and the UIKit side.
// @Observable means SwiftUI views get notified when these values change.
@Observable
final class FrameBridge {
// The mini player's small cover, in screen (global) coordinates.
var miniArtworkFrame: CGRect = .zero
// The big cover's position on the full screen now playing view.
var targetArtworkFrame: CGRect = .zero
// Is a morph running right now? Used to hide the real covers while it is.
var isMorphing: Bool = false
// Which track is playing, so the stand-in can grab the right cover.
var track: Track?
}Now the mini player needs to write its own position into the bridge whenever it gets redrawn. In SwiftUI, the way to find out a view's screen position is onGeometryChange, which tells you "your size or position changed, here's the new value."
MiniPlayer.swift
MiniPlayerView(onTap: {
// On tap, ask for the full screen to open.
isNowPlayingPresented = true
})
// Keep writing the mini cover's screen position into the bridge.
.onGeometryChange(for: CGRect.self) { proxy in
proxy.frame(in: .global) // .global is position relative to the whole screen
} action: { frame in
bridge.miniArtworkFrame = frame
}Why global coordinates
Every view has its own local coordinate space. Our stand-in is going to float over the entire screen, so it needs positions measured against the whole screen, not against some parent. A local position like "12 points from the left edge of the tab bar" means nothing to the stand-in. A global one like "bottom left, x=12, y=800" works anywhere.
The big cover does the same thing in reverse. When the full screen view draws, it writes its own global position into targetArtworkFrame. Now the bridge knows both ends: where to start from and where to end up. We're ready to set up the animation.
Flying the cover
Here's where we step down from SwiftUI into UIKit. The task is "put a temporary view at the very top of the screen, spring it from one frame to another, then remove it," and UIKit gives us pixel level control over exactly that. Custom transitions like this are written against a protocol called UIViewControllerAnimatedTransitioning, which hands you a transition context and lets you run the animation yourself.
Step 1: build the stand-in
The stand-in is a plain box with the album cover image inside it. One thing matters more than it looks: it has to carry the same shadow as the real cover. Otherwise, the moment the stand-in is removed and the real cover appears, the shadow snaps into place and you see the seam.
MorphAnimator.swift
func makeArtworkProxy(frame: CGRect, cornerRadius: CGFloat) -> UIView {
let wrapper = UIView(frame: frame)
wrapper.backgroundColor = .clear
// Match the real cover's shadow exactly, so nothing pops when we swap them.
wrapper.layer.shadowOpacity = 0.35
wrapper.layer.shadowRadius = 24
wrapper.layer.shadowOffset = CGSize(width: 0, height: 8)
// The cover image is already in memory, since the mini player was just
// showing it. We draw it instantly with no network or disk round trip,
// which is what keeps the transition smooth.
let imageView = UIImageView(frame: wrapper.bounds)
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
imageView.layer.cornerRadius = cornerRadius
if let track = bridge.track,
let image = ArtworkCache.shared.cachedImage(for: track) {
imageView.image = image
}
wrapper.addSubview(imageView)
return wrapper
}Small but it matters
The stand-in's image has to come from memory. The mini player was already displaying that cover, so it's sitting right there in the cache. If you try to reload it off the network when the transition fires, the cover shows up blank for the first few frames and the whole effect falls apart. Reuse what's already loaded.
Step 2: work out the start and end frames
The positions in the bridge are measured against the whole screen. We're going to put the stand-in inside the transition's container view, so we convert them into that container's coordinates. There's a wrinkle on the very first open: the full screen view hasn't drawn yet, so its target frame isn't known. We need a fallback for that case.
MorphAnimator.swift
func targetFrame(in container: UIView) -> CGRect {
let global = bridge.targetArtworkFrame
if global != .zero {
// Normal case: the full screen view is laid out, use its real position.
return container.convert(global, from: nil)
}
// Fallback: first open, view not drawn yet. Estimate from a measured ratio.
// That 0.16 was not a guess. I read it off device logs (see section 10).
let side = min(container.bounds.width * 0.85, 380)
let x = (container.bounds.width - side) / 2
let y = container.bounds.height * 0.16
return CGRect(x: x, y: y, width: side, height: side)
}Step 3: spring it across
Moving the cover in a straight line from one frame to the other feels robotic. A spring feels alive: the cover picks up speed toward the target and settles softly, with a little bit of give at the end. In UIKit you get that from CASpringAnimation. To land near the Apple Music feel, the values to tune are the response, which is roughly how quick the reaction is, and the damping, which is how much it overshoots.
MorphAnimator.swift
func addSpring(to layer: CALayer, keyPath: String,
from: Any, to: Any, initialVelocity: CGFloat = 0) {
let spring = CASpringAnimation(keyPath: keyPath)
// Translate SwiftUI's response/damping spring into CASpringAnimation terms.
let omega = 2 * CGFloat.pi / response // response was 0.35s
spring.stiffness = omega * omega
spring.damping = 2 * dampingFraction * omega // dampingFraction 1.0, no overshoot
spring.mass = 1
spring.initialVelocity = initialVelocity // carried over from the finger's speed
spring.fromValue = from
spring.toValue = to
spring.duration = spring.settlingDuration // let the spring pick its own duration
// Aim for the full frame rate on 120Hz ProMotion displays.
spring.preferredFrameRateRange = CAFrameRateRange(minimum: 80, maximum: 120, preferred: 120)
layer.setValue(to, forKeyPath: keyPath) // commit the final value
layer.add(spring, forKey: "morph-\(keyPath)")
}Step 4: swap the stand-in for the real cover
When the animation finishes, we pull the stand-in off the screen and, in the same beat, flip the isMorphing flag on the bridge back to false. The full screen view watches that flag. While it's true the view keeps its real cover hidden, so it can't overlap the stand-in. When it goes false the real cover appears. The handoff lines up frame for frame.
MorphAnimator.swift
CATransaction.begin()
CATransaction.setCompletionBlock {
self.bridge.isMorphing = false // the real cover can show now
proxy.removeFromSuperview() // the stand-in is gone
context.completeTransition(true)
}
// ... the addSpring calls from step 3 go here ...
CATransaction.commit()Timing the controls
While the cover flies, the controls around it (the scrubber, the buttons, the track info) need to come in too. Starting them at the same moment as the cover looks busy and rushed, though. Apple's move is to hold the controls back a touch and let them fade in once the cover has done most of its travelling. There's a subtler thing on top of that: opening and closing aren't mirror images of each other. On the way in, the controls fade up gently. On the way out, they cut instantly. A slow fade on close leaves a faint glow hanging in the air while the screen darkens behind it, which looks wrong. Getting that asymmetry right is a surprising amount of the polish.
MorphAnimator.swift
// Opening: once about 80% of the morph has elapsed, fade the controls in.
let revealAt = duration * (1 - 0.8) // reveal kicks off at the 20% mark
DispatchQueue.main.asyncAfter(deadline: .now() + revealAt) {
bridge.chromeRevealAnimation = .easeOut(duration: 0.1) // soft fade
bridge.chromeRevealed = true
}
// Closing: no animation at all, so the controls vanish at once.
bridge.chromeRevealAnimation = nil
bridge.chromeRevealed = falseNowPlayingView.swift
// Inside NowPlayingView, on the controls container:
.opacity(bridge.chromeRevealed ? 1 : 0)
.animation(bridge.chromeRevealAnimation, value: bridge.chromeRevealed)
// The animation is scoped to that value: easeOut on open, nil on close.Hiding the mini player
Here's a quiet problem. While the stand-in is in flight, the real mini player is still sitting on screen. For a moment you've got two covers visible, the flying one and the parked one. So the mini player needs to disappear for the duration. "Just remove it," you'd think. This is where iOS lays one of its sneakier traps.
Trap: the shadow flash
When you take the mini player out of the tree, iOS re-lays-out the whole tab bar substructure underneath it: the shadow layer, the edge effects, all of it. Then when the transition ends and the mini player comes back, those layers snap back into place with a one frame flash. Setting alpha = 0 instead doesn't save you. The system reads that as a visibility change and runs the same re-layout anyway.
What worked, found by testing on a real device, is to leave the system's idea of the layout completely alone and instead mask the mini player at the render layer. It stays in the tree, so the system sees no change, but it's painted invisible. No re-layout fires, so there's no flash.
PillMask.swift
// Make the mini player invisible without removing it, so no re-layout fires.
func hide() {
// Pin opacity to 0 on the presentation (render) layer, not the model.
// The system still believes it's visible, so nothing re-lays-out.
let pin = CABasicAnimation(keyPath: "opacity")
pin.fromValue = 0; pin.toValue = 0
pin.duration = 3600 // long enough to count as permanent
pin.isRemovedOnCompletion = false
container.layer.add(pin, forKey: "pillHide")
container.isUserInteractionEnabled = false // and stop it taking taps
}
func show() {
container.layer.removeAnimation(forKey: "pillHide") // visible again
container.isUserInteractionEnabled = true
}The general lesson
For a fair number of visual problems, removing the element feels like the natural fix but it disturbs whatever the system has built underneath. Often the cleaner move is to leave the element where it is and only suppress how it looks. The system sees no change, so it doesn't try to compensate for one.
Closing it again
Opening is the easy direction. The user gives you a clear signal: they tapped, so open it. Closing, going from full screen back down to the mini player, is where most morph attempts come unstuck. The trouble is that on the way out, UIKit's flow and SwiftUI's flow drift out of step with each other.
Trap: the shrink, grow, shrink wobble
The dismiss falls apart like this:
The user swipes down, and UIKit kicks off its dismiss animation.
UIKit reports that it's done, but SwiftUI's "is it open?" value only catches up a moment later, asynchronously.
In that gap, some unrelated state changes and SwiftUI re-evaluates. It still sees "open," so it presents the full screen all over again.
You get a shrink, then a grow, then a shrink. A visible wobble.
The fix is a latch. When UIKit finishes dismissing, set a flag that blocks the re-present path until SwiftUI has caught up.
MorphPresenter.swift
var awaitingDismissSync = false
// When UIKit finishes dismissing:
hostedController = nil
awaitingDismissSync = true // close the latch
DispatchQueue.main.async {
self.isPresented = false // let SwiftUI catch up a beat later
}
// When SwiftUI re-evaluates:
if presentedViewController == nil && !awaitingDismissSync {
presentMorph(...) // only re-present once the latch is open
}One last detail makes it feel right. When the user lets go partway down after a swipe, the close should carry on from wherever their finger was, not jump back to the top first. And the speed of the swipe should flow into the animation, so a fast flick closes fast. Those touches are what make it feel like a real, physical object rather than a canned animation.
What cost me time
Knowing these up front would have saved me a few days.
The SwiftUI environment doesn't cross the UIKit boundary
In SwiftUI, dependencies like services and shared data flow down through an invisible channel called the environment. The catch is that once you host the full screen view inside UIKit with UIHostingController, that channel stops at the boundary. You have to hand-inject every dependency the hosted view reads. Miss one and you don't get a compile error. You get a crash, the instant that view first draws. I hit this when a particular badge, shown only under one setting, reached for a service I'd forgotten to pass across. The fix was to gather every dependency the hosted tree needs into one place and re-inject the whole set on the far side of the boundary, so there's a single list to keep honest.
Measure the frames on a real device
A reasonable sounding guess like "the target cover sits about 26% down from the top" looks fine on paper and falls apart on hardware. My first value was off by something like 80 pixels. I had to read the right ratio, which came out around 16%, straight off the device logs. There was a second layer to it too. The frame SwiftUI reported was the content's, a 32 point cover, while the system container quietly added 8 points of padding top and bottom for a real height of 48. Missing that created a little jump right at the start.
The lesson
Check transition geometry by measuring, not by eye. A value that's off by even one frame on device gives you that "something's wrong and I can't tell what" feeling. Log the numbers, take a screen recording, step through it frame by frame. Guessing is the single biggest source of animation bugs I ran into.
The little details, like the pause scale
The full screen cover shrinks to 86% when the track is paused, the same way Apple Music does. If the stand-in doesn't know about that, it flies to full size and then the real cover turns up 14% smaller, a pop the eye catches right away. The stand-in's target frame has to apply the same scale. These "both ends have to look identical" details are what decide whether a seam shows up in the morph or not.
The unified glass layer in iOS 26
In iOS 26, glass surfaces like the tab bar and the mini player are drawn into a single shared layer. That means you can't hide the mini player's glass on its own. It's all of the tab bar's glass or none of it. What worked was applying a geometric mask to that shared glass layer to cut a hole where the mini player sits. Figuring it out meant poking at the system's internal render structure, which isn't documented, so it came down to measuring again.
Borrowing the idea
Your app might not be a music player. You might not have a mini player, or even be on iOS. The shape of the technique still carries over. Stripped down, the recipe is this:
Name the two ends. The small element where the transition starts and the big element where it lands. An album cover, a profile photo, a product image, anything that's shared across the two screens.
Get both global positions into one shared spot. Even when the two ends live on separate screens or in separate trees, have each one write its screen position into a shared object.
Make a temporary stand-in and put it on top of everything. Don't move the real elements. Fly a copy from the start position to the end position on the topmost layer. This is what makes the tree boundary problem go away.
Use a spring, not a straight line. It's the difference between alive and mechanical.
Swap the stand-in for the real element in a single frame. Use a flag to keep the real elements hidden while the transition is running.
Time the surrounding controls with a delay, and make open and close asymmetric. Soft fade in, instant hide out.
Guard the close with a latch. It stops the re-present race that causes the wobble.
In one line
A morph comes down to: hide the real elements, fly a single-use copy from start to end on top of everything with a spring, then delete the copy and reveal the real one. The bridge, the masking, the latch all exist for one reason, which is to keep that simple idea looking clean despite everything the system does to get in the way.
If the stock tools can do the job for you, by all means use them. Less code, fewer traps. But when you have to cross a boundary like this one, with a separate hosting area, custom timing, and an interactive close, the hand-rolled stand-in gives you full control. The price is the traps in this post, and now you've seen them coming.
.iOS .SwiftUI .UIKit .CustomTransition .CASpringAnimation .SharedElement
Yorumlar