Skip to content
Container Transform preview
An animated render of the SwiftUI source on this page.

Container Transform

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

Interactive

A compact row that expands in place into a full detail panel, one continuous surface.

Cards · SwiftUI · Flutter · iOS 17 · Flutter 3.27

  • card
  • morph
  • expand
  • container-transform
  • detail
  • material
  • transition

The actual source

MorphContainer.swift
// Container Transform · syxUI · https://syxui.dev/components/morph-container
// Free in any project. Keep this line and credit syxUI where a person can read it.

import SwiftUI

/// A compact row that expands in place into a full detail panel.
///
/// One view, not two. Every dimension — height, corner radius, artwork rect,
/// text origin and scale — is lerped from a single `progress`, so the collapsed
/// row, the open panel and every frame between them come out of the same
/// layout pass. That is what makes the morph scrubbable, and it is why there
/// is no `matchedGeometryEffect` here: a matched pair cannot be driven by a
/// `Double`, so it cannot be scrolled, previewed, or tested.
struct MorphContainer: View {
    var surface: Color = Color(white: 1.0)
    var accent: Color = Color(red: 0.184, green: 0.435, blue: 0.929)
    var artA: Color = Color(red: 0.486, green: 0.361, blue: 1.0)
    var artB: Color = Color(red: 0.184, green: 0.435, blue: 0.929)
    /// Corner radius of the open panel. The collapsed row always sits at 16.
    var cornerRadiusOpen: CGFloat = 28
    /// Spring damping for the expansion. Below about 0.75 the panel overshoots
    /// its open height, which reads as bounce rather than as weight.
    var dampingFraction: Double = 0.82

    /// Preview and scroll override. 0 is the collapsed row, 1 the open panel.
    /// When set, the component renders that point of the morph instead of
    /// driving its own state.
    var progress: Double? = nil

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

    private var p: Double { progress ?? interactive }

    private let cardWidth: CGFloat = 340
    private let closedHeight: CGFloat = 96
    private let openHeight: CGFloat = 384

    var body: some View {
        ZStack(alignment: .topLeading) {
            artwork
            header
            chevron
            detail
        }
        .frame(width: cardWidth, height: lerp(closedHeight, openHeight, p), alignment: .topLeading)
        .background(surface)
        .clipShape(RoundedRectangle(cornerRadius: lerp(16, cornerRadiusOpen, p), style: .continuous))
        // Three shadow parameters move together. A radius that grows without
        // the offset growing with it makes a large card look like it is lying
        // flat on the page.
        .shadow(
            color: Color(white: 0).opacity(lerp(0.06, 0.16, p)),
            radius: lerp(8, 34, p),
            x: 0,
            y: lerp(2, 16, p)
        )
        .contentShape(Rectangle())
        .onTapGesture(perform: toggle)
        .accessibilityElement(children: .ignore)
        .accessibilityAddTraits(.isButton)
        .accessibilityLabel("Northern Lights, ambient mix, 12 tracks")
        .accessibilityValue(p > 0.5 ? "Expanded" : "Collapsed")
        .accessibilityHint(p > 0.5 ? "Double tap to collapse" : "Double tap to expand")
    }

    // MARK: - Layers

    /// The artwork is the anchor of the whole transform: it starts as a 64pt
    /// thumbnail inset from the leading edge and ends flush with the top of the
    /// card. Its bottom corners straighten to 0 while the top pair grow into
    /// the card radius, so at p = 1 it reads as part of the surface rather than
    /// as a rounded tile sitting on it.
    private var artwork: some View {
        let topRadius = lerp(10, cornerRadiusOpen, p)
        let bottomRadius = lerp(10, 0, p)

        return UnevenRoundedRectangle(
            topLeadingRadius: topRadius,
            bottomLeadingRadius: bottomRadius,
            bottomTrailingRadius: bottomRadius,
            topTrailingRadius: topRadius,
            style: .continuous
        )
        .fill(
            LinearGradient(
                colors: [artA, artB],
                startPoint: .topLeading,
                endPoint: .bottomTrailing
            )
        )
        .frame(width: lerp(64, cardWidth, p), height: lerp(64, 196, p))
        .overlay {
            // Drawn once at the large size and scaled down, because
            // `.font(.system(size:))` snaps between integral sizes instead of
            // tweening through them.
            Image(systemName: "waveform")
                .font(.system(size: 46, weight: .light))
                .foregroundStyle(Color(white: 1).opacity(lerp(0.92, 0.62, p)))
                .scaleEffect(lerp(22.0 / 46.0, 1, p))
        }
        .offset(x: lerp(16, 0, p), y: lerp(16, 0, p))
    }

    /// Title and subtitle are positioned absolutely rather than stacked, so
    /// each one owns its origin at both ends of the morph. A `VStack` would
    /// lay out at the unscaled size and the spacing would drift as the scale
    /// changed.
    private var header: some View {
        ZStack(alignment: .topLeading) {
            Text("Northern Lights")
                .font(.system(size: 24, weight: .semibold))
                .foregroundStyle(Color(white: 0.09))
                .fixedSize()
                .scaleEffect(lerp(15.0 / 24.0, 1, p), anchor: .topLeading)
                .offset(x: lerp(92, 20, p), y: lerp(26, 212, p))

            Text("Ambient mix · 12 tracks")
                .font(.system(size: 13, weight: .regular))
                .foregroundStyle(Color(white: 0.46))
                .fixedSize()
                .scaleEffect(lerp(11.0 / 13.0, 1, p), anchor: .topLeading)
                .offset(x: lerp(92, 20, p), y: lerp(56, 250, p))
        }
    }

    /// Out early. A disclosure arrow that lingers into the open state is the
    /// clearest tell that two views were crossfaded.
    private var chevron: some View {
        Image(systemName: "chevron.right")
            .font(.system(size: 13, weight: .semibold))
            .foregroundStyle(Color(white: 0.64))
            .opacity(1 - ramp(p, 0, 0.25))
            .offset(x: 312, y: 41)
    }

    /// Body copy and actions arrive deliberately late — the last 40% of the
    /// morph — and rise 12pt as they do. Fading text across the whole
    /// expansion is what makes cheap container transforms look like a
    /// dissolve; here the surface has almost finished growing before there is
    /// anything to read inside it.
    private var detail: some View {
        let bodyIn = easeOut(ramp(p, 0.50, 0.88))
        let actionIn = easeOut(ramp(p, 0.62, 0.98))

        return ZStack(alignment: .topLeading) {
            Text("Slow synth pads recorded across three winters in Tromsø. Best played quietly, and late.")
                .font(.system(size: 13))
                .foregroundStyle(Color(white: 0.40))
                .lineSpacing(4)
                .frame(width: 300, alignment: .leading)
                .fixedSize(horizontal: false, vertical: true)
                .opacity(bodyIn)
                .offset(x: 20, y: lerp(198, 272, p) + lerp(12, 0, bodyIn))

            actions
                .opacity(actionIn)
                .offset(x: 20, y: lerp(258, 332, p) + lerp(12, 0, actionIn))
        }
    }

    private var actions: some View {
        HStack(spacing: 10) {
            Text("Play")
                .font(.system(size: 15, weight: .semibold))
                .foregroundStyle(Color(white: 1))
                .frame(width: 145, height: 40)
                .background(accent, in: RoundedRectangle(cornerRadius: 12, style: .continuous))

            Text("Save")
                .font(.system(size: 15, weight: .medium))
                .foregroundStyle(accent)
                .frame(width: 145, height: 40)
                .background(
                    accent.opacity(0.10),
                    in: RoundedRectangle(cornerRadius: 12, style: .continuous)
                )
        }
    }

    // MARK: - Interaction

    private func toggle() {
        guard progress == nil else { return }
        let opening = interactive < 0.5

        guard !reduceMotion else {
            interactive = opening ? 1 : 0
            return
        }

        // Opening carries a little overshoot; closing does not. Bounce on the
        // way out reads as a glitch rather than as physics. A second tap
        // mid-flight retargets the same spring instead of queueing, which is
        // why this is one `withAnimation` and not a sequence.
        let curve: Animation = opening
            ? .spring(response: 0.42, dampingFraction: dampingFraction)
            : .spring(response: 0.36, dampingFraction: min(1, dampingFraction + 0.08))

        withAnimation(curve) { interactive = opening ? 1 : 0 }
    }

    // MARK: - Maths

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

    /// `value` remapped from the window `from...to` onto 0...1, clamped. Every
    /// staged fade in this component is one of these, which keeps the geometry
    /// linear in `p` and the sequencing readable in one place.
    private func ramp(_ value: Double, _ from: Double, _ to: Double) -> Double {
        guard to > from else { return value >= to ? 1 : 0 }
        return min(1, max(0, (value - from) / (to - from)))
    }

    private func easeOut(_ t: Double) -> Double {
        1 - pow(1 - t, 3)
    }
}

#Preview {
    MorphContainer()
        .frame(width: 400, height: 440)
}
iOS 17 · No dependencies

SwiftUI note. Because the geometry is lerped from one `progress` rather than matched between two views, a scroll offset or a drag can scrub the morph as easily as a tap can run it; for a real push onto a NavigationStack use `.navigationTransition(.zoom(sourceID:in:))` on iOS 18 instead. The title is drawn at the open size and scaled down — `.font(.system(size:))` snaps between sizes rather than tweening.

Dependencies

SwiftUI
No external dependencies

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27