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.
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.
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.
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.
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.
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:
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 }
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.
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.
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.
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 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/moA 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.
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.
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)
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))
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.
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)
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.
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.
The top of the screen has ten seconds to earn a scroll. Here's the machine that does it.
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))
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 )
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.
"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).
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.
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() }
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") }
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 ) )
.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.
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.
Motion is not decoration. Motion is information. Here's how to use the minimum amount of it to sell the maximum amount of polish.
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
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.
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.
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 }
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).
Tutorials usually end when the last animation lands. Real projects ship images. Here's the pipeline we use.
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.
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))
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.
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.
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.
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