
Ios Hig
- 230 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
ios-hig: A skill for development.
About
ios-hig: A skill for development. This provides functionality for development workflows.
- ios-hig
Ios Hig by the numbers
- 230 all-time installs (skills.sh)
- +7 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,715 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-higAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 230 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I use ios-hig for development tasks?
Use ios-hig for development tasks
Who is it for?
Best when you're working on backend & apis and need structured help with ios hig.
Skip if: Teams with no backend & apis needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to use ios-hig for development tasks, or when ios-hig: a skill for development.
What you get
Structured output aligned to ios-hig: ios-hig.
Files
Apple iOS HIG Best Practices
Comprehensive guide for Apple Human Interface Guidelines compliance in iOS apps built with SwiftUI. Contains 34 rules across 6 categories covering navigation, interaction design, accessibility, user feedback, UX patterns, and visual design.
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 navigation hierarchies with tab bars, NavigationStack, or split views
- Designing touch interactions, gestures, and haptic feedback
- Ensuring accessibility with VoiceOver, Dynamic Type, and color contrast
- Implementing loading states, error handling, and empty states
- Building onboarding flows, permission requests, and confirmation dialogs
- Supporting dark mode, SF Symbols, and standard layout margins
- Reviewing apps for HIG compliance
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Navigation | CRITICAL | nav- |
| 2 | Interaction Design | CRITICAL | inter- |
| 3 | Accessibility | CRITICAL | acc- |
| 4 | User Feedback | HIGH | feed- |
| 5 | UX Patterns | HIGH | ux- |
| 6 | Visual Design | HIGH | vis- |
Quick Reference
1. Navigation (CRITICAL)
- `nav-tab-bar` - Design tab bars for top-level navigation
- `nav-navigation-stack` - Use NavigationStack for hierarchical navigation
- `nav-toolbar-placement` - Place actions in toolbars using standard placements
2. Interaction Design (CRITICAL)
- `inter-touch-targets` - Maintain 44pt minimum touch targets
- `inter-gesture-patterns` - Use standard gesture patterns
- `inter-haptic-feedback` - Add haptic feedback for meaningful events
- `inter-keyboard-handling` - Handle keyboard appearance gracefully
- `inter-drag-drop` - Support drag and drop for content transfer
- `inter-pull-to-refresh` - Support pull to refresh for lists
- `inter-swipe-actions` - Add swipe actions for contextual operations
- `inter-list-search` - Use searchable for built-in search
3. Accessibility (CRITICAL)
- `acc-labels` - Provide meaningful accessibility labels
- `acc-dynamic-type` - Support Dynamic Type for all text
- `acc-color-contrast` - Maintain sufficient color contrast
- `acc-reduce-motion` - Respect reduce motion preference
- `acc-color-independent` - Never rely on color alone
- `acc-focus-management` - Manage focus for assistive technologies
- `acc-scaled-metric` - Use ScaledMetric for adaptive sizing
- `acc-view-that-fits` - Use ViewThatFits for adaptive layouts
4. User Feedback (HIGH)
- `feed-loading-states` - Show appropriate loading indicators
- `feed-error-states` - Handle errors with clear recovery actions
- `feed-notifications` - Use notifications judiciously
- `feed-success-confirmation` - Confirm successful actions appropriately
- `feed-empty-states` - Design helpful empty states
5. UX Patterns (HIGH)
- `ux-onboarding` - Design minimal onboarding
- `ux-permissions` - Request permissions in context
- `ux-modality` - Use modality appropriately
- `ux-confirmation-dialog` - Use confirmation dialogs for destructive actions
- `ux-data-entry` - Minimize data entry friction
- `ux-undo` - Support undo for destructive actions
- `ux-settings` - Organize settings logically
6. Visual Design (HIGH)
- `vis-dark-mode` - Support dark mode with semantic colors
- `vis-sf-symbols` - Use SF Symbols with correct rendering mode and weight
- `vis-layout-margins` - Use standard layout margins and safe areas
How to Use
Read individual reference files for detailed explanations and code examples:
- Section definitions - Category structure and impact levels
- Rule template - Template for adding new rules
Reference Files
| File | Description |
|---|---|
| references/_sections.md | Category definitions and ordering |
| assets/templates/_template.md | Template for new rules |
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.1.7",
"organization": "Apple iOS HIG Best Practices",
"technology": "iOS 26 / Swift 6.2 (SwiftUI)",
"date": "February 2026",
"abstract": "Comprehensive Apple Human Interface Guidelines compliance guide for iOS apps built with SwiftUI, designed for AI agents and LLMs. Contains rules across categories covering navigation, interaction design, accessibility, user feedback, and UX patterns. Each rule includes detailed explanations with incorrect vs. correct SwiftUI implementations, quantified impact metrics, and references to official Apple HIG documentation. Covers iOS 26 / Swift 6.2 patterns including NavigationStack, ContentUnavailableView, sensoryFeedback, confirmationDialog, and ViewThatFits. Aligned with the iOS 26 / Swift 6.2 clinic modular MVVM-C architecture.",
"references": [
"https://developer.apple.com/design/human-interface-guidelines/",
"https://developer.apple.com/design/human-interface-guidelines/accessibility",
"https://developer.apple.com/design/human-interface-guidelines/navigation-and-search",
"https://developer.apple.com/tutorials/develop-in-swift/",
"https://developer.apple.com/documentation/swiftui"
],
"category": "Design Guidelines"
}
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.
---
1. Navigation (nav)
Impact: CRITICAL Description: Tab bars, navigation stacks, split views, and toolbar placement define how users move through your app. Navigation is the most fundamental HIG topic — getting it wrong makes every screen harder to reach.
2. Interaction Design (inter)
Impact: CRITICAL Description: Touch targets, gestures, haptics, keyboard handling, and swipe actions define how users physically interact with your app. Getting these wrong breaks the native iOS feel.
3. Accessibility (acc)
Impact: CRITICAL Description: VoiceOver labels, Dynamic Type, color contrast, reduce motion, and focus management are not optional. Accessibility support is required for App Store quality and reaches 15%+ of users.
4. User Feedback (feed)
Impact: HIGH Description: Loading states, error handling, notifications, success confirmation, and empty states communicate system status to users. Clear feedback builds trust and reduces confusion.
5. UX Patterns (ux)
Impact: HIGH Description: Onboarding, permissions, modality, data entry, undo, confirmation dialogs, and settings organization follow Apple's established patterns that users already understand.
6. Visual Design (vis)
Impact: HIGH Description: Dark mode support, SF Symbols usage, layout margins, and safe area handling ensure your app looks native and adapts to all system configurations.
Maintain Sufficient Color Contrast
Text must have sufficient contrast against its background. WCAG requires 4.5:1 for normal text and 3:1 for large text (18pt+ regular or 14pt+ bold).
Incorrect (insufficient contrast):
// Light gray text on white - fails contrast
Text("Hard to read")
.foregroundColor(Color(white: 0.7))
.background(Color.white)
struct LowContrastLabel: View {
var body: some View {
Text("Important info")
.foregroundColor(.gray) // ~3:1 ratio on white
.background(.white)
}
}
// Custom accent with poor contrast
Button("Submit") {
// action
}
.tint(Color(red: 0.6, green: 0.8, blue: 1.0)) // Too lightCorrect (accessible contrast):
// Use semantic colors (guaranteed accessible)
Text("Easy to read")
.foregroundColor(.primary) // 4.5:1+ contrast
// Secondary text still maintains contrast
Text("Supporting info")
.foregroundStyle(.secondary) // System color ensures contrast
// System colors are contrast-optimized
Button("Submit") {
// action
}
.buttonStyle(.borderedProminent) // System handles contrast
// Accent color designed for contrast
Button("Tap here") { }
.foregroundStyle(.accentColor) // Designed for contrastSystem colors automatically handle contrast:
// These adapt to light/dark mode with proper contrast
.primary // Black/White - highest contrast
.secondary // ~60% opacity - 4.5:1 minimum
.tertiary // ~30% opacity - use sparingly
.accentColor // Tinted, always meets contrast
// Be careful with custom colors
Color("CustomGray") // Must verify contrast in both modesTesting contrast:
// Use Accessibility Inspector to check contrast ratios
// Or online tools like webaim.org/resources/contrastchecker
// Preview in increased contrast mode
#Preview {
ContentView()
.environment(\.accessibilityContrast, .increased)
}Contrast checking tools:
- Xcode Accessibility Inspector
- Color Contrast Analyzer
- WebAIM Contrast Checker
Reference: Accessibility - Human Interface Guidelines
Never Rely on Color Alone
Always supplement color with another indicator (icon, label, pattern) to convey information. Approximately 8% of men have some form of color vision deficiency.
Incorrect (color as only indicator):
// Status only shown by color
Circle()
.fill(isOnline ? Color.green : Color.red)
.frame(width: 12, height: 12)
// Error indicated only by red text
TextField("Email", text: $email)
.foregroundColor(hasError ? .red : .primary)
// Colorblind user can't see the difference
// Chart with color-only legend
ForEach(datasets) { data in
LineMark(x: .value("X", data.x), y: .value("Y", data.y))
.foregroundStyle(by: .value("Category", data.category))
}
// Relies on distinguishing colorsCorrect (color plus other indicators):
// Status with icon and color
HStack(spacing: 4) {
Image(systemName: isOnline ? "circle.fill" : "circle")
.foregroundColor(isOnline ? .green : .secondary)
.font(.caption2)
Text(isOnline ? "Online" : "Offline")
.font(.caption)
}
// Error with icon and color
VStack(alignment: .leading) {
TextField("Email", text: $email)
if hasError {
Label("Invalid email format", systemImage: "exclamationmark.circle")
.font(.caption)
.foregroundColor(.red)
}
}
// Form validation with multiple cues
HStack {
TextField("Password", text: $password)
Image(systemName: isValid ? "checkmark.circle.fill" : "xmark.circle.fill")
.foregroundColor(isValid ? .green : .red)
.accessibilityLabel(isValid ? "Valid" : "Invalid")
}
// Charts with patterns or labels
ForEach(datasets) { data in
LineMark(x: .value("X", data.x), y: .value("Y", data.y))
.foregroundStyle(by: .value("Category", data.category))
.symbol(by: .value("Category", data.category))
// Different symbols for each category
}Supplementary indicators:
- Icons (checkmark, x, warning)
- Text labels ("Error", "Success")
- Patterns (dashed, dotted lines)
- Shapes (circle, square, triangle)
- Position or size changes
Reference: Accessibility - Human Interface Guidelines
Support Dynamic Type for All Text
All text must scale with Dynamic Type settings. Users with low vision may need text up to 310% larger. Layouts must accommodate this without truncation. Over 25% of users adjust their text size settings.
Incorrect (fixed font sizes):
// Fixed size ignores user preferences
Text("Hello")
.font(.system(size: 17))
// Layout breaks at larger sizes
HStack {
Text("Long label text here")
.font(.body)
Spacer()
Text("Value")
.lineLimit(1) // Truncates at large sizes
}
// Fixed frame truncates
Text(title)
.frame(height: 44) // Too small for large text
// Fixed sizes in a profile header
struct ProfileHeader: View {
let user: User
var body: some View {
HStack {
Avatar(url: user.avatarURL)
.frame(width: 60, height: 60)
VStack(alignment: .leading) {
Text(user.name)
.font(.system(size: 18)) // Fixed, won't scale
Text(user.bio)
.font(.system(size: 14)) // Fixed
}
}
}
}Correct (scales with Dynamic Type):
// Use text styles
Text("Hello")
.font(.body)
// Layout adapts to text size
@ScaledMetric var spacing: CGFloat = 8
VStack(alignment: .leading, spacing: spacing) {
Text("Long label text here")
.font(.body)
Text("Value")
.font(.body)
.foregroundColor(.secondary)
}
// Flexible layout with ViewThatFits
ViewThatFits {
HStack {
Text(label)
Spacer()
Text(value)
}
VStack(alignment: .leading) {
Text(label)
Text(value)
.foregroundColor(.secondary)
}
}
// Adaptive layout switching at accessibility sizes
struct ProfileHeader: View {
let user: User
@Environment(\.dynamicTypeSize) var typeSize
var body: some View {
Group {
if typeSize.isAccessibilitySize {
VStack(alignment: .leading, spacing: 12) { content }
} else {
HStack(spacing: 16) { content }
}
}
}
@ViewBuilder private var content: some View {
Avatar(url: user.avatarURL)
.frame(width: typeSize.isAccessibilitySize ? 80 : 60,
height: typeSize.isAccessibilitySize ? 80 : 60)
VStack(alignment: .leading) {
Text(user.name).font(.headline)
Text(user.bio).font(.subheadline)
}
}
}Using ScaledMetric for custom values:
struct CustomCard: View {
@ScaledMetric(relativeTo: .body) var iconSize = 24
@ScaledMetric(relativeTo: .body) var spacing = 12
var body: some View {
HStack(spacing: spacing) {
Image(systemName: "star")
.frame(width: iconSize, height: iconSize)
Text("Favorite")
}
}
}Scaled image sizes:
@ScaledMetric(relativeTo: .body) var iconSize: CGFloat = 24
Image(systemName: "star")
.font(.system(size: iconSize))
// UIKit Dynamic Type
label.font = UIFont.preferredFont(forTextStyle: .body)
label.adjustsFontForContentSizeCategory = true
label.numberOfLines = 0 // Allow wrappingLimiting scaling for specific elements:
Text("Price")
.font(.caption)
.dynamicTypeSize(...DynamicTypeSize.accessibility1) // Cap at accessibility1Testing Dynamic Type:
// Preview at different sizes
#Preview {
ProfileHeader(user: .preview)
.environment(\.dynamicTypeSize, .accessibility3)
}Accessibility categories:
- xSmall through xxxLarge (7 sizes)
- AX1 through AX5 (5 more for accessibility)
- Settings -> Accessibility -> Display & Text Size -> Larger Text
- Enable "Larger Accessibility Sizes" and test at largest size (AX5)
Reference: Typography - Human Interface Guidelines
Manage Focus for Assistive Technologies
Manage focus order to ensure logical navigation. Focus should move through content in a meaningful sequence, and move to new content when it appears.
Incorrect (poor focus management):
// Modal appears but focus doesn't move
.sheet(isPresented: $showSheet) {
SheetContent()
}
// VoiceOver user still focused on background
// Logical order broken
VStack {
Button("Action") { }
// After action, new content appears above
if showResult {
Text("Result here") // Focus doesn't move here
}
}
// Custom order that doesn't make sense
HStack {
Text("Step 2")
.accessibilitySortPriority(1)
Text("Step 1")
.accessibilitySortPriority(2)
}Correct (proper focus management):
// Focus moves to sheet content automatically
.sheet(isPresented: $showSheet) {
NavigationStack {
SheetContent()
.navigationTitle("Title")
}
}
// SwiftUI handles focus for standard presentations
// Announce and focus new content
@AccessibilityFocusState var isResultFocused: Bool
VStack {
if showResult {
Text("Result: Success")
.accessibilityFocused($isResultFocused)
}
Button("Get Result") {
showResult = true
isResultFocused = true
}
}
// Logical grouping
VStack {
Text("Section Title")
Text("Section content here")
}
.accessibilityElement(children: .combine)
// Custom focus order when needed
HStack {
Text("Name: ")
TextField("", text: $name)
}
.accessibilityElement(children: .contain)
// Groups for logical navigation
// Announce dynamic content changes
Text(statusMessage)
.accessibilityAddTraits(.updatesFrequently)
.onChange(of: statusMessage) { _, newValue in
UIAccessibility.post(notification: .announcement, argument: newValue)
}Focus management principles:
- Let system handle standard presentations
- Move focus to new content (alerts, results)
- Group related elements logically
- Use
.accessibilityFocusStatefor dynamic changes - Announce important updates
Reference: Accessibility - Human Interface Guidelines
Provide Meaningful Accessibility Labels
Every interactive element needs a meaningful accessibility label. VoiceOver reads these labels to describe UI elements. Without them, users hear unhelpful descriptions like "button" or "image". Labels should describe what the element does, not what it looks like.
Incorrect (missing or poor labels):
// No label - VoiceOver says "button"
Button {
toggleFavorite()
} label: {
Image(systemName: "heart")
}
// Label describes appearance, not function
Button {
toggleFavorite()
} label: {
Image(systemName: "heart")
}
.accessibilityLabel("Heart icon")
// Missing label on icon-only elements
Image(systemName: "info.circle")
.onTapGesture { showInfo() }
// VoiceOver can't interact with this
// Icon buttons without descriptive labels
struct SocialActions: View {
var body: some View {
HStack {
Button { like() } label: {
Image(systemName: "heart") // VoiceOver: "heart, button"
}
Button { share() } label: {
Image(systemName: "square.and.arrow.up") // Unhelpful
}
}
}
}Correct (meaningful accessibility labels):
// Describes the action with state
Button {
toggleFavorite()
} label: {
Image(systemName: isFavorite ? "heart.fill" : "heart")
}
.accessibilityLabel(isFavorite ? "Remove from favorites" : "Add to favorites")
// Include state information
Toggle("Notifications", isOn: $notificationsEnabled)
// SwiftUI handles "on/off" state automatically
// Custom value for complex controls
Slider(value: $volume, in: 0...100)
.accessibilityLabel("Volume")
.accessibilityValue("\(Int(volume)) percent")
// Descriptive labels for icon buttons
struct SocialActions: View {
let isLiked: Bool
let isBookmarked: Bool
var body: some View {
HStack {
Button { like() } label: {
Image(systemName: isLiked ? "heart.fill" : "heart")
}
.accessibilityLabel(isLiked ? "Unlike" : "Like")
Button { share() } label: {
Image(systemName: "square.and.arrow.up")
}
.accessibilityLabel("Share")
.accessibilityHint("Opens share sheet to share this item")
Button { bookmark() } label: {
Image(systemName: isBookmarked ? "bookmark.fill" : "bookmark")
}
.accessibilityLabel(isBookmarked ? "Remove bookmark" : "Add bookmark")
}
}
}Common accessibility modifiers:
Image("profile-photo")
.accessibilityLabel("Profile photo of \(user.name)")
Button("X") { dismiss() }
.accessibilityLabel("Close")
.accessibilityHint("Dismisses this screen")
TextField("Search", text: $query)
.accessibilityLabel("Search recipes")Combine related elements:
HStack {
Image("user-avatar")
VStack(alignment: .leading) {
Text(user.name)
Text(user.email)
}
}
.accessibilityElement(children: .combine)
.accessibilityLabel("\(user.name), \(user.email)")Hiding decorative elements:
// Decorative images don't need VoiceOver
Image("decorative-divider")
.accessibilityHidden(true)
// Group related elements
VStack {
Image(systemName: "star.fill")
Text("4.5")
}
.accessibilityElement(children: .combine)
.accessibilityLabel("Rating: 4.5 stars")Label guidelines:
- Describe function, not appearance
- Include state ("selected", "expanded")
- Use hints sparingly (for non-obvious actions)
- Hide purely decorative elements
- Test with VoiceOver enabled
Reference: Accessibility - Human Interface Guidelines
Respect Reduce Motion Preference
When users enable "Reduce Motion", replace animations with fades or instant transitions. This prevents discomfort for users with vestibular disorders.
Incorrect (ignoring motion preference):
// Always uses bouncy animation
withAnimation(.spring(response: 0.6, dampingFraction: 0.6)) {
showDetail = true
}
// Parallax effects always on
ScrollView {
GeometryReader { geo in
Image("header")
.offset(y: geo.frame(in: .global).minY / 2)
}
}
// Auto-playing animations
LottieView(animation: .loading)
.looping()
// Always bounces, even with Reduce Motion enabled
struct BouncyButton: View {
@State private var isPressed = false
var body: some View {
Button("Tap Me") { }
.scaleEffect(isPressed ? 0.9 : 1.0)
.animation(.spring(response: 0.3, dampingFraction: 0.5), value: isPressed)
}
}Correct (respects motion preference):
// Check reduce motion setting
@Environment(\.accessibilityReduceMotion) var reduceMotion
// Conditional animation
withAnimation(reduceMotion ? .none : .spring()) {
showDetail = true
}
// Replace motion with crossfade
.transition(reduceMotion ? .opacity : .move(edge: .trailing))
// Button respecting preference
struct BouncyButton: View {
@State private var isPressed = false
@Environment(\.accessibilityReduceMotion) var reduceMotion
var body: some View {
Button("Tap Me") { }
.scaleEffect(isPressed ? 0.95 : 1.0)
.animation(reduceMotion ? .none : .spring(), value: isPressed)
}
}Alternative animations for reduce motion:
struct AnimatedView: View {
@State private var isVisible = false
@Environment(\.accessibilityReduceMotion) var reduceMotion
var body: some View {
if isVisible {
ContentView()
.transition(reduceMotion ? .opacity : .slide)
}
}
}Using withAnimation conditionally:
func toggle() {
if reduceMotion {
isExpanded.toggle() // Instant change
} else {
withAnimation(.spring()) {
isExpanded.toggle()
}
}
}Disable parallax when needed:
struct ParallaxHeader: View {
@Environment(\.accessibilityReduceMotion) var reduceMotion
var body: some View {
if reduceMotion {
Image("header")
} else {
GeometryReader { geo in
Image("header")
.offset(y: geo.frame(in: .global).minY / 2)
}
}
}
}
// Control auto-play
@Environment(\.accessibilityReduceMotion) var reduceMotion
LottieView(animation: .loading)
.looping(!reduceMotion)Animation wrapper extension:
extension View {
func conditionalAnimation<V: Equatable>(
_ animation: Animation?,
value: V,
reduceMotion: Bool
) -> some View {
self.animation(reduceMotion ? nil : animation, value: value)
}
}What to simplify with Reduce Motion:
- Replace sliding/bouncing with fades
- Remove parallax effects
- Disable auto-playing animations
- Reduce transition durations
- Use crossfades instead of spatial transitions
- Allow essential animations (loading spinners)
- Test with Settings -> Accessibility -> Motion -> Reduce Motion
Reference: Motion - Human Interface Guidelines
Use @ScaledMetric for Size-Adaptive Values
Dynamic Type scales text automatically, but hardcoded spacing, padding, and icon sizes stay fixed. This creates visual imbalance when users increase their text size: large text with tiny icons or cramped padding. @ScaledMetric scales any numeric value proportionally with the user's Dynamic Type setting.
Incorrect (hardcoded icon size stays fixed at all text sizes):
struct CategoryLabel: View {
let category: Category
var body: some View {
Label {
Text(category.name)
.font(.body)
} icon: {
Image(systemName: category.icon)
.frame(width: 24, height: 24)
.padding(6)
.background(category.color.opacity(0.2))
.clipShape(RoundedRectangle(cornerRadius: 4))
}
}
}Correct (@ScaledMetric values grow with Dynamic Type):
struct CategoryLabel: View {
let category: Category
@ScaledMetric private var iconSize: CGFloat = 24 // scales with Dynamic Type
@ScaledMetric private var iconPadding: CGFloat = 6
var body: some View {
Label {
Text(category.name)
.font(.body)
} icon: {
Image(systemName: category.icon)
.frame(width: iconSize, height: iconSize)
.padding(iconPadding)
.background(category.color.opacity(0.2))
.clipShape(RoundedRectangle(cornerRadius: 4))
}
}
}Reference: Develop in Swift Tutorials
Use ViewThatFits for Adaptive Layouts
When users increase their text size or rotate to a narrow orientation, fixed horizontal layouts overflow and clip content. ViewThatFits evaluates each child view in order and renders the first one that fits within the available space, letting you provide graceful fallbacks without manual size calculations.
Incorrect (fixed horizontal layout overflows with large text):
struct EventActions: View {
let event: CalendarEvent
var body: some View {
HStack(spacing: 12) {
Button("Accept") { acceptEvent(event) }
.buttonStyle(.borderedProminent)
Button("Decline") { declineEvent(event) }
.buttonStyle(.bordered)
Button("Tentative") { tentativeEvent(event) }
.buttonStyle(.bordered)
}
}
}Correct (ViewThatFits switches to vertical layout when needed):
struct EventActions: View {
let event: CalendarEvent
var body: some View {
ViewThatFits { // picks the first layout that fits
HStack(spacing: 12) {
Button("Accept") { acceptEvent(event) }
.buttonStyle(.borderedProminent)
Button("Decline") { declineEvent(event) }
.buttonStyle(.bordered)
Button("Tentative") { tentativeEvent(event) }
.buttonStyle(.bordered)
}
VStack(spacing: 8) {
Button("Accept") { acceptEvent(event) }
.buttonStyle(.borderedProminent)
Button("Decline") { declineEvent(event) }
.buttonStyle(.bordered)
Button("Tentative") { tentativeEvent(event) }
.buttonStyle(.bordered)
}
}
}
}Reference: Develop in Swift Tutorials
Design Helpful Empty States
Empty states should explain why the screen is empty and guide users on how to add content. Never show a blank screen or just "No items."
Incorrect (unhelpful empty states):
// Just text
if items.isEmpty {
Text("No items")
}
// Completely blank
List(items) { item in
ItemRow(item: item)
}
// When empty, shows nothing
// Error-like empty state
if favorites.isEmpty {
Text("Error: No favorites found")
.foregroundColor(.red)
}Correct (helpful empty states):
// ContentUnavailableView (iOS 17+)
if items.isEmpty {
ContentUnavailableView(
"No Items Yet",
systemImage: "tray",
description: Text("Items you add will appear here.")
)
}
// With action button
if favorites.isEmpty {
ContentUnavailableView {
Label("No Favorites", systemImage: "heart.slash")
} description: {
Text("Tap the heart on items you love to save them here.")
} actions: {
Button("Browse Items") {
selectedTab = .browse
}
.buttonStyle(.borderedProminent)
}
}
// Search empty state
if searchResults.isEmpty && !searchText.isEmpty {
ContentUnavailableView.search(text: searchText)
}
// Custom empty state
if orders.isEmpty {
VStack(spacing: 16) {
Image(systemName: "bag")
.font(.system(size: 64))
.foregroundColor(.secondary)
Text("No Orders Yet")
.font(.headline)
Text("When you place an order, it will appear here.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
Button("Start Shopping") {
selectedTab = .shop
}
.buttonStyle(.borderedProminent)
}
.padding()
}Empty state guidelines:
- Use relevant SF Symbol icon
- Explain why it's empty
- Provide action to add content
- Don't make it feel like an error
- Match the tone of your app
- Consider first-run vs later empty
Reference: Feedback - Human Interface Guidelines
Handle Errors with Clear Recovery Actions
When errors occur, explain what went wrong and provide clear actions to resolve. Never show technical error messages or leave users without a path forward.
Incorrect (unhelpful error handling):
// Technical error message
.alert("Error", isPresented: $showError) {
Button("OK") { }
} message: {
Text(error.localizedDescription)
// "Error Domain=NSURLErrorDomain Code=-1009..."
}
// No error handling at all
Button("Save") {
try? await save() // Silently fails
}
// Generic error without context
Text("Something went wrong")
.foregroundColor(.red)Correct (helpful error recovery):
// User-friendly error with action
.alert("Unable to Save", isPresented: $showError) {
Button("Try Again") { retry() }
Button("Cancel", role: .cancel) { }
} message: {
Text("Check your internet connection and try again.")
}
// Error state with recovery
ContentUnavailableView {
Label("Connection Lost", systemImage: "wifi.slash")
} description: {
Text("Unable to load items. Check your connection and pull to refresh.")
} actions: {
Button("Try Again") {
Task { await loadItems() }
}
.buttonStyle(.borderedProminent)
}
// Inline field validation
VStack(alignment: .leading) {
TextField("Email", text: $email)
.textContentType(.emailAddress)
if !emailError.isEmpty {
Label(emailError, systemImage: "exclamationmark.circle")
.font(.caption)
.foregroundColor(.red)
}
}
// Network error with offline state
if networkError {
VStack {
Image(systemName: "wifi.exclamationmark")
.font(.largeTitle)
Text("You're Offline")
.font(.headline)
Text("Connect to the internet to see updates")
.font(.subheadline)
.foregroundColor(.secondary)
}
}Error message guidelines:
- Explain what happened in plain language
- Avoid blaming the user
- Provide a clear action to resolve
- Use appropriate severity (error vs warning)
- Log technical details, don't show them
Reference: Feedback - Human Interface Guidelines
Show Appropriate Loading Indicators
Always show loading state during async operations. Use progress indicators for known durations and activity indicators for unknown durations. Never leave users wondering.
Incorrect (no loading feedback):
// Button with no loading state
Button("Submit") {
await submit() // User has no idea it's working
}
// Empty screen during load
if items.isEmpty {
Text("No items") // Shown during load too
}
// Full-screen spinner for quick loads
if isLoading {
ProgressView()
.scaleEffect(2)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
// Overkill for sub-second operationsCorrect (appropriate loading feedback):
// Button with loading state
Button {
isSubmitting = true
await submit()
isSubmitting = false
} label: {
if isSubmitting {
ProgressView()
} else {
Text("Submit")
}
}
.disabled(isSubmitting)
// List with loading, empty, and content states
Group {
if isLoading {
ProgressView("Loading items...")
} else if items.isEmpty {
ContentUnavailableView(
"No Items",
systemImage: "tray",
description: Text("Items you add will appear here")
)
} else {
List(items) { item in
ItemRow(item: item)
}
}
}
// Progress bar for known duration
ProgressView(value: progress, total: 100) {
Text("Uploading...")
} currentValueLabel: {
Text("\(Int(progress))%")
}
// Inline loading for partial refresh
List {
ForEach(items) { item in
ItemRow(item: item)
}
if isLoadingMore {
ProgressView()
.frame(maxWidth: .infinity)
}
}Loading indicator types:
| Type | Usage |
|---|---|
| Activity indicator | Unknown duration |
| Progress bar | Known progress |
| Skeleton/placeholder | Content shape known |
| Inline spinner | Partial updates |
Use Notifications Judiciously
Only send notifications for information the user truly needs. Every notification should be actionable and timely. Over-notifying leads to users disabling all notifications.
Incorrect (notification spam):
// Marketing notifications
UNUserNotificationCenter.current().add(
UNNotificationRequest(
identifier: "promo",
content: {
let content = UNMutableNotificationContent()
content.title = "Don't miss out!"
content.body = "Check out our new features!"
return content
}(),
trigger: nil
)
)
// User didn't ask for marketing
// Notification for minor events
// "John viewed your profile" - Not actionable
// Duplicate notifications
// Push + in-app alert for same eventCorrect (valuable notifications):
// Timely, actionable notification
let content = UNMutableNotificationContent()
content.title = "Your order is arriving"
content.body = "Driver is 5 minutes away"
content.categoryIdentifier = "ORDER_ARRIVING"
content.sound = .default
// Rich notification with actions
let trackAction = UNNotificationAction(
identifier: "TRACK",
title: "Track Order",
options: [.foreground]
)
let category = UNNotificationCategory(
identifier: "ORDER_ARRIVING",
actions: [trackAction],
intentIdentifiers: []
)
UNUserNotificationCenter.current()
.setNotificationCategories([category])
// Badge for unread count (iOS 16+)
try? await UNUserNotificationCenter.current().setBadgeCount(unreadCount)
// Clear when seen
try? await UNUserNotificationCenter.current().setBadgeCount(0)Notification guidelines:
- Only notify for user-relevant events
- Include actionable information
- Use sound sparingly
- Update badge count accurately
- Clear badges when content is read
- Provide notification settings in-app
- Group related notifications
Good notification examples:
- Message from a person
- Reminder user created
- Order status change
- Time-sensitive event
Bad notification examples:
- "We miss you!"
- Minor activity updates
- Promotional content
- Feature announcements
Reference: Notifications - Human Interface Guidelines
Confirm Successful Actions Appropriately
Confirm successful actions at the right level of prominence. Minor actions need subtle feedback; major actions may warrant more prominent confirmation.
Incorrect (wrong confirmation level):
// Alert for minor action
Button("Add to Favorites") {
addToFavorites()
}
.alert("Added!", isPresented: $showSuccess) {
Button("OK") { }
}
// Too prominent for simple action
// No confirmation for important action
Button("Transfer $500") {
await transfer()
// Nothing - user wonders if it worked
}
// Blocking confirmation for quick actions
Button("Like") {
like()
}
.sheet(isPresented: $showConfirmation) {
Text("You liked this post!")
Button("Great!") { }
}
// Ridiculous for a like buttonCorrect (proportional confirmation):
// Subtle feedback for minor actions
Button {
isFavorite.toggle()
} label: {
Image(systemName: isFavorite ? "heart.fill" : "heart")
.foregroundColor(isFavorite ? .red : .secondary)
}
.sensoryFeedback(.success, trigger: isFavorite)
// Visual change + haptic is enough
// Toast/banner for moderate actions
Button("Save") {
await save()
savedMessage = "Changes saved"
}
// Show brief banner that auto-dismisses
// Full confirmation for significant actions
Button("Complete Purchase") {
await purchase()
}
// Navigate to confirmation screen with:
// - Order summary
// - Confirmation number
// - Next steps
// Inline success state
HStack {
TextField("Email", text: $email)
if isValid {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
}
}Confirmation hierarchy:
| Action | Feedback |
|---|---|
| Toggle, like | Animation + haptic |
| Save, update | Brief toast |
| Delete | Confirmation first, then toast |
| Purchase, transfer | Confirmation screen |
| Account change | Email + in-app confirmation |
Reference: Feedback - Human Interface Guidelines
Support Drag and Drop for Content Transfer
Support drag and drop for moving and copying content. This is essential on iPad and useful on iPhone for reordering and cross-app sharing.
Incorrect (no drag support for movable content):
// List with reorder but no drag feedback
List {
ForEach(items) { item in
ItemRow(item: item)
}
.onMove { from, to in
items.move(fromOffsets: from, toOffset: to)
}
}
// Works in edit mode only, no direct drag
// Images without drag capability
Image("photo")
// Can't drag to share or saveCorrect (proper drag and drop support):
// Reorderable list with drag
List {
ForEach(items) { item in
ItemRow(item: item)
.draggable(item) // Enable drag
}
.onMove { from, to in
items.move(fromOffsets: from, toOffset: to)
}
}
// Drop target
DropZone()
.dropDestination(for: Item.self) { items, location in
handleDrop(items)
return true
}
// Draggable image
Image("photo")
.draggable(Image("photo")) {
// Custom drag preview
Image("photo")
.resizable()
.frame(width: 100, height: 100)
.opacity(0.8)
}
// Transferable for custom types
struct Item: Codable, Transferable {
static var transferRepresentation: some TransferRepresentation {
CodableRepresentation(contentType: .data)
}
}
// Combined drag and drop for reorder
List {
ForEach(items) { item in
ItemRow(item: item)
}
.onMove { /* ... */ }
}
.environment(\.editMode, .constant(.active))
// Shows drag handlesDrag and drop guidelines:
- Show lift animation on drag start
- Provide clear drop targets with highlight
- Support both move and copy where appropriate
- Use spring animation for drops
- Cancel gracefully if dropped outside valid targets
Reference: Drag and drop - Human Interface Guidelines
Use Standard Gesture Patterns
Use standard iOS gestures for their expected purposes. Don't override system gestures or create novel interactions where standards exist.
Incorrect (non-standard gestures):
// Overriding system edge swipe
.gesture(
DragGesture()
.onEnded { value in
if value.startLocation.x < 50 {
// Custom action instead of back navigation
showMenu = true
}
}
)
// Conflicts with system back gesture
// Double-tap for action instead of zoom
Image("photo")
.onTapGesture(count: 2) {
likePhoto() // Users expect zoom
}
// Horizontal swipe in a vertical list
List {
ForEach(items) { item in
ItemRow(item: item)
.gesture(
DragGesture()
.onEnded { _ in performAction() }
)
}
}
// Use swipeActions insteadCorrect (standard gesture usage):
// Standard swipe actions on list rows
List {
ForEach(items) { item in
ItemRow(item: item)
.swipeActions(edge: .trailing) {
Button("Delete", role: .destructive) { delete(item) }
Button("Archive") { archive(item) }
}
.swipeActions(edge: .leading) {
Button("Pin") { pin(item) }
}
}
}
// Pull to refresh
List {
// content
}
.refreshable {
await loadData()
}
// Pinch to zoom on images (iOS 18+)
ScrollView {
Image("photo")
.resizable()
.scaledToFit()
}
.defaultScrollAnchor(.center)
// Long press for context menu (system standard)
ItemRow(item: item)
.contextMenu {
Button("Share") { }
Button("Edit") { }
}Standard gesture expectations:
| Gesture | Expected Action |
|---|---|
| Tap | Primary action |
| Double-tap | Zoom (images), like (social) |
| Long press | Context menu |
| Swipe left | Destructive actions |
| Swipe right | Positive actions |
| Pull down | Refresh, dismiss sheet |
| Edge swipe | Back navigation |
| Pinch | Zoom |
Reference: Gestures - Human Interface Guidelines
Use Haptic Feedback for Meaningful Events
Use haptic feedback to confirm actions and provide tactile responses. Match haptic intensity to action importance. Don't overuse haptics or they lose meaning.
Incorrect (misused or excessive haptics):
// Haptic on every scroll
ScrollView {
ForEach(items) { item in
ItemRow(item: item)
.onAppear {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
}
}
}
// Overwhelming and meaningless
// Wrong haptic for action
Button("Delete") {
UINotificationFeedbackGenerator().notificationOccurred(.success)
// Success haptic for destructive action feels wrong
}
// No tactile feedback at all
struct RatingView: View {
@State private var rating = 0
var body: some View {
HStack {
ForEach(1...5, id: \.self) { star in
Image(systemName: star <= rating ? "star.fill" : "star")
.onTapGesture {
rating = star // Silent, no feedback
}
}
}
}
}Correct (meaningful haptic feedback):
// SwiftUI sensoryFeedback (iOS 17+)
Button("Add to Cart") {
addToCart()
}
.sensoryFeedback(.success, trigger: addedToCart)
// Toggle with haptic
Toggle("Enable Notifications", isOn: $notificationsEnabled)
.sensoryFeedback(.selection, trigger: notificationsEnabled)
// Rating with haptic on selection
struct RatingView: View {
@State private var rating = 0
var body: some View {
HStack {
ForEach(1...5, id: \.self) { star in
Image(systemName: star <= rating ? "star.fill" : "star")
.foregroundStyle(star <= rating ? .yellow : .gray)
.onTapGesture {
rating = star
UIImpactFeedbackGenerator(style: .light).impactOccurred()
}
}
}
}
}Haptic types:
// Impact - physical collision feel
UIImpactFeedbackGenerator(style: .light).impactOccurred() // Subtle tap
UIImpactFeedbackGenerator(style: .medium).impactOccurred() // Button press
UIImpactFeedbackGenerator(style: .heavy).impactOccurred() // Strong impact
// Selection - scrolling through options
UISelectionFeedbackGenerator().selectionChanged()
// Notification - success/warning/error
UINotificationFeedbackGenerator().notificationOccurred(.success)
UINotificationFeedbackGenerator().notificationOccurred(.warning)
UINotificationFeedbackGenerator().notificationOccurred(.error)UIKit haptics with lower latency:
struct HapticButton: View {
let action: () -> Void
// Prepare before the expected event for lower latency
let generator = UIImpactFeedbackGenerator(style: .medium)
var body: some View {
Button("Submit") {
action()
generator.impactOccurred()
}
.onAppear { generator.prepare() } // warm up engine ahead of time
}
}UIKit HapticManager for reuse:
struct HapticManager {
static func impact(_ style: UIImpactFeedbackGenerator.FeedbackStyle) {
UIImpactFeedbackGenerator(style: style).impactOccurred()
}
static func notification(_ type: UINotificationFeedbackGenerator.FeedbackType) {
UINotificationFeedbackGenerator().notificationOccurred(type)
}
}
// Success completion
HapticManager.notification(.success)
// Error or failure
HapticManager.notification(.error)
// Selection changed
HapticManager.impact(.light)
// Button press
HapticManager.impact(.medium)
// Significant action
HapticManager.impact(.heavy)Haptic type guidelines:
| Haptic | Usage |
|---|---|
.selection | Picker changes, toggles |
.success | Task completed, saved |
.warning | Approaching limit |
.error | Failed action |
.light | Subtle feedback, toggle/switch changes |
.medium | Standard button, pull to refresh trigger |
.heavy | Significant action |
When to use haptics:
- Toggle/switch changes: .light impact
- Button confirmations: .medium impact
- Destructive actions: .warning notification
- Success states: .success notification
- Picker selection: .selectionChanged
- Pull to refresh trigger: .medium impact
Reference: Playing haptics - Human Interface Guidelines
Handle Keyboard Appearance Gracefully
Ensure the keyboard never obscures the active input field. Use keyboard avoidance, scroll into view, or adjust content insets automatically.
Incorrect (keyboard obscures input):
// Fixed layout doesn't move
VStack {
Spacer()
TextField("Message", text: $message)
Button("Send") { }
}
// Input field hidden when keyboard appears
// Manual padding that doesn't adapt
VStack {
TextField("Email", text: $email)
TextField("Password", text: $password)
Button("Login") { }
}
.padding(.bottom, 300) // Hardcoded, wrong on different keyboardsCorrect (proper keyboard handling):
// ScrollView automatically adjusts
ScrollView {
VStack(spacing: 16) {
TextField("Email", text: $email)
.textContentType(.emailAddress)
SecureField("Password", text: $password)
.textContentType(.password)
Button("Login") { login() }
.buttonStyle(.borderedProminent)
}
.padding()
}
// ScrollView adjusts contentInset for keyboard
// Form handles keyboard automatically
Form {
Section("Account") {
TextField("Email", text: $email)
SecureField("Password", text: $password)
}
}
// For fixed bottom input (chat-style)
VStack {
ScrollView {
// Message list
}
HStack {
TextField("Message", text: $message)
Button("Send") { }
}
.padding()
}
.safeAreaInset(edge: .bottom) {
// Already handles keyboard
}
Dismiss keyboard with @FocusState:
@FocusState private var isFocused: Bool
TextField("Message", text: $message)
.focused($isFocused)
Button("Done") { isFocused = false }
// Or dismiss on scroll
.scrollDismissesKeyboard(.interactively)Keyboard handling patterns:
- ScrollView/Form: Automatic adjustment
- Fixed input: Use
.safeAreaInset - Dismiss:
.scrollDismissesKeyboard(.interactively) - Focus management:
@FocusState
Reference: Keyboards - Human Interface Guidelines
Use searchable for Built-In Search
The .searchable modifier gives you a system-standard search bar that integrates with NavigationStack, handles keyboard dismissal, and follows platform conventions automatically. Building a custom TextField for search requires manually managing placement, styling, and focus behavior, and the result will look and feel inconsistent with the rest of iOS.
Incorrect (custom TextField for search):
struct ContactsView: View {
@State private var searchText = ""
let contacts = ["Alice", "Bob", "Charlie", "Diana", "Edward"]
var filteredContacts: [String] {
searchText.isEmpty ? contacts : contacts.filter { $0.localizedCaseInsensitiveContains(searchText) }
}
var body: some View {
NavigationStack {
VStack {
TextField("Search contacts...", text: $searchText) // manual search field outside list
.textFieldStyle(.roundedBorder)
.padding(.horizontal)
List(filteredContacts, id: \.self) { contact in
Text(contact)
}
}
.navigationTitle("Contacts")
}
}
}Correct (using .searchable modifier):
struct ContactsView: View {
@State private var searchText = ""
let contacts = ["Alice", "Bob", "Charlie", "Diana", "Edward"]
var filteredContacts: [String] {
searchText.isEmpty ? contacts : contacts.filter { $0.localizedCaseInsensitiveContains(searchText) }
}
var body: some View {
NavigationStack {
List(filteredContacts, id: \.self) { contact in
Text(contact)
}
.navigationTitle("Contacts")
.searchable(text: $searchText) // system search bar with automatic placement
}
}
}Reference: Develop in Swift Tutorials
Support Pull-to-Refresh for Lists
Implement pull-to-refresh for scrollable content that can be updated from a server. Use the system .refreshable modifier for consistent behavior.
Incorrect (non-standard refresh):
// Manual refresh button instead of pull
VStack {
Button("Refresh") {
loadData()
}
List(items) { item in
ItemRow(item: item)
}
}
// Users expect pull gesture
// Custom pull implementation that feels wrong
List {
// content
}
.simultaneousGesture(
DragGesture()
.onEnded { _ in
loadData() // No visual feedback, wrong timing
}
)Correct (system pull-to-refresh):
// SwiftUI refreshable
List(items) { item in
ItemRow(item: item)
}
.refreshable {
await loadData()
}
// With custom content
ScrollView {
LazyVStack {
ForEach(items) { item in
ItemRow(item: item)
}
}
}
.refreshable {
await loadData()
}
// UIKit implementation
class ItemsViewController: UITableViewController {
override func viewDidLoad() {
super.viewDidLoad()
refreshControl = UIRefreshControl()
refreshControl?.addTarget(
self,
action: #selector(refresh),
for: .valueChanged
)
}
@objc func refresh() {
loadData { [weak self] in
self?.refreshControl?.endRefreshing()
}
}
}
// Combine with loading state
List {
if isLoading && items.isEmpty {
ProgressView()
} else {
ForEach(items) { item in
ItemRow(item: item)
}
}
}
.refreshable {
await loadData()
}Pull-to-refresh guidelines:
- Only for server-refreshable content
- Use system implementation for consistent feel
- Show activity indicator during refresh
- Update content when complete
- Works with List, ScrollView
- Don't use for offline-only content
Reference: Loading - Human Interface Guidelines
Add Swipe Actions for Contextual Operations
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.
Swipe actions are the standard iOS pattern for row-level operations like delete, archive, and favorite. Embedding dedicated buttons directly inside each row clutters the visible layout and deviates from platform conventions users already know. The .swipeActions modifier keeps the row clean while making destructive and common actions discoverable through the familiar swipe gesture.
Incorrect (custom swipe gesture or visible buttons):
// Don't implement custom swipe gestures for standard actions
List(friends) { friend in
Text(friend.name)
.gesture(DragGesture()...) // Complex and non-standard
}
// Dedicated delete button visible in each row clutters the layout
struct InboxView: View {
@State private var messages = ["Meeting tomorrow", "Lunch plans", "Project update"]
var body: some View {
List {
ForEach(messages, id: \.self) { message in
HStack {
Text(message)
Spacer()
Button("Delete") {
messages.removeAll { $0 == message }
}
.foregroundStyle(.red)
}
}
}
}
}Correct (using .swipeActions and .onDelete):
// Simple delete with onDelete
struct FriendListView: View {
@Environment(\.modelContext) private var modelContext
@Query private var friends: [Friend]
var body: some View {
List {
ForEach(friends) { friend in
Text(friend.name)
}
.onDelete(perform: deleteFriends)
}
}
private func deleteFriends(at offsets: IndexSet) {
for index in offsets {
modelContext.delete(friends[index])
}
}
}
// Custom swipe actions with leading and trailing edges
struct InboxView: View {
@State private var messages = ["Meeting tomorrow", "Lunch plans", "Project update"]
@State private var favorites: Set<String> = []
var body: some View {
List {
ForEach(messages, id: \.self) { message in
Text(message)
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
messages.removeAll { $0 == message }
} label: {
Label("Delete", systemImage: "trash")
}
}
.swipeActions(edge: .leading) {
Button {
favorites.insert(message)
} label: {
Label("Favorite", systemImage: "star")
}
.tint(.yellow)
}
}
}
}
}Swipe action patterns:
.onDelete()enables Edit mode and swipe-to-delete.swipeActions(edge:)for custom actions- Use
role: .destructivefor delete actions - Leading edge for positive actions, trailing for destructive
Reference: Develop in Swift Tutorials - Create, update, and delete data
Maintain 44pt Minimum Touch Targets
All interactive elements must have a minimum touch target of 44x44 points. This is essential for accessibility and reduces tap errors for all users, especially those with motor impairments.
Incorrect (too small touch targets):
// Icon button without adequate touch area
Button {
toggleFavorite()
} label: {
Image(systemName: "heart")
.font(.system(size: 16))
}
// Actual tap area may be only 16x16pt
// Small close button
Button {
dismiss()
} label: {
Image(systemName: "xmark")
}
.frame(width: 20, height: 20) // Too small
// Tightly spaced buttons
HStack(spacing: 4) {
Button("A") { }
Button("B") { }
Button("C") { }
}
// Easy to tap wrong buttonCorrect (adequate touch targets):
// Explicit minimum frame
Button {
toggleFavorite()
} label: {
Image(systemName: "heart")
.font(.system(size: 20))
}
.frame(minWidth: 44, minHeight: 44)
// Close button with proper size
Button {
dismiss()
} label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 24))
.foregroundColor(.secondary)
}
.frame(width: 44, height: 44)
// contentShape for custom tap areas
Button {
action()
} label: {
HStack {
Image(systemName: "star")
Text("Favorite")
}
}
.contentShape(Rectangle())
.frame(minHeight: 44)
// Adequate spacing between targets
HStack(spacing: 16) {
ForEach(actions) { action in
Button(action.title) { }
.frame(minWidth: 44, minHeight: 44)
}
}Using contentShape for custom hit areas:
struct CompactRow: View {
let item: Item
let action: () -> Void
var body: some View {
HStack {
Text(item.title)
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 8) // Visual padding
.contentShape(Rectangle()) // Entire row is tappable
.frame(minHeight: 44) // Minimum height
.onTapGesture(perform: action)
}
}Spacing between targets:
struct ActionBar: View {
var body: some View {
HStack(spacing: 8) { // Minimum 8pt between targets
ForEach(actions) { action in
Button { } label: {
Image(systemName: action.icon)
.frame(width: 44, height: 44)
}
}
}
}
}Touch target guidelines:
- Minimum size: 44x44 points
- Minimum spacing between targets: 8pt
- Icons can be smaller visually with larger tap area
- Test with finger, not mouse pointer
Common violations to avoid:
- Icon buttons without frame expansion
- Dense toolbars with < 8pt spacing
- Small checkboxes or radio buttons
- Text links without padding
Testing with accessibility inspector:
// Accessibility inspector shows touch target sizes
// Xcode > Open Developer Tool > Accessibility InspectorReference: Accessibility - Human Interface Guidelines
Use NavigationStack for Hierarchical 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.
NavigationStack (iOS 16+) replaces NavigationView for drill-down navigation. It supports programmatic navigation via NavigationPath, type-safe destinations, and deep linking. NavigationView is deprecated.
Incorrect (deprecated NavigationView):
// NavigationView is deprecated in iOS 16+
NavigationView {
List(items) { item in
NavigationLink(destination: DetailView(item: item)) {
ItemRow(item: item)
}
}
.navigationTitle("Items")
}Correct (NavigationStack with type-safe destinations):
struct ItemListView: View {
@State private var path = NavigationPath()
let items: [Item]
var body: some View {
NavigationStack(path: $path) {
List(items) { item in
NavigationLink(value: item) {
ItemRow(item: item)
}
}
.navigationTitle("Items")
.navigationDestination(for: Item.self) { item in
DetailView(item: item)
}
}
}
}Programmatic navigation for deep linking:
// Push screens programmatically
Button("Go to Settings") {
path.append(Route.settings)
}
// Pop to root
Button("Back to Home") {
path.removeLast(path.count)
}When to use NavigationStack vs NavigationSplitView:
- NavigationStack: iPhone-first, single-column drill-down
- NavigationSplitView: iPad/Mac, multi-column sidebar + detail
Reference: Navigation - Human Interface Guidelines
Design Tab Bars for Top-Level Navigation
Tab bars provide instant access to top-level sections. Use a tab bar for apps with 3-5 equally important sections. Each tab should represent a distinct category of content, not an action.
Incorrect (misused tab bar):
// Too many tabs, actions mixed with navigation
TabView {
HomeView().tabItem { Label("Home", systemImage: "house") }
SearchView().tabItem { Label("Search", systemImage: "magnifyingglass") }
AddView().tabItem { Label("Add", systemImage: "plus.circle") } // action, not a section
FavoritesView().tabItem { Label("Favorites", systemImage: "heart") }
ProfileView().tabItem { Label("Profile", systemImage: "person") }
SettingsView().tabItem { Label("Settings", systemImage: "gear") } // 6th tab overflows
}Correct (focused tab bar with distinct sections):
TabView {
HomeView()
.tabItem { Label("Home", systemImage: "house") }
SearchView()
.tabItem { Label("Search", systemImage: "magnifyingglass") }
FavoritesView()
.tabItem { Label("Favorites", systemImage: "heart") }
ProfileView()
.tabItem { Label("Profile", systemImage: "person") }
}
// Actions like "Add" belong in toolbars, not tabsTab bar guidelines:
- Maximum 5 tabs on iPhone
- Each tab is a section, not an action
- Always show icon + label (never icon-only)
- Use SF Symbols that match Apple's weight conventions
- Preserve tab state when switching between tabs
Reference: Tab bars - Human Interface Guidelines
Place Actions in Toolbars Using Standard Placements
Toolbars organize actions in predictable positions. Use standard placements so users find controls where they expect them. Don't put primary actions in unexpected locations.
Incorrect (actions in wrong positions):
NavigationStack {
ContentView()
.toolbar {
// Cancel in wrong position
ToolbarItem(placement: .topBarTrailing) {
Button("Cancel") { dismiss() }
}
// Save in wrong position
ToolbarItem(placement: .topBarLeading) {
Button("Save") { save() }
}
}
}Correct (standard toolbar placements):
NavigationStack {
ContentView()
.navigationTitle("New Item")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") { save() }
}
}
}Standard placement conventions:
| Placement | Usage | Position |
|---|---|---|
.cancellationAction | Cancel, Close | Leading |
.confirmationAction | Save, Done | Trailing |
.primaryAction | Main action | Trailing |
.destructiveAction | Delete | Varies |
.bottomBar | Frequent actions | Bottom |
.keyboard | Input helpers | Above keyboard |
Reference: Toolbars - Human Interface Guidelines
Use Confirmation Dialogs for Destructive Actions
Use .confirmationDialog (action sheet) for destructive actions that can't be undone. Alerts are for important information; confirmation dialogs are for choosing between actions. Never delete data without confirmation.
Incorrect (immediate destructive action or alert misuse):
// No confirmation before permanent delete
Button("Delete Account", role: .destructive) {
deleteAccount() // gone forever, no warning
}
// Alert used instead of confirmation dialog
.alert("Delete?", isPresented: $showDelete) {
Button("Delete", role: .destructive) { delete() }
Button("Cancel", role: .cancel) { }
}
// Alerts are for information, not action selectionCorrect (confirmation dialog for destructive actions):
Button("Delete Account", role: .destructive) {
showDeleteConfirmation = true
}
.confirmationDialog(
"Delete Account",
isPresented: $showDeleteConfirmation,
titleVisibility: .visible
) {
Button("Delete Account", role: .destructive) {
deleteAccount()
}
} message: {
Text("This will permanently delete your account and all data. This cannot be undone.")
}
// Multiple destructive options
.confirmationDialog("Photo", isPresented: $showPhotoOptions) {
Button("Take Photo") { openCamera() }
Button("Choose from Library") { openLibrary() }
Button("Delete Photo", role: .destructive) { deletePhoto() }
}When to use each:
| Control | Usage |
|---|---|
.confirmationDialog | Choosing between actions, destructive confirmation |
.alert | Important information, simple yes/no decisions |
| Inline undo | Reversible actions (archive, mark as read) |
Reference: Alerts - Human Interface Guidelines
Minimize Data Entry Friction
Make data entry as easy as possible: use appropriate keyboards, enable AutoFill, provide defaults, and minimize required fields.
Incorrect (friction-heavy forms):
// All fields required, no help
Form {
TextField("First Name", text: $firstName)
TextField("Last Name", text: $lastName)
TextField("Email", text: $email)
TextField("Phone", text: $phone)
TextField("Address Line 1", text: $address1)
TextField("Address Line 2", text: $address2)
TextField("City", text: $city)
TextField("State", text: $state)
TextField("ZIP", text: $zip)
TextField("Country", text: $country)
}
// 10 fields, no AutoFill, generic keyboards
// Manual date entry
TextField("Birth Date (MM/DD/YYYY)", text: $birthDate)
// Error-prone formatCorrect (friction-minimized input):
Form {
Section("Contact") {
TextField("Name", text: $name)
.textContentType(.name)
.textInputAutocapitalization(.words)
TextField("Email", text: $email)
.textContentType(.emailAddress)
.keyboardType(.emailAddress)
.textInputAutocapitalization(.never)
TextField("Phone", text: $phone)
.textContentType(.telephoneNumber)
.keyboardType(.phonePad)
}
Section("Address") {
TextField("Street", text: $street)
.textContentType(.streetAddressLine1)
TextField("City", text: $city)
.textContentType(.addressCity)
// Picker instead of text for state
Picker("State", selection: $state) {
ForEach(states, id: \.self) { state in
Text(state)
}
}
TextField("ZIP", text: $zip)
.textContentType(.postalCode)
.keyboardType(.numberPad)
}
}
// Native date picker
DatePicker("Birth Date", selection: $birthDate, displayedComponents: .date)
// Sensible defaults
Toggle("Same as billing address", isOn: $sameAsBilling)
// Pre-fill shipping with billing addressData entry principles:
- Use pickers over text fields when options are limited
- Enable AutoFill with textContentType
- Show appropriate keyboard
- Pre-fill from available data
- Mark optional vs required clearly
- Validate inline, not just on submit
Reference: Entering data - Human Interface Guidelines
Use Modality Appropriately
Use modal presentations (sheets, alerts) only for focused tasks that require completion before continuing. Don't trap users in modal flows unnecessarily.
Incorrect (modal overuse):
// Modal for navigation
.sheet(isPresented: $showProfile) {
ProfileView() // This should be pushed, not modal
}
// Modal within modal
.sheet(isPresented: $showForm) {
FormView()
.sheet(isPresented: $showPicker) {
PickerView() // Nested modals confuse users
}
}
// Modal for viewing content
.sheet(isPresented: $showArticle) {
ArticleView(article: article) // Should be navigation
}
// No way to exit
.fullScreenCover(isPresented: $showOnboarding) {
OnboardingView()
.interactiveDismissDisabled(true)
}Correct (appropriate modality):
// Modal for self-contained task
.sheet(isPresented: $showCompose) {
NavigationStack {
ComposeMessageView()
.navigationTitle("New Message")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { showCompose = false }
}
}
}
}
// Navigation for viewing content
NavigationLink(value: article) {
ArticleRow(article: article)
}
.navigationDestination(for: Article.self) { article in
ArticleView(article: article)
}
// Sheet with detents for partial modal
.sheet(isPresented: $showFilter) {
FilterView()
.presentationDetents([.medium])
.presentationDragIndicator(.visible)
}
// Full screen only for immersive content
.fullScreenCover(isPresented: $showCamera) {
CameraView(onCapture: { showCamera = false })
}When to use modality:
| Scenario | Presentation |
|---|---|
| View content | NavigationLink |
| Self-contained task | Sheet |
| Quick selection | Sheet (medium) |
| Camera/media capture | Full screen |
| Critical decision | Alert |
Modal guidelines:
- Task must be completable or cancellable
- Avoid nested modals (max 1 level)
- Provide clear exit (Cancel/Done/X)
- Warn before dismissing unsaved changes
Reference: Modality - Human Interface Guidelines
Design Minimal Onboarding
Onboarding should be brief and optional. Show only what users need to get started, and let them learn through use. Users want to use your app, not read about it.
Incorrect (excessive onboarding):
// Too many screens
PageView {
OnboardingPage(title: "Welcome", description: "...")
OnboardingPage(title: "Feature 1", description: "...")
OnboardingPage(title: "Feature 2", description: "...")
OnboardingPage(title: "Feature 3", description: "...")
OnboardingPage(title: "Feature 4", description: "...")
}
// 5+ screens exhausts patience
// Can't skip
if !hasCompletedOnboarding {
OnboardingView()
.interactiveDismissDisabled() // Trapped
}
// Teaching obvious features
OnboardingPage(
title: "Tap buttons",
description: "Tap buttons to perform actions"
)Correct (minimal, valuable onboarding):
// Quick start with 2-3 screens max
TabView {
WelcomePage()
KeyFeaturePage()
GetStartedPage()
}
.tabViewStyle(.page)
// Always allow skip
VStack {
TabView { /* onboarding pages */ }
Button("Skip") {
completeOnboarding()
}
.foregroundColor(.secondary)
}
// Or skip onboarding entirely - show empty state guidance
if items.isEmpty {
ContentUnavailableView {
Label("Start Your Collection", systemImage: "plus.circle")
} description: {
Text("Tap + to add your first item")
} actions: {
Button("Add First Item") { addItem() }
.buttonStyle(.borderedProminent)
}
}
// Contextual tooltips instead of upfront tour
.popover(isPresented: $showFeatureTip) {
VStack {
Text("New: Swipe to archive")
.font(.headline)
Text("Swipe left on any item to quickly archive it")
}
.padding()
}Onboarding principles:
- Max 3 screens for initial onboarding
- Always offer skip option
- Show value, not features
- Use progressive disclosure
- Empty states teach by doing
- Remember "seen" state properly
Reference: Onboarding - Human Interface Guidelines
Request Permissions in Context
Request permissions when the user needs the feature, not at launch. Explain why you need the permission before the system dialog appears.
Incorrect (poor permission timing):
// All permissions at launch
struct ContentView: View {
var body: some View {
HomeView()
.onAppear {
requestCameraPermission()
requestLocationPermission()
requestNotificationPermission()
requestContactsPermission()
}
}
}
// Overwhelming and no context
// No explanation before request
Button("Take Photo") {
AVCaptureDevice.requestAccess(for: .video) { _ in }
}
// User doesn't know why camera is neededCorrect (contextual permission requests):
// Pre-permission explanation
struct PhotoFeatureView: View {
@State private var showPermissionExplanation = false
var body: some View {
Button("Add Photo") {
let status = AVCaptureDevice.authorizationStatus(for: .video)
switch status {
case .notDetermined:
showPermissionExplanation = true
case .authorized:
openCamera()
case .denied, .restricted:
showSettingsPrompt()
@unknown default:
break
}
}
.alert("Camera Access", isPresented: $showPermissionExplanation) {
Button("Enable Camera") {
AVCaptureDevice.requestAccess(for: .video) { granted in
if granted { openCamera() }
}
}
Button("Not Now", role: .cancel) { }
} message: {
Text("We need camera access to let you add photos to your recipes.")
}
}
}
// Request when feature is used
struct LocationFeatureView: View {
var body: some View {
Button("Find Nearby Stores") {
locationManager.requestWhenInUseAuthorization()
}
// Only asks when user wants location-based feature
}
}
// Handle denied permission gracefully
if locationStatus == .denied {
VStack {
Text("Location access is needed to find stores near you")
Button("Open Settings") {
UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!)
}
}
}Permission best practices:
- Ask only when feature requires it
- Explain benefit before system dialog
- Don't ask for multiple permissions at once
- Handle denial gracefully
- Provide alternative if permission denied
Reference: Privacy - Human Interface Guidelines
Organize Settings Logically
Group related settings together and use clear labels. Most users should never need to visit settings - defaults should work well. Avoid burying essential features.
Incorrect (poor settings organization):
// Flat list of unrelated settings
List {
Toggle("Notifications", isOn: $notifications)
Toggle("Dark Mode", isOn: $darkMode) // System handles this
TextField("Username", text: $username) // Not a setting
Toggle("Auto-play", isOn: $autoPlay)
Toggle("Sound", isOn: $sound)
Picker("Language", selection: $language) // System handles this
Toggle("Analytics", isOn: $analytics)
}
// Essential features hidden in settings
// Main feature only accessible via Settings → Advanced → Enable FeatureCorrect (organized settings):
List {
Section {
NavigationLink {
AccountSettingsView()
} label: {
Label("Account", systemImage: "person.circle")
}
}
Section("Preferences") {
NavigationLink {
NotificationSettingsView()
} label: {
Label("Notifications", systemImage: "bell")
}
NavigationLink {
AppearanceSettingsView()
} label: {
Label("Appearance", systemImage: "paintbrush")
}
}
Section("Content") {
Toggle("Auto-play Videos", isOn: $autoPlay)
Toggle("Sound Effects", isOn: $sound)
Picker("Default Quality", selection: $quality) {
Text("Auto").tag(Quality.auto)
Text("High").tag(Quality.high)
Text("Low").tag(Quality.low)
}
}
Section("Privacy") {
Toggle("Share Analytics", isOn: $analytics)
NavigationLink {
PrivacyPolicyView()
} label: {
Text("Privacy Policy")
}
}
Section {
NavigationLink {
AboutView()
} label: {
Text("About")
}
}
}
.navigationTitle("Settings")Settings principles:
- Group by category (Account, Privacy, etc.)
- Use sections with headers
- Keep frequently-used settings accessible
- Don't duplicate system settings (Dark Mode, Language)
- Link to system Settings for permissions
- Show current values inline when possible
Reference: Settings - Human Interface Guidelines
Support Undo for Destructive Actions
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.
Provide undo capability for destructive or significant actions. This gives users confidence to explore without fear of permanent mistakes.
Incorrect (no recovery from mistakes):
// Immediate permanent deletion
Button("Delete", role: .destructive) {
permanentlyDelete(item) // No recovery
}
// Edit without undo
TextField("Bio", text: $bio)
.onChange(of: bio) { newValue in
// Auto-saves immediately, no undo
save(bio: newValue)
}Correct (undo support):
// Soft delete with undo
Button("Delete", role: .destructive) {
deletedItem = item
withAnimation {
items.removeAll { $0.id == item.id }
}
showUndoToast = true
}
// Undo toast
.safeAreaInset(edge: .bottom) {
if showUndoToast {
HStack {
Text("Item deleted")
Spacer()
Button("Undo") {
if let item = deletedItem {
items.append(item)
deletedItem = nil
}
showUndoToast = false
}
}
.padding()
.background(.regularMaterial)
.transition(.move(edge: .bottom))
}
}
// System undo manager integration
class Document: ObservableObject {
let undoManager: UndoManager?
func updateTitle(_ newTitle: String) {
let oldTitle = title
undoManager?.registerUndo(withTarget: self) { doc in
doc.updateTitle(oldTitle)
}
undoManager?.setActionName("Update Title")
title = newTitle
}
}Undo guidelines:
- Always support undo for deletion
- Show brief toast with undo option
- Support shake-to-undo system gesture
- Implement UndoManager for complex edits
- Undo should restore exact previous state
Reference: Undo and redo - Human Interface Guidelines
Support Dark Mode with Semantic Colors
Use system semantic colors (.primary, .secondary, .background) instead of hardcoded colors. Semantic colors automatically adapt to light and dark mode. Custom colors need both light and dark variants in the asset catalog.
Incorrect (hardcoded colors break in dark mode):
struct CardView: View {
var body: some View {
VStack {
Text("Title")
.foregroundColor(.black) // invisible on dark background
Text("Subtitle")
.foregroundColor(Color(red: 0.5, green: 0.5, blue: 0.5))
}
.background(Color.white) // glaring white card in dark mode
}
}Correct (semantic colors adapt automatically):
struct CardView: View {
var body: some View {
VStack {
Text("Title")
.foregroundStyle(.primary) // adapts to light/dark
Text("Subtitle")
.foregroundStyle(.secondary)
}
.background(.background) // matches system background
}
}Custom colors with asset catalog variants:
// Define "BrandBlue" in Assets.xcassets with:
// - Any Appearance: #0066CC (dark blue)
// - Dark Appearance: #4DA6FF (lighter blue for dark backgrounds)
Text("Brand text")
.foregroundStyle(Color("BrandBlue")) // auto-adapts
// Preview both modes
#Preview {
ContentView()
.preferredColorScheme(.dark)
}Dark mode guidelines:
- Never hardcode
.black,.white, or RGB values for text/backgrounds - Use
.primary,.secondary,.background,.accentColor - Test in both modes: use Xcode preview
.preferredColorScheme(.dark) - Custom colors: add dark variant in asset catalog
Reference: Dark Mode - Human Interface Guidelines
Use Standard Layout Margins and Safe Areas
Use system layout margins (16pt horizontal on iPhone) and respect safe areas. Content should never extend behind the Dynamic Island, home indicator, or status bar without explicit intent.
Incorrect (hardcoded margins that ignore safe areas):
// Ignores safe area — content hidden behind Dynamic Island
VStack {
Text("Welcome")
.padding(.top, 20) // may overlap status bar
Spacer()
Button("Continue") { }
.padding(.bottom, 20) // overlaps home indicator
}
.ignoresSafeArea() // dangerous without intent
// Non-standard margins
Text("Content")
.padding(.horizontal, 24) // inconsistent with iOS standard 16ptCorrect (standard margins with safe area respect):
// Default padding matches iOS layout margins
VStack {
Text("Welcome")
Spacer()
Button("Continue") { }
}
.padding() // 16pt on all sides, respects safe areas automatically
// Explicit safe area handling for edge-to-edge backgrounds
ZStack {
Color.blue.ignoresSafeArea() // background extends to edges
VStack {
Text("Welcome")
.foregroundStyle(.white)
}
.padding() // content stays within safe area
}Layout conventions:
- Default horizontal padding: 16pt (
.padding()handles this) - List/Form: system handles margins automatically
- Full-bleed images: use
.ignoresSafeArea()for background only - Bottom actions: use
.safeAreaInset(edge: .bottom)for floating buttons
Reference: Layout - Human Interface Guidelines
Use SF Symbols with Correct Rendering Mode and Weight
SF Symbols scale with Dynamic Type, match system font weight, and support multiple rendering modes. Using custom image assets for standard icons wastes bundle size and breaks visual consistency.
Incorrect (custom images or mismatched symbols):
// Custom asset instead of SF Symbol
Image("custom-heart-icon")
.resizable()
.frame(width: 24, height: 24) // doesn't scale with Dynamic Type
// Symbol weight doesn't match adjacent text
HStack {
Image(systemName: "star.fill")
.font(.system(size: 24, weight: .ultraLight)) // mismatch
Text("Favorites")
.font(.headline) // .headline is semibold
}Correct (SF Symbols with proper configuration):
// Symbol scales with text automatically
Label("Favorites", systemImage: "star.fill")
.font(.headline) // symbol inherits weight and size
// Explicit rendering mode for multi-color icons
Image(systemName: "cloud.sun.fill")
.symbolRenderingMode(.multicolor) // shows natural colors
// Hierarchical rendering for depth
Image(systemName: "square.stack.3d.up")
.symbolRenderingMode(.hierarchical)
.foregroundStyle(.blue)Rendering modes:
| Mode | Usage |
|---|---|
.monochrome | Single color, default |
.hierarchical | Primary color with depth layers |
.palette | Two or more custom colors |
.multicolor | Fixed Apple-designed colors |
Symbol effects (iOS 17+):
Image(systemName: "wifi")
.symbolEffect(.variableColor.iterative) // animated signal bars
Image(systemName: "checkmark.circle")
.symbolEffect(.bounce, value: isComplete) // bounce on changeReference: SF Symbols - Human Interface Guidelines
Related skills
FAQ
What does ios-hig do?
ios-hig: A skill for development.
When should I use ios-hig?
When you need to use ios-hig for development tasks, or when ios-hig: a skill for development.
What are the main capabilities?
ios-hig.