Skip to content
Player Expand preview
An animated render of the SwiftUI source on this page.

Player Expand

From syxUI — written for both platforms, not translated between them.

Interactive

A mini player bar that opens into a full now-playing screen, artwork growing to the centre.

Music · SwiftUI · Flutter · iOS 17 · Flutter 3.27

  • music
  • player
  • expand
  • morph
  • now-playing
  • artwork
  • scrubber
  • drag-dismiss

The actual source

MorphPlayerExpand.swift
// Player Expand · syxUI · https://syxui.dev/components/morph-player-expand
// Free in any project. Keep this line and credit syxUI where a person can read it.

import SwiftUI

/// A mini player bar that opens into a full now-playing sheet, the artwork
/// growing to the centre.
///
/// The bar's 2pt progress hairline and the sheet's scrubber are the same
/// `Capsule` — height, width and thumb radius all lerp from one `p` — which
/// is the single most convincing part of the morph. Drag-to-dismiss writes
/// straight into that same `p`, so the whole sheet runs backwards under a
/// finger instead of a separate dismiss animation taking over.
struct MorphPlayerExpand: View {
    var artA: Color = Color(red: 0.486, green: 0.361, blue: 1.0)
    var artB: Color = Color(red: 0.925, green: 0.282, blue: 0.6)
    var accent: Color = Color(red: 0.925, green: 0.282, blue: 0.6)
    var surface: Color = Color(red: 0.063, green: 0.075, blue: 0.09)
    var textColor: Color = Color(white: 1.0)
    var artSize: CGFloat = 300
    /// Preview override. 0 is the mini bar, 1 the full sheet.
    var progress: Double? = nil

    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @State private var interactive: Double = 0
    @State private var stage: Stage = .closed

    private enum Stage { case closed, open }

    private var p: Double { progress ?? interactive }

    private let barHeight: CGFloat = 56
    private let artClosed: CGFloat = 40

    private var width: CGFloat { max(340, artSize + 40) }
    private var sheetHeight: CGFloat { artSize + 230 }
    private var height: CGFloat { lerp(barHeight, sheetHeight, p) }
    private var radius: CGFloat { lerp(16, 28, p) }

    private var artworkSize: CGFloat { lerp(artClosed, artSize, p) }
    private var artworkRadius: CGFloat { lerp(8, 14, p) }
    private var artworkCentre: CGPoint {
        let closed = CGPoint(x: 8 + artClosed / 2, y: barHeight / 2)
        let open = CGPoint(x: width / 2, y: 28 + artSize / 2)
        return CGPoint(x: lerp(closed.x, open.x, p), y: lerp(closed.y, open.y, p))
    }

    private var titleCentre: CGPoint {
        let closed = CGPoint(x: 8 + artClosed + 12 + 70, y: 20)
        let open = CGPoint(x: width / 2, y: 28 + artSize + 30)
        return CGPoint(x: lerp(closed.x, open.x, p), y: lerp(closed.y, open.y, p))
    }
    private var artistCentre: CGPoint {
        let closed = CGPoint(x: 8 + artClosed + 12 + 70, y: 36)
        let open = CGPoint(x: width / 2, y: 28 + artSize + 56)
        return CGPoint(x: lerp(closed.x, open.x, p), y: lerp(closed.y, open.y, p))
    }
    private var textScale: Double { lerp(13, 20, p) / 20 }
    private var artistScale: Double { lerp(11, 15, p) / 15 }

    private var scrubberWidth: CGFloat { lerp(width - 16, width - 40, p) }
    private var scrubberHeight: CGFloat { lerp(2, 6, p) }
    private var thumbRadius: CGFloat { lerp(0, 7, p) }
    private var scrubberY: CGFloat { lerp(barHeight - 2, height - 110, p) }
    /// A fixed demo position — the scrubber's own value is not what is
    /// being morphed here, only its geometry.
    private let trackProgress: Double = 0.35

    private var controlsOpacity: Double { ramp(p, 0.60, 0.85) }
    private var miniControlsOpacity: Double { 1 - ramp(p, 0, 0.30) }
    private var backdropOpacity: Double { 0.55 * p }

    var body: some View {
        let shape = RoundedRectangle(cornerRadius: radius, style: .continuous)

        return ZStack(alignment: .topLeading) {
            // Surface first, then the backdrop blends over it at its own
            // alpha — reversing this order would let the opaque surface
            // hide the backdrop completely.
            shape.fill(surface)
            backdrop

            artwork
            title.scaleEffect(textScale).position(titleCentre)
            artist.scaleEffect(artistScale).position(artistCentre)

            scrubber
            controls.opacity(controlsOpacity)
            miniPlayButton.opacity(miniControlsOpacity)
        }
        .frame(width: width, height: height)
        .clipShape(shape)
        .contentShape(Rectangle())
        .highPriorityGesture(dragGesture)
        .onTapGesture(perform: tap)
        .accessibilityElement()
        .accessibilityAddTraits(.isButton)
        .accessibilityLabel("Now playing, Nocturne by Aurora Bell")
        .accessibilityValue(stage == .open ? "Expanded" : "Collapsed")
    }

    private var backdrop: some View {
        LinearGradient(colors: [artA, artB], startPoint: .topLeading, endPoint: .bottomTrailing)
            .blur(radius: 40)
            .saturation(1.6)
            .opacity(backdropOpacity)
            .accessibilityHidden(true)
    }

    private var artwork: some View {
        RoundedRectangle(cornerRadius: artworkRadius, style: .continuous)
            .fill(LinearGradient(colors: [artA, artB], startPoint: .topLeading, endPoint: .bottomTrailing))
            .frame(width: artworkSize, height: artworkSize)
            .position(artworkCentre)
            .accessibilityHidden(true)
    }

    private var title: some View {
        Text("Nocturne")
            .font(.system(size: 20, weight: .semibold))
            .foregroundStyle(textColor)
            .lineLimit(1)
            .accessibilityHidden(true)
    }

    private var artist: some View {
        Text("Aurora Bell")
            .font(.system(size: 15, weight: .medium))
            .foregroundStyle(textColor.opacity(0.65))
            .lineLimit(1)
            .accessibilityHidden(true)
    }

    private var scrubber: some View {
        ZStack(alignment: .leading) {
            Capsule(style: .continuous)
                .fill(textColor.opacity(0.18))
                .frame(width: scrubberWidth, height: scrubberHeight)
            Capsule(style: .continuous)
                .fill(accent)
                .frame(width: scrubberWidth * trackProgress, height: scrubberHeight)
            Circle()
                .fill(textColor)
                .frame(width: thumbRadius * 2, height: thumbRadius * 2)
                .offset(x: scrubberWidth * trackProgress - thumbRadius)
        }
        .frame(width: scrubberWidth, height: max(scrubberHeight, thumbRadius * 2), alignment: .leading)
        .position(x: width / 2, y: scrubberY)
        .accessibilityHidden(true)
    }

    private var controls: some View {
        HStack(spacing: 30) {
            Image(systemName: "backward.fill")
            Image(systemName: "pause.fill").font(.system(size: 28))
            Image(systemName: "forward.fill")
            Spacer().frame(width: 16)
            Image(systemName: "speaker.wave.2.fill").font(.system(size: 14))
        }
        .font(.system(size: 18))
        .foregroundStyle(textColor)
        .position(x: width / 2, y: height - 46)
        .accessibilityHidden(true)
    }

    private var miniPlayButton: some View {
        Image(systemName: "pause.fill")
            .font(.system(size: 16, weight: .semibold))
            .foregroundStyle(textColor)
            .position(x: width - 26, y: barHeight / 2)
            .accessibilityHidden(true)
    }

    private var dragGesture: some Gesture {
        DragGesture(minimumDistance: 6)
            .onChanged { value in
                guard progress == nil, stage == .open else { return }
                let dy = max(0, value.translation.height)
                interactive = 1 - min(dy / 260, 1)
            }
            .onEnded { value in
                guard progress == nil, stage == .open else { return }
                let dy = max(0, value.predictedEndTranslation.height)
                if dy > 130 {
                    stage = .closed
                    withAnimation(springOrSnap(response: 0.42, damping: 0.88)) { interactive = 0 }
                } else {
                    withAnimation(springOrSnap(response: 0.50, damping: 0.85)) { interactive = 1 }
                }
            }
    }

    private func tap() {
        guard progress == nil, stage == .closed else { return }
        stage = .open
        withAnimation(springOrSnap(response: 0.50, damping: 0.85)) { interactive = 1 }
    }

    private func springOrSnap(response: Double, damping: Double) -> Animation {
        reduceMotion ? .linear(duration: 0) : .spring(response: response, dampingFraction: damping)
    }
}

private func clamp(_ value: Double) -> Double { min(max(value, 0), 1) }

private func ramp(_ value: Double, _ from: Double, _ to: Double) -> Double {
    clamp((value - from) / (to - from))
}

private func lerp(_ a: CGFloat, _ b: CGFloat, _ t: Double) -> CGFloat {
    a + (b - a) * CGFloat(t)
}

private func lerp(_ a: Double, _ b: Double, _ t: Double) -> Double {
    a + (b - a) * t
}

#Preview {
    VStack(spacing: 40) {
        MorphPlayerExpand()
        MorphPlayerExpand(progress: 0.55)
    }
    .padding(40)
    .background(Color(white: 0.15))
}
iOS 17 · No dependencies

SwiftUI note. The bottom hairline and the scrubber are the same Capsule — height, width and thumb radius all lerp from `p` — never two views swapped at a threshold. In a real app the equivalent is `matchedGeometryEffect(id:in:)` between the mini bar and the sheet inside one ZStack, or `.navigationTransition(.zoom(...))` on iOS 18+; neither can be driven by a Double, so this file ships the lerped version and drag-scrubs it directly instead.

Dependencies

SwiftUI
No external dependencies

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27