Project · Pro

Designing Muse — a modern dating-app interface in SwiftUI, from zero to ship.

Not a line-by-line code dump. A senior-developer walkthrough of the design decisions, the iOS patterns that make it feel native, and the coding attitude that keeps the codebase shippable at the end. Every step shows the live render on the right as you read.

SwiftUI · iOS 16+7 chapters · 31 stepsEst. 70 minPro · Teaser free

Table of contents

01Free · Teaser

Foundation — the MVVM skeleton and why we keep it boring

Before we make anything pretty, we make it predictable. Here's how a senior dev opens a blank SwiftUI project and doesn't paint themselves into a corner on day one.

Step 01 · Design intent

Decide what the app feels like before you decide what it does.

Muse is a dating app, so the feel has to come first: warm, personal, premium, calm. Not loud. Not gamified. Every colour choice, every motion, every piece of copy refers back to this sentence.

We pick a palette that carries the feel — a pink/purple gradient for the brand and love metaphors, off-white neutrals everywhere else. Anything we're tempted to add that doesn't serve warm, personal, premium, calm gets cut.

Senior mindset

Write the one-sentence feel at the top of your README. When you're debating a colour or a transition two weeks from now, that sentence is the judge — not your taste.

Step 02 · Project layout

MVVM by default, four folders, no clever architecture yet.

We use the boring, canonical SwiftUI layout: Model/, View/, ViewModel/, plus Assets.xcassets/. No Composable Architecture. No Redux. Nothing we'd have to defend in code review.

// Shared/
//   MuseApp.swift          — entry point
//   ContentView.swift      — one line: Home()
//   Model/
//     Profile.swift        — the data
//     Category.swift       — tab groups
//   View/
//     Home.swift           — parallax + sections
//     HeaderView.swift     — top chrome
//     CardView.swift       — one profile card
//   ViewModel/
//     HomeViewModel.swift  — scroll offset + selected tab

Two weeks from now when a new developer opens this project, they should know where anything lives in under ten seconds. That's the goal.

Step 03 · The data model

Let the model describe the feel, not just the fields.

Most tutorials write name: String, age: Int and move on. A senior dev thinks about what each field does to the UI before adding it:

  • isVerified — earns a blue check. Adds trust without taking space.
  • isOnline + lastActive — one drives a pulse; the other fills the text hierarchy.
  • interests: [String] — three chips max, because four breaks the width and five feels desperate.
  • matchPercent: Int — a number and a ring. Both have to read instantly.
struct Profile: Identifiable {
    var id = UUID().uuidString
    var name: String
    var age: Int
    var distanceKm: Double
    var bio: String
    var interests: [String]    // three, exactly
    var matchPercent: Int      // drives the ring
    var isVerified: Bool
    var isOnline: Bool
    var lastActive: String     // "Active now" / "2h ago"
    var image: String
}
Design principle

A field that doesn't pay rent on screen gets cut. Every property here drives a visible detail: the chip, the dot, the ring, the line height. No silent fields.

Step 04 · The view model

Two pieces of state. That's it.

The view model only needs to know two things: how far the user has scrolled (drives the entire top chrome) and which tab is currently in the viewport (drives the chip selection).

class HomeViewModel: ObservableObject {
    @Published var offset: CGFloat = 0
    @Published var selectedtab = categoryItems.first!.tab
}

No fetch logic. No cache. No speculative state. When we need any of those, we'll add them — and regret them only if we added them before we needed them.

Senior mindset

A ViewModel is a drawer, not a warehouse. If you're stuffing it with things the view doesn't bind to, you've built a ball of mud.

Step 05 · What we've earned

A boring skeleton is the most expensive part of the project.

None of this renders anything interesting yet. That's the point — every hour here buys ten hours of "oh I just drop a view in and it works" later. The next six chapters all assume this scaffolding is trustworthy.

Next up we build the card. This is where the fun starts.

Pro unlocks chapters 2 – 7

Keep reading — from the card, to the hero, to a shippable asset pipeline.

Pro unlocks Muse, Swift Intro, SwiftUI Intro, API Deep Dive, AVFoundation, and every upcoming project build.

Subscribe for $7.99/mo
Or $9 one-time lifetime beta license · upgrade
Already subscribed? Sign in with Google.
02Pro

The card — information hierarchy, interest chips, and a match ring

A card is an argument. Every element is trying to convince the user to tap it. Here's how we stack them so the argument actually lands.

Step 06 · The six zones

Divide the card into zones, then fill them deliberately.

Before touching SwiftUI, sketch the card as a 2×3 grid: Photo, Identity, Meta, Bio, Signals, Action. If a candidate element doesn't fit a zone, it doesn't belong on the card.

Step 07 · Interest chips

Three chips, one gradient, no rainbow.

Chips are a classic overreach point in dating-app design. Five categorical colours and the card looks like a slot machine. We use one pink/purple gradient at 18% opacity with a slightly darker border. Visual consistency reads as premium.

.padding(.horizontal, 8).padding(.vertical, 4)
.background(
    Capsule().fill(
        LinearGradient(colors: [.pink.opacity(0.18), .purple.opacity(0.18)],
                       startPoint: .leading, endPoint: .trailing)
    )
)
.overlay(Capsule().stroke(.pink.opacity(0.25), lineWidth: 0.5))
.foregroundColor(.pink)
Step 08 · The match ring

A number, a ring, and an angular gradient — one component, three reading levels.

Three reading levels: glance (ring fill), skim (bold percentage), read(label). This is a classic iOS affordance — see Apple's Activity rings.

Circle()
    .trim(from: 0, to: fraction)
    .stroke(
        AngularGradient(colors: [.pink, .purple, .pink],
                        center: .center),
        style: StrokeStyle(lineWidth: 4, lineCap: .round)
    )
    .rotationEffect(.degrees(-90))
Pattern

Angular gradient + trim on a Circle = Apple's Activity Ring recipe. You'll reach for it for progress, confidence, battery — anything where a percentage wants a shape.

Step 09 · The Like button

Animate the state, not the tap.

Off: outline on soft pink tint. On: filled pink/purple gradient and 10% larger. Spring response 0.35, damping 0.55 — snappy but not jittery. A little overshoot makes it feel like it's reacting emotionally.

.scaleEffect(liked ? 1.1 : 1.0)
.animation(.spring(response: 0.35, dampingFraction: 0.55), value: liked)
Senior mindset

.animation(.default) is the "I'll think about this later" animation. Pick a spring with real response/damping for anything emotional.

Step 10 · The container

Rounded card, faint pink border, soft shadow. That's the whole chrome.

.secondarySystemBackground, 22pt continuous rounded rectangle, 1pt .pink.opacity(0.08)border, soft black shadow (opacity 0.04, radius 8, y 4). That shadow is what sells "floating" without the card trying.

03Pro

The hero — parallax, cinematic gradients, and a huge name

The top of the screen has ten seconds to earn a scroll. Here's the machine that does it.

Step 11 · The parallax math

Stretch when pulled, offset when pushed.

A GeometryReader around the hero reads reader.frame(in: .global).minY. Positive (pulled down): stretch. Negative (scrolling up): translate at the same rate, so the image parallaxes behind the content.

.frame(height: 360 + (offset > 0 ? offset : 0))
.offset(y: (offset > 0 ? -offset : 0))
Step 12 · The cinematic gradient

Dark at the edges, clear in the middle — twice.

Layer two linear gradients: a top-to-clear at 55% black (for the status bar) and a clear-to-bottom at 45% (for the title). The middle stays transparent so the subject breathes.

LinearGradient(
    colors: [.black.opacity(0.60), .clear, .clear, .black.opacity(0.45)],
    startPoint: .top, endPoint: .bottom
)
Step 13 · The name, big and unapologetic

44pt. Heavy. One shadow. Done.

Font.system(size: 44, weight: .heavy) with a single soft black shadow at 30% opacity. If your title needs more than one shadow, your gradient is too weak.

Step 14 · Signal chips — two kinds, side by side

A gradient chip (brand) and a material chip (context).

"Featured Today" uses a solid gradient. Beside it, "Online · 2 km" uses .ultraThinMaterial like a frosted tag clipped onto the photo. Two distinct styles that still belong to the same family — iOS does this constantly (Control Center toggles vs pill labels).

04Pro

The navigation chrome — one dense row that behaves like Apple Music

The bar at the top of a social app is its hardest single component. Here's how we ship one that doesn't look like a web banner.

Step 15 · Don't use a Section header

Pinned section headers create banners. Use an overlay.

SwiftUI's pinnedViews: [.sectionHeaders] pins below the safe area and always looks like a banner strip. For chrome that extends under the status bar, put it in a ZStack overlay above the scroll view and drive it with scroll offset.

ZStack(alignment: .top) {
    ScrollView { … }
        .ignoresSafeArea(.container, edges: .top)
    HeaderView()
}
Step 16 · One row, not two

Wordmark, chips, and icons live on the same row.

Our first pass had two rows. Empty centre in the wordmark row looked cheap. Merged: tiny wordmark left, horizontally scrolling chip strip centre, icons right. 48pt tall. One row.

HStack(spacing: 10) {
    wordmark.opacity(easedT)
    chipStrip.opacity(easedT)
    iconButton("bell")
    iconButton("suit.heart.fill")
}
Step 17 · The edge-faded chip strip

Mask both ends so the scroll hints itself.

A horizontal scroll view with hard edges inside a nav bar reads as "cut off." Masking both ends with an alpha gradient turns the cut-off into a "more to see" affordance.

.mask(
    LinearGradient(
        colors: [.clear, .black, .black, .black, .clear],
        startPoint: .leading, endPoint: .trailing
    )
)
Step 18 · Material crossfade

Transparent over the hero, .regularMaterial over content.

At the top, only two frosted icons float over the photo. As the user scrolls, a .regularMaterial backdrop fades in (opacity tied to scroll), with a 5% pink/purple tint. The 0.5pt hairline at the bottom eases in only when fully materialised.

Pattern

Apple Music "Listen Now," Maps' location sheet, Photos' nav — all the same recipe: transparent at rest, material on scroll, hairline at full. Your users already know this language — speak it.

05Pro

Motion — smoothstep easing, matchedGeometryEffect, and knowing when to stop

Motion is not decoration. Motion is information. Here's how to use the minimum amount of it to sell the maximum amount of polish.

Step 19 · One curve, everywhere

Derive a single eased parameter. Tie every animation to it.

Number-one sign of an amateur UI: every element animates with a slightly different duration and curve. Fix is embarrassingly simple: compute one t: CGFloat from offset, smoothstep it, use easedT everywhere. In sync by construction.

var t: CGFloat {
    let raw = (homeData.offset - 140) / 140
    return min(max(raw, 0), 1)
}
var easedT: CGFloat { t * t * (3 - 2 * t) }  // smoothstep
Step 20 · matchedGeometryEffect for the selected chip

Let SwiftUI interpolate the indicator between tabs.

The indicator capsule glides between tabs because we use matchedGeometryEffect(id: "chipBG", in: chipNS). Nothing animates explicitly — SwiftUI interpolates size and position because both "from" and "to" chips share the namespace.

Pattern

Same recipe as Apple's segmented control indicator and Music's "now playing." Once you see it you can't un-see it.

Step 21 · The online pulse, honestly

An autoreverses: false ripple, started on .onAppear.

The green halo ripples outward and fades. .repeatForever(autoreverses: false) so it always grows from small to large — shrink-back makes pulsing UI look broken.

withAnimation(.easeOut(duration: 1.4).repeatForever(autoreverses: false)) {
    pulse = true
}
Step 22 · Know when to remove motion

No bounce on scroll offset bindings. No delay on state crossfades.

Scroll-driven animations are already smoothed by the scroll system — stacking a spring on top looks drunk. State crossfades want .easeInOut(0.25–0.3), not a spring. Springs belong on discrete state changes (the Like button).

06Pro

Polish — accessibility, Dynamic Type, reduce-motion, haptics

Separate from motion and layout. This is the pass that turns a shippable prototype into an app someone's grandmother, VoiceOver user, or chronic-migraine sufferer can actually use.

Step 23 · Combine the card into one VoiceOver element

VoiceOver should hear a card, not a checklist.

A fresh SwiftUI view with ten subviews produces ten VoiceOver stops. For a card, that's grating. Combine everything into a single element with a deliberate label, and expose real actions for the Rotor.

.accessibilityElement(children: .combine)
.accessibilityLabel(accessibilitySummary)          // "Mei Lin, 26, verified, 94% match, ..."
.accessibilityAddTraits(.isButton)
.accessibilityAction(named: liked ? "Unlike" : "Like") {
    toggleLike()
}

Decorative children (gradients, shadows, the pulsing halo) get .accessibilityHidden(true) so the summary reads cleanly.

Senior mindset

If the card has a primary action (Like), expose it as an accessibilityAction. The Rotor user shouldn't have to go hunting for a 36×36 button — they should just rotate to "Actions" and hear "Like."

Step 24 · Dynamic Type without breaking the composition

Semantic fonts where they scale; hardcoded pt where the design demands it.

Reading copy — bios, metadata, chip labels — uses the semantic scale (.caption2, .caption, .footnote) so it tracks the user's Dynamic Type setting. Display type — the big hero name — stays pt-locked so the visual rhythm survives, with a safety net:

Text("Mei Lin, 26")
    .font(.system(size: 44, weight: .heavy))
    .minimumScaleFactor(0.7)          // shrink rather than truncate
    .lineLimit(1)

Numerals (match percentages, timers, prices) get .monospacedDigit()so layout doesn't jitter when digits change width.

Pattern

The heuristic: anything the user reads scales; anything the user sees as a graphic stays fixed with a fallback. A headline at 44pt is a graphic. A 12pt label is reading.

Step 25 · Respect reduce-motion honestly

Replace the animation, don't just shorten it.

Reduce Motion isn't a hint — it's a medical accommodation for vestibular disorders. Halve-the-duration is the wrong fix. The right fix is to remove the effect and present the final state.

@Environment(\.accessibilityReduceMotion) private var reduceMotion

// Parallax hero: zero the stretch + translate under reduce motion
let stretch   = reduceMotion ? 0 : max(0, offset)
let translate = reduceMotion ? 0 : (offset > 0 ? -offset : 0)

// Online pulse: skip the repeatForever halo entirely
if !reduceMotion {
    Circle().fill(Color.green.opacity(0.35))   // halo
        .frame(width: pulse ? 22 : 12, ...)
}

// Header crossfade: pass a nil animation to make it instant
.animation(reduceMotion ? nil : .easeInOut(0.3), value: easedT)

The static dot still communicates "online"; the ring without parallax still communicates "hero." Nothing is lost except motion.

Step 26 · Haptics on meaningful state changes

sensoryFeedback is one line and it's free delight.

iOS 17 added a declarative hook for haptics: it fires whenever the trigger value changes. Use it where a user decides something — Like, select a tab, complete a step.

// On the Like button — medium impact thud
.sensoryFeedback(.impact(weight: .medium), trigger: liked)

// On the nav chrome root — softer selection click as the tab changes
.sensoryFeedback(.selection, trigger: homeData.selectedtab)
Senior mindset

Don't over-haptic. Every tap is not a state change. A haptic on scroll or on hover is spam. Reserve them for moments the user genuinely caused a decision to land — liking, selecting, committing — and they become an unmistakable signature of polish.

Step 27 · Kill the side-effects-in-render anti-pattern

Reads are allowed in body. Writes are not.

The classic SwiftUI parallax tutorial does this inside a GeometryReader:

GeometryReader { reader -> AnyView in
    let offset = reader.frame(in: .global).minY
    if -offset >= 0 {
        DispatchQueue.main.async {
            self.homeData.offset = -offset   // ⚠️ side effect in render
        }
    }
    return AnyView(heroImage(offset: offset))
}

It works, but it writes to observable state while the view is building. SwiftUI emits "Modifying state during view update" in debug. Cure:

GeometryReader { reader in
    let y = reader.frame(in: .global).minY
    Color.clear
        .onChange(of: y) { _, newY in
            if -newY >= 0 { homeData.offset = -newY }
        }
        .overlay(heroImage(offset: y))
}

Same effect, zero render-time mutation, and it plays nicely with iOS 17's strict runtime checks.

Pattern

On iOS 18+ the cleanest form is .onScrollGeometryChange(for: CGFloat.self) { $0.contentOffset.y } action: ... — one-liner, no GeometryReader needed. Fall back to the overlay trick for iOS 17.

07Pro

Assets — AI generation, watermarking, and an honest archive

Tutorials usually end when the last animation lands. Real projects ship images. Here's the pipeline we use.

Step 23 · Generate originals, archive the clean copies

Two folders, non-negotiable: generated/original/ and generated/watermarked/.

Submit prompts to an image API, save the clean output to assets/generated/original/. We nevership those. They're master copies — if the model version changes, we can still reconstruct exactly what we shipped.

Every prompt is saved beside the image as <name>.prompt.json with model, dimensions, and text. Reproducibility as a habit.

Step 24 · The watermark that isn't noise

Bottom-right, ~12pt, 55% white on a soft shadow.

A good watermark holds up on any background. White at 55% alpha, 3-pixel soft black shadow drop. Padding is 1.5% of image width so it scales with the asset. Size floors at 11pt so it never disappears.

# scripts/watermark.py
for dx, dy in [(1,1), (0,1), (1,0)]:
    draw.text((x+dx, y+dy), MARK, font=font, fill=(0,0,0,120))
draw.text((x, y), MARK, font=font, fill=(255,255,255,140))
Step 25 · Install into the xcassets catalog

Overwrite the imagesets, rewrite Contents.json, leave nothing behind.

Pipeline wipes stale placeholder JPGs inside each .imageset, copies the watermarked PNG in, rewrites Contents.json. Re-runnable, idempotent, never half-installs.

Senior mindset

If a human has to drag files into Xcode, the pipeline is incomplete. Every asset-delivery step should be a shell script that takes you from fresh clone to shippable binary.

Step 26 · Ship

Build on a clean sim, record a 30-second demo, post it.

When every element reports its state honestly through motion, typography, and material, shipping is the easy part. Build Release, screen-record the scroll flow, post it.

That's Muse. Seven chapters, thirty-one steps. You now have every pattern for the next twenty projects.

Finish the build

Keep going — every unlocked Muse pattern works in the next project.

Pro gives you unlimited reads across Swift, SwiftUI, AVFoundation, API Deep Dive, Promotion, and every Muse-style project build — plus the downloadable source of every project tutorial.

Go Pro — $7.99/mo