
Ios Ui Refactor
- 220 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
ios-ui-refactor: A skill for development. This provides functionality for development workflows.
Key points
- ios-ui-refactor
Ios Ui Refactor by the numbers
- 220 all-time installs (skills.sh)
- +7 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,770 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill ios-ui-refactorAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 220 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I use ios-ui-refactor for development tasks?
Use ios-ui-refactor for development tasks
Who is it for?
Best when you're working on backend & apis and need structured help with ios-ui-refactor.
Skip if: Teams with no backend & apis needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to use ios-ui-refactor for development tasks, or when ios-ui-refactor: a skill for development. this provides functionality for development workflows.
What you get
Structured output aligned to ios-ui-refactor: ios-ui-refactor.
Files
Apple HIG SwiftUI iOS 26 / Swift 6.2 Best Practices
A principal designer's lens for evaluating and refactoring SwiftUI interfaces to Apple-quality standards, grounded in Rams, Segall, and Edson. Contains 51 rules across 8 categories, each grounded in specific principles from three foundational design texts:
- Dieter Rams — Ten Principles for Good Design ("less, but better," "design should be honest")
- Ken Segall — Insanely Simple (simplicity as a core principle for intuitive, beautiful products)
- John Edson — Design Like Apple (design-focused culture, prototyping to perfection, the product is the marketing)
Categories are ordered by a visual review process: start with what to remove, then what to clarify, then what to make honest, invisible, systematic, thorough, enduring, and finally what to refine.
Scope & Relationship to Sibling Skills
This skill is the refactoring and review lens — it evaluates existing UI and identifies visual anti-patterns to fix. When loaded alongside ios-design (building new UI), ios-hig (HIG compliance), or swift-refactor (code-level refactoring), this skill supersedes overlapping rules with more detailed "incorrect -> correct" transformations and "When NOT to apply" guidance. Use this skill for auditing and improving existing screens; use the siblings for greenfield implementation.
Clinic Architecture Contract (iOS 26 / Swift 6.2)
All guidance in this skill assumes the clinic modular MVVM-C architecture:
- Feature modules import
Domain+DesignSystemonly (neverData, never sibling features) - App target is the convergence point and owns
DependencyContainer, concrete coordinators, and Route Shell wiring Domainstays pure Swift and defines models plus repository,*Coordinating,ErrorRouting, andAppErrorcontractsDataowns SwiftData/network/sync/retry/background I/O and implements Domain protocols- Read/write flow defaults to stale-while-revalidate reads and optimistic queued writes
- ViewModels call repository protocols directly (no default use-case/interactor layer)
When to Apply
Reference these guidelines when:
- Reviewing existing SwiftUI screens for visual quality issues
- Auditing whether every element on screen earns its place (Rams #10)
- Evaluating if the interface is self-explanatory without tooltips (Rams #4)
- Checking that colors, states, and hierarchy tell the truth (Rams #6)
- Ensuring animations and materials are invisible, not decorative (Rams #5)
- Verifying spacing, radii, and colors form a coherent system (Edson "Systems Thinking")
- Confirming edge cases — reduce motion, touch targets, safe areas — are handled (Rams #8)
- Adopting iOS 26 / Swift 6.2 APIs that refine previously impossible interactions (Edson "Design Out Loud")
Rule Categories by Priority
| Priority | Category | Principle | Impact | Prefix | Rules |
|---|---|---|---|---|---|
| 1 | Less, But Better | Rams #10 + Segall "Think Minimal" | CRITICAL | less- | 7 |
| 2 | Self-Evident Design | Rams #4 + Segall "Think Human" | CRITICAL | evident- | 6 |
| 3 | Honest Interfaces | Rams #6 + Segall "Think Brutal" | CRITICAL | honest- | 6 |
| 4 | Invisible Design | Rams #5 + Edson "Product Is Marketing" | HIGH | invisible- | 6 |
| 5 | Systems, Not Pieces | Edson "Systems Thinking" + Rams #8 | HIGH | system- | 6 |
| 6 | Thorough to the Last Detail | Rams #8 + Rams #2 | HIGH | thorough- | 7 |
| 7 | Enduring Over Trendy | Rams #7 + Edson "Design With Conviction" | MEDIUM-HIGH | enduring- | 5 |
| 8 | Refined Through Iteration | Edson "Design Out Loud" + Rams #1/#3 | MEDIUM | refined- | 8 |
Quick Reference
1. Less, But Better (CRITICAL)
Rams #10: "Good design is as little design as possible." Segall: Apple succeeded by saying no to a thousand things.
- `less-single-focal` - One primary focal point per screen
- `less-type-restraint` - Limit to 3-4 distinct type treatments per screen
- `less-one-typeface` - One typeface per app, differentiate with weight and size
- `less-color-restraint` - Reserve saturated colors for small interactive elements
- `less-one-color-purpose` - Each semantic color serves exactly one purpose
- `less-purposeful-motion` - Every animation must communicate state change or provide feedback
- `less-fewer-controls` - Remove controls that do not serve the core task
2. Self-Evident Design (CRITICAL)
Rams #4: "Good design makes a product understandable." Segall: interfaces must speak in terms people understand.
- `evident-visual-weight` - Combine size, weight, and contrast for hierarchy
- `evident-whitespace-grouping` - Use whitespace to separate conceptual groups
- `evident-progressive-disclosure` - Use progressive disclosure for dense information
- `evident-reading-order` - Align visual weight with logical reading order
- `evident-navigation-intent` - Sheets for tasks and creation, push for drill-down hierarchy
- `evident-label-clarity` - Use clear labels over ambiguous icons
3. Honest Interfaces (CRITICAL)
Rams #6: "Good design is honest." Segall: clarity without sugar-coating.
- `honest-semantic-colors` - Use semantic colors, never hard-coded black or white
- `honest-contrast` - Ensure WCAG AA contrast ratios
- `honest-dark-mode` - Define light and dark variants for every custom color
- `honest-foreground-style` - Use foregroundStyle over foregroundColor
- `honest-depth-cues` - Use materials for layering, not drop shadows for depth
- `honest-loading-states` - Show real progress, not indefinite spinners
4. Invisible Design (HIGH)
Rams #5: "Good design is unobtrusive." Edson: the product itself is the marketing.
- `invisible-spring-physics` - Default to spring animations for all UI transitions
- `invisible-spring-presets` - Use .smooth for routine, .snappy for interactive, .bouncy for delight
- `invisible-no-easing` - Prefer springs over linear and easeInOut for UI elements
- `invisible-system-materials` - Use system materials, not custom semi-transparent backgrounds
- `invisible-symbol-effects` - Use built-in symbolEffect, not manual symbol animation
- `invisible-content-transitions` - Use contentTransition for changing text and numbers
5. Systems, Not Pieces (HIGH)
Edson: "Design is systems thinking." Rams #8: nothing must be arbitrary or left to chance.
- `system-spacing-grid` - Use a 4pt base unit for all spacing
- `system-consistent-padding` - Use consistent padding across all screens
- `system-corner-radii` - Standardize corner radii per component type
- `system-alignment` - Consistent alignment per content type within a screen
- `system-color-naming` - Name custom colors by role, not hue
- `system-brand-integration` - Map brand palette onto iOS semantic color roles
6. Thorough to the Last Detail (HIGH)
Rams #8: "Care and accuracy in the design process show respect for the user." Rams #2: if the user cannot reliably use it, the product has failed.
- `thorough-reduce-motion` - Always provide reduce motion fallback
- `thorough-touch-targets` - All interactive elements at least 44x44 points
- `thorough-safe-areas` - Always respect safe areas
- `thorough-readable-weights` - Avoid light font weights for body text
- `thorough-vibrancy-levels` - Match vibrancy level to content importance
- `thorough-material-thickness` - Choose material thickness by contrast needs
- `thorough-background-interaction` - Enable background interaction for peek-style sheets
7. Enduring Over Trendy (MEDIUM-HIGH)
Rams #7: "Good design is long-lasting." Edson: commit to a voice that persists across product generations.
- `enduring-system-text-styles` - Use Apple text styles, never fixed font sizes
- `enduring-weight-not-caps` - Use weight for emphasis, not ALL CAPS
- `enduring-swipe-back` - Never break the system back-swipe gesture
- `enduring-zoom-navigation` - Use zoom transitions for collection-to-detail navigation
- `enduring-card-modularity` - Use self-contained cards for dashboard layouts
8. Refined Through Iteration (MEDIUM)
Edson: "Design out loud" — prototype relentlessly until every interaction feels inevitable. Rams #1: innovation serves genuine purpose.
- `refined-scroll-transitions` - Use scrollTransition for scroll-position visual effects
- `refined-phase-animator` - Use PhaseAnimator for multi-step animation sequences
- `refined-mesh-gradients` - Use MeshGradient for premium dynamic backgrounds
- `refined-text-renderer` - Use TextRenderer for hero text animations only
- `refined-inspector` - Use inspector for trailing-edge detail panels
- `refined-multi-detent` - Provide multiple sheet detents with drag indicator
- `refined-matched-geometry` - Use matchedGeometryEffect for contextual origin transitions
- `refined-no-hard-cuts` - Always animate between states, even minimally
How to Use
Read individual reference files for detailed explanations and code examples:
- Section definitions - Category structure, principle sources, and impact levels
- Rule template - Template for adding new rules
Reference Files
| File | Description |
|---|---|
| references/_sections.md | Category definitions and principle grounding |
| assets/templates/_template.md | Template for new rules |
| metadata.json | Version and reference information |
Rule Title Here
1-3 sentences explaining WHY this matters from a principal designer's perspective. Focus on the visual or experiential impact of the anti-pattern and how fixing it elevates the app toward Apple-quality.
Incorrect (what's wrong):
// Bad code example — production-realistic, not strawman
// Comments explaining the design costCorrect (what's right):
// Good code example — minimal diff from incorrect
// Comments explaining the design benefitWhen NOT to use this pattern:
- Exception 1
- Exception 2
Benefits:
- Benefit 1
- Benefit 2
Reference: Apple HIG — Relevant Section
{
"version": "1.0.9",
"organization": "Apple HIG + Rams · Segall · Edson",
"technology": "SwiftUI iOS 26 / Swift 6.2",
"date": "February 2026",
"abstract": "Principal-level iOS UI review and refactoring guide for SwiftUI, grounded in three foundational design texts: Dieter Rams' Ten Principles for Good Design (less but better, honest, thorough), Ken Segall's Insanely Simple (remove complexity, speak human), and John Edson's Design Like Apple (systems thinking, the product is the marketing, prototype to perfection). Contains 51 rules across 8 principle-based categories: Less But Better (CRITICAL), Self-Evident Design (CRITICAL), Honest Interfaces (CRITICAL), Invisible Design (HIGH), Systems Not Pieces (HIGH), Thorough to the Last Detail (HIGH), Enduring Over Trendy (MEDIUM-HIGH), and Refined Through Iteration (MEDIUM). Each rule identifies a specific anti-pattern, grounds the fix in a named principle, and provides the correct iOS 26 / Swift 6.2 SwiftUI implementation. Aligned with the iOS 26 / Swift 6.2 clinic modular MVVM-C architecture.",
"references": [
"Dieter Rams: Ten Principles for Good Design — Cees W. de Jong (Prestel, 2017)",
"Insanely Simple: The Obsession That Drives Apple's Success — Ken Segall (Portfolio/Penguin, 2012)",
"Design Like Apple: Seven Principles For Creating Insanely Great Products, Services, and Experiences — John Edson (Wiley, 2012)",
"https://developer.apple.com/design/human-interface-guidelines/",
"https://developer.apple.com/design/human-interface-guidelines/color",
"https://developer.apple.com/design/human-interface-guidelines/typography",
"https://developer.apple.com/design/human-interface-guidelines/motion",
"https://developer.apple.com/design/human-interface-guidelines/materials",
"https://developer.apple.com/design/human-interface-guidelines/layout",
"https://developer.apple.com/design/human-interface-guidelines/accessibility",
"https://developer.apple.com/documentation/swiftui/animation",
"https://developer.apple.com/documentation/swiftui/view-fundamentals",
"https://developer.apple.com/videos/play/wwdc2023/10156/",
"https://developer.apple.com/videos/play/wwdc2023/10257/",
"https://developer.apple.com/videos/play/wwdc2024/10151/",
"https://developer.apple.com/videos/play/wwdc2024/10145/",
"https://developer.apple.com/design/awards/"
]
}
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
Each category is grounded in principles from three foundational design texts:
- Dieter Rams — Ten Principles for Good Design (Cees W. de Jong)
- Ken Segall — Insanely Simple: The Obsession That Drives Apple's Success
- John Edson — Design Like Apple: Seven Principles For Creating Insanely Great Products
---
1. Less, But Better (less)
Impact: CRITICAL Principle: Rams #10 "Good design is as little design as possible" · Segall "Think Minimal" Description: Every element on screen must earn its place. Rams taught that concentration on essential aspects means the product is not burdened with non-essentials. Segall showed that Apple succeeded by saying no to a thousand things — distilling a lineup of twenty models into four. In SwiftUI, this means one focal point per screen, one typeface, fewer type treatments, restrained color, and zero purposeless animation. If removing an element doesn't degrade the experience, it should never have been there.
2. Self-Evident Design (evident)
Impact: CRITICAL Principle: Rams #4 "Good design makes a product understandable" · Segall "Think Human" Description: Rams believed the best design clarifies a product's structure and makes it self-explanatory — no manual required. Segall's Think Human principle demands that interfaces speak in terms people understand, not technical abstractions. In SwiftUI, this means clear visual hierarchy through size and weight contrast, whitespace that groups related content, progressive disclosure that surfaces the essential and hides the rest, and navigation paradigms that match the user's mental model — all without a single explanatory tooltip.
3. Honest Interfaces (honest)
Impact: CRITICAL Principle: Rams #6 "Good design is honest" · Segall "Think Brutal" Description: Rams insisted design must not attempt to make a product more innovative, powerful, or valuable than it really is — it does not manipulate the consumer with promises that cannot be kept. Segall's Think Brutal demands clarity without sugar-coating. In SwiftUI, honesty means semantic colors that never lie about dark mode, contrast ratios that genuinely serve readability, loading states that reflect real progress, and foreground styles that accurately represent content hierarchy. Every visual decision tells the truth about what the interface is and what it does.
4. Invisible Design (invisible)
Impact: HIGH Principle: Rams #5 "Good design is unobtrusive" · Edson "The Product Is the Marketing" Description: Rams compared well-designed products to neutral tools — they should not be decorative objects or works of art, but leave room for the user's self-expression. Edson showed that at Apple, the product itself is the marketing — the interface quality speaks for itself without calling attention to the mechanism. In SwiftUI, this means system materials instead of hand-tuned opacity, spring physics instead of artificial easing curves, built-in symbol effects instead of manual animation. The best interaction design is the one the user never notices — they notice only the content.
5. Systems, Not Pieces (system)
Impact: HIGH Principle: Edson "Design Is Systems Thinking" · Rams #8 "Good design is thorough down to the last detail" Description: Edson's fourth principle teaches designers to zoom out and see relationships between objects, understanding how the product's context creates a compelling, coherent system. Rams insisted nothing must be arbitrary or left to chance. In SwiftUI, this means a unified spacing grid derived from a base unit, color roles named by purpose not hue, consistent corner radii per component type, and brand identity mapped onto iOS semantic roles — so every screen feels like part of one deliberate system rather than a collection of individually designed views.
6. Thorough to the Last Detail (thorough)
Impact: HIGH Principle: Rams #8 "Good design is thorough down to the last detail" · Rams #2 "Good design makes a product useful" Description: Rams was uncompromising: care and accuracy in the design process show respect for the user. Nothing must be arbitrary or left to chance. A product that ignores edge cases — reduce motion preferences, minimum touch targets, safe area insets, unreadable font weights — fails at being useful regardless of how beautiful the happy path looks. In SwiftUI, thoroughness means every interaction works for every user in every context, without exception.
7. Enduring Over Trendy (enduring)
Impact: MEDIUM-HIGH Principle: Rams #7 "Good design is long-lasting" · Edson "Design With Conviction" Description: Rams warned that fashionable design becomes antiquated — unlike good design, which is never outdated. Edson's seventh principle demands commitment to a unique voice that persists across product generations. In SwiftUI, enduring design means system text styles that scale with platform evolution, weight-based emphasis over stylistic gimmicks, platform navigation gestures preserved, and modular card patterns that compose into any future layout. These choices outlast trends because they align with the platform's own trajectory.
8. Refined Through Iteration (refined)
Impact: MEDIUM Principle: Edson "Design Out Loud" · Rams #1 "Good design is innovative" · Rams #3 "Good design is aesthetic" Description: Edson's fifth principle teaches the courage to relentlessly prototype — to design out loud until every interaction feels inevitable. Rams saw innovation not as an end in itself but as a means of serving genuine purposes, and aesthetic quality as integral to usefulness. In SwiftUI, this means adopting modern APIs like scroll transitions, phase animators, and matched geometry — not for novelty, but because they enable refinements that were previously impossible. Each iteration removes friction and adds polish until the interface feels effortless.
Use Self-Contained Cards for Dashboard Layouts
Rams designed the Braun T3 radio with modular zones that are still referenced 65 years later. Cards are the digital equivalent — self-contained modules that feel complete on their own and compose into any arrangement. Apple's Weather, Health, and Fitness apps all bet on this pattern because it scales from iPhone SE to iPad. A flat list of mixed content forces the user to mentally group related data; cards do that grouping visually, creating scannable islands of meaning. Each card can be rearranged, added, or removed without breaking the whole — modularity that earns its keep at every screen size.
Incorrect (flat list of heterogeneous data without visual boundaries):
struct DashboardView: View {
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 8) {
// No visual grouping — everything runs together
Text("Today's Summary")
.font(.headline)
Text("Revenue: $12,430")
Text("Orders: 84")
Text("Avg Order: $148")
Divider() // dividers are not grouping
Text("Top Products")
.font(.headline)
Text("1. Widget Pro — $3,200")
Text("2. Gadget Air — $2,100")
Text("3. Tool Kit — $1,800")
Divider()
Text("Recent Activity")
.font(.headline)
Text("Order #1042 — Shipped")
Text("Order #1041 — Processing")
Text("Refund #87 — Completed")
}
.padding()
}
}
}Correct (modular cards with consistent treatment):
struct DashboardView: View {
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 16) {
DashboardCard {
Label("Today's Summary", systemImage: "chart.bar.fill")
.font(.subheadline.weight(.medium))
.foregroundStyle(.blue)
Text("$12,430")
.font(.largeTitle.weight(.bold))
HStack(spacing: 16) {
LabeledContent("Orders", value: "84")
LabeledContent("Avg", value: "$148")
}
.font(.subheadline)
.foregroundStyle(.secondary)
}
DashboardCard {
Label("Top Products", systemImage: "star.fill")
.font(.subheadline.weight(.medium))
.foregroundStyle(.orange)
// ... product rows
}
DashboardCard {
Label("Recent Activity", systemImage: "clock.fill")
.font(.subheadline.weight(.medium))
.foregroundStyle(.green)
// ... activity rows
}
}
.padding()
}
.navigationTitle("Dashboard")
}
}
}
// Reusable card container — consistent radius, padding, background
struct DashboardCard<Content: View>: View {
@ViewBuilder let content: Content
var body: some View {
content
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(.regularMaterial,
in: RoundedRectangle(cornerRadius: 16))
}
}Card design conventions on iOS:
- Corner radius: 16pt (matches system cards in Weather, Health)
- Internal padding: 16pt (standard
.padding()) - Background:
.regularMaterialor.quaternary.opacity(0.3)— never hard white/gray - Card spacing: 16pt between cards, tighter within cards
- Each card has a label header with icon + tint for scannability
When NOT to apply: Simple lists of homogeneous items (use List instead) and forms or settings screens (use Form with sections).
Reference: Materials - Human Interface Guidelines, WWDC22 — What's new in SwiftUI (layout patterns)
Never Break the System Back-Swipe Gesture
For iOS 26 / Swift 6.2 clinic architecture flows, this rule applies to coordinator-driven NavigationStack routes and Route Shell wiring exactly as it does to local feature screens.
The left-edge swipe to go back is muscle memory for hundreds of millions of iPhone users — breaking it feels like someone moved the light switch in your own home. The disorientation is immediate and the trust damage is lasting. This gesture will outlast any custom interaction you replace it with, because it is wired into the muscle memory of everyone who has ever used an iPhone. Respecting it is not a constraint — it is a commitment to the spatial language your users already speak fluently.
Incorrect (custom drag gesture conflicts with system back swipe):
struct ImageViewer: View {
@State private var offset: CGSize = .zero
var body: some View {
// This DragGesture captures ALL horizontal drags,
// including the system's edge-swipe-to-go-back
Image("photo")
.resizable()
.scaledToFit()
.offset(offset)
.gesture(
DragGesture()
.onChanged { value in
offset = value.translation
}
.onEnded { _ in
withAnimation(.smooth) { offset = .zero }
}
)
}
}Correct (gesture restricted to avoid edge-swipe conflict):
struct ImageViewer: View {
@State private var offset: CGSize = .zero
@GestureState private var isDragging = false
var body: some View {
Image("photo")
.resizable()
.scaledToFit()
.offset(offset)
.gesture(
DragGesture(minimumDistance: 20)
.updating($isDragging) { _, state, _ in
state = true
}
.onChanged { value in
// Only handle vertical drag-to-dismiss,
// leave horizontal edge swipes to the system
if abs(value.translation.height) > abs(value.translation.width) {
offset = CGSize(width: 0, height: value.translation.height)
}
}
.onEnded { value in
if abs(value.translation.height) > 200 {
dismiss()
} else {
withAnimation(.smooth) { offset = .zero }
}
}
)
}
@Environment(\.dismiss) private var dismiss
}Common causes of broken swipe-back:
| Anti-pattern | Fix |
|---|---|
TabView with .tabViewStyle(.page) inside a NavigationStack | Place paging content in a non-navigated context, or use a custom pager that yields the leading edge |
.navigationBarBackButtonHidden(true) with no replacement | Always provide a custom back button and keep the swipe gesture via .toolbar placement instead |
Custom NavigationView replacement (e.g., manual stack with AnyView) | Migrate to NavigationStack which handles the interactive pop transition natively |
DragGesture() with no directional constraint | Constrain to vertical axis, or use simultaneousGesture with .highPriorityGesture to yield to the system |
Alternative (custom back button without losing swipe):
struct DetailView: View {
@Environment(\.dismiss) private var dismiss
var body: some View {
ContentView()
// Hide the default back button but keep the swipe gesture
.navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .navigation) {
Button(action: { dismiss() }) {
HStack(spacing: 4) {
Image(systemName: "chevron.left")
Text("Back")
}
}
}
}
}
}When NOT to apply: Full-screen immersive experiences (games, camera, drawing canvases) where the entire screen is a continuous interaction surface and a back button or close gesture is more appropriate than an edge swipe.
Reference: Apple HIG — Navigation — "Always provide a clear path back. People usually know how they got to the current screen and expect to be able to go back."
Use Apple Text Styles, Never Fixed Font Sizes
Hard-coded font sizes are a snapshot of one moment's aesthetic — they lock your app to today while Apple's type system evolves underneath. When iOS 19 adjusts Dynamic Type curves, apps using .body evolve with the platform; apps using .system(size: 16) stay frozen. It is the difference between a living typeface that breathes with the OS and a number chiseled into stone. Semantic text styles are not a convenience wrapper — they are a pact with the platform that your typography will age as gracefully as the system itself.
Incorrect (fixed point sizes that bypass Dynamic Type):
struct ArticleCard: View {
let article: Article
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(article.category)
.font(.system(size: 11))
.foregroundStyle(.secondary)
Text(article.title)
.font(.custom("Helvetica Neue", size: 18))
.fontWeight(.bold)
Text(article.excerpt)
.font(.system(size: 14))
.lineLimit(3)
Text(article.author)
.font(Font.system(size: 12, weight: .medium))
}
}
}Correct (semantic text styles with Dynamic Type support):
struct ArticleCard: View {
let article: Article
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(article.category)
.font(.caption)
.foregroundStyle(.secondary)
Text(article.title)
.font(.headline)
Text(article.excerpt)
.font(.body)
.lineLimit(3)
Text(article.author)
.font(.subheadline)
.fontWeight(.medium)
}
}
}Benefits:
- Dynamic Type scales all text automatically — no manual
@ScaledMetricplumbing needed. - Apple's type scale maintains proportional relationships between levels at every accessibility size.
- Eliminates arbitrary font sizes that drift across PRs.
When NOT to apply:
- Display text in a branded hero banner may use a fixed size with
@ScaledMetricfor manual scaling. In that case, pair it with.dynamicTypeSize(...:.accessibility3)to cap maximum growth and prevent layout breakage. - Custom typefaces still benefit from text styles: use
Font.custom("YourFont", size: 17, relativeTo: .body)to get Dynamic Type scaling with a custom face.
Reference: Apple HIG — Typography
Use Weight for Emphasis, Not ALL CAPS
ALL CAPS body text feels like being shouted at. It reduces word-shape recognition — we read word silhouettes, not individual letters — and it dates the interface like a print convention that never translated well to screens. Emphasis should guide the eye, not assault it. Font weight creates hierarchy through contrast without sacrificing the natural letterforms that make text scannable. The iOS emphasis system — weight and text style hierarchy — will remain correct as long as the platform exists, while uppercase styling ages the way bold wallpaper ages a room.
Incorrect (ALL CAPS for emphasis on section headers and body content):
struct OrderSummaryView: View {
let order: Order
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Order Details")
.font(.body)
.textCase(.uppercase)
.tracking(1.2)
Text("Shipping Address")
.font(.subheadline)
.textCase(.uppercase)
Text(order.address)
.font(.body)
Text("Payment Method")
.font(.subheadline)
.textCase(.uppercase)
Text(order.paymentSummary)
.font(.body)
}
}
}Correct (weight and text style hierarchy for emphasis):
struct OrderSummaryView: View {
let order: Order
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Order Details")
.font(.headline)
Text("Shipping Address")
.font(.subheadline)
.fontWeight(.semibold)
Text(order.address)
.font(.body)
Text("Payment Method")
.font(.subheadline)
.fontWeight(.semibold)
Text(order.paymentSummary)
.font(.body)
}
}
}When ALL CAPS is acceptable:
- Short status badges or tags (e.g.,
Text("NEW").font(.caption2).textCase(.uppercase)) where the text is 1-2 words, purely decorative, and not the primary reading content. - Tab bar labels or segmented control items that follow platform convention.
When NOT to apply: Legal disclaimers or regulatory labels where ALL CAPS is a legal requirement, and short status badges (1-2 words) where uppercase is a deliberate typographic convention that aids scannability.
Reference: Apple HIG — Typography
Use Zoom Transitions for Collection-to-Detail Navigation
When you tap a photo in a grid and it zooms open from exactly where your finger touched, your brain processes this as a physical act — you "opened" the photo. Standard push navigation from a grid cell breaks this spatial link and leaves the user momentarily disoriented: "where did this come from?" Zoom transitions anchor the detail view to the tapped cell, reflecting how human spatial cognition actually works. This is not a visual flourish — it is a reflection of how we perceive cause and effect on a touch surface, and it will remain valid for as long as people use their fingers to explore content.
Incorrect (standard push from a grid cell with no spatial connection):
struct PhotoGrid: View {
let photos: [Photo]
var body: some View {
NavigationStack {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))], spacing: 2) {
ForEach(photos) { photo in
// Standard push: detail slides in from the right,
// no visual link to the tapped thumbnail
NavigationLink(value: photo) {
AsyncImage(url: photo.thumbnailURL) { image in
image.resizable().scaledToFill()
} placeholder: {
Color(.systemGray5)
}
.frame(minHeight: 100)
.clipped()
}
}
}
}
.navigationDestination(for: Photo.self) { photo in
PhotoDetailView(photo: photo)
}
}
}
}Correct (zoom transition anchored to the tapped cell):
struct PhotoGrid: View {
let photos: [Photo]
@Namespace private var namespace
var body: some View {
NavigationStack {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))], spacing: 2) {
ForEach(photos) { photo in
NavigationLink(value: photo) {
AsyncImage(url: photo.thumbnailURL) { image in
image.resizable().scaledToFill()
} placeholder: {
Color(.systemGray5)
}
.frame(minHeight: 100)
.clipped()
}
// Mark this cell as the zoom origin
.matchedTransitionSource(id: photo.id, in: namespace)
}
}
}
.navigationDestination(for: Photo.self) { photo in
PhotoDetailView(photo: photo)
// Zoom the detail view out of the matched source
.navigationTransition(.zoom(sourceID: photo.id, in: namespace))
}
}
}
}Exceptional (the creative leap) — navigation that feels spatial:
struct ProjectGrid: View {
let projects: [Project]
@Namespace private var hero
@State private var selectedProject: Project?
var body: some View {
NavigationStack {
ScrollView {
LazyVGrid(
columns: [GridItem(.adaptive(minimum: 160))],
spacing: 16
) {
ForEach(projects) { project in
NavigationLink(value: project) {
ProjectCard(project: project)
.matchedTransitionSource(
id: project.id, in: hero
) { source in
source
.clipShape(RoundedRectangle(
cornerRadius: 20,
style: .continuous))
.shadow(
color: .black.opacity(0.08),
radius: 8, y: 4)
}
}
}
}
.padding()
}
.navigationDestination(for: Project.self) { project in
ProjectDetailView(project: project)
.navigationTransition(.zoom(
sourceID: project.id, in: hero))
}
}
}
}The standard zoom transition already communicates origin, but the creative leap is shaping the transition itself — giving the source a continuous corner radius and a subtle shadow during the animation so the card feels like a physical object lifting off the grid and expanding to fill the screen. The siblings don't just sit there; they recede under the shadow as if the tapped card is rising above them in z-space. It stops feeling like a page navigation and starts feeling like you physically opened something. That moment of spatial conviction is what makes users instinctively swipe back instead of hunting for a back button — their hands already understand where they are.
When NOT to apply: Flat lists where items are text-only rows (standard push is appropriate for Settings-style drill-down), and tabs or top-level navigation switches where zoom implies spatial containment rather than lateral movement.
Reference: WWDC 2024 "Enhance your UI animations and transitions" — demonstrates navigationTransition(.zoom) as the recommended pattern for collection-to-detail flows.
Use Clear Labels Over Ambiguous Icons
A tiny square with an arrow pointing up. Is that share? Export? Upload? You hesitate for just a moment — but that moment is a crack in your confidence. Multiply it across five unlabeled toolbar icons and the interface becomes a guessing game, each tap a small leap of faith. The frustration is quiet but real: the app is speaking in pictograms and expecting you to translate. Words dissolve that ambiguity instantly. A label beneath the icon turns a riddle into a statement. Apple's own tab bars always pair icons with text because even familiar glyphs carry just enough uncertainty to slow you down. Self-evident design speaks the user's language, not in symbols that demand interpretation.
Incorrect (icon-only toolbar, no labels):
struct DocumentToolbar: View {
var body: some View {
HStack(spacing: 24) {
// What does each icon mean? Users must tap to find out.
Button(action: {}) { Image(systemName: "doc.on.doc") }
Button(action: {}) { Image(systemName: "arrow.uturn.backward") }
Button(action: {}) { Image(systemName: "textformat.size") }
Button(action: {}) { Image(systemName: "rectangle.and.pencil.and.ellipsis") }
Button(action: {}) { Image(systemName: "ellipsis.circle") }
}
.font(.title3)
}
}Correct (labels paired with icons, or at minimum, accessibility labels):
struct DocumentToolbar: View {
var body: some View {
HStack(spacing: 16) {
// Labels make every action instantly understandable
Button(action: {}) {
Label("Duplicate", systemImage: "doc.on.doc")
}
Button(action: {}) {
Label("Undo", systemImage: "arrow.uturn.backward")
}
Button(action: {}) {
Label("Format", systemImage: "textformat.size")
}
Menu {
Button("Rename", systemImage: "pencil") {}
Button("Move", systemImage: "folder") {}
Button("Delete", role: .destructive) {}
} label: {
Label("More", systemImage: "ellipsis.circle")
}
}
.labelStyle(.titleAndIcon)
.font(.subheadline)
}
}When to use labels with icons:
// Tab bars: ALWAYS show labels
TabView {
Text("Home").tabItem {
Label("Home", systemImage: "house.fill")
}
Text("Search").tabItem {
Label("Search", systemImage: "magnifyingglass")
}
}
// Toolbars on iPad: show labels (.titleAndIcon)
// Toolbars on iPhone: icon-only acceptable BUT add accessibility labels
struct CompactToolbar: View {
var body: some View {
HStack {
Button(action: {}) {
Label("Duplicate", systemImage: "doc.on.doc")
}
.labelStyle(.iconOnly)
.accessibilityLabel("Duplicate")
}
}
}
// Primary actions: always show labels
Button {
// action
} label: {
Label("Add to Cart", systemImage: "cart.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)When NOT to apply:
- Universal media controls (play/pause, skip, volume) where the icons are globally understood across all platforms and cultures
- iOS navigation bar items where space is extremely constrained — but still provide
.accessibilityLabel()
Benefits:
- Reduces onboarding friction — users discover features immediately without trial-and-error
- Passes WCAG 2.5.3 (Label in Name) — accessibility users can navigate by spoken labels
- Survives icon redesigns — even if SF Symbols change, the text label remains constant
Reference: Apple HIG — Labels
Sheets for Tasks and Creation, Push for Drill-Down Hierarchy
You tap "Compose" and the screen slides in from the right — the same animation as drilling into a message thread. For a split second, something feels wrong. Where's the Send button? The back arrow sits where Cancel should be, but does going back discard your draft or save it? That flicker of disorientation is the cost of mismatched navigation intent. Users carry a deep, learned vocabulary of motion: a push means "I'm going deeper into content," a sheet rising from below means "I'm doing a task and coming back." When the paradigm matches the mental model, navigation feels invisible. When it doesn't, every transition becomes a small riddle the user shouldn't have to solve.
Incorrect (push navigation for a creation/task flow):
struct InboxView: View {
var body: some View {
NavigationStack {
List(messages) { message in
NavigationLink(value: message) {
MessageRow(message: message)
}
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
// Pushing a compose screen buries it in the nav stack —
// user sees a back arrow instead of Cancel/Send
NavigationLink("Compose") {
ComposeMessageView()
}
}
}
.navigationDestination(for: Message.self) { message in
MessageDetailView(message: message)
}
}
}
}Correct (sheet for compose, push for detail):
struct InboxView: View {
@State private var isComposing = false
var body: some View {
NavigationStack {
List(messages) { message in
// Push: drill into existing content hierarchy
NavigationLink(value: message) {
MessageRow(message: message)
}
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Compose", systemImage: "square.and.pencil") {
isComposing = true
}
}
}
.navigationDestination(for: Message.self) { message in
MessageDetailView(message: message)
}
// Sheet: self-contained task with its own Cancel/Send toolbar
.sheet(isPresented: $isComposing) {
NavigationStack {
ComposeMessageView()
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { isComposing = false }
}
ToolbarItem(placement: .confirmationAction) {
Button("Send") { sendMessage() }
}
}
}
}
}
}
}Decision framework:
| Scenario | Pattern | Example |
|---|---|---|
| Browse deeper into content | NavigationLink (push) | Message → thread → attachment |
| Create, edit, or complete a task | .sheet | Compose email, add contact, edit profile |
| Immersive content requiring full attention | .fullScreenCover | Video playback, onboarding, camera |
| Quick reference without leaving context | .popover on iPad, .sheet on iPhone | Date picker, font selector |
When NOT to apply: Wizard-style multi-step creation flows where each step builds on the previous one -- push navigation through the steps preserves the back-trackable history that sheets would lose.
Reference: Apple HIG — Modality — "Use a modal presentation only when it makes sense to require people to complete a task or dismiss a message before continuing."
Use Progressive Disclosure for Dense Information
Thirteen health metrics staring at you from one screen. Steps, HRV, blood oxygen, deep sleep, respiratory rate — all at the same volume, all demanding attention at once. The feeling is not "informed," it is overwhelmed. You glaze over, scroll past, engage with none of it. Now imagine landing on a single calm ring that tells you how your day is going, with a heart card and a sleep card below it — just enough to answer "am I okay?" If you want the deeper numbers, they're one tap away, waiting patiently in their own space. That calm completeness is progressive disclosure: not hiding information, but organizing it into layers of depth that the user controls, revealing the right detail at the right moment.
Incorrect (all data visible at once, no prioritization):
struct HealthDashboard: View {
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 8) {
// Wall of undifferentiated metrics
Text("Steps: 8,432")
Text("Distance: 3.8 km")
Text("Flights Climbed: 12")
Text("Active Calories: 342 kcal")
Text("Resting Calories: 1,650 kcal")
Text("Heart Rate: 72 bpm")
Text("HRV: 45 ms")
Text("Blood Oxygen: 98%")
Text("Sleep: 7h 23m")
Text("Deep Sleep: 1h 45m")
Text("REM Sleep: 2h 10m")
Text("Respiratory Rate: 14 brpm")
Text("Noise Level: 42 dB")
}
.font(.body)
.padding()
}
}
}Correct (summary first, detail on demand):
struct HealthDashboard: View {
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 16) {
// Hero metric: the single most important number
ActivityRingSummary(
moveCalories: 342,
exerciseMinutes: 28,
standHours: 10
)
// Summary cards — tap to drill into detail
NavigationLink { HeartDetailView() } label: {
SummaryCard(title: "Heart", headline: "72 BPM",
subtitle: "Resting average today",
systemImage: "heart.fill", tint: .red)
}
NavigationLink { SleepDetailView() } label: {
SummaryCard(title: "Sleep", headline: "7h 23m",
subtitle: "Last night",
systemImage: "bed.double.fill", tint: .cyan)
}
DisclosureGroup("More Health Data") {
LabeledContent("Blood Oxygen", value: "98%")
LabeledContent("Respiratory Rate", value: "14 brpm")
LabeledContent("Noise Level", value: "42 dB")
}
.padding()
.background(.quaternary.opacity(0.3),
in: RoundedRectangle(cornerRadius: 12))
}
.padding()
}
.navigationTitle("Health")
}
}
}
// SummaryCard: title + headline + subtitle + icon with chevron,
// wrapped in .padding() + .background(.quaternary.opacity(0.3),
// in: RoundedRectangle(cornerRadius: 12))Exceptional (the creative leap) — disclosure as conversation:
struct HeartSummaryCard: View {
@State private var isExpanded = false
@Namespace private var cardHero
var body: some View {
VStack(alignment: .leading, spacing: 12) {
// Always visible: the single calm answer
HStack {
Image(systemName: "heart.fill")
.foregroundStyle(.red)
.matchedGeometryEffect(
id: "icon", in: cardHero)
Text("72 BPM")
.font(.title2.weight(.semibold))
.matchedGeometryEffect(
id: "headline", in: cardHero)
Spacer()
}
if isExpanded {
// The deeper answer — unfolds in place
VStack(alignment: .leading, spacing: 8) {
LabeledContent("Resting Average", value: "68 BPM")
LabeledContent("Walking Average", value: "94 BPM")
LabeledContent("HRV", value: "45 ms")
LabeledContent("Range Today", value: "58–112 BPM")
}
.font(.subheadline)
.foregroundStyle(.secondary)
.transition(.opacity.combined(with: .move(edge: .top)))
}
Button {
withAnimation(.snappy(duration: 0.35)) {
isExpanded.toggle()
}
} label: {
Text(isExpanded ? "Show Less" : "Show Details")
.font(.subheadline.weight(.medium))
.foregroundStyle(.blue)
}
}
.padding()
.background(.quaternary.opacity(0.3),
in: RoundedRectangle(cornerRadius: 12))
}
}The difference between "expandable section" and "conversation" is how the reveal feels in motion. The matchedGeometryEffect keeps the heart icon and headline anchored in place while the detail rows materialize beneath them with a combined opacity-and-slide transition — the card doesn't jump or reflow, it grows. The .snappy spring gives the expansion a quick, confident feel, like the interface heard your question and is answering without hesitation. This transforms progressive disclosure from a mechanical show/hide into a dialogue: you asked, I'll tell you more, right here, at your pace.
Progressive disclosure patterns on iOS:
NavigationLinkfor detail screens (primary pattern)DisclosureGroupfor inline expansion of secondary data.sheetfor contextual detail without leaving the current flow- Section headers with "Show More" buttons for lists
When NOT to apply: Reference screens where users need to compare all values simultaneously (e.g., a stock ticker), and settings screens where each row is already a single value that is progressively disclosed via NavigationLink.
Reference: WWDC20 — Design for Intelligence, Apple Health (summary ring pattern)
Align Visual Weight with Logical Reading Order
You open a restaurant page and the first thing you see is a pair of grey capsules — "Italian" and "$$" — followed by the opening hours. Somewhere below all that decorative metadata, the restaurant's actual name finally appears. The frustration is immediate: your eye went where the layout told it to go, but the layout lied about what was important. Good visual structure is honest. It places the heaviest weight on the thing users came to find, then walks them naturally through supporting detail — top-left to right, then down — so the eye never has to hunt. When position and priority agree, the screen reads like a story told in the right order.
Incorrect (key action buried, secondary info dominates the top):
struct RestaurantDetailView: View {
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
// Decorative info dominates prime real estate
HStack {
Text("Italian")
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.gray.opacity(0.2))
.clipShape(Capsule())
Text("$$")
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.gray.opacity(0.2))
.clipShape(Capsule())
}
Text("Open until 10 PM")
.font(.subheadline)
.foregroundStyle(.secondary)
// Restaurant name — the primary identifier — is third
Text("Osteria Francescana")
.font(.title2.bold())
Text("4.8 ★ (2,340 reviews)")
// Photo buried below text
Image("restaurant-hero")
.resizable()
.scaledToFill()
.frame(height: 200)
.clipped()
// Primary action at the very bottom
Text("Via Stella 22, Modena")
.font(.footnote)
.foregroundStyle(.secondary)
Button("Reserve a Table") { }
.buttonStyle(.borderedProminent)
}
.padding()
}
}
}Correct (visual weight matches information priority):
struct RestaurantDetailView: View {
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
// POSITION 1: Hero image — instant recognition
Image("restaurant-hero")
.resizable()
.scaledToFill()
.frame(height: 260)
.clipped()
VStack(alignment: .leading, spacing: 20) {
// POSITION 2: Name + rating
VStack(alignment: .leading, spacing: 4) {
Text("Osteria Francescana")
.font(.title2.weight(.bold))
HStack(spacing: 4) {
Text("4.8 ★").fontWeight(.medium)
Text("(2,340 reviews)").foregroundStyle(.secondary)
}
.font(.subheadline)
}
// POSITION 3: Key decision factors
HStack(spacing: 12) {
Label("Italian", systemImage: "fork.knife")
Label("$$", systemImage: "dollarsign.circle")
Label("Open until 10 PM", systemImage: "clock")
}
.font(.subheadline).foregroundStyle(.secondary)
// POSITION 4: Primary action
Button("Reserve a Table") { }
.buttonStyle(.borderedProminent)
.controlSize(.large)
.frame(maxWidth: .infinity)
// POSITION 5: Supporting detail
Label("Via Stella 22, Modena", systemImage: "mappin")
.font(.subheadline).foregroundStyle(.secondary)
}
.padding()
}
}
}
}Priority-to-position mapping:
// Position 1 (top): Hero visual or primary identifier
// Position 2: Title + key metric (name, rating, price)
// Position 3: Decision-support metadata (category, hours, distance)
// Position 4: Primary call to action
// Position 5+ (scroll): Supporting details, secondary actions
//
// Rule: if users need information to make a decision,
// it must appear BEFORE the call to action.
// For sticky actions that must always be reachable:
.safeAreaInset(edge: .bottom) {
Button("Reserve a Table") { }
.buttonStyle(.borderedProminent)
.frame(maxWidth: .infinity)
.padding()
.background(.regularMaterial)
}When NOT to apply: Content-browsing screens (Photos, Instagram) where the content itself is the primary element and fills the viewport edge-to-edge with minimal chrome, and search-first screens (Maps, Spotlight) where the input field correctly dominates.
Reference: Layout - Human Interface Guidelines, WWDC23 — Design with SwiftUI
Combine Size, Weight, and Contrast for Hierarchy
When every line of text looks the same, the eye drifts. Nothing pulls you in, nothing recedes — it's like listening to someone speak in a monotone. You can't tell what matters. But when size, weight, and contrast work together — the title steps forward, the timestamp falls back, the amount anchors the row — the hierarchy reads like a well-composed sentence, each word at the right volume. That effortless flow is what self-evident design feels like. Color alone can't carry it; 8% of males with color vision deficiency would lose the signal entirely. True visual hierarchy speaks through multiple channels layered together, so the structure is perceivable by every human eye.
Incorrect (hierarchy relies on color alone):
struct TransactionRow: View {
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
// Same size, same weight — only color differentiates
Text("Coffee Shop")
.font(.body)
.foregroundStyle(Color.black)
Text("Today, 9:41 AM")
.font(.body)
.foregroundStyle(Color.gray) // only signal is color
Text("Checking Account")
.font(.body)
.foregroundStyle(Color.blue) // color = category?
}
Spacer()
Text("-$4.50")
.font(.body)
.foregroundStyle(Color.red) // red = negative, invisible to protanopia
}
}
}Correct (size + weight + foregroundStyle layered together):
struct TransactionRow: View {
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
// Level 1: largest + heaviest
Text("Coffee Shop")
.font(.body)
.fontWeight(.semibold)
.foregroundStyle(.primary)
// Level 2: smaller + lighter color
Text("Today, 9:41 AM")
.font(.subheadline)
.foregroundStyle(.secondary)
// Level 3: smallest + lightest
Text("Checking Account")
.font(.caption)
.foregroundStyle(.tertiary)
}
Spacer()
// Amount: weight + semantic color (not color alone)
Text("-$4.50")
.font(.body)
.fontWeight(.semibold)
.foregroundStyle(.primary)
.accessibilityLabel("Debit four dollars and fifty cents")
}
}
}The three-axis hierarchy system:
// Each level MUST differ on at least 2 of 3 axes
//
// Level Size Weight ForegroundStyle
// ─────────────────────────────────────────────────────
// Primary .body+ .semibold+ .primary
// Secondary .subheadline .regular .secondary
// Tertiary .caption .regular .tertiary
// Test: convert your UI to grayscale (Accessibility Inspector)
// If levels become indistinguishable, add a weight or size differenceExceptional (the creative leap) — hierarchy as emotional arc:
struct NotificationView: View {
let event: NotificationEvent
var body: some View {
VStack(spacing: 24) {
Spacer()
// The moment: what happened
Text(event.headline)
.font(.title.weight(.bold))
.foregroundStyle(.primary)
.multilineTextAlignment(.center)
// The context: why it matters
Text(event.detail)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 280)
Spacer()
// The action: what to do next — the only color
Button(action: event.primaryAction) {
Text(event.actionLabel)
.font(.body.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.borderedProminent)
.padding(.horizontal, 40)
// The escape: low-priority dismiss
Button("Not Now", action: event.dismiss)
.font(.subheadline)
.foregroundStyle(.tertiary)
.padding(.bottom, 8)
}
.padding(24)
}
}This isn't just a hierarchy — it's a narrative in three acts. The bold headline arrives first and lands with weight: something happened. The lighter, smaller detail line steps back and offers context without competing for attention. Then the eye falls to the only saturated element on the entire screen — the tinted action button — which resolves the tension the headline created. The "Not Now" option exists in .tertiary, barely visible, because it's an exit, not a destination. The emotional arc moves from impact to understanding to resolution, and visual weight is the instrument that conducts it. When hierarchy serves emotion rather than just logic, people don't read the screen — they feel it.
When NOT to apply:
- Decorative or artistic typography (e.g., a splash screen or promotional banner) where visual uniformity is an intentional stylistic choice
- Single-level lists where every item is genuinely equal in priority (e.g., a flat checklist) — forcing artificial hierarchy adds noise rather than clarity
Benefits:
- Passes WCAG 1.4.1 (Use of Color) — hierarchy never depends on color alone
- Survives grayscale, low brightness, and sunlight-washed screens
- Dynamic Type scales all three levels proportionally
Reference: Color and Effects - Human Interface Guidelines, WCAG 1.4.1 Use of Color
Use Whitespace to Separate Conceptual Groups
A wall of evenly spaced text feels like a run-on sentence — no pauses, no breathing room, every line blurring into the next. Your eye has nowhere to rest, so you skim without absorbing. Now picture the same content with gentle pauses between groups: the title and host sit together like a chapter heading, the date and location cluster like a sidebar, the action rises from its own clearing. Suddenly the screen has rhythm. Those silent gaps are doing real work — they create structure without adding a single element. The Gestalt principle of proximity is hardwired into human perception: things near each other belong together. Whitespace is not empty space; it is the punctuation of visual design.
Incorrect (uniform spacing between all elements):
struct EventDetailView: View {
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
// Every element has identical 12pt spacing
// No way to tell which items belong together
Text("WWDC25 Watch Party")
.font(.title2.bold())
Text("June 9, 2025 at 10:00 AM")
Text("Apple Park, Cupertino")
Text("Hosted by Developer Relations")
Text("Join us for the keynote livestream with snacks and networking.")
Text("42 attending")
Text("12 spots remaining")
Button("RSVP") { }
.buttonStyle(.borderedProminent)
}
.padding()
}
}
}Correct (grouped spacing reflects information architecture):
struct EventDetailView: View {
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
// Group 1: Identity (tight 4pt internal spacing)
VStack(alignment: .leading, spacing: 4) {
Text("WWDC25 Watch Party")
.font(.title2)
.fontWeight(.bold)
Text("Hosted by Developer Relations")
.font(.subheadline)
.foregroundStyle(.secondary)
}
// 24pt gap — signals new conceptual group
// Group 2: Logistics (tight 6pt internal spacing)
VStack(alignment: .leading, spacing: 6) {
Label("June 9, 2025 at 10:00 AM", systemImage: "calendar")
Label("Apple Park, Cupertino", systemImage: "mappin")
}
.font(.subheadline)
.foregroundStyle(.secondary)
// 24pt gap
// Group 3: Description (standalone)
Text("Join us for the keynote livestream with snacks and networking.")
.font(.body)
// 24pt gap
// Group 4: Attendance + action (tight 8pt)
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("42 attending")
Spacer()
Text("12 spots remaining")
.foregroundStyle(.secondary)
}
.font(.subheadline)
Button("RSVP") { }
.buttonStyle(.borderedProminent)
.frame(maxWidth: .infinity)
}
}
.padding()
}
}
}Spacing ratios that create clear grouping:
// Within a group: 4-8pt (elements feel connected)
// Between groups: 20-32pt (clear visual break)
// Ratio: ~3:1 or higher between inter-group and intra-group
// SwiftUI pattern: nest VStacks with different spacing
VStack(spacing: 24) { // inter-group: 24pt
VStack(spacing: 4) { ... } // intra-group: 4pt
VStack(spacing: 6) { ... } // intra-group: 6pt
VStack(spacing: 8) { ... } // intra-group: 8pt
}
// Avoid: Divider() as a substitute for whitespace
// Dividers add visual noise — spacing alone is sufficient
// Exception: List rows where dividers are the system conventionExceptional (the creative leap) — spacing as visual rhythm:
struct BookingConfirmation: View {
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
// Tight opening — dense identity cluster (4pt)
VStack(alignment: .leading, spacing: 4) {
Text("Booking Confirmed")
.font(.title2.bold())
Text("Reservation #TH-4829")
.font(.caption)
.foregroundStyle(.tertiary)
}
Spacer().frame(height: 16) // Brief pause
// Mid section — details breathe a little more (8pt)
VStack(alignment: .leading, spacing: 8) {
Label("Mar 14 – Mar 21, 2025", systemImage: "calendar")
Label("Brighton, East Sussex", systemImage: "mappin")
Label("2 cats, 1 dog", systemImage: "pawprint")
}
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer().frame(height: 28) // Longer breath
// Description — standalone, room to absorb
Text("You'll be caring for Luna, Miso, and Barkley in a Victorian terrace five minutes from the sea.")
.font(.body)
.lineSpacing(4)
Spacer().frame(height: 40) // Generous clearing
// Action — rises from its own space, unhurried
Button("View House Details") { }
.buttonStyle(.borderedProminent)
.controlSize(.large)
.frame(maxWidth: .infinity)
}
.padding()
}
}
}The spacing tells a story the same way a musician uses rests between phrases. Tight at the top where identity needs to land quickly, then progressively more generous as the eye travels down — 4, 16, 8, 28, 40. By the time the user reaches the button, the generous clearing around it makes it feel like an invitation rather than a demand. The rhythm guides without pushing: each pause is a breath that lets the previous group settle before the next one begins.
Benefits:
- Eliminates Divider clutter — whitespace communicates the same grouping with less noise
- Works across Dynamic Type sizes because spacing scales proportionally
- Reduces view count compared to adding separator views
When NOT to apply: Dense data tables and spreadsheet-style views where uniform tight spacing is necessary to display maximum information and users expect to scan rows and columns without visual interruption.
Reference: Layout - Human Interface Guidelines, WWDC22 — Compose custom layouts with SwiftUI
Ensure WCAG AA Contrast Ratios
You know the feeling — squinting at gray-on-white text, tilting your phone, cupping your hand around the screen to block the glare, and still not being able to read the number that tells you what you owe. There is a quiet indignity in being unable to read something that is supposedly there for you. That whisper-thin Color.gray.opacity(0.5) on white looks tasteful on the designer's Retina display, but it produces a contrast ratio of 1.8:1 — less than half the WCAG AA minimum. For 8% of male users with color vision deficiency and most users over 50, it is not subtle; it is invisible. A well-made interface does not confuse refinement with disappearance. Apple's semantic styles (.primary, .secondary, .tertiary) are pre-tuned to meet AA ratios in both appearances — they let you achieve visual hierarchy without sacrificing the basic promise that text exists to be read.
Incorrect (decorative opacity destroys legibility):
struct PaymentSummary: View {
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Payment Summary")
.font(.headline)
HStack {
Text("Subtotal")
.foregroundStyle(Color.gray.opacity(0.5))
Spacer()
Text("$42.00")
.foregroundStyle(Color.gray.opacity(0.5))
}
HStack {
Text("Tax")
.foregroundStyle(Color.gray.opacity(0.4))
Spacer()
Text("$3.36")
.foregroundStyle(Color.gray.opacity(0.4))
}
Divider()
HStack {
Text("Total")
.font(.headline)
Spacer()
Text("$45.36")
.font(.headline)
.foregroundStyle(Color(red: 0.6, green: 0.6, blue: 0.6))
}
}
.padding()
.background(.white)
}
}Correct (system-validated semantic styles meet AA minimums):
struct PaymentSummary: View {
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Payment Summary")
.font(.headline)
HStack {
Text("Subtotal")
.foregroundStyle(.secondary)
Spacer()
Text("$42.00")
.foregroundStyle(.secondary)
}
HStack {
Text("Tax")
.foregroundStyle(.secondary)
Spacer()
Text("$3.36")
.foregroundStyle(.secondary)
}
Divider()
HStack {
Text("Total")
.font(.headline)
Spacer()
Text("$45.36")
.font(.headline)
.foregroundStyle(.primary)
}
}
.padding()
.background(Color(.systemBackground))
}
}Minimum contrast ratios (WCAG 2.1 AA):
| Text type | Minimum ratio | Example |
|---|---|---|
| Body text (< 18pt) | 4.5:1 | .secondary on system background passes |
| Large text (>= 18pt bold or >= 24pt) | 3:1 | .tertiary on system background passes |
| UI components and icons | 3:1 | Inactive controls, chevrons, dividers |
| Decorative only | No minimum | Background textures, non-informational art |
Verification workflow: 1. Open Xcode Accessibility Inspector (Xcode → Open Developer Tool → Accessibility Inspector) 2. Use the Color Contrast Calculator to check foreground/background pairs 3. Toggle between light and dark mode — a color that passes in light may fail in dark 4. Enable Increase Contrast in Settings → Accessibility → Display & Text Size and verify again
When NOT to apply: Placeholder text in search bars that disappears on focus, purely decorative gradient overlays, and disabled controls (which have separate WCAG guidance at 3:1).
Reference: Accessibility - Human Interface Guidelines, WCAG 2.1 Success Criterion 1.4.3
Define Light and Dark Variants for Every Custom Color
It is 11 PM, you are in bed, and you switch to dark mode to save your eyes. The app goes dark — except for one banner that stays brand teal on white, a searing rectangle that flares across your retina like a flashlight in a dark room. The designer never opened this screen after sunset. A single-variant custom color is a broken promise: it claims to work everywhere but was only ever tested in one lighting condition. Every custom color needs a light version and a dark version, defined explicitly in the asset catalog — "Any Appearance" and "Dark" — because an interface that blinds you at nightfall is not a styling oversight, it is a craft failure. The honest approach is to treat both appearances as first-class from the start, never relying on a single value that looked fine on a Retina display in a lit office.
Incorrect (single-value custom color used in both appearances):
struct MembershipBanner: View {
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Premium Member")
.font(.headline)
.foregroundStyle(Color("brandTeal"))
Text("Your subscription renews on March 1")
.font(.subheadline)
.foregroundStyle(Color("brandDarkGreen"))
}
.padding()
.background(Color("brandLightBackground"))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
// Asset catalog: "brandTeal" has only one value: #008080
// In dark mode: #008080 on near-black background = 3.2:1 contrast (fails AA)
// Asset catalog: "brandLightBackground" has only one value: #F5F5F0
// In dark mode: renders as a bright rectangle against a dark interfaceCorrect (adaptive color with explicit light and dark variants):
// Option A: Asset catalog with paired variants
// "brandAccent" → Any Appearance: #008080, Dark: #40C8C8
// "backgroundBrandSubtle" → Any Appearance: #F5F5F0, Dark: #1C2A2A
struct MembershipBanner: View {
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Premium Member")
.font(.headline)
.foregroundStyle(Color("brandAccent"))
Text("Your subscription renews on March 1")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding()
.background(Color("backgroundBrandSubtle"))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
// Option B: Programmatic adaptive color (useful for dynamic theming)
extension Color {
static let brandAccent = Color(
uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 0.25, green: 0.78, blue: 0.78, alpha: 1)
: UIColor(red: 0.0, green: 0.50, blue: 0.50, alpha: 1)
}
)
}Dark variant creation guidelines:
| Light variant property | Dark variant adjustment |
|---|---|
| Dark foreground text color | Lighten to maintain 4.5:1 on dark backgrounds |
| Light background surface | Darken to sit within iOS dark mode elevation scale |
| Saturated accent | Increase lightness 15-20% to maintain vibrancy on dark surfaces |
| Subtle tint/wash | Reduce opacity or shift to a dark-native desaturated tone |
Verification checklist: 1. Open every custom color set in the asset catalog — if "Dark" row is empty, the color is broken 2. Run the app in both appearances and screenshot every screen side by side 3. Check Increase Contrast accessibility setting — asset catalogs support "High Contrast" variants too 4. Use UITraitCollection.performAsCurrent in unit tests to validate both variants exist
When NOT to apply: Apps that intentionally use a fixed dark or light theme as a core brand identity (e.g., a cinema or astronomy app locked to dark mode), where the content context makes a single appearance the correct choice.
Reference: Dark Mode - Human Interface Guidelines, WWDC19 — Implementing Dark Mode on iOS
Use Materials for Layering, Not Drop Shadows for Depth
You feel it before you can name it — a card with a drop shadow on iOS that looks almost right but not quite. Something about it is heavy, foreign, like a word borrowed from another language that never fully naturalizes. That instinct is correct: it speaks the wrong dialect. iOS communicates depth through blur and translucency; drop shadows belong to Material Design, where elevation is literal and measured in dp. Using .shadow() to separate UI layers on iOS is a visual lie — it says "this is how depth works here" while rendering an aesthetic that fights the lightness Apple's design language is built on. Shadows go invisible in dark mode, bleed color where they shouldn't, and clash with every system component sitting next to them. Reserve .shadow() for elements that genuinely float — draggable items mid-gesture, popovers, FABs — where the shadow tells a physical truth. For layered cards and grouped content, reach for materials: the honest depth cue that blurs the background and works in every appearance without rendering artifacts.
Incorrect (shadow-based elevation for layered cards):
struct FeedCard: View {
let title: String
let summary: String
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.headline)
Text(summary)
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 16))
// Shadow as depth cue — Material Design pattern
.shadow(color: .black.opacity(0.15), radius: 8, y: 4)
}
}
struct FeedView: View {
var body: some View {
ScrollView {
VStack(spacing: 16) {
FeedCard(title: "Getting Started",
summary: "Learn the basics of the platform.")
FeedCard(title: "Advanced Tips",
summary: "Power-user techniques and shortcuts.")
}
.padding()
}
.background(Color(.secondarySystemBackground))
}
}Correct (material-based layering for cards, shadow only for floating actions):
struct FeedCard: View {
let title: String
let summary: String
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.headline)
Text(summary)
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
// Material — iOS depth through blur, not shadow
.background(.regularMaterial,
in: RoundedRectangle(cornerRadius: 16))
}
}
struct FeedView: View {
var body: some View {
ZStack(alignment: .bottomTrailing) {
ScrollView {
VStack(spacing: 16) {
FeedCard(title: "Getting Started",
summary: "Learn the basics of the platform.")
FeedCard(title: "Advanced Tips",
summary: "Power-user techniques and shortcuts.")
}
.padding()
}
// FAB — physically floating, shadow is correct here
Button {
// compose action
} label: {
Image(systemName: "plus")
.font(.title2)
.fontWeight(.semibold)
.foregroundStyle(.white)
.frame(width: 56, height: 56)
.background(.blue, in: Circle())
.shadow(color: .black.opacity(0.25),
radius: 8, y: 4)
}
.padding()
}
}
}Exceptional (the creative leap) — depth as spatial narrative:
struct NowPlayingCard: View {
@State private var isExpanded = false
var body: some View {
ZStack(alignment: .bottom) {
// Background layer — content shifts subtly behind the card,
// creating a sense of depth you can almost reach into
ScrollView {
LazyVStack(spacing: 16) {
ForEach(0..<20) { i in
PlaylistRow(index: i)
}
}
.padding()
}
.scaleEffect(isExpanded ? 0.92 : 1.0)
.blur(radius: isExpanded ? 3 : 0)
.animation(.smooth(duration: 0.4), value: isExpanded)
// Foreground card — material separates the layer honestly,
// shadow matches the system's own depth language
VStack(spacing: 12) {
Capsule()
.frame(width: 36, height: 5)
.foregroundStyle(.tertiary)
HStack(spacing: 16) {
RoundedRectangle(cornerRadius: 8)
.frame(width: 48, height: 48)
.foregroundStyle(.quaternary)
VStack(alignment: .leading, spacing: 2) {
Text("Midnight City")
.font(.headline)
Text("M83")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
Button { } label: {
Image(systemName: "play.fill")
.font(.title2)
}
}
}
.padding()
.background(.thickMaterial,
in: RoundedRectangle(cornerRadius: 24))
.shadow(color: .black.opacity(0.12), radius: 16, y: -4)
.onTapGesture { isExpanded.toggle() }
}
}
}The card does not just sit on top of the content — it creates a spatial moment. When it expands, the playlist behind it recedes gently: a slight scale-down and blur that tells your eye "this is further away now." The thick material lets the background breathe through, and the upward shadow with its soft 16-point blur matches the weight iOS uses for its own sheets. The result is layers you feel you could peel apart with your fingers. Depth becomes a gesture, not a decoration.
When shadows are appropriate on iOS:
- Floating action buttons (FABs) that physically hover over scrollable content
- Draggable items during an active drag gesture (lift-off feedback)
- Popovers and tooltips where the system itself adds shadows
- Navigation bar shadows on scroll (use
.toolbarBackground(.visible)instead of manual shadows)
When NOT to apply: Card-to-background separation (use materials or Color(.secondarySystemBackground)), section grouping (use GroupBox or Section in a List), and any static layer boundary -- if it does not physically move, it should not cast a shadow.
Reference: Materials - Human Interface Guidelines, WWDC21 — What's new in SwiftUI
Use foregroundStyle Over foregroundColor
Good craft shows in the names of things. foregroundColor says "I style your foreground" but only accepts a Color — try handing it a gradient and it refuses. The name overpromises and the type system under-delivers, a small dishonesty that compounds across every view in your codebase. foregroundStyle is the honest tool: it says what it does, and it accepts what it promises. Pass it a Color, a LinearGradient, a Material, a HierarchicalShapeStyle — it handles them all because its contract matches its capability. This is the craft distinction between an API you fight and one that fits your hand. Since iOS 15, foregroundStyle has been the preferred API; in iOS 17+ codebases there is no reason to reach for the deprecated, limited alternative.
Incorrect (foregroundColor limiting style options):
struct NotificationRow: View {
let notification: AppNotification
var body: some View {
HStack(spacing: 12) {
Image(systemName: notification.icon)
.foregroundColor(.blue)
VStack(alignment: .leading, spacing: 2) {
Text(notification.title)
.font(.headline)
.foregroundColor(.primary)
Text(notification.body)
.font(.subheadline)
.foregroundColor(.secondary)
Text(notification.timestamp.formatted(.relative(presentation: .named)))
.font(.caption)
.foregroundColor(Color.gray.opacity(0.8))
}
}
}
}Correct (foregroundStyle with hierarchical rendering):
struct NotificationRow: View {
let notification: AppNotification
var body: some View {
HStack(spacing: 12) {
Image(systemName: notification.icon)
.foregroundStyle(.tint)
VStack(alignment: .leading, spacing: 2) {
Text(notification.title)
.font(.headline)
.foregroundStyle(.primary)
Text(notification.body)
.font(.subheadline)
.foregroundStyle(.secondary)
Text(notification.timestamp.formatted(.relative(presentation: .named)))
.font(.caption)
.foregroundStyle(.tertiary)
}
}
}
}Benefits beyond the type system:
// Gradient text — impossible with foregroundColor
Text("Premium")
.font(.title)
.fontWeight(.bold)
.foregroundStyle(
.linearGradient(
colors: [.purple, .blue],
startPoint: .leading,
endPoint: .trailing
)
)
// Multi-level hierarchical style on a label
Label("Downloads", systemImage: "arrow.down.circle.fill")
.foregroundStyle(.blue, .blue.opacity(0.3))When NOT to apply:
- If your deployment target is below iOS 15,
foregroundColoris the only option. However, for iOS 17+ codebases (which this skill targets), there is no such constraint. - When interfacing with UIKit views through
UIViewRepresentable, you may need to useUIColordirectly rather than either SwiftUI modifier.
Reference: Apple Developer — foregroundStyle(_:))
Show Real Progress, Not Indefinite Spinners
Is it frozen? Did my payment go through? Should I close the app and start over? An indefinite spinner with no label and no progress is a tiny anxiety machine — it withholds the one thing the user needs to stay calm: information. Every second of ambiguity erodes trust. The spinner says "something is happening" but refuses to say what, how long, or whether it is still working at all. If you know the total bytes, show a progress bar. If you know the steps, show which step you are on. If the operation is fast enough that the user will not notice, show nothing — just cut straight to the result. Only reach for an indefinite spinner when you genuinely cannot estimate progress, and even then, be honest about the expected duration: a label like "This may take a minute" transforms a black box into a promise the user can hold you to.
Incorrect (every async operation shows the same indefinite spinner):
struct DownloadView: View {
@State private var isDownloading = false
@State private var isProcessing = false
var body: some View {
VStack(spacing: 24) {
if isDownloading {
// Same spinner for a 50MB download — no progress info
ProgressView()
Text("Downloading...")
.foregroundStyle(.secondary)
}
if isProcessing {
// Same spinner for a 3-step import — no step indication
ProgressView()
Text("Processing...")
.foregroundStyle(.secondary)
}
}
}
}Correct (determinate progress where possible, step indication where applicable):
struct DownloadView: View {
@State private var downloadProgress: Double = 0
@State private var processingStep = 0
private let totalSteps = 3
private let stepLabels = ["Validating", "Importing", "Indexing"]
var body: some View {
VStack(spacing: 24) {
// Determinate: user sees exactly how much remains
VStack(spacing: 8) {
ProgressView(value: downloadProgress)
.progressViewStyle(.linear)
Text("\(Int(downloadProgress * 100))% downloaded")
.font(.subheadline)
.foregroundStyle(.secondary)
.contentTransition(.numericText())
}
// Step-based: user knows where they are in the sequence
VStack(spacing: 8) {
ProgressView(value: Double(processingStep),
total: Double(totalSteps))
.progressViewStyle(.linear)
Text("Step \(processingStep + 1) of \(totalSteps): \(stepLabels[processingStep])")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
.padding()
}
}Exceptional (the creative leap) — progress that builds trust:
struct FileDownloadView: View {
@State private var progress: Double = 0.42
@State private var bytesDownloaded: Int64 = 54_002_688
@State private var totalBytes: Int64 = 128_974_848
@State private var currentFile = "Beach_Sunset_4K.mov"
@State private var estimatedSeconds = 34
@State private var speed = "3.2 MB/s"
var body: some View {
VStack(spacing: 16) {
// File identity — the user always knows WHAT is happening
HStack(spacing: 12) {
Image(systemName: "arrow.down.circle")
.font(.title2)
.foregroundStyle(.blue)
.symbolEffect(.pulse, options: .repeating)
VStack(alignment: .leading, spacing: 2) {
Text(currentFile)
.font(.headline)
Text("\(formattedBytes(bytesDownloaded)) of \(formattedBytes(totalBytes))")
.font(.caption)
.foregroundStyle(.secondary)
.contentTransition(.numericText())
}
Spacer()
}
// Progress — smooth, honest, alive
ProgressView(value: progress)
.progressViewStyle(.linear)
.animation(.smooth, value: progress)
// Context — answers "how long?" before the user asks
HStack {
Text(speed)
Spacer()
Text("About \(estimatedSeconds)s remaining")
}
.font(.caption)
.foregroundStyle(.tertiary)
.contentTransition(.numericText())
}
.padding()
.background(.regularMaterial,
in: RoundedRectangle(cornerRadius: 16))
}
private func formattedBytes(_ bytes: Int64) -> String {
ByteCountFormatter.string(fromByteCount: bytes,
countStyle: .file)
}
}The pulsing symbol says "alive" without spinning in place. The file name says "I know what you asked for." The byte count and speed say "here is the proof." The estimated time says "I respect your schedule." Every piece of information answers a question the user would otherwise have to wonder about, and that absence of wondering is what trust feels like. The user can glance away and come back without losing context — the screen remembers on their behalf.
Honest loading indicators by situation:
| Situation | Honest indicator |
|---|---|
| Known total bytes/items | Determinate ProgressView with percentage |
| Known step count | Step indicator "Step 2 of 4: Importing" |
| Unknown duration, fast (<2s) | No indicator — show result directly |
| Unknown duration, slow (2-10s) | Indefinite spinner with descriptive label |
| Unknown duration, very slow (10s+) | Indefinite spinner + "This may take a minute" |
When NOT to apply: Pull-to-refresh and inline loading indicators in lists where the system provides standard loading patterns. Brief operations under 1 second should show no loading indicator at all — the interface should simply transition from the current state to the result state without drawing attention to the gap.
Use Semantic Colors, Never Hard-Coded Black or White
Color.black in dark mode is a lie: the interface claims to show text but renders invisible content — black glyphs on a near-black background, swallowed whole. It promises readability in every context but delivers blindness at nightfall. The problem is not the color itself but the false certainty it carries: black is not "dark text," it is a literal absence of light that never adapts, never defers, never asks the system what the user actually needs. A semantic color like .primary tells the truth instead — it says "this is important text" and lets SwiftUI translate that role into the right color for every appearance, accessibility setting, and contrast mode. The craft difference is between a value that looks correct on your monitor and a role that remains correct everywhere the interface travels.
Incorrect (hard-coded color values that ignore appearance):
struct SettingsRow: View {
let title: String
let subtitle: String
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.foregroundStyle(Color.black)
Text(subtitle)
.foregroundStyle(Color(red: 0.4, green: 0.4, blue: 0.4))
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(Color(red: 0.8, green: 0.8, blue: 0.8))
}
.padding()
.background(Color.white)
}
}Correct (semantic colors that adapt to every appearance):
struct SettingsRow: View {
let title: String
let subtitle: String
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.foregroundStyle(.primary)
Text(subtitle)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(.tertiary)
}
.padding()
.background(Color(.systemBackground))
}
}Semantic color mapping cheat sheet:
Color.black→.primary(adapts to white in dark mode)Color.white→Color(.systemBackground)(adapts to near-black in dark mode)Color(red:green:blue:)gray →.secondaryor.tertiary(pre-validated for both modes)- Light gray background →
Color(.secondarySystemBackground)(grouped table style) Color(.separator)for dividers instead ofColor.gray.opacity(0.3)
When NOT to apply: Decorative illustrations, brand logos, and photography where the exact color is part of the content itself. Even then, test in both appearances.
Reference: Color - Human Interface Guidelines, UI Element Colors - UIKit
Use contentTransition for Changing Text and Numbers
Watch a score change in Apple Fitness: each digit rolls independently, the way the numbers on an airport departure board click over one column at a time. You register the new value almost subconsciously because the motion tells you what changed and by how much. Now picture the same score snapping instantly from 42 to 43 — no transition, just a different number where the old one was. The jarring swap forces you to re-read the label to confirm what happened. The departure-board trick works because it animates the content itself, not a wrapper around it. No opacity fade, no scale bounce on the entire view — just the digits that actually changed, rolling into place. Rams called this neutrality: the transition should never overshadow the content it serves. The polish lives in the details users barely notice, and .contentTransition(.numericText) is exactly that kind of detail.
Incorrect (opacity crossfade or no animation on number change):
struct ScoreView: View {
@State private var score = 0
var body: some View {
VStack {
// No animation: number snaps instantly, feels static
Text("\(score)")
.font(.largeTitle.bold())
// Opacity hack: entire label fades, digits don't animate individually
Text("$\(score, format: .number)")
.font(.title)
.opacity(score > 0 ? 1 : 0.5)
.animation(.easeInOut, value: score)
Button("Add Point") {
score += 1
}
}
}
}Correct (contentTransition for polished number and text animation):
struct ScoreView: View {
@State private var score = 0
var body: some View {
VStack {
// numericText: each digit rolls independently
Text("\(score)")
.font(.largeTitle.bold())
.contentTransition(.numericText(value: Double(score)))
.animation(.snappy, value: score)
// numericText with formatted currency
Text("$\(score, format: .number)")
.font(.title)
.contentTransition(.numericText(value: Double(score)))
.animation(.snappy, value: score)
Button("Add Point") {
score += 1
}
}
}
}Other content transitions:
// Interpolate: smoothly morph between text styles or content
Text(isMetric ? "km" : "mi")
.contentTransition(.interpolate)
.animation(.smooth, value: isMetric)
// Symbol replacement with matched geometry
Label("Status", systemImage: isActive ? "checkmark.circle" : "circle")
.contentTransition(.symbolEffect(.replace))
.animation(.smooth, value: isActive)When NOT to apply: Rapidly updating values (60fps sensor data, timers updating every millisecond) where .numericText would cause animation queue buildup. For high-frequency updates, use .monospacedDigit() without animation instead.
Reference: WWDC 2023 "Animate with springs" demonstrates contentTransition(.numericText) as the recommended approach for animating dynamic numeric content in SwiftUI.
Prefer Springs Over Linear and EaseInOut for UI Elements
Picture a sidebar that slides in at perfectly constant speed and then stops dead. Something feels wrong — not broken, just off, the way a mannequin's smile is technically a smile but reads as unsettling. That is the uncanny valley of motion: linear movement and abrupt stops belong to machines, not to anything you would touch with your hands. A drawer glides and decelerates. A door swings and settles. Nothing in the physical world moves at constant speed and then halts on a dime, so when the screen does it, you feel the software instead of the content. Replacing easing curves with springs is not a style choice — it is removing a lie. Rams called the principle unobtrusiveness: the mechanism should never be visible. Springs tell the truth about momentum, and the truth is invisible.
See also: invisible-spring-physics for the physics behind spring animations, and invisible-spring-presets for which named preset to use in place of removed easing curves.
Incorrect (linear and easeInOut on interactive UI):
struct NotificationBadge: View {
@State private var count = 0
var body: some View {
ZStack {
Circle()
.fill(.red)
.frame(width: 24, height: 24)
// Linear: constant speed looks unnatural
.scaleEffect(count > 0 ? 1 : 0)
.animation(.linear(duration: 0.3), value: count)
Text("\(count)")
.font(.caption2.bold())
.foregroundStyle(.white)
}
}
}
struct SidebarView: View {
@State private var isSidebarOpen = false
var body: some View {
HStack(spacing: 0) {
if isSidebarOpen {
SidebarContent()
.frame(width: 280)
.transition(.move(edge: .leading))
}
MainContent()
}
// easeInOut: stutters when toggled rapidly
.animation(.easeInOut(duration: 0.5), value: isSidebarOpen)
}
}Correct (spring presets for natural motion):
struct NotificationBadge: View {
@State private var count = 0
var body: some View {
ZStack {
Circle()
.fill(.red)
.frame(width: 24, height: 24)
// .snappy: quick + slight bounce for interactive feedback
.scaleEffect(count > 0 ? 1 : 0)
.animation(.snappy, value: count)
Text("\(count)")
.font(.caption2.bold())
.foregroundStyle(.white)
}
}
}
struct SidebarView: View {
@State private var isSidebarOpen = false
var body: some View {
HStack(spacing: 0) {
if isSidebarOpen {
SidebarContent()
.frame(width: 280)
.transition(.move(edge: .leading))
}
MainContent()
}
// .smooth: calm, no bounce, handles rapid toggles gracefully
.animation(.smooth, value: isSidebarOpen)
}
}When linear or easeInOut IS acceptable: Progress bars, loading indicators, and continuous rotations (e.g., a spinning refresh icon) where constant speed is the correct visual metaphor. EaseInOut is also fine for timed opacity fades on non-interruptible sequences (toast notifications, auto-dismiss banners). These are not interactive UI transitions.
When NOT to apply: Progress bars, loading indicators, and continuous rotations (e.g., a spinning refresh icon) where constant speed is the correct visual metaphor. EaseInOut is also acceptable for timed opacity fades on non-interruptible sequences like toast notifications and auto-dismiss banners.
Reference: WWDC 2023 "Animate with springs" — Apple explicitly recommends replacing all easing-curve animations with springs for UI transitions because springs are the only animation model that maintains velocity continuity across interruptions.
Default to Spring Animations for All UI Transitions
Flick a card and watch it decelerate like a stone skipped across water — your finger lifts, and the card gradually settles as if it has weight. You feel in control because the screen obeys the same physics your hand expects. Now imagine the same flick but the card moves at constant speed and stops dead. The illusion breaks instantly: your hand says one thing, the screen says another, and for a split second the glass between you and the software becomes visible. Spring animations work because they model real inertia — acceleration, momentum, friction — not because they are clever, but because they are honest. Rams called this unobtrusiveness: the best design tool is the one you never notice. When motion respects physics, users never think "that was a nice animation." They simply feel a responsive interface.
See also: invisible-spring-presets for mapping the three named presets to interaction intent, and invisible-no-easing for why linear and easeInOut curves feel robotic.
Incorrect (hardcoded easing curves that break on interruption):
struct CardView: View {
@State private var isExpanded = false
var body: some View {
VStack {
RoundedRectangle(cornerRadius: 16)
.frame(height: isExpanded ? 300 : 120)
// easeInOut stops dead if tapped mid-animation
.animation(.easeInOut(duration: 0.3), value: isExpanded)
.onTapGesture {
isExpanded.toggle()
}
}
}
}Correct (spring animation that handles interruptions gracefully):
struct CardView: View {
@State private var isExpanded = false
var body: some View {
VStack {
RoundedRectangle(cornerRadius: 16)
.frame(height: isExpanded ? 300 : 120)
// .smooth spring: no bounce, natural deceleration
.animation(.smooth, value: isExpanded)
.onTapGesture {
isExpanded.toggle()
}
}
}
}Exceptional (the creative leap) — spring with tuned personality:
struct ExpandableCard: View {
@State private var isExpanded = false
// Two springs, two moods:
// Expand — slightly bouncy, inviting, "come look"
private let expandSpring = Spring(
mass: 1.0, stiffness: 200, damping: 18
)
// Collapse — critically damped, decisive, "done"
private let collapseSpring = Spring(
mass: 1.0, stiffness: 300, damping: 28
)
private var activeSpring: Spring {
isExpanded ? expandSpring : collapseSpring
}
var body: some View {
VStack(spacing: 12) {
HStack {
Text("Trip to Brighton")
.font(.headline)
Spacer()
Image(systemName: "chevron.down")
.rotationEffect(.degrees(isExpanded ? 180 : 0))
.animation(.spring(activeSpring), value: isExpanded)
}
if isExpanded {
VStack(alignment: .leading, spacing: 8) {
Text("Mar 14 – Mar 21")
.font(.subheadline)
.foregroundStyle(.secondary)
Text("Victorian terrace, five minutes from the sea. Two cats and a very lazy spaniel.")
.font(.body)
}
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
.padding()
.background(.regularMaterial,
in: RoundedRectangle(cornerRadius: 16))
.onTapGesture {
withAnimation(.spring(activeSpring)) {
isExpanded.toggle()
}
}
}
}The two springs give the card a personality you feel but never consciously notice. The expand spring has low damping — it overshoots just slightly, like a door swinging open a touch further than it needs to, as if to say "welcome in." The collapse spring is stiffer and critically damped — it closes with quiet authority, no hesitation, no bounce. That asymmetry is what separates motion that has character from motion that merely moves. The card feels alive because it has opinions about how it wants to open and close.
Benefits:
- Rapid taps no longer cause visual stuttering; each tap smoothly redirects motion
- Gesture-driven animations (drag-to-dismiss, swipe) preserve finger velocity on release
withAnimation {}with no arguments already uses springs on iOS 26 / Swift 6.2, so removing explicit easing is often the entire fix
When NOT to apply: Progress bars, indeterminate loading spinners, and continuous looping animations where constant-speed linear motion is the correct visual metaphor because no user gesture initiates or interrupts the movement.
Reference: WWDC 2023 "Animate with springs" — Apple recommends springs as the universal default because they model real-world physics and handle interruption without discontinuity.
Use .smooth for Routine, .snappy for Interactive, .bouncy for Delight
Open Apple Fitness after closing a ring and the checkmark bounces with a little flourish — it is celebrating with you. Now open Apple Health and tap between tabs: the transition is calm, almost invisible, because Health is a reference tool, not a coach. Both apps ship from the same company, yet their motion feels different because each draws from a small palette of exactly three springs. .smooth for routine movement, .snappy for things you tap, .bouncy for moments worth celebrating. Three presets are enough to build an entire motion language — one that becomes invisible through consistency the way a typeface disappears when every paragraph uses the same family. Rams called it unobtrusiveness: when every transition picks from the same short vocabulary, no single animation calls attention to itself, and the whole app feels crafted at once.
See also: invisible-spring-physics for why springs model real inertia, and invisible-no-easing for why linear and easeInOut curves should be replaced by these presets.
Incorrect (bouncy spring applied to every transition):
struct SettingsView: View {
@State private var showDetail = false
@State private var notificationsOn = false
@State private var showSuccess = false
var body: some View {
VStack {
// Bouncy on a sheet presentation feels juvenile
Button("Account Details") { showDetail = true }
.sheet(isPresented: $showDetail) {
AccountDetailView()
.transition(.move(edge: .bottom))
.animation(.bouncy, value: showDetail)
}
// Bouncy on a toggle feels sluggish
Toggle("Notifications", isOn: $notificationsOn)
.animation(.bouncy, value: notificationsOn)
// Custom Spring parameters that don't match any system preset
if showSuccess {
Label("Saved", systemImage: "checkmark.circle.fill")
.transition(.scale)
.animation(
.spring(response: 0.4, dampingFraction: 0.6),
value: showSuccess
)
}
}
}
}Correct (preset matched to interaction intent):
struct SettingsView: View {
@State private var showDetail = false
@State private var notificationsOn = false
@State private var showSuccess = false
var body: some View {
VStack {
// .smooth — routine navigation, no bounce needed
Button("Account Details") { showDetail = true }
.sheet(isPresented: $showDetail) {
AccountDetailView()
.transition(.move(edge: .bottom))
.animation(.smooth, value: showDetail)
}
// .snappy — interactive control, fast + slight bounce for responsiveness
Toggle("Notifications", isOn: $notificationsOn)
.animation(.snappy, value: notificationsOn)
// .bouncy — celebration/delight moment
if showSuccess {
Label("Saved", systemImage: "checkmark.circle.fill")
.transition(.scale)
.animation(.bouncy, value: showSuccess)
}
}
}
}When to use each preset:
| Preset | Character | Use for |
|---|---|---|
.smooth | No bounce, calm | Tab switches, sheet presentations, layout changes, most UI |
.snappy | Small bounce, responsive | Toggles, buttons, drag-and-drop, interactive controls |
.bouncy | Larger bounce, playful | Success confirmations, celebrations, onboarding highlights |
When NOT to apply: Branded hero animations or game-like interactions where custom spring parameters (mass, stiffness, damping) are intentionally tuned to create a specific motion personality that the three presets cannot express.
Reference: WWDC 2023 "Animate with springs" — Apple introduced these three presets specifically to replace ad-hoc Spring() parameters with a shared motion vocabulary.
Use System Materials, Not Custom Semi-Transparent Backgrounds
There is a difference between a hand-tuned Color.black.opacity(0.3) overlay and .ultraThinMaterial that you can feel before you can explain. The hand-tuned value is a developer's best guess — it looks passable in light mode on one wallpaper, then falls apart in dark mode, or washes out over a bright photo, or ignores Increase Contrast entirely. It whispers "look at my clever blur." A system material says nothing at all. It simply lets content through at exactly the right density, adapts to every appearance setting automatically, and gets out of the way so you can focus on what is behind the glass. The craft is in choosing the material that disappears. Rams called this neutrality: a good tool leaves room for the user's world, never competing with it.
Incorrect (hand-tuned opacity that ignores appearance and vibrancy):
struct WeatherCard: View {
let temperature: String
let condition: String
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(temperature)
.font(.system(size: 48, weight: .thin))
Text(condition)
.font(.subheadline)
.foregroundStyle(.white.opacity(0.7))
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
// Flat tint — no blur, no vibrancy, breaks in dark mode
.background(Color.black.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: 16))
}
}Correct (system material with automatic adaptation):
struct WeatherCard: View {
let temperature: String
let condition: String
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(temperature)
.font(.system(.largeTitle, design: .rounded, weight: .thin))
.foregroundStyle(.primary)
Text(condition)
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(.ultraThinMaterial,
in: RoundedRectangle(cornerRadius: 16))
}
}Material selection guide:
.ultraThinMaterial— high-contrast backgrounds (vivid photos, gradients, video). Maximum background visibility..thinMaterial— moderately busy backgrounds. Good default for overlaid cards..regularMaterial— general-purpose. Equivalent to the system navigation bar blur..thickMaterial/.ultraThickMaterial— low-contrast or text-heavy backgrounds where readability is critical..bar— matches the exact treatment of system toolbars and tab bars.
When NOT to apply: Solid-color backgrounds where no content sits behind the layer. Materials over a plain white background waste GPU compositing for zero visual benefit -- use Color(.secondarySystemBackground) instead.
Reference: Materials - Human Interface Guidelines, WWDC21 — What's new in SwiftUI (material modifiers)
Related skills
FAQ
What does ios-ui-refactor do?
ios-ui-refactor: A skill for development. This provides functionality for development workflows.
When should I use ios-ui-refactor?
When you need to use ios-ui-refactor for development tasks, or when ios-ui-refactor: a skill for development. this provides functionality for development workflows.
What are the main capabilities?
ios-ui-refactor.