32 KiB
SwiftUI Design Guidelines
Design rules based on Apple Human Interface Guidelines for building native iOS interfaces with SwiftUI.
Design Philosophy
iOS design prioritizes content over chrome. The interface should feel invisible—users focus on their tasks, not the UI.
Key mindsets:
-
Let content breathe — Use full-screen layouts, minimize borders and boxes, let images and text take center stage
-
Leverage system conventions — Users already know how iOS works; don't reinvent navigation, gestures, or controls
-
Design for fingers — Touch is imprecise; generous tap targets and forgiving gesture recognition matter more than pixel-perfect layouts
-
Respect user choices — Honor Dynamic Type, Dark Mode, Reduce Motion, and other accessibility settings as first-class requirements
iOS 26+ Liquid Glass: The latest iOS introduces translucent UI elements that respond to lighting and content behind them. Typography is bolder, text tends left-aligned for easier scanning.
1. Layout & Safe Areas
Impact: CRITICAL
1.1 Minimum 44pt Touch Targets
All interactive elements must have minimum 44x44 points (not pixels—points scale with screen density).
Button(action: handleTap) {
Image(systemName: "heart.fill")
}
.frame(minWidth: 44, minHeight: 44)
Avoid placing critical interactions near screen edges where system gestures operate.
1.2 Respect Safe Areas
Never place interactive or essential content under the status bar, Dynamic Island, or home indicator. SwiftUI respects safe areas by default. Use .ignoresSafeArea() only for background fills, images, or decorative elements—never for text or interactive controls.
ZStack {
LinearGradient(colors: [.blue, .purple], startPoint: .top, endPoint: .bottom)
.ignoresSafeArea()
VStack {
Text("Welcome")
.font(.largeTitle)
Button("Get Started") { }
}
}
1.3 Primary Actions in Thumb Zone
Place primary actions at the bottom of the screen where the user's thumb naturally rests. Secondary actions and navigation belong at the top.
VStack {
ScrollView {
// Content
}
Spacer()
Button("Submit") { submit() }
.buttonStyle(.borderedProminent)
.padding(.horizontal)
.padding(.bottom)
}
1.4 Support All Screen Sizes
Design for iPhone SE (375pt) through iPad Pro (1024pt+). Use Size Classes to adapt:
@Environment(\.horizontalSizeClass) private var sizeClass
var body: some View {
if sizeClass == .compact {
VStack { content }
} else {
HStack { content }
}
}
| Size Class | Devices |
|---|---|
| Compact width | iPhone portrait, small iPhone landscape |
| Regular width | iPad, large iPhone landscape |
Use flexible layouts, avoid hardcoded widths:
HStack(spacing: 16) {
ForEach(categories) { category in
CategoryCard(category: category)
.frame(maxWidth: .infinity)
}
}
1.5 8pt Grid Alignment
Align spacing, padding, and element sizes to multiples of 8 points (8, 16, 24, 32, 40, 48). Use 4pt for fine adjustments.
1.6 Landscape Support
Support landscape orientation unless the app is task-specific (e.g., camera). Use ViewThatFits or GeometryReader for adaptive layouts.
ViewThatFits {
HStack { contentViews }
VStack { contentViews }
}
2. Navigation
Impact: CRITICAL
2.1 Tab Bar for Top-Level Sections
Use a tab bar at the bottom of the screen for 3 to 5 top-level sections. Each tab should represent a distinct category of content or functionality.
TabView(selection: $selectedTab) {
HomeView()
.tabItem {
Label("Home", systemImage: "house")
}
.tag(Tab.home)
DiscoverView()
.tabItem {
Label("Discover", systemImage: "magnifyingglass")
}
.tag(Tab.discover)
AccountView()
.tabItem {
Label("Account", systemImage: "person")
}
.tag(Tab.account)
}
2.2 Navigation Architecture
Tab Bar (Flat) — For 3-5 equal-importance sections
- Always visible except when covered by modals
- Each tab maintains its own navigation stack
- Most important content leftmost (easier thumb access)
Hierarchical (Drill-Down) — For tree-structured info
- Push/pop navigation with back button
- Minimize depth (3-4 levels max)
- Provide search as escape hatch for deep trees
Modal (Focused Tasks) — For self-contained workflows
- Full-screen for critical tasks
- Page sheet for dismissible tasks (swipe-down)
- Clear Done/Cancel with confirmation if data loss possible
Never use hamburger menus—they reduce feature discoverability significantly.
2.3 Large Titles in Primary Views
Use .navigationBarTitleDisplayMode(.large) for top-level views. Titles transition to inline when the user scrolls.
NavigationStack {
List(conversations) { conversation in
ConversationRow(conversation: conversation)
}
.navigationTitle("Inbox")
.navigationBarTitleDisplayMode(.large)
}
2.4 Never Override Back Swipe
The swipe-from-left-edge gesture for back navigation is a system-level expectation. Never attach custom gesture recognizers that interfere with it.
2.5 Use NavigationStack for Hierarchical Content
Use NavigationStack (not the deprecated NavigationView) for drill-down content. Use NavigationPath for programmatic navigation.
@State private var navPath = NavigationPath()
NavigationStack(path: $navPath) {
List(products) { product in
NavigationLink(value: product) {
ProductRow(product: product)
}
}
.navigationDestination(for: Product.self) { product in
ProductDetailView(product: product)
}
}
2.6 Preserve State Across Navigation
When users navigate back and then forward, or switch tabs, restore the previous scroll position and input state.
@SceneStorage("selectedTab") private var selectedTab = Tab.home
@SceneStorage("scrollPosition") private var scrollPosition: String?
3. Typography & Dynamic Type
Impact: HIGH
3.1 Use Built-in Text Styles
Always use semantic text styles—they scale with Dynamic Type automatically:
| Style | Usage |
|---|---|
.largeTitle |
Screen titles |
.title, .title2, .title3 |
Section headers |
.headline |
Emphasized body text |
.body |
Primary content (17pt default) |
.callout |
Secondary emphasized |
.subheadline |
Supporting labels |
.footnote, .caption |
Tertiary info |
.caption2 |
Minimum size (11pt) |
VStack(alignment: .leading, spacing: 8) {
Text("Article Title")
.font(.headline)
Text("Published by Author Name")
.font(.subheadline)
.foregroundStyle(.secondary)
Text(articleBody)
.font(.body)
}
3.2 Support Dynamic Type Including Accessibility Sizes
Dynamic Type can scale text up to approximately 200% at the largest accessibility sizes. Layouts must reflow—never truncate or clip essential text.
@Environment(\.dynamicTypeSize) private var typeSize
var body: some View {
if typeSize.isAccessibilitySize {
VStack(alignment: .leading) { content }
} else {
HStack { content }
}
}
3.3 Custom Fonts Must Scale
If you use a custom typeface, scale it with Font.custom(_:size:relativeTo:) so it responds to Dynamic Type.
Text("Brand Text")
.font(.custom("Avenir-Medium", size: 17, relativeTo: .body))
3.4 SF Pro as System Font
Use the system font (SF Pro) unless brand requirements dictate otherwise. SF Pro is optimized for legibility on Apple displays.
3.5 Minimum 11pt Text
Never display text smaller than 11pt. Prefer 17pt for body text. Use the caption2 style (11pt) as the absolute minimum.
3.6 Hierarchy Through Weight and Size
Establish visual hierarchy through font weight and size. Do not rely solely on color to differentiate text levels.
3.7 SF Symbols
Use SF Symbols (6,900+ icons) instead of custom image assets:
// Basic usage with automatic text alignment
Label("Favorites", systemImage: "star.fill")
// Rendering modes
Image(systemName: "cloud.sun.rain")
.symbolRenderingMode(.hierarchical) // or .multicolor, .palette
.imageScale(.large) // .small, .medium, .large
SF Symbols automatically match text weight, scale with Dynamic Type, and align to text baselines. Let them size naturally—don't force them into fixed-dimension containers.
4. Color & Dark Mode
Impact: HIGH
4.1 Use Semantic System Colors
Never use hard-coded RGB, hex, or .black/.white directly. Use semantic colors:
Labels:
.primary,.secondary,.tertiary,.quaternary
Backgrounds:
Color(.systemBackground)— primary surfaceColor(.secondarySystemBackground)— cards, groupedColor(.tertiarySystemBackground)— nested elements
System Colors (adapt to appearance):
.blue,.red,.green,.orange,.yellow,.purple,.pink,.cyan,.mint,.teal,.indigo,.brown,.gray
VStack {
Text("Primary content")
.foregroundStyle(.primary)
Text("Supporting info")
.foregroundStyle(.secondary)
}
.background(Color(.systemBackground))
4.2 Custom Colors Need 4 Variants
For custom colors, define in asset catalog with all appearance combinations:
- Light mode
- Dark mode
- Light mode + High Contrast
- Dark mode + High Contrast
Text("Branded element")
.foregroundStyle(Color("AccentBrand"))
For dynamic colors in code:
let dynamicColor = UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 0.9, green: 0.9, blue: 1.0, alpha: 1.0)
: UIColor(red: 0.1, green: 0.1, blue: 0.2, alpha: 1.0)
}
4.3 Never Rely on Color Alone
Always pair color with text, icons, or shapes to convey meaning. Approximately 8% of men have some form of color vision deficiency.
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
Text("Connection failed")
}
.foregroundStyle(.red)
4.4 4.5:1 Contrast Ratio Minimum
All text must meet WCAG AA contrast ratios: 4.5:1 for normal text, 3:1 for large text (18pt+ or 14pt+ bold).
4.5 Support Display P3 Wide Gamut
Use Display P3 color space for vibrant, accurate colors on modern iPhones. Define colors in the asset catalog with the Display P3 gamut.
4.6 Background Hierarchy
Layer backgrounds to create visual depth:
// Level 1: Main view background
Color(.systemBackground)
// Level 2: Cards, grouped sections
Color(.secondarySystemBackground)
// Level 3: Nested elements within cards
Color(.tertiarySystemBackground)
4.7 One Accent Color for Interactive Elements
Choose a single tint/accent color for all interactive elements (buttons, links, toggles). This creates a consistent, learnable visual language.
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.tint(.orange)
}
}
}
5. Accessibility
Impact: CRITICAL
5.1 VoiceOver Labels on All Interactive Elements
Every button, control, and interactive element must have a meaningful accessibility label.
Button(action: toggleFavorite) {
Image(systemName: isFavorite ? "heart.fill" : "heart")
}
.accessibilityLabel(isFavorite ? "Remove from favorites" : "Add to favorites")
5.2 Logical VoiceOver Navigation Order
Ensure VoiceOver reads elements in a logical order. Use .accessibilitySortPriority() to adjust when the visual layout doesn't match the reading order.
HStack {
Text("$49.99")
.accessibilitySortPriority(2)
Text("Premium Plan")
.accessibilitySortPriority(1)
}
5.3 Support Bold Text
When the user enables Bold Text in Settings, SwiftUI text styles handle this automatically. Custom text must respond to UIAccessibility.isBoldTextEnabled.
5.4 Support Reduce Motion
Disable decorative animations and parallax when Reduce Motion is enabled.
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
CardView()
.animation(reduceMotion ? nil : .spring(duration: 0.4), value: expanded)
}
5.5 Support Increase Contrast
When the user enables Increase Contrast, ensure custom colors have higher-contrast variants. Use @Environment(\.colorSchemeContrast) to detect.
5.6 Don't Convey Info Only by Color, Shape, or Position
Information must be available through multiple channels. Pair visual indicators with text or accessibility descriptions.
5.7 Alternative Interactions for All Gestures
Every custom gesture must have an equivalent tap-based or menu-based alternative for users who cannot perform complex gestures.
5.8 Support Switch Control and Full Keyboard Access
Ensure all interactions work with Switch Control (external switches) and Full Keyboard Access (Bluetooth keyboards). Test navigation order and focus behavior.
6. Gestures & Input
Impact: HIGH
6.1 Use Standard Gestures
Stick to gestures users already know:
- Tap — Select items, trigger buttons
- Long press — Show context menus, enter edit mode
- Horizontal swipe — List row actions (delete/archive), back navigation
- Vertical swipe — Scroll content, dismiss sheets
- Pinch — Scale images/maps
- Rotate — Adjust angle (photos, maps)
6.2 Never Override System Gestures
iOS reserves these edge gestures—do not intercept:
- Left edge swipe → back navigation
- Top-left pull → Notification Center
- Top-right pull → Control Center
- Bottom edge swipe → home/app switcher
6.3 Custom Gestures Must Be Discoverable
If you add a custom gesture, provide visual hints (e.g., a grabber handle) and ensure the action is also available through a visible button or menu item.
6.4 Support All Input Methods
Design for touch first, but also support hardware keyboards, assistive devices (Switch Control, head tracking), and pointer input.
7. Components
Impact: HIGH
7.1 Button Styles
Use the built-in button styles appropriately:
VStack(spacing: 16) {
Button("Checkout") { checkout() }
.buttonStyle(.borderedProminent)
Button("Add to Wishlist") { addToWishlist() }
.buttonStyle(.bordered)
Button("Remove Item", role: .destructive) { removeItem() }
}
7.2 Alerts — Critical Info Only
Use alerts sparingly for critical information that requires a decision. Prefer 2 buttons; maximum 3.
.alert("Discard Draft?", isPresented: $showDiscardAlert) {
Button("Discard", role: .destructive) { discardDraft() }
Button("Keep Editing", role: .cancel) { }
} message: {
Text("Your unsaved changes will be lost.")
}
7.3 Sheets for Scoped Tasks
Present sheets for self-contained tasks. Always provide a way to dismiss (close button or swipe down).
.sheet(isPresented: $showEditor) {
NavigationStack {
EditorView()
.navigationTitle("Edit Profile")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { showEditor = false }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") { saveProfile() }
}
}
}
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
}
7.4 Lists — The Foundation of iOS Apps
Most iOS apps are lists ("90% of mobile design is list design").
List Styles:
.insetGrouped— Modern default (rounded corners, margins).grouped— Traditional grouped sections.plain— Edge-to-edge rows.sidebar— Three-column iPad layout
Swipe Actions:
- Leading swipe → Positive actions (mark read, archive)
- Trailing swipe → Destructive actions (delete at far right)
- Maximum 3-4 actions per side
Row Accessories:
- Chevron → Indicates navigation
- Checkmark → Shows selection
- Detail button → Additional info without navigation
List {
Section("Notifications") {
ForEach(notifications) { notification in
NotificationRow(notification: notification)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
delete(notification)
} label: {
Label("Delete", systemImage: "trash")
}
Button {
markRead(notification)
} label: {
Label("Read", systemImage: "envelope.open")
}
.tint(.blue)
}
.swipeActions(edge: .leading) {
Button {
pin(notification)
} label: {
Label("Pin", systemImage: "pin")
}
.tint(.orange)
}
}
}
}
.listStyle(.insetGrouped)
7.5 Tab Bar Behavior
- Use SF Symbols for tab icons — filled variant for the selected tab, outline for unselected
- Never hide the tab bar when navigating deeper within a tab
- Badge important counts with
.badge()
NotificationsView()
.tabItem {
Label("Notifications", systemImage: "bell")
}
.badge(unreadCount)
7.6 Search
Place search using .searchable(). Provide search suggestions and support recent searches.
NavigationStack {
List(searchResults) { item in
ItemRow(item: item)
}
.searchable(text: $query, prompt: "Search products")
.searchSuggestions {
ForEach(recentSearches, id: \.self) { term in
Text(term)
.searchCompletion(term)
}
}
}
7.7 Context Menus
Use context menus (long press) for secondary actions. Never use a context menu as the only way to access an action.
ImageThumbnail(image: image)
.contextMenu {
Button { shareImage(image) } label: {
Label("Share", systemImage: "square.and.arrow.up")
}
Button { copyImage(image) } label: {
Label("Copy", systemImage: "doc.on.doc")
}
Divider()
Button(role: .destructive) { deleteImage(image) } label: {
Label("Delete", systemImage: "trash")
}
}
7.8 Forms and Input
Text Fields:
- 44pt minimum height
- Match keyboard type to input (
.emailAddress,.numberPad,.URL) - Clear button when text entered
- Placeholder uses
.quaternarylabel color
Form {
Section("Account") {
TextField("Email", text: $email)
.textContentType(.emailAddress)
.keyboardType(.emailAddress)
.autocapitalization(.none)
SecureField("Password", text: $password)
.textContentType(.password)
}
Section {
Button("Sign In") { signIn() }
.disabled(email.isEmpty || password.isEmpty)
}
}
Pickers:
- Inline → 3-7 options
- Menu → 2-5 options (iOS 14+)
- Wheel → Date/time or long lists
7.9 Progress Indicators
- Determinate
ProgressView(value:total:)for operations with known duration - Indeterminate
ProgressView()for unknown duration - Never block the entire screen with a spinner
VStack {
ProgressView(value: uploadProgress, total: 1.0)
.progressViewStyle(.linear)
Text("\(Int(uploadProgress * 100))% uploaded")
.font(.caption)
.foregroundStyle(.secondary)
}
8. Patterns
Impact: MEDIUM
8.1 Onboarding — Max 3 Pages, Skippable
Keep onboarding to 3 or fewer pages. Always provide a skip option. Defer sign-in until the user needs authenticated features.
TabView(selection: $currentPage) {
OnboardingPage(icon: "sparkles", title: "Smart Features", description: "...")
.tag(0)
OnboardingPage(icon: "bell.badge", title: "Stay Notified", description: "...")
.tag(1)
OnboardingPage(icon: "lock.shield", title: "Private & Secure", description: "...")
.tag(2)
}
.tabViewStyle(.page)
.overlay(alignment: .topTrailing) {
Button("Skip") { finishOnboarding() }
.padding()
}
8.2 Loading — Skeleton Views, No Blocking Spinners
Use skeleton/placeholder views that match the layout of the content being loaded. Never show a full-screen blocking spinner.
if isLoading {
ForEach(0..<5, id: \.self) { _ in
ArticleRowPlaceholder()
.redacted(reason: .placeholder)
}
} else {
ForEach(articles) { article in
ArticleRow(article: article)
}
}
8.3 Launch Screen — Match First Screen
The launch storyboard must visually match the initial screen of the app. No splash logos, no branding screens. This creates the perception of instant launch.
8.4 Modality — Use Sparingly
Present modal views only when the user must complete or abandon a focused task. Always provide a clear dismiss action. Never stack modals on top of modals.
8.5 Notifications — High Value Only
Only send notifications for content the user genuinely cares about. Support actionable notifications. Categorize notifications so users can control them granularly.
8.6 Settings Placement
- Frequent settings: In-app settings screen accessible from a profile or gear icon
- Privacy/permission settings: Defer to the system Settings app via URL scheme
- Never duplicate system-level controls in-app
8.7 Action Sheets
For destructive or multiple-choice actions:
.confirmationDialog("Delete Photo?", isPresented: $showDelete, titleVisibility: .visible) {
Button("Delete", role: .destructive) { deletePhoto() }
Button("Cancel", role: .cancel) { }
} message: {
Text("This action cannot be undone.")
}
- Destructive action at top (red)
- Cancel at bottom
- Dismiss by tapping outside
8.8 Pull-to-Refresh
Standard pattern for content updates:
List(items) { item in
ItemRow(item: item)
}
.refreshable {
await loadNewItems()
}
8.9 Haptic Feedback
Provide tactile response for significant actions:
| Generator | Usage |
|---|---|
UIImpactFeedbackGenerator |
Physical impacts (.light, .medium, .heavy) |
UINotificationFeedbackGenerator |
Success, warning, error |
UISelectionFeedbackGenerator |
Selection changes |
Button("Complete") {
let feedback = UINotificationFeedbackGenerator()
feedback.notificationOccurred(.success)
markComplete()
}
9. Privacy & Permissions
Impact: HIGH
9.1 Request Permissions in Context
Request a permission at the moment the user takes an action that needs it—never at app launch.
Button("Take Photo") {
AVCaptureDevice.requestAccess(for: .video) { granted in
if granted {
showCamera = true
}
}
}
9.2 Explain Before System Prompt
Show a custom explanation screen before triggering the system permission dialog. The system dialog only appears once—if the user denies, the app must direct them to Settings.
struct LocationPermissionView: View {
var body: some View {
VStack(spacing: 20) {
Image(systemName: "location.fill")
.font(.system(size: 48))
.foregroundStyle(.blue)
Text("Find Nearby Places")
.font(.title2.bold())
Text("We use your location to show relevant results. Your location is never stored or shared.")
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
Button("Enable Location") {
locationManager.requestWhenInUseAuthorization()
}
.buttonStyle(.borderedProminent)
Button("Not Now") { dismiss() }
.foregroundStyle(.secondary)
}
.padding()
}
}
9.3 Support Sign in with Apple
If the app offers any third-party sign-in (Google, Facebook), it must also offer Sign in with Apple. Present it as the first option.
9.4 Don't Require Accounts Unless Necessary
Let users explore the app before requiring sign-in. Gate only features that genuinely need authentication (purchases, sync, social features).
9.5 App Tracking Transparency
If you track users across apps or websites, display the ATT prompt. Respect denial—do not degrade the experience for users who opt out.
9.6 Location Button for One-Time Access
Use LocationButton for actions that need location once without requesting ongoing permission.
LocationButton(.currentLocation) {
fetchNearbyResults()
}
.symbolVariant(.fill)
.labelStyle(.titleAndIcon)
10. System Integration
Impact: MEDIUM
10.1 Widgets for Glanceable Data
Provide widgets using WidgetKit for information users check frequently. Widgets are not interactive (beyond tapping to open the app), so show the most useful snapshot.
10.2 App Shortcuts for Key Actions
Define App Shortcuts so users can trigger key actions from Siri, Spotlight, and the Shortcuts app.
struct MyAppShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: QuickAddIntent(),
phrases: ["Add item in \(.applicationName)"],
shortTitle: "Quick Add",
systemImageName: "plus.circle"
)
}
}
10.3 Spotlight Indexing
Index app content with CSSearchableItem so users can find it from Spotlight search.
10.4 Share Sheet Integration
Support the system share sheet for content that users might want to send elsewhere.
ShareLink(item: article.url, subject: Text(article.title)) {
Label("Share Article", systemImage: "square.and.arrow.up")
}
10.5 Live Activities
Use Live Activities and the Dynamic Island for real-time, time-bound events (delivery tracking, sports scores, workouts).
10.6 Handle Interruptions Gracefully
Save state and pause gracefully when interrupted by phone calls, Siri invocations, notifications, app switcher, or FaceTime SharePlay.
@Environment(\.scenePhase) private var scenePhase
var body: some View {
ContentView()
.onChange(of: scenePhase) { _, newPhase in
switch newPhase {
case .active:
resumeActivity()
case .inactive:
pauseActivity()
case .background:
saveState()
@unknown default:
break
}
}
}
Quick Reference
Navigation & Structure
| Component | When to Use |
|---|---|
TabView |
3-5 main app sections |
NavigationStack |
Hierarchical content drill-down |
.sheet |
Focused tasks requiring user completion |
.alert |
Decisions that block workflow |
.contextMenu |
Additional actions (always provide alternatives) |
Data Display
| Component | When to Use |
|---|---|
List |
Scrollable rows with sections |
LazyVGrid / LazyHGrid |
Grid layouts |
.searchable |
Filterable content |
ProgressView |
Loading or task progress |
User Input
| Component | When to Use |
|---|---|
TextField |
Single-line text |
TextEditor |
Multi-line text |
Picker |
Selection from options |
Toggle |
Binary on/off choice |
Stepper |
Numeric increment/decrement |
System Features
| Component | When to Use |
|---|---|
ShareLink |
Content sharing |
LocationButton |
One-time location access |
PhotosPicker |
Image selection |
UIImpactFeedbackGenerator |
Tactile response |
Anti-Patterns
Avoid these common HIG violations:
| Pattern | Problem | Solution |
|---|---|---|
| Hamburger/drawer menu | Hides navigation, users miss features | Use TabView with 3-5 tabs |
| Broken back swipe | Custom gestures block system navigation | Keep NavigationStack default behavior |
| Full-screen spinner | App feels frozen, no progress indication | Use skeleton views with .redacted() |
| Logo splash screen | Artificial delay, wastes user time | Match launch screen to first view |
| Permissions at launch | Users deny without context | Request when action requires it |
| Fixed font sizes | Breaks Dynamic Type, accessibility issues | Use .font(.body) semantic styles |
| Color-only status | Colorblind users miss information | Add icons or text labels |
| Alert overuse | Interrupts flow for minor info | Use inline messages or banners |
| Hidden tab bar | Users lose navigation context | Keep tab bar visible on push |
| Content in unsafe areas | Text hidden under notch/Dynamic Island | Only ignore safe area for backgrounds |
| No modal dismiss | Users trapped in view | Add cancel button and swipe dismiss |
| Gesture-only actions | Accessibility users blocked | Provide button/menu alternatives |
| Small tap targets | Frequent mis-taps | Minimum 44x44pt hit area |
| Nested modals | Navigation confusion | Use NavigationStack within single sheet |
| Hardcoded colors | Broken in Dark Mode | Use semantic colors or asset variants |
Review Checklist
Code review checklist for SwiftUI apps:
Layout
- Interactive elements have 44pt minimum touch area
- Essential content stays within safe area bounds
- Main actions positioned for one-handed use (bottom)
- UI works across iPhone SE to Pro Max screen sizes
- Spacing uses 8pt increments
Navigation
- Main sections use bottom TabView (3-5 tabs)
- No drawer/hamburger navigation
- Root views show large navigation titles
- System back gesture not blocked
- Tab state persists when switching
Text & Fonts
- Text uses semantic styles (
.body,.headline, etc.) - Dynamic Type works at all sizes including accessibility
- Content reflows without truncation at large sizes
- No text below 11pt
Colors
- Uses
.primary,.secondary,Color(.systemBackground) - Custom colors have light/dark variants in assets
- Status indicators combine color with icon/text
- Text contrast ratio meets WCAG AA
Accessibility
- Icon buttons have
.accessibilityLabel() - VoiceOver order matches logical flow
- Animations respect
accessibilityReduceMotion - All actions have non-gesture alternatives
Modals & Alerts
- Alerts reserved for critical decisions only
- Sheets provide clear dismiss mechanism
- No stacked modal presentations
Permissions
- Permissions requested at point of use
- Pre-permission explanation screens used
- Core features work without sign-in
iPad Adaptation
iPad users expect different interaction patterns:
Layout: Use NavigationSplitView for master-detail:
NavigationSplitView(columnVisibility: $columnVisibility) {
SidebarView()
} content: {
ListContentView()
} detail: {
DetailView()
}
.navigationSplitViewStyle(.balanced)
Presentation: Action sheets become popovers automatically, but you can force popover:
.popover(isPresented: $showOptions) {
OptionsView()
}
Keyboard: Add shortcuts for power users:
.keyboardShortcut("n", modifiers: .command) // Cmd+N
Drag & Drop: Enable cross-app data transfer:
.draggable(item)
.dropDestination(for: Item.self) { items, location in
handleDrop(items)
return true
}
Pre-Release Verification
Run through these scenarios before shipping:
Visual consistency:
- Switch between Light/Dark mode—does everything remain readable?
- Crank Dynamic Type to maximum—does layout adapt or break?
- Enable Bold Text—do custom fonts respond?
Interaction quality:
- Can you complete every action using only VoiceOver?
- Do all buttons feel tappable on first try (no mis-taps)?
- Does back-swipe work everywhere in navigation?
Edge cases:
- What happens on iPhone SE's small screen?
- What happens on iPad with keyboard attached?
- What shows when network fails mid-operation?
- What happens if user denies permissions?
Platform compliance:
- Are you using SF Symbols instead of custom icon PNGs?
- Are all colors from semantic palette or asset catalog with variants?
- Do destructive actions require explicit confirmation?
SwiftUI, SF Symbols, Dynamic Island, and Apple are trademarks of Apple Inc.