
Ios Design
- 320 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
ios-design is a Claude Code mobile skill that guides developers through Apple Human Interface Guidelines, SwiftUI and UIKit layout patterns, and native iOS visual design decisions for shipping polished iPhone and iPad ap
About
ios-design is a mobile design skill from pproenca/dot-skills for iOS application development tasks requiring Apple platform visual and interaction standards. It helps developers apply Human Interface Guidelines, choose appropriate SwiftUI or UIKit components, and resolve spacing, typography, navigation, and accessibility patterns common in native iOS apps. Teams reach for ios-design when implementing screens, refining tap targets, adapting layouts across iPhone and iPad sizes, or aligning feature UI with Apple's native look and feel during active mobile builds.
- ios-design
Ios Design by the numbers
- 320 all-time installs (skills.sh)
- +12 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,280 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-designAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 320 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do you follow Apple HIG in SwiftUI layouts?
Use ios-design for development tasks
Who is it for?
iOS developers implementing SwiftUI or UIKit screens who need Human Interface Guidelines compliance during active feature work.
Skip if: Android or cross-platform Flutter teams that do not target native Apple design conventions.
When should I use this skill?
User builds iOS UI, asks about HIG, SwiftUI layout, UIKit patterns, or native iPhone and iPad design decisions.
What you get
HIG-aligned screen specs, component selections, spacing and typography guidance, and accessibility notes
- screen-layout-spec
- component-recommendations
- accessibility-notes
Files
Apple SwiftUI iOS Design Best Practices
A builder's guide for implementing Apple-quality iOS interfaces in SwiftUI, grounded in two foundational design texts:
- Ken Kocienda — Creative Selection (empathy for the user, craft in coding, taste in choosing the best solution, demo culture of iterative refinement)
- John Edson — Design Like Apple (systems thinking, the product is the marketing, design out loud, design with conviction)
Contains 62 rules across 8 principle-based categories. 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.
Scope & Relationship to Sibling Skills
This skill is the building and implementation guide — it teaches how to construct new SwiftUI interfaces from scratch using Apple-quality patterns. When loaded alongside ios-ui-refactor (reviewing/refactoring existing UI), this skill covers the greenfield implementation that ios-ui-refactor later audits. Use this skill for building new screens; use the sibling for evaluating and improving existing ones.
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:
- Building new SwiftUI views and screens from scratch
- Choosing between semantic colors, system typography, and spacing grids (Edson's Systems Thinking)
- Managing state with @State, @Binding, @Observable, @Environment (Kocienda's Craft)
- Selecting the right component: List vs LazyVStack, Sheet vs FullScreenCover (Kocienda's Taste)
- Composing views with @ViewBuilder, custom modifiers, and value types (Kocienda's Creative Selection)
- Implementing navigation with NavigationStack, TabView, sheets (Edson's Conversation)
- Laying out content with stacks, grids, frames, and adaptive layouts (Edson's Design Out Loud)
- Ensuring VoiceOver, touch targets, Dark Mode, and reduce motion support (Kocienda's Empathy)
- Adding transitions, loading states, and animation polish (Edson's Product Is the Marketing)
Rule Categories by Priority
| Priority | Category | Principle | Impact | Prefix | Rules |
|---|---|---|---|---|---|
| 1 | Empathy in Every Pixel | Kocienda "Empathy" · Edson "Design Is About People" | CRITICAL | empathy- | 8 |
| 2 | The Visual System | Edson "Systems Thinking" · Kocienda "Convergence" | CRITICAL | system- | 8 |
| 3 | Craft: State as Foundation | Kocienda "Craft" | CRITICAL | craft- | 7 |
| 4 | Creative Composition | Kocienda "Creative Selection" | HIGH | compose- | 6 |
| 5 | Taste: The Right Choice | Kocienda "Taste" · Edson "Design with Conviction" | HIGH | taste- | 8 |
| 6 | Navigation as Conversation | Edson "Design Is a Conversation" · Kocienda "The Demo" | HIGH | converse- | 9 |
| 7 | Design Out Loud: Layout | Edson "Design Out Loud" · Kocienda "Intersection" | HIGH | layout- | 8 |
| 8 | The Product Speaks | Edson "Product Is the Marketing" · Kocienda "Demo Culture" | MEDIUM | product- | 8 |
Quick Reference
1. Empathy in Every Pixel (CRITICAL)
Kocienda: "Empathy — trying to see the world from other people's perspectives." Edson: design begins with the person holding the device.
- `empathy-semantic-colors` - Use semantic colors, never hard-coded values
- `empathy-dark-mode` - Support Dark Mode from day one
- `empathy-foreground-style` - Use foregroundStyle over foregroundColor
- `empathy-safe-areas` - Always respect safe areas for content
- `empathy-voiceover-labels` - Add VoiceOver labels to every interactive element
- `empathy-touch-targets` - Ensure 44x44 point minimum touch targets
- `empathy-reduce-motion` - Always provide reduce motion fallback
- `empathy-readable-width` - Constrain text to readable width on iPad
2. The Visual System (CRITICAL)
Edson: "Zoom out to see relationships between objects." Kocienda: convergence — many decisions narrowing toward one coherent whole.
- `system-typography` - Use system typography styles, never fixed sizes
- `system-visual-hierarchy` - Establish clear visual hierarchy through size, weight, and color
- `system-spacing-grid` - Use a 4pt base unit for all spacing
- `system-material-backgrounds` - Use material backgrounds for depth and layering
- `system-sf-symbols` - Use SF Symbols for consistent iconography
- `system-gradients` - Apply gradients for visual depth, not decoration
- `system-standard-margins` - Use system standard margins consistently
- `system-stack-config` - Configure stack alignment and spacing explicitly
3. Craft: State as Foundation (CRITICAL)
Kocienda: "Craft — applying skill to achieve a high-quality result."
- `craft-state-local` - Use @State for view-local value types
- `craft-state-binding` - Use @Binding for child view mutations
- `craft-state-environment` - Use @Environment for shared app-wide data
- `craft-state-observable` - Use @Observable for model classes
- `craft-avoid-body-state` - Never create state inside the view body
- `craft-minimize-scope` - Minimize state scope to reduce re-renders
- `craft-state-bindable` - Use @Bindable for @Observable bindings
4. Creative Composition (HIGH)
Kocienda: "Creative selection — great software is built through composition and recombination."
- `compose-body-some-view` - Return some View from body, never concrete types
- `compose-custom-properties` - Use properties to make views configurable
- `compose-modifier-order` - Apply view modifiers in the correct order
- `compose-viewbuilder` - Use @ViewBuilder for flexible slot-based composition
- `compose-prefer-value-types` - Prefer value types for view data
- `compose-prefer-composition` - Prefer composition over inheritance for view reuse
5. Taste: The Right Choice (HIGH)
Kocienda: "Taste — refined judgment, the ability to choose the one right solution." Edson: commit to one approach and perfect it.
- `taste-list-vs-lazyvstack` - Choose List for system features, LazyVStack for custom layouts
- `taste-sheet-vs-fullscreen` - Choose sheet for tasks, fullScreenCover for immersion
- `taste-picker` - Choose the right picker style for the data type
- `taste-grid-vs-lazygrid` - Choose Grid for aligned data, LazyVGrid for scrollable collections
- `taste-button` - Use button styles that match the action's importance
- `taste-textfield` - Configure text input with the right keyboard and content type
- `taste-alerts` - Use alerts only for critical, blocking information
- `taste-action-sheets` - Use confirmation dialogs for contextual multi-choice actions
6. Navigation as Conversation (HIGH)
Edson: "Design is a conversation between the product and the person." Kocienda: demos as conversations about whether the interface speaks clearly.
- `converse-navigationstack` - Use NavigationStack for programmatic, type-safe navigation
- `converse-tabview` - Organize app sections with TabView for parallel navigation
- `converse-sheet-item` - Use item binding for data-driven sheet presentation
- `converse-dismiss` - Use environment dismiss for modal closure
- `converse-toolbar` - Place toolbar items in the correct semantic positions
- `converse-tab-bar` - Use tab bar for top-level section navigation
- `converse-nav-bar` - Configure navigation bar to communicate context
- `converse-hierarchy` - Design clear navigation hierarchy before writing code
- `converse-search` - Integrate search with the searchable modifier
7. Design Out Loud: Layout (HIGH)
Edson: "Design Out Loud — prototype relentlessly until layout feels inevitable." Kocienda: the intersection of technology and liberal arts.
- `layout-stacks` - Use stacks instead of manual positioning
- `layout-spacer` - Use Spacer for flexible space distribution
- `layout-frame-sizing` - Use frame() for explicit size constraints
- `layout-zstack` - Use ZStack for purposeful layered composition
- `layout-grid` - Use Grid for aligned non-scrolling tabular content
- `layout-lazy-grids` - Use LazyVGrid for scrollable multi-column layouts
- `layout-adaptive` - Use adaptive layouts for different size classes
- `layout-scroll-indicators` - Show scroll indicators for long scrollable content
8. The Product Speaks (MEDIUM)
Edson: "The product itself is the marketing." Kocienda: every animation and loading state built to survive Steve Jobs' scrutiny.
- `product-transitions` - Use semantic transitions for appearing views
- `product-loading-states` - Show honest loading states, not indefinite spinners
- `product-with-animation` - Use withAnimation for explicit state-driven animation
- `product-matched-geometry` - Use matchedGeometryEffect for contextual origin transitions
- `product-list-cells` - Design list cells with standard layouts
- `product-content-unavailable` - Use ContentUnavailableView for empty and error states
- `product-segmented` - Use segmented controls for visible, mutually exclusive options
- `product-menus` - Use menus for secondary actions without cluttering the interface
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
Brief explanation of WHY this matters and the performance/quality implications. Keep to 1-3 sentences.
Incorrect (description of the problem/cost):
// Production-realistic bad code example
// Include comment explaining the consequence
struct ExampleView: View {
var body: some View {
Text("Example")
}
}Correct (description of the benefit/solution):
// Production-realistic good code example
// Minimal diff from incorrect version
struct ExampleView: View {
var body: some View {
Text("Example")
}
}Alternative (when to use this approach):
// Optional: alternative approach for different contextsWhen NOT to use this pattern:
- Exception case 1
- Exception case 2
Reference: Documentation Title
{
"version": "1.0.8",
"organization": "Apple HIG + Kocienda · Edson",
"technology": "SwiftUI iOS 26 / Swift 6.2",
"date": "February 2026",
"abstract": "SwiftUI implementation patterns for building Apple-quality iOS interfaces, grounded in two foundational design texts: Ken Kocienda's Creative Selection (empathy for the user, craft in coding, taste in choosing the best solution, and the demo culture of iterative refinement) and John Edson's Design Like Apple (systems thinking, the product is the marketing, design out loud, and design with conviction). Contains 62 rules across 8 principle-based categories: Empathy in Every Pixel (CRITICAL), The Visual System (CRITICAL), Craft: State as Foundation (CRITICAL), Creative Composition (HIGH), Taste: The Right Choice (HIGH), Navigation as Conversation (HIGH), Design Out Loud: Layout (HIGH), and The Product Speaks (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": [
"Creative Selection: Inside Apple's Design Process During the Golden Age of Steve Jobs — Ken Kocienda (St. Martin's Press, 2018)",
"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 two foundational design texts:
- Ken Kocienda — Creative Selection: Inside Apple's Design Process During the Golden Age of Steve Jobs
- John Edson — Design Like Apple: Seven Principles For Creating Insanely Great Products
---
1. Empathy in Every Pixel (empathy)
Impact: CRITICAL Principle: Kocienda "Empathy" · Edson "Design Is About People" Description: Kocienda's first lesson from building the iPhone keyboard is that great software starts with empathy — trying to see the world from other people's perspectives and creating work that fits into their lives. Edson's foundational principle demands that design begin with genuine understanding of the person holding the device. In SwiftUI, empathy means semantic colors that never lie about appearance mode, safe areas that protect content on every device, VoiceOver labels that make every interaction accessible, touch targets that respect human fingers, reduce motion alternatives that prevent vestibular harm, readable widths that serve aging eyes, and foreground styles that honestly represent content hierarchy. If your app causes harm or exclusion for even one user, you built it for yourself, not for them.
2. The Visual System (system)
Impact: CRITICAL Principle: Edson "Design Is Systems Thinking" · Kocienda "Convergence" Description: Edson teaches designers to zoom out and see relationships between objects — understanding how a product's context creates a compelling, coherent system. Kocienda describes "convergence" — the process by which many individual decisions narrow toward one coherent whole, like the keyboard team trying dozens of key sizes before finding the one that worked. In SwiftUI, systems thinking means a unified typography scale that communicates hierarchy without explanation, a spacing grid derived from a base unit so every measurement relates to every other, material backgrounds that create honest depth, SF Symbols that guarantee iconographic consistency, and gradients that add dimension without dishonesty. When every visual element participates in one system, every screen feels like it was designed by one mind.
3. Craft: State as Foundation (craft)
Impact: CRITICAL Principle: Kocienda "Craft" Description: Kocienda defines craft as applying skill to achieve a high-quality result — the difference between code that works and code that sings. At Apple, the engineers who built the iPhone keyboard didn't just make keys that typed letters; they crafted an autocorrect system that anticipated mistakes before they happened. In SwiftUI, craft means choosing the right state wrapper for each situation — @State for view-local values, @Binding for parent-child communication, @Observable for shared models, @Environment for app-wide concerns. Wrong state management is invisible to the user until it isn't: cascading re-renders that stutter, state that resets when it shouldn't, bindings that silently overwrite on every parent re-render. Craftsmanship in state is the foundation every visible layer stands on.
4. Creative Composition (compose)
Impact: HIGH Principle: Kocienda "Creative Selection" Description: Kocienda's title concept — creative selection — describes how great software is built through composition and recombination of small, well-crafted pieces. The iPhone keyboard wasn't designed as a monolith; it was composed from individual key views, a suggestion bar, an autocorrect engine, and a touch model, each refined independently and then assembled. In SwiftUI, creative composition means views that return some View from a clear body property, configurable through properties rather than subclassing, modifiers applied in the correct order so each layer builds on the last, @ViewBuilder for flexible slot-based APIs, value types that prevent shared-state bugs, and composition over inheritance so pieces can be freely recombined. The best views are small enough to understand in a glance and composable enough to build anything.
5. Taste: The Right Choice (taste)
Impact: HIGH Principle: Kocienda "Taste" · Edson "Design with Conviction" Description: Kocienda writes that taste is the ability to discern quality — a refined sense of judgment that develops through experience and exposure to excellent work. When the iPhone team had to choose between a hardware keyboard and a software keyboard, taste was what let them see that the software keyboard, despite being harder, was the right answer. Edson's "Design with Conviction" demands committing to one approach and perfecting it rather than hedging with half-measures. In SwiftUI, taste means choosing List when you need swipe actions and selection, LazyVStack when you need custom layouts, sheets for tasks and fullScreenCover for immersive experiences, the right picker style for the data type, button styles that match the action's importance, and alerts only when the situation truly demands interruption. Every component choice is a taste decision — twenty valid options, one right answer.
6. Navigation as Conversation (converse)
Impact: HIGH Principle: Edson "Design Is a Conversation" · Kocienda "The Demo" Description: Edson's third principle frames design as an ongoing conversation between the product and the person using it. Every navigation action is a sentence in that dialogue: a tap says "tell me more," a swipe-back says "never mind," a sheet says "let me do this one thing." Kocienda's demo culture reinforced this — every Friday demo was a conversation between the engineer and Steve Jobs about whether the interface spoke clearly. In SwiftUI, conversation means NavigationStack for drill-down exploration, TabView for parallel topic switching, sheets for self-contained tasks, environment dismiss for graceful exit, toolbars placed where the hand naturally rests, and search integrated where the user expects to find it. When navigation matches the user's mental model, they never ask "where am I?" — the conversation flows naturally.
7. Design Out Loud: Layout (layout)
Impact: HIGH Principle: Edson "Design Out Loud" · Kocienda "Intersection of Technology and Liberal Arts" Description: Edson's fifth principle — Design Out Loud — demands the courage to prototype relentlessly, laying out views and rearranging them until the spatial relationships feel inevitable. Kocienda describes the "intersection of technology and liberal arts" that Steve Jobs championed — layout is exactly that intersection, where mathematical constraints meet visual rhythm and human perception. In SwiftUI, designing out loud means building with stacks that flow naturally, spacers that distribute breathing room, frames that set explicit boundaries, ZStacks that layer content with purpose, grids that align data honestly, lazy grids that scale to any collection size, adaptive layouts that reshape for every screen class, and scroll indicators that tell the user there's more to discover. Layout is the grammar of visual communication — get it wrong and nothing else matters.
8. The Product Speaks (product)
Impact: MEDIUM Principle: Edson "The Product Is the Marketing" · Kocienda "Demo Culture" Description: Edson's final principle states that at Apple, the product itself is the marketing — every pixel, every transition, every empty state communicates the quality of the team that built it. Kocienda's demo culture meant that every animation, every loading state, every list cell was built to survive Steve Jobs' scrutiny in a live demo. In SwiftUI, the product speaks through semantic transitions that give spatial context to appearing views, loading states that show honest progress instead of indefinite spinners, matched geometry effects that maintain object permanence, list cells designed with standard layouts that feel native, empty states that guide rather than abandon, segmented controls that make options scannable, and menus that organize secondary actions without cluttering the primary interface. These are not polish — they are the product.
Return some View from Body, Never Concrete Types
Kocienda's creative selection means building pieces that can be freely recombined. When a view's body returns some View, the concrete type is hidden behind an opaque wrapper — you can change from VStack to HStack to ZStack without breaking any dependent code. This is the compositional freedom that makes iterative refinement possible. Concrete return types lock the implementation, turning every layout experiment into a refactoring exercise.
Incorrect (concrete return type locks implementation):
struct ProfileSection: View {
// Returning concrete type exposes implementation detail
var body: VStack<TupleView<(Text, Text)>> {
VStack {
Text("John Appleseed")
Text("iOS Engineer")
}
}
}Correct (opaque return type enables free iteration):
struct ProfileSection: View {
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text("John Appleseed")
.font(.headline)
Text("iOS Engineer")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}This also applies to extracted helper methods:
struct EventDetailView: View {
let event: Event
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
headerSection
descriptionSection
locationSection
}
.padding()
}
}
// Computed properties also use some View
private var headerSection: some View {
VStack(alignment: .leading, spacing: 4) {
Text(event.title)
.font(.title.bold())
Text(event.date.formatted(date: .long, time: .shortened))
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
private var descriptionSection: some View {
Text(event.description)
.font(.body)
}
private var locationSection: some View {
Label(event.location, systemImage: "mappin.circle.fill")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}When concrete types are acceptable: Generic container views that accept Content: View as a generic parameter (like DashboardCard<Content: View>) expose the generic type intentionally to enable type-safe composition.
Reference: View fundamentals - Apple Documentation
Use Properties to Make Views Configurable
Kocienda's creative selection means building small pieces that can be recombined into different wholes. A view with hardcoded text, colors, and sizes can only serve one context. A view with properties for content, style, and behavior can be recombined into dozens of contexts. This is the difference between a one-off implementation and a reusable component — the same effort, vastly more value.
Incorrect (hardcoded values prevent reuse):
struct StatusBadge: View {
var body: some View {
Text("Active")
.font(.caption.bold())
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.green, in: Capsule())
}
}
// Only works for "Active" in green — need a new view for every statusCorrect (configurable properties enable reuse):
struct StatusBadge: View {
let title: String
let color: Color
var body: some View {
Text(title)
.font(.caption.bold())
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(color, in: Capsule())
}
}
// One view, many contexts
StatusBadge(title: "Active", color: .green)
StatusBadge(title: "Pending", color: .orange)
StatusBadge(title: "Expired", color: .red)
StatusBadge(title: "Draft", color: .secondary)Progressive configuration with defaults:
struct MetricCard: View {
let title: String
let value: String
var icon: String = "chart.bar.fill"
var tint: Color = .accentColor
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Label(title, systemImage: icon)
.font(.subheadline)
.foregroundStyle(tint)
Text(value)
.font(.title.bold())
}
.padding()
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
}
}
// Minimal configuration
MetricCard(title: "Revenue", value: "$12,430")
// Full configuration
MetricCard(title: "Orders", value: "84", icon: "bag.fill", tint: .purple)When NOT to add properties: Don't pre-emptively make everything configurable. Start with hardcoded values and extract properties only when you need the same view in a second context. Premature configuration adds complexity without value.
Reference: View fundamentals - Apple Documentation
Apply View Modifiers in the Correct Order
Kocienda's creative selection requires understanding how pieces compose. SwiftUI modifiers wrap the view in order — each modifier creates a new view that wraps the previous one. .padding().background(.blue) adds padding first, then paints the background (including the padded area). .background(.blue).padding() paints the background first, then adds transparent padding outside it. The visual difference is dramatic, and the only way to get it right is to understand the composition order.
Incorrect (modifier order creates unintended visual results):
struct TagLabel: View {
let text: String
var body: some View {
Text(text)
.background(.blue) // Background hugs text tightly
.padding(.horizontal, 12) // Transparent padding outside
.foregroundStyle(.white) // Works but order is confusing
.clipShape(Capsule()) // Clips the padded area
}
}Correct (modifiers applied in logical layer order):
struct TagLabel: View {
let text: String
var body: some View {
Text(text)
.font(.caption.bold())
.foregroundStyle(.white) // 1. Style the content
.padding(.horizontal, 12) // 2. Add internal spacing
.padding(.vertical, 6)
.background(.blue) // 3. Background covers padded area
.clipShape(Capsule()) // 4. Clip the final shape
}
}Modifier order mental model:
Text("Hello")
// Layer 1: Content styling (font, foreground, lineLimit)
.font(.headline)
.foregroundStyle(.primary)
// Layer 2: Internal spacing (padding)
.padding()
// Layer 3: Visual decoration (background, border, shadow)
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
// Layer 4: External spacing and positioning (padding again, frame)
.padding(.horizontal, 16)
// Layer 5: Interaction (onTapGesture, gesture)
.onTapGesture { }Common order mistakes:
.frame()before.padding()— frame constrains the content, padding adds to it.shadow()after.clipShape()— shadow follows the clipped shape (usually correct).overlay()after.clipShape()— overlay gets clipped (usually wrong)
When order doesn't matter: Modifiers that don't affect layout (.accessibilityLabel, .id, .tag) can go anywhere. But for consistency, group them at the end.
Reference: Configuring views - Apple Documentation
Prefer Composition Over Inheritance for View Reuse
Kocienda's title concept — creative selection — literally describes the process of composing great software from well-crafted pieces. SwiftUI's View protocol uses structs, which cannot inherit from each other. This is by design: Apple chose composition over inheritance because composition scales. Instead of a BaseCard that UserCard and ProductCard inherit from (and then fight to override), you compose from small building blocks: a card container, a header view, a detail row. Each piece can be freely recombined without the constraints of a class hierarchy.
Incorrect (attempting inheritance-style reuse):
// Trying to create a "base" view with shared behavior
struct BaseListRow: View {
let title: String
let subtitle: String
var body: some View {
HStack {
VStack(alignment: .leading) {
Text(title).font(.headline)
Text(subtitle).font(.subheadline).foregroundStyle(.secondary)
}
Spacer()
}
.padding()
}
}
// No way for UserRow or OrderRow to extend this without copying itCorrect (composition from small, focused pieces):
// Small composable pieces
struct ItemRow<Leading: View, Trailing: View>: View {
let title: String
let subtitle: String
@ViewBuilder let leading: Leading
@ViewBuilder let trailing: Trailing
var body: some View {
HStack(spacing: 12) {
leading
VStack(alignment: .leading, spacing: 2) {
Text(title).font(.headline)
Text(subtitle).font(.subheadline).foregroundStyle(.secondary)
}
Spacer()
trailing
}
}
}
// Composed into specific contexts
struct UserRow: View {
let user: User
var body: some View {
ItemRow(title: user.name, subtitle: user.role) {
Avatar(url: user.avatarURL, size: 40)
} trailing: {
StatusBadge(title: user.status, color: user.statusColor)
}
}
}
struct OrderRow: View {
let order: Order
var body: some View {
ItemRow(title: order.number, subtitle: order.date.formatted()) {
Image(systemName: "bag.fill")
.foregroundStyle(.tint)
} trailing: {
Text(order.total, format: .currency(code: "USD"))
.font(.subheadline.bold())
}
}
}Composition patterns:
- Container + Content:
Card { ... },Section { ... } - Slot-based:
ItemRow(leading:trailing:) - ViewModifier:
.cardStyle()for shared visual treatment - Extension methods:
.primaryButton()for repeated modifier chains
When NOT to prefer composition: When the shared behavior is purely visual (same padding, background, corner radius), a custom ViewModifier is simpler than a container view.
Reference: View fundamentals - Apple Documentation
Prefer Value Types for View Data
Kocienda's creative selection demands pieces that can be composed without hidden side effects. Structs in Swift are value types — when you pass one to a child view, the child gets its own copy. Reference types (classes) share a single instance, meaning mutations in one view silently affect every other view holding a reference. This invisible coupling is the enemy of composition: you can't freely recombine pieces when changing one changes all the others.
Incorrect (class shared by reference — silent mutation coupling):
class RecipeData {
var title: String
var servings: Int
var isFavorite: Bool
}
struct RecipeCard: View {
let recipe: RecipeData
var body: some View {
// Mutating recipe.isFavorite here affects every other view
// holding a reference to the same instance
}
}Correct (struct copied by value — independent instances):
struct Recipe: Identifiable {
let id: UUID
var title: String
var servings: Int
var isFavorite: Bool
}
struct RecipeCard: View {
let recipe: Recipe // Independent copy
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(recipe.title)
.font(.headline)
HStack {
Label("\\(recipe.servings) servings", systemImage: "person.2")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Image(systemName: recipe.isFavorite ? "heart.fill" : "heart")
.foregroundStyle(recipe.isFavorite ? .red : .secondary)
}
}
}
}Value type hierarchy:
- Model data (Recipe, User, Order) →
struct - State management that needs identity →
@Observable class(explicit choice) - View configuration (style, layout params) →
structorenum - Pure data transfer →
struct
When to use reference types: When you need shared mutable state across many views (the app's data model), use @Observable class. This is an explicit architectural choice — the sharing is intentional, not accidental.
Use @ViewBuilder for Flexible Slot-Based Composition
Kocienda's creative selection is about composition — combining small pieces into larger wholes. @ViewBuilder is SwiftUI's mechanism for this: it lets a container view accept multiple child views using the same natural syntax as SwiftUI's built-in containers (VStack, List, Form). Without it, your custom containers feel foreign; with it, they feel like first-class citizens of the framework.
Incorrect (closure-based API that only accepts one view):
struct Card: View {
let content: AnyView // Type-erased, loses performance
var body: some View {
content
.padding()
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
}
}
// Awkward to use
Card(content: AnyView(
VStack {
Text("Title")
Text("Subtitle")
}
))Correct (@ViewBuilder enables natural composition):
struct Card<Content: View>: View {
@ViewBuilder let content: Content
var body: some View {
content
.padding()
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
}
}
// Natural SwiftUI syntax
Card {
Text("Today's Summary")
.font(.headline)
Text("$12,430 revenue across 84 orders")
.font(.subheadline)
.foregroundStyle(.secondary)
}Multi-slot containers:
struct DetailRow<Leading: View, Trailing: View>: View {
@ViewBuilder let leading: Leading
@ViewBuilder let trailing: Trailing
var body: some View {
HStack {
leading
Spacer()
trailing
.foregroundStyle(.secondary)
}
}
}
// Multiple content slots
DetailRow {
Label("Storage", systemImage: "externaldrive.fill")
} trailing: {
Text("128 GB")
}When NOT to use @ViewBuilder: When a view needs exactly one child of a specific type (e.g., NavigationLink needs a destination), use a typed parameter instead. @ViewBuilder is for containers that accept flexible content.
Reference: ViewBuilder - Apple Documentation
Use Environment Dismiss for Modal Closure
Edson's conversation principle means any modal view should be able to end the conversation gracefully. @Environment(\.dismiss) provides a universal dismissal mechanism that works whether the view was presented as a sheet, fullScreenCover, or pushed onto a NavigationStack. Kocienda's demo culture required that every flow could be exited cleanly — environment dismiss makes this automatic.
Incorrect (passing dismiss closure through initializers):
struct EditProfileView: View {
let onDismiss: () -> Void // Tight coupling to parent
var body: some View {
Form {
// fields...
}
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { onDismiss() }
}
}
}
}
// Parent must wire up dismissal
.sheet(isPresented: $showEdit) {
EditProfileView(onDismiss: { showEdit = false })
}Correct (environment dismiss works universally):
struct EditProfileView: View {
@Environment(\.dismiss) private var dismiss
var body: some View {
Form {
// fields...
}
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
saveChanges()
dismiss()
}
}
}
}
}
// Parent — no dismiss wiring needed
.sheet(isPresented: $showEdit) {
NavigationStack {
EditProfileView()
}
}Dismiss behavior by context:
- In a sheet: dismisses the sheet
- In a fullScreenCover: dismisses the cover
- In a NavigationStack: pops to the previous view
- In a popover: dismisses the popover
When NOT to use dismiss: When you need to perform validation before allowing dismissal (e.g., "You have unsaved changes"), intercept with .interactiveDismissDisabled() and handle dismissal manually after validation passes.
Reference: dismiss - Apple Documentation
Design Clear Navigation Hierarchy Before Writing Code
Edson's conversation principle means the navigation structure IS the conversation structure — users can only navigate what you've organized. Kocienda's iterative demo process always started with the navigation: "How does the user get from A to B to C?" A flat structure where everything is accessible from everywhere sounds flexible but actually overwhelms; a clear hierarchy with 2-3 levels guides the user through a natural conversation.
Incorrect (flat navigation — everything at the same level):
// Every screen accessible from root — no hierarchy
struct AppView: View {
var body: some View {
NavigationStack {
List {
NavigationLink("Orders") { OrdersView() }
NavigationLink("Order Detail") { OrderDetailView() }
NavigationLink("Product") { ProductView() }
NavigationLink("Settings") { SettingsView() }
NavigationLink("Edit Profile") { EditProfileView() }
NavigationLink("Notifications") { NotificationsView() }
// 15 more links — user must memorize which leads where
}
}
}
}Correct (hierarchical navigation — clear parent-child relationships):
struct AppView: View {
var body: some View {
TabView {
Tab("Shop", systemImage: "bag.fill") {
NavigationStack {
// Level 1: Categories
CategoryListView()
// Level 2: Products in category
.navigationDestination(for: Category.self) { category in
ProductListView(category: category)
}
// Level 3: Product detail
.navigationDestination(for: Product.self) { product in
ProductDetailView(product: product)
}
}
}
Tab("Orders", systemImage: "list.clipboard.fill") {
NavigationStack {
OrderListView()
.navigationDestination(for: Order.self) { order in
OrderDetailView(order: order)
}
}
}
Tab("Profile", systemImage: "person.fill") {
NavigationStack {
ProfileView()
// Settings as sheet, not push — it's a task, not deeper content
}
}
}
}
}Navigation hierarchy audit:
- Maximum 3 levels deep within any tab (root → list → detail)
- Each push should drill deeper into the SAME content type
- Tasks and creation flows use sheets, not push
- Settings and preferences use sheets from profile/account
- Cross-cutting features (search, notifications) go in their own tab or toolbar
When NOT to enforce strict hierarchy: Apps with highly interconnected content (social networks, wikis) may need cross-links between hierarchies. Use .navigationDestination for these cross-links, but keep the primary hierarchy linear.
Reference: Navigation - Human Interface Guidelines
Configure Navigation Bar to Communicate Context
Edson's conversation principle means the navigation bar is a contextual indicator, not just a label. A large title says "you're at the top level — this is a main section." An inline title says "you've navigated deeper — there's content above you." Kocienda's demo culture relied on instantly seeing where you were in an app; the navigation bar's title display mode provides that spatial awareness.
Incorrect (same title style at every level):
// Root: inline title — doesn't signal "top level"
struct InboxView: View {
var body: some View {
List(messages) { message in
NavigationLink(value: message) {
MessageRow(message: message)
}
}
.navigationTitle("Inbox")
.navigationBarTitleDisplayMode(.inline) // Should be large at root
}
}
// Detail: large title — doesn't signal "drilled in"
struct MessageDetailView: View {
var body: some View {
ScrollView { /* content */ }
.navigationTitle(message.subject)
.navigationBarTitleDisplayMode(.large) // Should be inline at detail
}
}Correct (title display mode matches navigation depth):
// Root level: large title signals "you're at the top"
struct InboxView: View {
var body: some View {
List(messages) { message in
NavigationLink(value: message) {
MessageRow(message: message)
}
}
.navigationTitle("Inbox")
.navigationBarTitleDisplayMode(.large)
}
}
// Detail level: inline title signals "you've drilled in"
struct MessageDetailView: View {
let message: Message
var body: some View {
ScrollView { /* content */ }
.navigationTitle(message.subject)
.navigationBarTitleDisplayMode(.inline)
}
}Title display mode conventions:
| Level | Display Mode | Example |
|---|---|---|
| Tab root | .large | Inbox, Search, Profile |
| Drill-down detail | .inline | Message, Product, Settings page |
| Modal sheet | .inline | Compose, Edit, Filter |
| Scrollable content | .automatic | Starts large, collapses on scroll |
When NOT to customize: .automatic is correct for most cases — it shows large at the top and collapses to inline when the user scrolls. Only set explicit modes when the automatic behavior doesn't match the navigation depth.
Reference: Navigation bars - Human Interface Guidelines
Use NavigationStack for Programmatic, Type-Safe Navigation
Clinic architecture alignment (iOS 26 / Swift 6.2): Keep Feature modules on Domain + DesignSystem only; keep App-target DependencyContainer, route shells, and concrete coordinators as the integration point; keep Data as the only owner of SwiftData/network/sync I/O.
Edson's conversation principle means navigation should be a clear dialogue between user and app. NavigationStack (iOS 16+) makes this conversation programmatic — the app can say "let me take you to this specific place" through path manipulation, and the user can say "take me back" through the system back gesture. Kocienda's demo culture demanded interfaces that could be navigated to any state instantly for a demo — programmatic navigation enables exactly that.
Incorrect (deprecated NavigationView with limited control):
struct ContentView: View {
var body: some View {
NavigationView {
List(items) { item in
NavigationLink(destination: DetailView(item: item)) {
ItemRow(item: item)
}
}
}
}
}Correct (NavigationStack with programmatic path):
struct ContentView: View {
@State private var navigationPath = NavigationPath()
var body: some View {
NavigationStack(path: $navigationPath) {
List(items) { item in
NavigationLink(value: item) {
ItemRow(item: item)
}
}
.navigationDestination(for: Item.self) { item in
DetailView(item: item)
}
.navigationDestination(for: Category.self) { category in
CategoryView(category: category)
}
}
}
func navigateToItem(_ item: Item) {
navigationPath.append(item)
}
func popToRoot() {
navigationPath.removeLast(navigationPath.count)
}
}Deep linking support:
struct AppView: View {
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
HomeView()
.navigationDestination(for: DeepLink.self) { link in
link.destination
}
}
.onOpenURL { url in
if let deepLink = DeepLink(url: url) {
path.append(deepLink)
}
}
}
}Navigation title styles:
.navigationTitle("Inbox")
.navigationBarTitleDisplayMode(.large) // Large scrollable title
.navigationBarTitleDisplayMode(.inline) // Small centered title
.navigationBarTitleDisplayMode(.automatic) // Context-dependentWhen NOT to use NavigationStack: Inside a tab that already has its own NavigationStack. Each tab should own its own navigation stack; don't nest stacks.
Reference: NavigationStack - Apple Documentation
Integrate Search with the Searchable Modifier
Edson's conversation principle means search is a question the user asks the app — and the app should answer using the standard iOS search language that every user already understands. Kocienda's demo culture demanded that features work exactly as the user expects; .searchable provides the standard search bar placement, keyboard behavior, suggestions UI, and cancel button that users know from every Apple app.
Incorrect (custom search field that misses system behavior):
struct RecipeListView: View {
@State private var searchText = ""
var body: some View {
VStack {
// Custom search bar — misses pull-to-reveal, cancel button, suggestions
TextField("Search recipes...", text: $searchText)
.textFieldStyle(.roundedBorder)
.padding(.horizontal)
List(filteredRecipes) { recipe in
RecipeRow(recipe: recipe)
}
}
}
}Correct (searchable modifier with standard behavior):
struct RecipeListView: View {
@State private var searchText = ""
var body: some View {
NavigationStack {
List(filteredRecipes) { recipe in
NavigationLink(value: recipe) {
RecipeRow(recipe: recipe)
}
}
.searchable(text: $searchText, prompt: "Search recipes")
.navigationTitle("Recipes")
}
}
private var filteredRecipes: [Recipe] {
if searchText.isEmpty {
return recipes
}
return recipes.filter { $0.title.localizedCaseInsensitiveContains(searchText) }
}
}Search with suggestions:
.searchable(text: $searchText) {
ForEach(searchSuggestions) { suggestion in
Text(suggestion.title)
.searchCompletion(suggestion.title)
}
}Search scopes for filtering categories:
.searchable(text: $searchText)
.searchScopes($searchScope) {
Text("All").tag(SearchScope.all)
Text("Breakfast").tag(SearchScope.breakfast)
Text("Dinner").tag(SearchScope.dinner)
Text("Dessert").tag(SearchScope.dessert)
}When NOT to use searchable: Screens where the content is small enough to scan visually (< 10 items), or where filtering is better served by a segmented control or picker.
Reference: Search - Human Interface Guidelines
Use Item Binding for Data-Driven Sheet Presentation
Edson's conversation principle means the interface should always know what it's talking about. When a sheet presents details for a selected item, using isPresented with a separate selectedItem state creates a timing gap — the sheet might appear before selectedItem is set, or the item might change while the sheet is visible. The item binding ties the presented content atomically to the sheet's lifecycle: set the item to present, nil it to dismiss.
Incorrect (separate boolean and item state — race condition):
struct OrderListView: View {
@State private var showDetail = false
@State private var selectedOrder: Order?
var body: some View {
List(orders) { order in
Button(order.title) {
selectedOrder = order
showDetail = true // Two state changes, potential race
}
}
.sheet(isPresented: $showDetail) {
if let order = selectedOrder {
OrderDetailView(order: order)
}
// selectedOrder might be nil if timing is off
}
}
}Correct (item binding — atomic presentation):
struct OrderListView: View {
@State private var selectedOrder: Order?
var body: some View {
List(orders) { order in
Button(order.title) {
selectedOrder = order // Single state change presents the sheet
}
}
.sheet(item: $selectedOrder) { order in
// 'order' is guaranteed non-nil for the sheet's lifetime
OrderDetailView(order: order)
}
}
}This pattern works for all modal presentations:
// Sheet with item
.sheet(item: $selectedItem) { item in DetailView(item: item) }
// FullScreenCover with item
.fullScreenCover(item: $selectedPhoto) { photo in PhotoViewer(photo: photo) }
// Alert with item
.alert(item: $errorToShow) { error in
Button("OK") { }
} message: { error in
Text(error.localizedDescription)
}When NOT to use item binding: When the sheet content doesn't depend on a selected item (e.g., a settings sheet, a compose form), isPresented is simpler and correct.
Reference: sheet(item:) - Apple Documentation)
Use Tab Bar for Top-Level Section Navigation
Edson's conversation principle means the user should always know where they are and how to switch topics. The tab bar is a persistent anchor — it stays visible across all navigation levels within each tab, providing constant orientation. Kocienda's demo culture valued the ability to switch instantly between app sections during a live demo; the tab bar makes this possible.
Incorrect (custom tab bar that disappears during navigation):
struct AppView: View {
@State private var selectedTab = 0
var body: some View {
VStack {
// Content
switch selectedTab {
case 0: HomeView()
case 1: SearchView()
default: ProfileView()
}
// Custom tab bar that disappears on push navigation
HStack {
ForEach(0..<3) { index in
Button { selectedTab = index } label: {
Image(systemName: tabIcon(index))
}
.frame(maxWidth: .infinity)
}
}
.padding()
}
}
}Correct (system TabView with persistent tab bar):
struct AppView: View {
var body: some View {
TabView {
Tab("Home", systemImage: "house.fill") {
NavigationStack {
HomeView()
// Tab bar stays visible during navigation
}
}
Tab("Search", systemImage: "magnifyingglass") {
NavigationStack {
SearchView()
}
}
Tab("Profile", systemImage: "person.fill") {
NavigationStack {
ProfileView()
}
}
}
}
}Tab bar best practices:
- 3-5 tabs — fewer is better, never more than 5 on iPhone
- Each tab icon should be instantly recognizable as an SF Symbol
- Use
.badge()for unread counts or notifications - Tab labels should be single words (Home, Search, Profile — not "My Account Settings")
- First tab should be the app's primary experience
When NOT to use tab bar: Apps with a single linear flow (camera, media playback), apps with <3 sections (use a single NavigationStack instead), or immersive experiences (games, full-screen media).
Reference: Tab bars - Human Interface Guidelines
Organize App Sections with TabView for Parallel Navigation
Edson's conversation principle recognizes that users have parallel conversations with different parts of an app — checking messages, browsing content, updating settings — and they expect to switch between these conversations without losing their place. TabView provides exactly this: each tab maintains its own navigation state. Kocienda's demo culture at Apple relied on being able to jump instantly between app sections; TabView makes this the default experience.
Incorrect (manual navigation between top-level sections):
struct AppView: View {
@State private var currentSection = "Home"
var body: some View {
NavigationStack {
switch currentSection {
case "Home": HomeView()
case "Search": SearchView()
case "Profile": ProfileView()
default: HomeView()
}
}
// Switching sections resets navigation state
}
}Correct (TabView with independent navigation per tab):
struct AppView: View {
var body: some View {
TabView {
Tab("Home", systemImage: "house.fill") {
NavigationStack {
HomeView()
}
}
Tab("Search", systemImage: "magnifyingglass") {
NavigationStack {
SearchView()
}
}
Tab("Favorites", systemImage: "heart.fill") {
NavigationStack {
FavoritesView()
}
}
Tab("Profile", systemImage: "person.fill") {
NavigationStack {
ProfileView()
}
}
}
}
}Tab bar conventions:
- 3-5 tabs maximum — more causes crowding and confusion
- Use SF Symbols for tab icons — they align with system tab bars
- Labels should be single words or very short phrases
- Each tab owns its own
NavigationStack— never share stacks between tabs - Tab order: most-used sections first, profile/settings last
When NOT to use TabView: Apps with a single primary flow (camera apps, media players, games) don't need tabs. Use tabs when the app has 3-5 genuinely parallel sections.
Reference: Tab bars - Human Interface Guidelines
Place Toolbar Items in the Correct Semantic Positions
Edson's conversation principle recognizes that toolbar placement is a language. Users have learned from thousands of iOS interactions that Cancel lives on the left and Done/Save lives on the right. This isn't a design preference — it's a protocol that Kocienda's team established and every Apple app reinforces. Placing actions in unexpected positions breaks the conversation's grammar.
Incorrect (actions in wrong positions):
struct ComposeView: View {
var body: some View {
Form { /* fields */ }
.toolbar {
// Done on the left, Cancel on the right — backwards
ToolbarItem(placement: .topBarLeading) {
Button("Send") { send() }
}
ToolbarItem(placement: .topBarTrailing) {
Button("Cancel") { cancel() }
}
}
}
}Correct (semantic toolbar placements):
struct ComposeView: View {
@Environment(\.dismiss) private var dismiss
var body: some View {
Form { /* fields */ }
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Send") { send() }
}
}
}
}Semantic placement guide:
| Placement | Position | Usage |
|---|---|---|
.cancellationAction | Leading | Cancel, Close |
.confirmationAction | Trailing | Save, Done, Send |
.primaryAction | Trailing (prominent) | Main action button |
.destructiveAction | Varies | Delete (use sparingly) |
.navigationBarLeading | Leading | Custom leading items |
.navigationBarTrailing | Trailing | Custom trailing items |
.bottomBar | Bottom toolbar | Secondary actions |
.keyboard | Above keyboard | Input accessories |
Bottom toolbar for multi-action screens:
.toolbar {
ToolbarItemGroup(placement: .bottomBar) {
Button { archive() } label: {
Image(systemName: "archivebox")
}
Spacer()
Button { reply() } label: {
Image(systemName: "arrowshape.turn.up.left")
}
Spacer()
Button { delete() } label: {
Image(systemName: "trash")
}
}
}When NOT to use toolbar: For actions that are part of the content (like/favorite buttons, inline actions), place them in the view hierarchy, not the toolbar.
Reference: Toolbars - Human Interface Guidelines
Never Create State Inside the View Body
Kocienda's craft means understanding the runtime lifecycle beneath the syntax. The body property is a computed property that SwiftUI calls frequently — up to 120 times per second during animations (once per frame at 120Hz). Creating @State, @StateObject, or @Observable instances inside body means allocating new state on every evaluation, which can trigger a new evaluation, creating an infinite loop. This is the kind of subtle bug that works in previews but crashes in production. A craftsman initializes state at the declaration site, never inside the computation.
Incorrect (state created in body — resets on every evaluation):
struct SearchView: View {
var body: some View {
// New model created on EVERY body call
let viewModel = SearchViewModel()
VStack {
TextField("Search", text: $viewModel.query)
// Query resets to "" every time the parent re-renders
}
}
}Correct (state declared as stored property):
struct SearchView: View {
@State private var viewModel = SearchViewModel()
var body: some View {
VStack {
TextField("Search", text: $viewModel.query)
// Query persists across re-renders
}
}
}Other body-allocation traps:
// Wrong: timer created on every body call
var body: some View {
let timer = Timer.publish(every: 1, on: .main, in: .common)
// Creates a NEW timer every re-render
}
// Right: declared as stored property or in onAppear
@State private var timer = Timer.publish(every: 1, on: .main, in: .common)
// Wrong: expensive computation in body without caching
var body: some View {
let sortedItems = items.sorted(by: { $0.date > $1.date })
// Re-sorts on every re-render
}
// Right: derive in the model or use a computed property outside body
var sortedItems: [Item] {
items.sorted(by: { $0.date > $1.date })
}When it's acceptable: Simple value computations that are cheap (let fullName = "\(first) \(last)") are fine in body. The rule applies to stateful objects, timers, publishers, and expensive computations.
Reference: Managing user interface state - Apple
Minimize State Scope to Reduce Re-Renders
Kocienda's craft applies at the architectural level: when state lives higher in the hierarchy than it needs to, every view between the owner and the consumer re-evaluates unnecessarily. The iPhone keyboard team optimized every touch handler to avoid unnecessary work — the same discipline applies to SwiftUI state. A @State property should live in the view that uses it, not in a parent that happens to be convenient. Moving state to the narrowest possible scope is the single most impactful performance optimization in SwiftUI.
Incorrect (state hoisted too high — entire list re-renders on toggle):
struct RecipeListView: View {
@State private var expandedRecipeID: Recipe.ID? // Owned at list level
var body: some View {
// EVERY RecipeRow re-evaluates when expandedRecipeID changes
List(recipes) { recipe in
RecipeRow(
recipe: recipe,
isExpanded: expandedRecipeID == recipe.id,
onToggle: { expandedRecipeID = recipe.id }
)
}
}
}Correct (state scoped to the row that uses it):
struct RecipeListView: View {
var body: some View {
List(recipes) { recipe in
RecipeRow(recipe: recipe)
}
}
}
struct RecipeRow: View {
let recipe: Recipe
@State private var isExpanded = false // Owned by the row
var body: some View {
VStack(alignment: .leading) {
Text(recipe.title)
.font(.headline)
if isExpanded {
Text(recipe.description)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
.onTapGesture { isExpanded.toggle() }
}
}Scope checklist:
- Does only one view read this state? →
@State privatein that view - Does a parent and child share this state? →
@Statein parent,@Bindingin child - Does one child among many siblings need it? →
@Statein the child, not the parent - Do many distant views need it? →
@Environmentat the appropriate ancestor
When state MUST be hoisted: When the parent needs to coordinate between children (e.g., "only one row expanded at a time"), the parent must own the state. But this is the exception, not the default.
Reference: Managing user interface state - Apple
Use @Bindable for @Observable Bindings
Kocienda's craft means mastering the tools available. @Observable gives you property-level tracking, but when a child view receives an @Observable object and needs to create $ bindings to its properties (for TextField, Toggle, Stepper), it needs @Bindable. This is the bridge between observation and mutation — without it, the child can read but not write, and form controls cannot function.
Incorrect (cannot create bindings without @Bindable):
@Observable class ProfileEditor {
var displayName = ""
var bio = ""
var isPublic = true
}
struct ProfileFormView: View {
var editor: ProfileEditor // Plain property — no $ access
var body: some View {
Form {
TextField("Name", text: $editor.displayName)
// Compiler error: cannot find '$editor' in scope
}
}
}Correct (@Bindable enables $ binding syntax):
@Observable class ProfileEditor {
var displayName = ""
var bio = ""
var isPublic = true
}
struct ProfileFormView: View {
@Bindable var editor: ProfileEditor
var body: some View {
Form {
TextField("Name", text: $editor.displayName)
TextField("Bio", text: $editor.bio, axis: .vertical)
.lineLimit(3...6)
Toggle("Public Profile", isOn: $editor.isPublic)
}
}
}
// Parent owns the state
struct ProfileScreen: View {
@State private var editor = ProfileEditor()
var body: some View {
NavigationStack {
ProfileFormView(editor: editor)
.navigationTitle("Edit Profile")
}
}
}When to use each wrapper:
| Wrapper | Use When |
|---|---|
@State | You own the @Observable object (create it in this view) |
@Bindable | You receive the object and need $ bindings |
@Environment | The object is injected via .environment() |
| Plain property | You only read the object, never need $ bindings |
When NOT to use @Bindable: If the child only reads properties and never binds to them (no TextField, Toggle, Stepper), pass the object as a plain parameter. @Bindable implies write access.
Reference: Bindable - Apple Documentation
Use @Binding for Child View Mutations
Kocienda's craft means understanding the communication protocol between components. When a child view needs to mutate a value owned by its parent, @Binding creates a two-way connection — the child reads and writes the parent's state directly. Passing a plain value creates a one-way copy: the child appears to work in isolation, but changes never propagate back, and the parent and child silently diverge. This is the SwiftUI equivalent of two people editing different copies of a document.
Incorrect (value copy — child mutations invisible to parent):
struct TemperatureControl: View {
var temperature: Double // Copy — changes don't propagate back
var body: some View {
Stepper("\\(Int(temperature))°F", value: $temperature)
// Compiler error: cannot create binding from plain property
}
}
// Parent
struct ThermostatView: View {
@State private var temperature = 72.0
var body: some View {
TemperatureControl(temperature: temperature)
// Parent never sees child's changes
}
}Correct (binding — parent and child share state):
struct TemperatureControl: View {
@Binding var temperature: Double
var body: some View {
Stepper("\\(Int(temperature))°F", value: $temperature, in: 60...90)
}
}
// Parent passes binding via $
struct ThermostatView: View {
@State private var temperature = 72.0
var body: some View {
VStack {
Text("Current: \\(Int(temperature))°F")
.font(.title)
TemperatureControl(temperature: $temperature)
}
}
}Creating bindings from @Observable models:
@Observable class Settings {
var notificationsEnabled = true
}
struct SettingsToggle: View {
@Bindable var settings: Settings
var body: some View {
Toggle("Notifications", isOn: $settings.notificationsEnabled)
}
}When NOT to use @Binding: When the child only reads the value and never mutates it, pass a plain value. Binding implies write access — don't create false expectations in your API.
Reference: Managing user interface state - Apple
Use @Environment for Shared App-Wide Data
Kocienda's craft means choosing the right tool for the scale of the problem. When data needs to reach views deep in the hierarchy — color scheme, locale, user session, feature flags — threading it through every intermediate initializer is brittle, verbose, and prone to breakage during refactoring. @Environment is SwiftUI's dependency injection: a parent injects a value, and any descendant can read it without intermediate views knowing it exists. This is craftsmanship at the architectural level.
Incorrect (prop drilling through every intermediate view):
struct AppView: View {
@State private var user = User.current
var body: some View {
TabView {
// Every intermediate view must accept and forward 'user'
HomeTab(user: user)
ProfileTab(user: user)
SettingsTab(user: user)
}
}
}
struct HomeTab: View {
let user: User // Only forwarding, doesn't use it
var body: some View {
NavigationStack {
FeedView(user: user) // More forwarding
}
}
}Correct (@Environment injects once, reads anywhere):
struct AppView: View {
@State private var user = User.current
var body: some View {
TabView {
HomeTab()
ProfileTab()
SettingsTab()
}
.environment(user)
}
}
// Deep descendant reads directly — no prop drilling
struct PostHeader: View {
@Environment(User.self) private var user
var body: some View {
HStack {
Avatar(url: user.avatarURL)
Text(user.displayName)
.font(.subheadline.bold())
}
}
}System environment values:
// Read system-provided values
@Environment(\.colorScheme) private var colorScheme
@Environment(\.dynamicTypeSize) private var typeSize
@Environment(\.dismiss) private var dismiss
@Environment(\.locale) private var locale
@Environment(\.horizontalSizeClass) private var sizeClassWhen NOT to use @Environment: For data that only one child needs, pass it as a direct parameter. Environment is for data shared across many views at different depths. Don't use it as a lazy alternative to explicit parameters.
Reference: Managing model data in your app - Apple
Use @State for View-Local Value Types
Kocienda defines craft as applying skill to achieve a high-quality result. The difference between var count = 0 and @State private var count = 0 is invisible in a screenshot but catastrophic in use — the first resets to zero every time SwiftUI re-evaluates the body, the second persists across re-renders. Craftsmanship means understanding the runtime behavior beneath the declaration syntax. A craftsman doesn't leave state management to chance; they choose the right wrapper for each situation with the precision of a watchmaker selecting a spring.
Incorrect (local variable resets on every body call):
struct FavoriteButton: View {
var isFavorited = false // Resets to false on every re-render
var body: some View {
Button {
isFavorited.toggle() // Compiler error: cannot mutate
} label: {
Image(systemName: isFavorited ? "heart.fill" : "heart")
}
}
}Correct (state persists across re-renders):
struct FavoriteButton: View {
@State private var isFavorited = false
var body: some View {
Button {
isFavorited.toggle() // Triggers re-render with new value
} label: {
Image(systemName: isFavorited ? "heart.fill" : "heart")
.foregroundStyle(isFavorited ? .red : .secondary)
}
}
}Always mark @State as private — when left non-private, parent views can set values through the memberwise initializer, silently overwriting state on every re-render:
// Wrong: parent overwrites state on every re-render
struct ExpandableSection: View {
@State var isExpanded = false
}
ExpandableSection(isExpanded: true) // resets every parent update
// Right: private prevents external mutation
struct ExpandableSection: View {
@State private var isExpanded = false
}When NOT to use @State:
- For reference types (classes) — use
@Statewith@Observableinstead - For data shared with parent views — use
@Binding - For app-wide data — use
@Environment - For data that outlives the view — use a model layer
Reference: Managing user interface state - Apple
Use @Observable for Model Classes
Clinic architecture alignment (iOS 26 / Swift 6.2): Keep Feature modules on Domain + DesignSystem only; keep App-target DependencyContainer, route shells, and concrete coordinators as the integration point; keep Data as the only owner of SwiftData/network/sync I/O.
Kocienda's craft demands using the best tool available, not the one you're used to. @Observable (iOS 26 / Swift 6.2) replaces ObservableObject with a fundamentally better observation model: property-level tracking. With ObservableObject, changing any @Published property re-evaluates every view that observes the object. With @Observable, only views that read the specific changed property re-evaluate. This is the kind of invisible craftsmanship that users feel in responsiveness without ever knowing why.
Incorrect (ObservableObject — all subscribers re-render on any change):
class BookingViewModel: ObservableObject {
@Published var guestName = ""
@Published var checkInDate = Date()
@Published var checkOutDate = Date()
@Published var specialRequests = ""
// Changing guestName re-evaluates EVERY view that observes this object
// even if they only read checkInDate
}
struct BookingForm: View {
@StateObject private var viewModel = BookingViewModel()
// ...
}Correct (@Observable — only affected views re-render):
@Observable class BookingViewModel {
var guestName = ""
var checkInDate = Date()
var checkOutDate = Date()
var specialRequests = ""
// Changing guestName only re-evaluates views that read guestName
}
struct BookingForm: View {
@State private var viewModel = BookingViewModel()
var body: some View {
Form {
// Only re-renders when guestName changes
TextField("Guest Name", text: $viewModel.guestName)
// Only re-renders when dates change
DatePicker("Check In", selection: $viewModel.checkInDate)
DatePicker("Check Out", selection: $viewModel.checkOutDate)
}
}
}Key differences from ObservableObject:
- No
@Publishedneeded — all stored properties are automatically observed - Use
@Stateinstead of@StateObjectto own the instance - Pass directly to child views — no
@ObservedObjectneeded - Use
@Bindablewhen you need$binding syntax in a child view - Computed properties are automatically tracked through their dependencies
When NOT to use @Observable: When targeting iOS 16 or earlier, continue using ObservableObject. For simple view-local state (a boolean toggle, a text field value), prefer @State over a full model class.
Reference: Managing model data in your app - Apple
Support Dark Mode from Day One
Kocienda describes how the iPhone team tested keyboard prototypes under every condition — walking, one-handed, in bright sunlight. Empathy meant understanding that the user wouldn't always be sitting at a desk with perfect lighting. Dark Mode is the same principle: the user reading your app in bed, in a dark theater, or in a car at night deserves the same quality experience as the person in a well-lit office. Edson's "design is about people" demands that you serve every context, not just the one you develop in.
Incorrect (custom colors without Dark Mode variants):
struct RecipeCard: View {
let recipe: Recipe
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(recipe.title)
.font(.headline)
.foregroundStyle(Color(red: 0.1, green: 0.1, blue: 0.1))
Text(recipe.description)
.font(.subheadline)
.foregroundStyle(Color(red: 0.5, green: 0.5, blue: 0.5))
}
.padding()
// Stark white in Dark Mode — blinds the user
.background(Color(red: 0.98, green: 0.98, blue: 0.98))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}Correct (asset catalog colors with light and dark variants):
struct RecipeCard: View {
let recipe: Recipe
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(recipe.title)
.font(.headline)
.foregroundStyle(.primary)
Text(recipe.description)
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}Custom color Dark Mode checklist: 1. Define every custom color in the asset catalog with "Any" and "Dark" appearances 2. Never use Color(red:green:blue:) for UI elements — always reference named colors 3. Use Xcode's "Appearance" toggle in previews to test both modes 4. Run the app with Dark Mode enabled before every PR
When NOT to customize: System colors (.primary, .secondary, .accent) already adapt. Only create custom named colors when your brand palette requires specific values beyond the system palette.
Reference: Dark Mode - Human Interface Guidelines
Use foregroundStyle over foregroundColor
Kocienda's empathy means understanding that the system has evolved to serve users better — and the developer's job is to keep up. foregroundColor accepts only Color, which cannot express hierarchy (.primary, .secondary, .tertiary) or material vibrancy. foregroundStyle accepts any ShapeStyle, including hierarchical colors that automatically adjust for background materials, elevated contexts, and accessibility settings. Using the old API isn't just outdated — it's an empathy failure, because it prevents SwiftUI from making the fine-grained adjustments that serve users in every context.
Incorrect (foregroundColor limits adaptability):
struct NotificationBanner: View {
let message: String
let detail: String
var body: some View {
HStack(spacing: 12) {
Image(systemName: "bell.fill")
.foregroundColor(.blue)
VStack(alignment: .leading) {
Text(message)
.foregroundColor(.black)
Text(detail)
.foregroundColor(.gray)
}
}
.padding()
}
}Correct (foregroundStyle enables full hierarchy):
struct NotificationBanner: View {
let message: String
let detail: String
var body: some View {
HStack(spacing: 12) {
Image(systemName: "bell.fill")
.foregroundStyle(.tint)
VStack(alignment: .leading) {
Text(message)
.foregroundStyle(.primary)
Text(detail)
.foregroundStyle(.secondary)
}
}
.padding()
}
}Key differences:
.foregroundStyle(.primary)adjusts for vibrancy over materials;Color.primarydoes not.foregroundStyle(.tint)respects the view's accent color inheritance chain.foregroundStyle(.secondary)on a material background automatically gets vibrancy treatment- Multi-level:
.foregroundStyle(.blue, .purple, .pink)sets primary, secondary, tertiary in one call
When NOT to migrate: If targeting iOS 14-16 in production, foregroundColor is still required. For iOS 17+ targets, always prefer foregroundStyle.
Reference: foregroundStyle - Apple Documentation)
Constrain Text to Readable Width on iPad
Kocienda's empathy extends beyond the iPhone — when the iPad launched, the same keyboard concepts had to adapt to a radically wider canvas. Text that stretches edge-to-edge on a 12.9" iPad Pro forces the reader's eye to travel so far that it loses its place on the return sweep to the next line. This is not a typography preference — it is a cognitive reality that Edson's people-first design demands we respect. The .readableContentGuide and .dynamicTypeSize tools exist because Apple's own teams discovered this problem and built the solution into UIKit and SwiftUI.
Incorrect (text stretches full width on iPad):
struct ArticleDetailView: View {
let article: Article
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Text(article.title)
.font(.largeTitle.bold())
Text(article.body)
.font(.body)
}
// On 12.9" iPad, lines stretch to ~150 characters
.padding(.horizontal, 16)
}
}
}Correct (constrained to readable width):
struct ArticleDetailView: View {
let article: Article
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Text(article.title)
.font(.largeTitle.bold())
Text(article.body)
.font(.body)
}
.padding(.horizontal, 16)
.frame(maxWidth: 672, alignment: .leading)
}
.frame(maxWidth: .infinity)
}
}Alternative using the dynamic readable guide:
// In a UIKit-backed layout
.frame(maxWidth: .readableContentWidth)
// Or constrain within a List/Form which automatically uses readableContentGuide
List {
Section {
Text(article.body)
}
}
.listStyle(.insetGrouped)Readable width guidelines:
- 50-80 characters per line is the optimal range for body text
- 672pt ≈ 80 characters in
.bodyat default Dynamic Type size ListandFormautomatically constrain to readable width on iPad- Use
.scenePadding(.horizontal)for automatic horizontal padding that adapts to size class
When NOT to constrain: Full-bleed media (images, maps, video), grid layouts, and dashboard cards should use the full available width. Only constrain running text.
Reference: Typography - Human Interface Guidelines, Layout - Human Interface Guidelines
Always Provide Reduce Motion Fallback
Kocienda tested the iPhone keyboard under every condition imaginable because empathy meant anticipating the user's reality, not assuming it. A user with a vestibular disorder who enables Reduce Motion is not an edge case — they are 10-15% of your audience. When your bouncing onboarding animation triggers their vertigo, you have failed at the most basic act of empathy. Edson's people-first design demands that every user, regardless of physical sensitivity, can use the product without discomfort.
Incorrect (animations with no Reduce Motion check):
struct OnboardingCard: View {
@State private var isVisible = false
var body: some View {
VStack {
Image(systemName: "hand.wave.fill")
.font(.system(size: 80))
.offset(y: isVisible ? 0 : 100)
.opacity(isVisible ? 1 : 0)
.animation(.bouncy, value: isVisible)
Text("Welcome!")
.font(.largeTitle.bold())
.scaleEffect(isVisible ? 1 : 0.5)
.animation(.bouncy.delay(0.2), value: isVisible)
}
.onAppear { isVisible = true }
}
}Correct (crossfade fallback when Reduce Motion is enabled):
struct OnboardingCard: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var isVisible = false
var body: some View {
VStack {
Image(systemName: "hand.wave.fill")
.font(.system(size: 80))
.offset(y: reduceMotion ? 0 : (isVisible ? 0 : 100))
.opacity(isVisible ? 1 : 0)
.animation(
reduceMotion ? .smooth(duration: 0.1) : .bouncy,
value: isVisible
)
Text("Welcome!")
.font(.largeTitle.bold())
.scaleEffect(reduceMotion ? 1 : (isVisible ? 1 : 0.5))
.opacity(isVisible ? 1 : 0)
.animation(
reduceMotion ? .smooth(duration: 0.1) : .bouncy.delay(0.2),
value: isVisible
)
}
.onAppear { isVisible = true }
}
}Reusable helper for consistent handling:
extension Animation {
static func adaptive(
_ animation: Animation,
reduceMotion: Bool
) -> Animation {
reduceMotion ? .smooth(duration: 0.1) : animation
}
}
// Usage
.animation(.adaptive(.bouncy, reduceMotion: reduceMotion), value: isVisible)What to reduce, not remove: Replace spatial movement (slides, bounces, zooms) with opacity crossfades. Keep opacity transitions under 150ms. Never remove the state change entirely — the user still needs to see that something happened.
Reference: Motion — Accessibility - HIG, WWDC 2023 "Animate with springs"
Always Respect Safe Areas for Content
Kocienda's keyboard team tested on every prototype device — they knew the physical form factor was a constraint to empathize with, not fight against. The Dynamic Island, home indicator, and status bar are the modern equivalents: physical constraints that protect the user's content from being obscured. Edson's "design is about people" means accepting that your content must live within the space the user can actually see and touch, not behind hardware elements they cannot move.
Incorrect (ignoring safe areas clips content behind system UI):
struct ArticleView: View {
let article: Article
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
Text(article.title)
.font(.largeTitle.bold())
Text(article.body)
.font(.body)
}
.padding(.horizontal, 16)
}
// Ignores safe areas — text hides behind Dynamic Island and home indicator
.ignoresSafeArea()
}
}Correct (safe areas protect content, edges extend backgrounds):
struct ArticleView: View {
let article: Article
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
Text(article.title)
.font(.largeTitle.bold())
Text(article.body)
.font(.body)
}
// Content respects safe areas automatically
.padding(.horizontal, 16)
}
// Only background extends to edges, not content
.background(.background)
}
}Safe area rules:
- Content (text, buttons, inputs) must always respect safe areas — never use
.ignoresSafeArea()on content - Backgrounds (colors, images, materials) should extend to edges using
.ignoresSafeArea()or.background { Color.blue.ignoresSafeArea() } - Scroll views automatically handle safe areas for their content; don't add manual insets
- Keyboard safe area: use
.safeAreaInset(edge: .bottom)for floating elements above the keyboard
When to use .ignoresSafeArea(): Only for decorative backgrounds, hero images, or full-bleed media where the content itself is not interactive or textual. Even then, overlay text must still respect safe areas.
Reference: Layout - Human Interface Guidelines
Use Semantic Colors, Never Hard-Coded Values
Kocienda writes that empathy means "trying to see the world from other people's perspectives." A developer who hard-codes Color.black has only seen the app in light mode on their desk. The user reading in bed at midnight with Dark Mode enabled sees invisible text on a glaring background. Edson's foundational principle — design is about people — demands that color decisions serve every context the user inhabits, not just the developer's preview canvas. A semantic color like .primary tells the truth: "this is important text," and SwiftUI translates that role into the appropriate color for every appearance, accessibility setting, and contrast mode.
Incorrect (hard-coded colors 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 use semantic colors: 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
Ensure 44x44 Point Minimum Touch Targets
Kocienda spent months refining the iPhone keyboard's touch model — enlarging the tap target of the letter the autocorrect engine predicted you'd type next. This is empathy expressed in code: understanding that human fingers are imprecise instruments, especially when the user is walking, riding transit, or has a motor disability. Edson's people-first principle demands that every interactive element be physically reachable by every person who might use it, not just the developer with steady hands and a desk.
Incorrect (icon button with no minimum tap area):
struct CompactToolbar: View {
var body: some View {
HStack(spacing: 8) {
// 18pt icon with no expanded hit area
Button { share() } label: {
Image(systemName: "square.and.arrow.up")
.font(.system(size: 18))
}
// Text button with tiny padding
Button("Edit") { edit() }
.font(.caption2)
.padding(4)
}
}
}Correct (minimum 44pt touch targets on every interactive element):
struct CompactToolbar: View {
var body: some View {
HStack(spacing: 8) {
Button { share() } label: {
Image(systemName: "square.and.arrow.up")
.font(.system(size: 18))
// Visual size stays small, touch area expands
.frame(minWidth: 44, minHeight: 44)
}
Button("Edit") { edit() }
.font(.caption2)
.frame(minHeight: 44)
.padding(.horizontal, 12)
}
}
}Common violations and fixes:
| Element | Problem | Fix |
|---|---|---|
| Icon-only button | 16-24pt visual size | .frame(minWidth: 44, minHeight: 44) |
| Text link in paragraph | Line height < 44pt | Wrap in Button with minimum frame |
| Close "X" button | Tiny circle in corner | .frame(minWidth: 44, minHeight: 44) |
| Rating stars | Stars touch each other | Add spacing and minimum frames |
When NOT to enforce: System controls like Toggle, Stepper, Picker, and Slider already meet Apple's touch target requirements. Don't add redundant frames to these.
Reference: Accessibility - Touch targets
Add VoiceOver Labels to Every Interactive Element
Kocienda describes empathy as creating work that fits into other people's lives. For a blind user, your app's entire interface is mediated through VoiceOver — if a button reads "heart fill" instead of "Add to favorites," you have communicated nothing. Edson's "design is about people" includes the person who will never see your beautiful gradient but still deserves to use your app with the same confidence as a sighted user. Every interactive element without a descriptive label is a door you've locked.
Incorrect (missing or unhelpful labels):
struct BookmarkButton: View {
@Binding var isBookmarked: Bool
var body: some View {
Button {
isBookmarked.toggle()
} label: {
Image(systemName: isBookmarked ? "bookmark.fill" : "bookmark")
}
// VoiceOver reads: "bookmark fill" — meaningless
}
}
// Decorative image that wastes VoiceOver time
Image("hero-banner")
// VoiceOver reads: "hero banner" — unhelpful noiseCorrect (descriptive labels and intentional hiding):
struct BookmarkButton: View {
@Binding var isBookmarked: Bool
var body: some View {
Button {
isBookmarked.toggle()
} label: {
Image(systemName: isBookmarked ? "bookmark.fill" : "bookmark")
}
.accessibilityLabel(isBookmarked ? "Remove bookmark" : "Add bookmark")
}
}
// Decorative image hidden from VoiceOver
Image("hero-banner")
.accessibilityHidden(true)
// Informational image with meaningful description
Image("revenue-chart")
.accessibilityLabel("Revenue chart showing 23% growth over 6 months")Group related elements into single VoiceOver stops:
HStack(spacing: 4) {
Image(systemName: "star.fill")
.foregroundStyle(.orange)
Text("4.8")
Text("(2,341 reviews)")
.foregroundStyle(.secondary)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("Rating: 4.8 out of 5 stars, 2,341 reviews")Accessibility audit checklist:
- Every
Buttonwith an icon-only label has.accessibilityLabel - Every decorative
Imagehas.accessibilityHidden(true) - Every informational
Imagehas.accessibilityLabeldescribing its content - Related elements are grouped with
.accessibilityElement(children:) - Actions include
.accessibilityHintwhen the result is non-obvious
Reference: Accessibility - Human Interface Guidelines
Use Adaptive Layouts for Different Size Classes
Edson's "Design Out Loud" means prototyping across every context your users will encounter. An iPhone SE, iPhone 15 Pro Max, iPad mini, and iPad Pro 12.9" in split-screen multitasking all present your app at different widths. Kocienda's intersection of technology and liberal arts demands layouts that adapt to each context gracefully — a single-column layout on iPhone that becomes two-column on iPad, not a stretched iPhone layout with awkward whitespace.
Incorrect (fixed layout that only works on one screen size):
struct DashboardView: View {
var body: some View {
VStack {
// Always single column — wastes space on iPad
MetricCard(title: "Revenue", value: "$12,430")
MetricCard(title: "Orders", value: "84")
MetricCard(title: "Customers", value: "1,203")
}
.padding()
}
}Correct (adaptive layout responds to size class):
struct DashboardView: View {
@Environment(\.horizontalSizeClass) private var sizeClass
var body: some View {
ScrollView {
if sizeClass == .compact {
// iPhone: single column
VStack(spacing: 16) {
MetricCard(title: "Revenue", value: "$12,430")
MetricCard(title: "Orders", value: "84")
MetricCard(title: "Customers", value: "1,203")
}
} else {
// iPad: two-column grid
LazyVGrid(
columns: [GridItem(.flexible()), GridItem(.flexible())],
spacing: 16
) {
MetricCard(title: "Revenue", value: "$12,430")
MetricCard(title: "Orders", value: "84")
MetricCard(title: "Customers", value: "1,203")
}
}
}
.padding()
}
}Using ViewThatFits for automatic adaptation:
struct AdaptiveRow: View {
let items: [Item]
var body: some View {
ViewThatFits {
// Try horizontal first
HStack(spacing: 12) {
ForEach(items) { item in
ItemCard(item: item)
}
}
// Fall back to vertical if horizontal doesn't fit
VStack(spacing: 12) {
ForEach(items) { item in
ItemCard(item: item)
}
}
}
}
}Size class reference:
| Size Class | Context |
|---|---|
.compact horizontal | iPhone portrait, iPad split 1/3 |
.regular horizontal | iPhone landscape, iPad portrait/landscape |
.compact vertical | iPhone landscape |
.regular vertical | iPhone portrait, iPad all orientations |
When NOT to adapt: Simple content views (article text, forms) naturally adapt through stack layout and readable width constraints. Only build explicit size class logic for significantly different layouts.
Reference: Layout - Human Interface Guidelines
Use frame() for Explicit Size Constraints
Edson's "Design Out Loud" requires explicit decisions about how much space each element occupies. SwiftUI views are intrinsically sized by their content — a Text is as wide as its text, an Image is as large as its asset. When content varies (user names, descriptions, dynamic data), unconstrained views create layouts that shift unpredictably. .frame() sets explicit boundaries: minimum, ideal, and maximum sizes that create predictable, consistent results.
Incorrect (unconstrained image causes layout shifts):
struct PropertyCard: View {
let property: Property
var body: some View {
VStack(alignment: .leading) {
// Image size varies with asset — layout shifts between cards
AsyncImage(url: property.imageURL) { image in
image.resizable()
} placeholder: {
Color(.systemFill)
}
Text(property.title)
.font(.headline)
}
}
}Correct (frame constrains image to consistent size):
struct PropertyCard: View {
let property: Property
var body: some View {
VStack(alignment: .leading, spacing: 8) {
AsyncImage(url: property.imageURL) { image in
image
.resizable()
.scaledToFill()
} placeholder: {
Color(.systemFill)
}
.frame(height: 200)
.clipped()
Text(property.title)
.font(.headline)
}
}
}Frame patterns:
// Fixed size
.frame(width: 44, height: 44)
// Full width with fixed height
.frame(maxWidth: .infinity)
.frame(height: 200)
// Minimum and maximum bounds
.frame(minHeight: 44, maxHeight: 200)
// Alignment within frame
.frame(maxWidth: .infinity, alignment: .leading)
// Aspect ratio instead of fixed dimensions
.aspectRatio(16/9, contentMode: .fill)When NOT to use frame: Don't constrain text views to fixed widths (use lineLimit instead). Don't set fixed heights on content that should scroll. Don't use .frame(maxWidth: .infinity) inside List rows — they already fill width.
Reference: Layout fundamentals - Apple Documentation
Use Grid for Aligned Non-Scrolling Tabular Content
Edson's "Design Out Loud" means choosing the layout tool that expresses the content's structure. When data has rows and columns — spec sheets, comparison tables, form summaries — Grid (iOS 16+) provides automatic column alignment that VStack of HStacks cannot achieve. Kocienda's intersection of technology and liberal arts demands that tabular data be presented with the visual clarity of a well-set table, not the ragged edges of manually positioned text.
Incorrect (VStack/HStack — columns don't align):
struct SpecSheet: View {
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Weight").foregroundStyle(.secondary)
Text("364 g")
}
HStack {
Text("Dimensions").foregroundStyle(.secondary)
Text("160.8 × 78.1 × 7.65 mm")
}
HStack {
Text("Display").foregroundStyle(.secondary)
Text("6.7\" Super Retina XDR")
}
// "Weight" and "Dimensions" are different lengths
// so values don't align vertically
}
}
}Correct (Grid — columns automatically align):
struct SpecSheet: View {
var body: some View {
Grid(alignment: .leading, verticalSpacing: 8) {
GridRow {
Text("Weight")
.foregroundStyle(.secondary)
Text("364 g")
}
GridRow {
Text("Dimensions")
.foregroundStyle(.secondary)
Text("160.8 × 78.1 × 7.65 mm")
}
GridRow {
Text("Display")
.foregroundStyle(.secondary)
Text("6.7\" Super Retina XDR")
}
}
}
}Grid features:
// Spanning multiple columns
GridRow {
Text("Full-width note")
.gridCellColumns(2)
.font(.caption)
.foregroundStyle(.secondary)
}
// Custom column alignment
Grid(alignment: .leading) {
GridRow(alignment: .firstTextBaseline) {
Text("Label")
Text("Multi-line\nvalue text")
}
}When NOT to use Grid: For scrollable content with many items, use LazyVGrid. Grid loads all content at once and is designed for small, fixed datasets (< 50 rows).
Reference: Grid - Apple Documentation
Use LazyVGrid for Scrollable Multi-Column Layouts
Edson's "Design Out Loud" means iterating on grid layouts until the spatial rhythm feels right. LazyVGrid is SwiftUI's scrollable grid — it creates multi-column layouts where items load on demand as they scroll into view. Kocienda's intersection principle applies: the grid must work technically (lazy loading for performance) and visually (consistent column sizes and spacing for rhythm).
Incorrect (non-lazy grid for large scrollable collection — loads everything at once):
struct ProductCatalog: View {
let products: [Product] // Hundreds of products
var body: some View {
ScrollView {
// VStack loads ALL products — causes memory spike and frozen UI
VStack {
ForEach(products) { product in
ProductCard(product: product)
}
}
}
}
}Correct (LazyVGrid loads items on demand with multi-column layout):
struct ProductCatalog: View {
let products: [Product]
private let columns = [GridItem(.adaptive(minimum: 160), spacing: 16)]
var body: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: 16) {
ForEach(products) { product in
ProductCard(product: product)
}
}
.padding(.horizontal, 16)
}
}
}Grid column configuration:
// Fixed: exact width per column
let columns = [
GridItem(.fixed(100)),
GridItem(.fixed(100)),
GridItem(.fixed(100))
]
// Flexible: minimum and maximum width per column
let columns = [
GridItem(.flexible(minimum: 100, maximum: 200)),
GridItem(.flexible(minimum: 100, maximum: 200))
]
// Adaptive: as many columns as fit with minimum width
let columns = [
GridItem(.adaptive(minimum: 150), spacing: 12)
]Photo gallery example:
struct PhotoGalleryView: View {
let photos: [Photo]
private let columns = [GridItem(.adaptive(minimum: 100), spacing: 2)]
var body: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: 2) {
ForEach(photos) { photo in
AsyncImage(url: photo.thumbnailURL) { image in
image
.resizable()
.scaledToFill()
} placeholder: {
Color(.systemFill)
}
.frame(minHeight: 100)
.clipped()
}
}
}
}
}Product catalog with sections:
struct CatalogView: View {
let categories: [Category]
private let columns = [GridItem(.adaptive(minimum: 160), spacing: 16)]
var body: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: 16, pinnedViews: [.sectionHeaders]) {
ForEach(categories) { category in
Section {
ForEach(category.products) { product in
ProductCard(product: product)
}
} header: {
Text(category.name)
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 8)
.background(.bar)
}
}
}
.padding(.horizontal, 16)
}
}
}When NOT to use LazyVGrid: For aligned tabular data with fixed rows, use Grid. For single-column scrollable lists, use List or LazyVStack.
Reference: LazyVGrid - Apple Documentation
Show Scroll Indicators for Long Scrollable Content
Edson's "Design Out Loud" means every element communicates something. Scroll indicators tell the user two things: "there's more content below" and "here's where you are in the content." Hiding them (the default in some configurations) removes this spatial awareness. Kocienda's intersection principle demands that even invisible elements like scroll indicators serve a communicative purpose.
Incorrect (scroll indicators hidden — user doesn't know there's more content):
struct LongFormView: View {
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
ForEach(sections) { section in
SectionView(section: section)
}
}
.padding()
}
.scrollIndicators(.hidden) // User has no idea how much content remains
}
}Correct (scroll indicators visible for long content):
struct LongFormView: View {
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
ForEach(sections) { section in
SectionView(section: section)
}
}
.padding()
}
// Default: indicators appear during scrolling, then fade
// No need to explicitly set — just don't hide them
}
}Scroll indicator configuration:
// Default: show during scrolling (recommended for most content)
ScrollView { /* content */ }
// Always visible (use for very long content where position matters)
ScrollView { /* content */ }
.scrollIndicators(.visible)
// Hidden (only for horizontal carousels and paged content)
ScrollView(.horizontal) { /* carousel */ }
.scrollIndicators(.hidden)When to hide scroll indicators:
- Horizontal carousels with paging (the page dots communicate position instead)
- Full-screen media galleries (indicators distract from content)
- Short content that doesn't actually scroll (indicators would flash and disappear)
When NOT to hide: Any vertically scrollable content with more than one screenful of material. The user needs spatial awareness.
Reference: Scroll views - Human Interface Guidelines
Related skills
How it compares
Choose ios-design for Apple-native HIG decisions rather than generic mobile UI skills aimed at cross-platform frameworks.
FAQ
What platforms does ios-design target?
ios-design targets native iOS development for iPhone and iPad apps, focusing on Human Interface Guidelines, SwiftUI and UIKit patterns, navigation, spacing, typography, and accessibility during interface implementation.
When should an agent invoke ios-design?
Invoke ios-design when a developer is building or refining iOS screens and needs HIG-aligned component choices, layout spacing, adaptive iPad layouts, or native visual polish in SwiftUI or UIKit.