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+ 6 chapters · 28 steps Est. 55 min Pro · Teaser free

Table of contents

01 Free · 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 will refer 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. No feature bundles. Nothing we'd have to defend in a 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 would 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 up space.
  • isOnline + lastActive — one drives a pulse; the other fills the card's text hierarchy.
  • interests: [String] — three chips max, because four breaks the width and five feels desperate.
  • matchPercent: Int — it's 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 filter state. Nothing speculative. When we need any of those, we'll add them — and we'll 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. And that's the point — every hour you spend here buys ten hours of "oh I just drop a view in and it works" later. The design decisions from the next five chapters all assume this scaffolding is trustworthy.

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

Pro unlocks chapters 2 – 6

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

Pro gets you all of Muse, all of Swift Intro, SwiftUI Intro, API Deep Dive, AVFoundation, and every unlocked article. The next chapters cover match rings, cinematic parallax, Apple-grade nav chrome, smoothstep motion, and an AI image pipeline that watermarks every asset before it ships.

Subscribe for $7.99/mo
Or $9 one-time lifetime beta license · upgrade
Already subscribed? Restore access.
02 Pro

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 (left, full height), Identity (name + age + verified), Meta (distance), Bio (three lines max), Signals (chips, match ring), Action (like button). 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 suddenly the card looks like a Hollywood slot machine. We use one pink/purple gradient at very low opacity (18%) 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.

We build a match indicator that reads at three distances: glance (the ring fill, instantly tells you "high" or "low"), skim (the bold "87%"), read (the "Match" label). Three reading levels 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. Once you've written it, you'll use it for progress, confidence, battery — anything where a percentage wants both a shape and a number.

Step 09 · The Like button

Animate the state, not the tap.

When the heart is off, it's an outline on a soft pink tint. When on, it flips to a filled pink/purple gradient and scales 10% larger. The transition is a spring with response 0.35 and damping 0.55 — snappy but not jittery. The button feels like it's reacting emotionally because the curve has a little overshoot.

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

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

Step 10 · The container

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

Cards sit on .secondarySystemBackground with a 22pt continuous rounded rectangle, a 1pt .pink.opacity(0.08) border, and a very soft black shadow (opacity 0.04, radius 8, y 4). That shadow is what sells "floating" without the card looking like it's trying.

03 Pro

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 wrapped around the hero image lets us read reader.frame(in: .global).minY. If it's positive (user pulled down), we stretch the image taller by that amount. If it's negative (user scrolling up), we translate the image by the same amount, so it appears to parallax 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.

One linear gradient won't do it. We layer two: a top-to-clear at 55% black (for the status bar text) and a clear-to-bottom at 45% black (for the hero title). The middle stays fully 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.

The hero title is Font.system(size: 44, weight: .heavy) with a single soft black shadow at 30% opacity. Not multiple drops. Not a stroke. Just one shadow, just enough to keep it legible across whatever photo lands underneath. 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" sits on a solid pink/purple gradient. Beside it, "Online · 2 km" uses .ultraThinMaterial so it reads like a frosted glass tag clipped onto the photo. Two distinct chip styles that still belong to the same family — iOS does this constantly (think Control Center toggles vs pill labels).

04 Pro

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] is tempting but it always looks like a banner strip because it pins below the safe area. For a chrome that extends under the status bar and blurs the content behind it, put the chrome in a ZStack overlay above the scroll view and drive it with the 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 the wordmark on row one and chips on row two — classic mistake. Empty centre in the wordmark row looked cheap. We merged: tiny wordmark left, horizontally scrolling chip strip centre, icons right. One row. 48pt tall.

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 tells the user "this is a list that got cut off." Masking both ends with a linear alpha gradient turns the cut-off into a "more to see" affordance — the eye reads fading edges as continuation.

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

Transparent over the hero, .regularMaterial over content.

When the user is at the top, the chrome should vanish — just two frosted icons float over the photo. As they scroll, a .regularMaterial backdrop fades in behind the chrome (opacity tied to scroll progress), with a subtle 5% pink/purple tint on top. The 0.5pt hairline at the bottom only eases in when the chrome is fully materialised.

Pattern

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

05 Pro

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.

The number-one sign of an amateur UI is that every element animates with a slightly different duration and curve. The fix is embarrassingly simple: compute one t: CGFloat from the scroll offset, pass it through a smoothstep, and use easedT everywhere. Now everything is 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 pink/purple indicator capsule behind the selected chip glides between tabs because we give it a single matchedGeometryEffect(id: "chipBG", in: chipNS). Nothing animates explicitly — SwiftUI interpolates position and size for us because both the "from" and "to" chip are in the namespace at different times.

Pattern

This is the same recipe Apple uses for the selected segment in a segmented control or the "playing now" indicator in Music. 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 online dot has a halo that ripples outward and fades. We use .repeatForever(autoreverses: false) so it always grows from small to large and doesn't "pop back" — 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 — adding a spring to them stacks curves and looks drunk. State crossfades (like the chrome opacity) want .easeInOut(0.25–0.3), not a spring. Springs belong on discrete state changes (like the Like button).

06 Pro

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/.

We submit prompts to an image API and save the clean AI output to assets/generated/original/. We never ship those. They're the 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, width, height, and text — reproducibility as a habit, not an afterthought.

Step 24 · The watermark that isn't noise

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

A good watermark has to hold up on any background the image throws at it. We use white text at 55% alpha with a 3-pixel soft black shadow drop. The padding is always 1.5% of image width so the watermark scales with the asset. Size floors at 11pt so it never disappears on small images.

# 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.

The pipeline wipes stale placeholder JPGs inside each .imageset, copies the watermarked PNG in, and rewrites Contents.json to reference it. 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 you can run from a fresh clone and end up with a shippable binary.

Step 26 · Ship

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

When every element of the app reports its state honestly through motion, typography, and material, shipping is the easy part. Build Release against the latest iPhone sim, screen-record the scroll-down-then-up-then-pick-a-tab flow, and post it.

That's Muse. Six chapters, twenty-six steps. You now have every pattern you need to ship the next twenty projects.

You made it

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 upcoming Muse-style project build.

Go Pro — $7.99/mo