Loading...
Loading...
Apple Human Interface Guidelines for iPhone. Use when building, reviewing, or refactoring SwiftUI/UIKit interfaces for iOS. Triggers on tasks involving iPhone UI, iOS components, accessibility, Dynamic Type, Dark Mode, or HIG compliance.
npx skill4agent add ehmo/platform-design-skills ios-design-guidelinesButton("Save") { save() }
.frame(minWidth: 44, minHeight: 44)// 20pt icon with no padding — too small to tap reliably
Button(action: save) {
Image(systemName: "checkmark")
.font(.system(size: 20))
}
// Missing .frame(minWidth: 44, minHeight: 44)safeAreaLayoutGuidestruct ContentView: View {
var body: some View {
VStack {
Text("Content")
}
// SwiftUI respects safe areas by default
}
}struct ContentView: View {
var body: some View {
VStack {
Text("Content")
}
.ignoresSafeArea() // Content will be clipped under notch/Dynamic Island
}
}.ignoresSafeArea()VStack {
ScrollView { /* content */ }
Button("Continue") { next() }
.buttonStyle(.borderedProminent)
.padding()
}VStack {
Button("Continue") { next() } // Top of screen — hard to reach one-handed
.buttonStyle(.borderedProminent)
.padding()
ScrollView { /* content */ }
}HStack(spacing: 12) {
ForEach(items) { item in
CardView(item: item)
.frame(maxWidth: .infinity) // Adapts to screen width
}
}HStack(spacing: 12) {
ForEach(items) { item in
CardView(item: item)
.frame(width: 180) // Breaks on SE, wastes space on Pro Max
}
}ViewThatFitsGeometryReaderTabView {
HomeView()
.tabItem {
Label("Home", systemImage: "house")
}
SearchView()
.tabItem {
Label("Search", systemImage: "magnifyingglass")
}
ProfileView()
.tabItem {
Label("Profile", systemImage: "person")
}
}// Hamburger menu hidden behind three lines — discoverability is near zero
NavigationView {
Button(action: { showMenu.toggle() }) {
Image(systemName: "line.horizontal.3")
}
}.navigationBarTitleDisplayMode(.large).inlineNavigationStack {
List(items) { item in
ItemRow(item: item)
}
.navigationTitle("Messages")
.navigationBarTitleDisplayMode(.large)
}.gesture(
DragGesture()
.onChanged { /* custom drawer */ } // Conflicts with system back swipe
)NavigationStackNavigationViewNavigationPathNavigationStack(path: $path) {
List(items) { item in
NavigationLink(value: item) {
ItemRow(item: item)
}
}
.navigationDestination(for: Item.self) { item in
ItemDetail(item: item)
}
}@SceneStorage@StateVStack(alignment: .leading, spacing: 4) {
Text("Section Title")
.font(.headline)
Text("Body content that explains the section.")
.font(.body)
Text("Last updated 2 hours ago")
.font(.caption)
.foregroundStyle(.secondary)
}VStack(alignment: .leading, spacing: 4) {
Text("Section Title")
.font(.system(size: 17, weight: .semibold)) // Won't scale with Dynamic Type
Text("Body content")
.font(.system(size: 15)) // Won't scale with Dynamic Type
}HStack {
Image(systemName: "star")
Text("Favorites")
.font(.body)
}
// At accessibility sizes, consider using ViewThatFits or
// AnyLayout to switch from HStack to VStack@Environment(\.dynamicTypeSize)@Environment(\.dynamicTypeSize) var dynamicTypeSize
var body: some View {
if dynamicTypeSize.isAccessibilitySize {
VStack { content }
} else {
HStack { content }
}
}UIFontMetricsextension Font {
static func scaledCustom(size: CGFloat, relativeTo textStyle: Font.TextStyle) -> Font {
.custom("CustomFont-Regular", size: size, relativeTo: textStyle)
}
}
// Usage
Text("Hello")
.font(.scaledCustom(size: 17, relativeTo: .body))caption2Text("Primary text")
.foregroundStyle(.primary) // Adapts to light/dark
Text("Secondary info")
.foregroundStyle(.secondary)
VStack { }
.background(Color(.systemBackground)) // White in light, black in darkText("Primary text")
.foregroundColor(.black) // Invisible on dark backgrounds
VStack { }
.background(.white) // Blinding in Dark Mode// In Assets.xcassets, define "BrandBlue" with:
// Any Appearance: #0066CC
// Dark Appearance: #4DA3FF
Text("Brand text")
.foregroundStyle(Color("BrandBlue")) // Automatically switchesHStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.red)
Text("Error: Invalid email address")
.foregroundStyle(.red)
}// Only color indicates the error — invisible to colorblind users
TextField("Email", text: $email)
.border(isValid ? .green : .red)systemBackgroundsecondarySystemBackgroundtertiarySystemBackground@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.tint(.indigo) // All interactive elements use indigo
}
}
}Button(action: addToCart) {
Image(systemName: "cart.badge.plus")
}
.accessibilityLabel("Add to cart")Button(action: addToCart) {
Image(systemName: "cart.badge.plus")
}
// VoiceOver reads "cart.badge.plus" — meaningless to users.accessibilitySortPriority()VStack {
Text("Price: $29.99")
.accessibilitySortPriority(1) // Read first
Text("Product Name")
.accessibilitySortPriority(2) // Read second
}.boldUIAccessibility.isBoldTextEnabled@Environment(\.accessibilityReduceMotion)@Environment(\.accessibilityReduceMotion) var reduceMotion
var body: some View {
CardView()
.animation(reduceMotion ? nil : .spring(), value: isExpanded)
}@Environment(\.colorSchemeContrast)| Gesture | Standard Use |
|---|---|
| Tap | Primary action, selection |
| Long press | Context menu, preview |
| Swipe horizontal | Delete, archive, navigate back |
| Swipe vertical | Scroll, dismiss sheet |
| Pinch | Zoom in/out |
| Two-finger rotate | Rotate content |
.borderedProminent.bordered.borderless.destructiveVStack(spacing: 16) {
Button("Purchase") { buy() }
.buttonStyle(.borderedProminent)
Button("Add to Wishlist") { wishlist() }
.buttonStyle(.bordered)
Button("Delete", role: .destructive) { delete() }
}.destructive.alert("Delete Photo?", isPresented: $showAlert) {
Button("Delete", role: .destructive) { deletePhoto() }
Button("Cancel", role: .cancel) { }
} message: {
Text("This photo will be permanently removed.")
}// Alert for non-critical info — should be a banner or toast
.alert("Tip", isPresented: $showTip) {
Button("OK") { }
} message: {
Text("Swipe left to delete items.")
}.presentationDetents().sheet(isPresented: $showCompose) {
NavigationStack {
ComposeView()
.navigationTitle("New Message")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { showCompose = false }
}
ToolbarItem(placement: .confirmationAction) {
Button("Send") { send() }
}
}
}
.presentationDetents([.medium, .large])
}.insetGroupedList {
Section("Recent") {
ForEach(recentItems) { item in
ItemRow(item: item)
.swipeActions(edge: .trailing) {
Button(role: .destructive) { delete(item) } label: {
Label("Delete", systemImage: "trash")
}
Button { archive(item) } label: {
Label("Archive", systemImage: "archivebox")
}
.tint(.blue)
}
}
}
}
.listStyle(.insetGrouped).badge()TabView {
MessagesView()
.tabItem {
Label("Messages", systemImage: "message")
}
.badge(unreadCount)
}.searchable()NavigationStack {
List(filteredItems) { item in
ItemRow(item: item)
}
.searchable(text: $searchText, prompt: "Search items")
.searchSuggestions {
ForEach(suggestions) { suggestion in
Text(suggestion.title)
.searchCompletion(suggestion.title)
}
}
}PhotoView(photo: photo)
.contextMenu {
Button { share(photo) } label: {
Label("Share", systemImage: "square.and.arrow.up")
}
Button { favorite(photo) } label: {
Label("Favorite", systemImage: "heart")
}
Button(role: .destructive) { delete(photo) } label: {
Label("Delete", systemImage: "trash")
}
}ProgressView(value:total:)ProgressView()TabView {
OnboardingPage(
image: "wand.and.stars",
title: "Smart Suggestions",
subtitle: "Get personalized recommendations based on your preferences."
)
OnboardingPage(
image: "bell.badge",
title: "Stay Updated",
subtitle: "Receive notifications for things that matter to you."
)
OnboardingPage(
image: "checkmark.shield",
title: "Private & Secure",
subtitle: "Your data stays on your device."
)
}
.tabViewStyle(.page)
.overlay(alignment: .topTrailing) {
Button("Skip") { completeOnboarding() }
.padding()
}if isLoading {
ForEach(0..<5) { _ in
SkeletonRow() // Placeholder matching final row layout
.redacted(reason: .placeholder)
}
} else {
ForEach(items) { item in
ItemRow(item: item)
}
}if isLoading {
ProgressView("Loading...") // Blocks the entire view
} else {
List(items) { item in ItemRow(item: item) }
}UIImpactFeedbackGeneratorUINotificationFeedbackGeneratorUISelectionFeedbackGeneratorButton("Complete") {
let generator = UINotificationFeedbackGenerator()
generator.notificationOccurred(.success)
completeTask()
}Button("Take Photo") {
// Request camera permission only when the user taps this button
AVCaptureDevice.requestAccess(for: .video) { granted in
if granted { showCamera = true }
}
}// In AppDelegate.didFinishLaunching — too early, no context
func application(_ application: UIApplication, didFinishLaunchingWithOptions ...) {
AVCaptureDevice.requestAccess(for: .video) { _ in }
CLLocationManager().requestWhenInUseAuthorization()
UNUserNotificationCenter.current().requestAuthorization(options: [.alert]) { _, _ in }
}struct LocationExplanation: View {
var body: some View {
VStack(spacing: 16) {
Image(systemName: "location.fill")
.font(.largeTitle)
Text("Find Nearby Stores")
.font(.headline)
Text("We use your location to show stores within walking distance. Your location is never shared or stored.")
.font(.body)
.multilineTextAlignment(.center)
Button("Enable Location") {
locationManager.requestWhenInUseAuthorization()
}
.buttonStyle(.borderedProminent)
Button("Not Now") { dismiss() }
.foregroundStyle(.secondary)
}
.padding()
}
}LocationButtonLocationButton(.currentLocation) {
fetchNearbyStores()
}
.labelStyle(.titleAndIcon)struct MyAppShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: StartWorkoutIntent(),
phrases: ["Start a workout in \(.applicationName)"],
shortTitle: "Start Workout",
systemImageName: "figure.run"
)
}
}CSSearchableItemUIActivityItemSourceShareLinkShareLink(item: article.url) {
Label("Share", systemImage: "square.and.arrow.up")
}scenePhase@Environment(\.scenePhase) var scenePhase
.onChange(of: scenePhase) { _, newPhase in
switch newPhase {
case .active: resumeActivity()
case .inactive: pauseActivity()
case .background: saveState()
@unknown default: break
}
}| Need | Component | Notes |
|---|---|---|
| Top-level sections (3-5) | | Bottom tab bar, SF Symbols |
| Hierarchical drill-down | | Large title on root, inline on children |
| Self-contained task | | Swipe to dismiss, cancel/done buttons |
| Critical decision | | 2 buttons preferred, max 3 |
| Secondary actions | | Long press; must also be accessible elsewhere |
| Scrolling content | | 44pt min row, swipe actions |
| Text input | | Label above, validation below |
| Selection (few options) | | Segmented for 2-5, wheel for many |
| Selection (on/off) | | Aligned right in a list row |
| Search | | Suggestions, recent searches |
| Progress (known) | | Show percentage or time remaining |
| Progress (unknown) | | Inline, never full-screen blocking |
| One-time location | | No persistent permission needed |
| Sharing content | | System share sheet |
| Haptic feedback | | |
| Destructive action | | Red tint, confirm via alert |
UIFontMetrics.destructiveNavigationStack.ignoresSafeArea()