ios-design-guidelines by ehmo/platform-design-skills
npx skills add https://github.com/ehmo/platform-design-skills --skill ios-design-guidelines源自 Apple 人机界面指南的综合规则。在构建、审查或重构任何 iPhone 应用界面时应用这些规则。
影响程度: 关键
所有交互元素必须具有至少 44x44 点的最小点击目标。这包括按钮、链接、开关和自定义控件。
正确示例:
Button("Save") { save() }
.frame(minWidth: 44, minHeight: 44)
错误示例:
// 20 点图标且无内边距 — 太小,无法可靠点击
Button(action: save) {
Image(systemName: "checkmark")
.font(.system(size: 20))
}
// 缺少 .frame(minWidth: 44, minHeight: 44)
切勿将交互式或重要内容放置在状态栏、灵动岛或主屏幕指示器下方。使用 SwiftUI 的自动安全区域处理或 UIKit 的 safeAreaLayoutGuide。
正确示例:
struct ContentView: View {
var body: some View {
VStack {
Text("Content")
}
// SwiftUI 默认尊重安全区域
}
}
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
错误示例:
struct ContentView: View {
var body: some View {
VStack {
Text("Content")
}
.ignoresSafeArea() // 内容将被刘海屏/灵动岛裁剪
}
}
仅在背景填充、图像或装饰性元素上使用 .ignoresSafeArea() — 切勿用于文本或交互式控件。
将主要操作放置在屏幕底部,即用户拇指自然放置的位置。次要操作和导航应位于顶部。
正确示例:
VStack {
ScrollView { /* content */ }
Button("Continue") { next() }
.buttonStyle(.borderedProminent)
.padding()
}
错误示例:
VStack {
Button("Continue") { next() } // 屏幕顶部 — 单手难以触及
.buttonStyle(.borderedProminent)
.padding()
ScrollView { /* content */ }
}
设计应适配从 iPhone SE(375 点宽)到 iPhone Pro Max(430 点宽)的所有机型。使用灵活的布局,避免硬编码宽度。
正确示例:
HStack(spacing: 12) {
ForEach(items) { item in
CardView(item: item)
.frame(maxWidth: .infinity) // 适应屏幕宽度
}
}
错误示例:
HStack(spacing: 12) {
ForEach(items) { item in
CardView(item: item)
.frame(width: 180) // 在 SE 上会破坏布局,在 Pro Max 上浪费空间
}
}
将间距、内边距和元素尺寸对齐到 8 点的倍数(8、16、24、32、40、48)。使用 4 点进行精细调整。
除非应用是任务特定的(例如相机),否则应支持横屏方向。使用 ViewThatFits 或 GeometryReader 实现自适应布局。
影响程度: 关键
在屏幕底部使用标签栏来展示 3 到 5 个顶级部分。每个标签应代表一个不同的内容或功能类别。
正确示例:
TabView {
HomeView()
.tabItem {
Label("Home", systemImage: "house")
}
SearchView()
.tabItem {
Label("Search", systemImage: "magnifyingglass")
}
ProfileView()
.tabItem {
Label("Profile", systemImage: "person")
}
}
错误示例:
// 隐藏在三条线后面的汉堡菜单 — 可发现性几乎为零
NavigationView {
Button(action: { showMenu.toggle() }) {
Image(systemName: "line.horizontal.3")
}
}
汉堡菜单(抽屉式菜单)会隐藏导航、降低可发现性,并违反 iOS 惯例。应使用标签栏替代。如果超过 5 个部分,请进行整合或使用“更多”标签。
在顶级视图中使用 .navigationBarTitleDisplayMode(.large)。当用户滚动时,标题会过渡到内联模式(.inline)。
正确示例:
NavigationStack {
List(items) { item in
ItemRow(item: item)
}
.navigationTitle("Messages")
.navigationBarTitleDisplayMode(.large)
}
从左侧边缘滑动的返回导航手势是系统级的预期行为。切勿附加会干扰它的自定义手势识别器。
错误示例:
.gesture(
DragGesture()
.onChanged { /* custom drawer */ } // 与系统返回滑动冲突
)
对于深入式内容,使用 NavigationStack(而非已弃用的 NavigationView)。使用 NavigationPath 进行编程式导航。
正确示例:
NavigationStack(path: $path) {
List(items) { item in
NavigationLink(value: item) {
ItemRow(item: item)
}
}
.navigationDestination(for: Item.self) { item in
ItemDetail(item: item)
}
}
当用户返回后再次前进,或切换标签时,应恢复之前的滚动位置和输入状态。使用 @SceneStorage 或 @State 来持久化视图状态。
保持当前位置、最近选择和可用目的地可见。恢复标签、滚动、筛选和选择状态,以便用户通过识别继续操作,而不是从记忆中重建上下文。
影响程度: 高
始终使用语义化文本样式,而不是硬编码的尺寸。这些样式会自动随动态类型缩放。
正确示例:
VStack(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)) // 不会随动态类型缩放
Text("Body content")
.font(.system(size: 15)) // 不会随动态类型缩放
}
动态类型可以将文本缩放到最大辅助功能尺寸的约 200%。布局必须能够重排 — 切勿截断或裁剪重要文本。
正确示例:
HStack {
Image(systemName: "star")
Text("Favorites")
.font(.body)
}
// 在辅助功能尺寸下,考虑使用 ViewThatFits 或
// AnyLayout 从 HStack 切换到 VStack
使用 @Environment(\.dynamicTypeSize) 来检测尺寸类别并调整布局:
@Environment(\.dynamicTypeSize) var dynamicTypeSize
var body: some View {
if dynamicTypeSize.isAccessibilitySize {
VStack { content }
} else {
HStack { content }
}
}
如果使用自定义字体,请对其进行缩放,使其能响应动态类型。不同框架的 API 有所不同。
正确示例(SwiftUI):
extension Font {
static func scaledCustom(size: CGFloat, relativeTo textStyle: Font.TextStyle) -> Font {
.custom("CustomFont-Regular", size: size, relativeTo: textStyle)
}
}
// 用法
Text("Hello")
.font(.scaledCustom(size: 17, relativeTo: .body))
正确示例(UIKit):
let metrics = UIFontMetrics(forTextStyle: .body)
let customFont = UIFont(name: "CustomFont-Regular", size: 17)!
label.font = metrics.scaledFont(for: customFont)
label.adjustsFontForContentSizeCategory = true
除非品牌要求另有规定,否则请使用系统字体(SF Pro)。SF Pro 针对 Apple 显示屏的易读性进行了优化。
切勿显示小于 11 点的文本。正文文本建议使用 17 点。使用 caption2 样式(11 点)作为绝对最小值。
通过字体粗细和尺寸建立视觉层次。不要仅依赖颜色来区分文本层级。
影响程度: 高
使用系统提供的语义化颜色,这些颜色会自动适应浅色和深色模式。
正确示例:
Text("Primary text")
.foregroundStyle(.primary) // 适应浅色/深色模式
Text("Secondary info")
.foregroundStyle(.secondary)
VStack { }
.background(Color(.systemBackground)) // 浅色模式下为白色,深色模式下为黑色
错误示例:
Text("Primary text")
.foregroundColor(.black) // 在深色背景上不可见
VStack { }
.background(.white) // 深色模式下刺眼
在资源目录中定义自定义颜色,同时包含“任何外观”和“深色外观”变体。
// 在 Assets.xcassets 中,定义 "BrandBlue":
// 任何外观: #0066CC
// 深色外观: #4DA3FF
Text("Brand text")
.foregroundStyle(Color("BrandBlue")) // 自动切换
始终将颜色与文本、图标或形状结合使用以传达含义。大约 8% 的男性有某种形式的色觉缺陷。
正确示例:
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.red)
Text("Error: Invalid email address")
.foregroundStyle(.red)
}
错误示例:
// 仅颜色指示错误 — 对色盲用户不可见
TextField("Email", text: $email)
.border(isValid ? .green : .red)
所有文本必须满足 WCAG AA 对比度比率:正常文本为 4.5:1,大文本(18 点以上或 14 点以上粗体)为 3:1。
在现代 iPhone 上使用 Display P3 色彩空间以获得鲜艳、准确的颜色。在资源目录中使用 Display P3 色域定义颜色。
使用三级背景层次来体现深度:
systemBackground — 主要表面secondarySystemBackground — 分组内容、卡片tertiarySystemBackground — 分组内容内的元素为所有交互元素(按钮、链接、开关)选择一种单一的色调/强调色。这创造了一致且可学习的视觉语言。
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.tint(.indigo) // 所有交互元素都使用靛蓝色
}
}
}
影响程度: 关键
每个按钮、控件和交互元素都必须有有意义的辅助功能标签。
正确示例:
Button(action: addToCart) {
Image(systemName: "cart.badge.plus")
}
.accessibilityLabel("Add to cart")
错误示例:
Button(action: addToCart) {
Image(systemName: "cart.badge.plus")
}
// VoiceOver 会读出 "cart.badge.plus" — 对用户毫无意义
确保 VoiceOver 以逻辑顺序读取元素。当视觉布局与阅读顺序不匹配时,使用 .accessibilitySortPriority() 进行调整。
VStack {
Text("Price: $29.99")
.accessibilitySortPriority(1) // 第二个读取(数字越小优先级越低)
Text("Product Name")
.accessibilitySortPriority(2) // 第一个读取(数字越大优先级越高)
}
当用户在设置中启用粗体文本时,自定义渲染的文本必须适应。SwiftUI 文本样式会自动处理此问题。对于 SwiftUI 自定义渲染,使用 @Environment(\.legibilityWeight) 来应用更粗的字重。UIKit 代码必须检查 UIAccessibility.isBoldTextEnabled 并在 UIAccessibility.boldTextStatusDidChangeNotification 时重新查询。
正确示例:
// SwiftUI — 标准文本样式自动适应
Text("Section Header")
.font(.headline)
// SwiftUI — 自定义渲染尊重 legibilityWeight
@Environment(\.legibilityWeight) var legibilityWeight
var body: some View {
Text("Custom Label")
.fontWeight(legibilityWeight == .bold ? .bold : .regular)
}
错误示例:
// 硬编码的字重忽略了粗体文本偏好
label.font = UIFont.systemFont(ofSize: 17, weight: .regular)
// 缺失:当 UIAccessibility.boldTextStatusDidChangeNotification 触发时重新查询字体
当启用减弱动态效果时,禁用装饰性动画和视差效果。使用 @Environment(\.accessibilityReduceMotion)。
正确示例:
@Environment(\.accessibilityReduceMotion) var reduceMotion
var body: some View {
CardView()
.animation(reduceMotion ? nil : .spring(), value: isExpanded)
}
当用户启用增强对比度时,确保自定义颜色具有更高对比度的变体。使用 @Environment(\.colorSchemeContrast) 来检测。
信息必须通过多种渠道提供。将视觉指示器与文本或辅助功能描述配对使用。
每个自定义手势都必须为无法执行复杂手势的用户提供等效的基于点击或基于菜单的替代方案。
确保所有交互都能与切换控制(外部开关)和全键盘访问(蓝牙键盘)配合使用。测试导航顺序和焦点行为。
影响程度: 高
使用标准的 iOS 手势词汇:点击、长按、滑动、捏合、旋转。用户已经理解这些手势。
| 手势 | 标准用途 |
|---|---|
| 点击 | 主要操作、选择 |
| 长按 | 上下文菜单、预览 |
| 水平滑动 | 删除、归档、返回导航 |
| 垂直滑动 | 滚动、关闭表单 |
| 捏合 | 放大/缩小 |
| 双指旋转 | 旋转内容 |
以下手势由系统保留,不得被拦截:
如果添加自定义手势,请提供视觉提示(例如,抓取器手柄),并确保该操作也可以通过可见按钮或菜单项获得。
首先为触摸设计,但也应支持:
影响程度: 高
适当地使用内置按钮样式:
.borderedProminent — 主要号召性用语.bordered — 次要操作.borderless — 三级或内联操作.destructive 角色 — 删除/移除操作的红色色调正确示例:
VStack(spacing: 16) {
Button("Purchase") { buy() }
.buttonStyle(.borderedProminent)
Button("Add to Wishlist") { wishlist() }
.buttonStyle(.bordered)
Button("Delete", role: .destructive) { delete() }
}
谨慎使用警告框,仅用于需要做出决定的关键信息。首选 2 个按钮;最多 3 个。破坏性选项应使用 .destructive 角色。
正确示例:
.alert("Delete Photo?", isPresented: $showAlert) {
Button("Delete", role: .destructive) { deletePhoto() }
Button("Cancel", role: .cancel) { }
} message: {
Text("This photo will be permanently removed.")
}
错误示例:
// 为非关键信息使用警告框 — 应该使用横幅或提示
.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])
}
默认使用 .insetGrouped 列表样式。支持常见操作的滑动操作。最小行高为 44 点。
正确示例:
List {
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)
使用 SF Symbols 作为标签图标 — 选中的标签使用填充变体,未选中的使用轮廓变体
在标签内深入导航时,切勿隐藏标签栏
使用 .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())用于未知持续时间为每个符号使用适当的渲染模式。单色是默认模式;分层、调色板和彩色模式在适当情况下提供更丰富的表达。始终优先选择最能传达含义的符号渲染模式 — 当彩色模式能传达关键状态时,不要默认使用单色模式。
正确示例:
// 分层:单一颜色,具有自动不透明度层
Image(systemName: "person.crop.circle.fill")
.symbolRenderingMode(.hierarchical)
.foregroundStyle(.blue)
// 彩色:每层使用系统定义的颜色(例如,电池、天气)
Image(systemName: "battery.100percent.bolt")
.symbolRenderingMode(.multicolor)
// 调色板:每层使用明确的颜色
Image(systemName: "folder.badge.plus")
.symbolRenderingMode(.palette)
.foregroundStyle(.white, .blue)
错误示例:
// 对具有有意义彩色层的符号使用单色模式
Image(systemName: "battery.100percent.bolt")
.foregroundColor(.gray) // 失去了上下文颜色的含义
使符号的字重与相邻文本的字重相匹配。使用缩放变体(.small、.medium、.large)而不是调整尺寸。符号的字重绝不应比相邻文本更粗。
正确示例:
Label("Download", systemImage: "arrow.down.circle.fill")
.font(.body.weight(.semibold))
// 符号通过 Label 自动继承 .semibold 字重
错误示例:
HStack {
Image(systemName: "arrow.down.circle.fill")
.font(.system(size: 32)) // 显式尺寸忽略了类型缩放
Text("Download")
.font(.body)
}
使用 symbolEffect 实现符号状态转换。对于操作,优先使用离散效果(.bounce、.pulse);对于持续状态,优先使用无限效果(.variableColor)。当 contentTransition(.symbolEffect) 可用时,不要手动在符号名称之间进行交叉淡入淡出。
正确示例:
Image(systemName: isLoading ? "arrow.2.circlepath" : "checkmark.circle")
.contentTransition(.symbolEffect(.replace))
.symbolEffect(.pulse, isActive: isLoading)
错误示例:
// 在符号名称之间手动进行不透明度交叉淡入淡出
if isLoading {
Image(systemName: "arrow.2.circlepath")
} else {
Image(systemName: "checkmark.circle")
}
影响程度: 中等
将新手指引保持在 3 页或更少。始终提供跳过选项。将登录推迟到用户需要身份验证功能时。
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() // 与最终行布局匹配的占位符
.redacted(reason: .placeholder)
}
} else {
ForEach(items) { item in
ItemRow(item: item)
}
}
错误示例:
if isLoading {
ProgressView("Loading...") // 阻塞整个视图
} else {
List(items) { item in ItemRow(item: item) }
}
启动故事板必须在视觉上与应用的初始屏幕匹配。不要有启动徽标,不要有品牌宣传屏幕。这能营造即时启动的感知。
仅当用户必须完成或放弃一项聚焦任务时才呈现模态视图。始终提供明确的关闭操作。切勿在模态视图上叠加模态视图。
仅发送用户真正关心的内容通知。支持可操作通知。对通知进行分类,以便用户可以精细地控制它们。
为每个用户操作提供即时反馈:
视觉状态变化(按钮高亮、动画)
使用 UIImpactFeedbackGenerator、UINotificationFeedbackGenerator 或 UISelectionFeedbackGenerator 为重要操作提供触觉反馈
Button("Complete") { let generator = UINotificationFeedbackGenerator() generator.notificationOccurred(.success) completeTask() }
如果一个操作无法立即完成,请立即确认点击,然后显示内联进度、骨架视图或部分结果。切勿在工作继续进行时让界面在视觉上保持不变。
影响程度: 高
在用户执行需要权限的操作时请求权限 — 切勿在应用启动时请求。
正确示例:
Button("Take Photo") {
// 仅当用户点击此按钮时才请求相机权限
AVCaptureDevice.requestAccess(for: .video) { granted in
if granted { showCamera = true }
}
}
错误示例:
// 在 AppDelegate.didFinishLaunching 中 — 太早,没有上下文
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()
}
}
如果应用提供任何第三方登录(Google、Facebook),则也必须提供“通过 Apple 登录”。将其作为第一个选项呈现。
让用户在需要登录前探索应用。仅对真正需要身份验证的功能(购买、同步、社交功能)进行限制。
如果跨应用或网站跟踪用户,请显示 ATT 提示。尊重拒绝 — 不要为选择退出的用户降低体验。
对于需要一次性位置而不请求持续权限的操作,使用 LocationButton。
import CoreLocationUI
LocationButton(.currentLocation) {
fetchNearbyStores()
}
.labelStyle(.titleAndIcon)
影响程度: 中等
使用 WidgetKit 提供用户频繁查看信息的小组件。显示最有用的快照。自 iOS 17 起,小组件支持交互式控件:使用由 App Intents 支持的 Button 和 Toggle,让用户无需打开应用即可直接从小组件执行操作。
// iOS 17+ 交互式小组件,带有一个 Button
struct TimerWidgetView: View {
let entry: TimerEntry
var body: some View {
VStack {
Text(entry.remaining, style: .timer)
.font(.title2.bold())
Button(intent: ToggleTimerIntent()) {
Label(entry.isRunning ? "Pause" : "Start",
systemImage: entry.isRunning ? "pause.fill" : "play.fill")
}
.buttonStyle(.borderedProminent)
}
}
}
定义应用快捷指令,以便用户可以从 Siri、聚焦搜索和快捷指令应用触发关键操作。
struct MyAppShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: StartWorkoutIntent(),
phrases: ["Start a workout in \(.applicationName)"],
shortTitle: "Start Workout",
systemImageName: "figure.run"
)
}
}
使用 CSSearchableItem 为应用内容建立索引,以便用户可以从聚焦搜索中找到它。
对于用户可能希望发送到其他地方的内容,支持系统共享表单。实现 UIActivityItemSource 或在 SwiftUI 中使用 ShareLink。
ShareLink(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
}
}
| 需求 | 组件 | 备注 |
|---|---|---|
| 顶级部分(3-5 个) | 带有 .tabItem 的 TabView | 底部标签栏,SF Symbols |
| 分层深入式内容 | NavigationStack | 根视图使用大标题,子视图使用内联标题 |
| 自包含任务 | .sheet | 可滑动关闭,取消/完成按钮 |
| 关键决策 | .alert | 首选 2 个按钮,最多 3 个 |
| 次要操作 | .contextMenu | 长按;也必须能在其他地方访问 |
| 滚动内容 | 带有 .insetGrouped 的 List | 最小行高 44 点,滑动操作 |
| 文本输入 | TextField / TextEditor | 标签在上方,验证在下方 |
| 选择(少数选项) | Picker | 2-5 个选项用分段式,多个选项用滚轮式 |
| 选择(开/关) | Toggle | 在列表行中右对齐 |
| 搜索 | .searchable | 建议、最近搜索 |
| 进度(已知) | ProgressView(value:total:) | 显示百分比或剩余时间 |
| 进度(未知) | ProgressView() | 内联显示,切勿全屏阻塞 |
| 一次性位置 | LocationButton | 无需持续权限 |
| 共享内容 | ShareLink | 系统共享表单 |
| 触觉反馈 | UIImpactFeedbackGenerator | .light、.medium、.heavy |
| 破坏性操作 | Button(role: .destructive) | 红色色调,通过警告框确认 |
使用此清单审核 iPhone 应用是否符合 HIG:
Font.custom(_:size:relativeTo:) 或 UIKit 中使用 UIFontMetrics)Comprehensive rules derived from Apple's Human Interface Guidelines. Apply these when building, reviewing, or refactoring any iPhone app interface.
Impact: CRITICAL
All interactive elements must have a minimum tap target of 44x44 points. This includes buttons, links, toggles, and custom controls.
Correct:
Button("Save") { save() }
.frame(minWidth: 44, minHeight: 44)
Incorrect:
// 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)
Never place interactive or essential content under the status bar, Dynamic Island, or home indicator. Use SwiftUI's automatic safe area handling or UIKit's safeAreaLayoutGuide.
Correct:
struct ContentView: View {
var body: some View {
VStack {
Text("Content")
}
// SwiftUI respects safe areas by default
}
}
Incorrect:
struct ContentView: View {
var body: some View {
VStack {
Text("Content")
}
.ignoresSafeArea() // Content will be clipped under notch/Dynamic Island
}
}
Use .ignoresSafeArea() only for background fills, images, or decorative elements — never for text or interactive controls.
Place primary actions at the bottom of the screen where the user's thumb naturally rests. Secondary actions and navigation belong at the top.
Correct:
VStack {
ScrollView { /* content */ }
Button("Continue") { next() }
.buttonStyle(.borderedProminent)
.padding()
}
Incorrect:
VStack {
Button("Continue") { next() } // Top of screen — hard to reach one-handed
.buttonStyle(.borderedProminent)
.padding()
ScrollView { /* content */ }
}
Design for iPhone SE (375pt wide) through iPhone Pro Max (430pt wide). Use flexible layouts, avoid hardcoded widths.
Correct:
HStack(spacing: 12) {
ForEach(items) { item in
CardView(item: item)
.frame(maxWidth: .infinity) // Adapts to screen width
}
}
Incorrect:
HStack(spacing: 12) {
ForEach(items) { item in
CardView(item: item)
.frame(width: 180) // Breaks on SE, wastes space on Pro Max
}
}
Align spacing, padding, and element sizes to multiples of 8 points (8, 16, 24, 32, 40, 48). Use 4pt for fine adjustments.
Support landscape orientation unless the app is task-specific (e.g., camera). Use ViewThatFits or GeometryReader for adaptive layouts.
Impact: CRITICAL
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.
Correct:
TabView {
HomeView()
.tabItem {
Label("Home", systemImage: "house")
}
SearchView()
.tabItem {
Label("Search", systemImage: "magnifyingglass")
}
ProfileView()
.tabItem {
Label("Profile", systemImage: "person")
}
}
Incorrect:
// Hamburger menu hidden behind three lines — discoverability is near zero
NavigationView {
Button(action: { showMenu.toggle() }) {
Image(systemName: "line.horizontal.3")
}
}
Hamburger (drawer) menus hide navigation, reduce discoverability, and violate iOS conventions. Use a tab bar instead. If you have more than 5 sections, consolidate or use a "More" tab.
Use .navigationBarTitleDisplayMode(.large) for top-level views. Titles transition to inline (.inline) when the user scrolls.
Correct:
NavigationStack {
List(items) { item in
ItemRow(item: item)
}
.navigationTitle("Messages")
.navigationBarTitleDisplayMode(.large)
}
The swipe-from-left-edge gesture for back navigation is a system-level expectation. Never attach custom gesture recognizers that interfere with it.
Incorrect:
.gesture(
DragGesture()
.onChanged { /* custom drawer */ } // Conflicts with system back swipe
)
Use NavigationStack (not the deprecated NavigationView) for drill-down content. Use NavigationPath for programmatic navigation.
Correct:
NavigationStack(path: $path) {
List(items) { item in
NavigationLink(value: item) {
ItemRow(item: item)
}
}
.navigationDestination(for: Item.self) { item in
ItemDetail(item: item)
}
}
When users navigate back and then forward, or switch tabs, restore the previous scroll position and input state. Use @SceneStorage or @State to persist view state.
Keep current location, recent choices, and available destinations visible. Restore tab, scroll, filter, and selection state so users continue from recognition instead of reconstructing context from memory.
Impact: HIGH
Always use semantic text styles rather than hardcoded sizes. These scale automatically with Dynamic Type.
Correct:
VStack(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)
}
Incorrect:
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
}
Dynamic Type can scale text up to approximately 200% at the largest accessibility sizes. Layouts must reflow — never truncate or clip essential text.
Correct:
HStack {
Image(systemName: "star")
Text("Favorites")
.font(.body)
}
// At accessibility sizes, consider using ViewThatFits or
// AnyLayout to switch from HStack to VStack
Use @Environment(\.dynamicTypeSize) to detect size category and adapt layouts:
@Environment(\.dynamicTypeSize) var dynamicTypeSize
var body: some View {
if dynamicTypeSize.isAccessibilitySize {
VStack { content }
} else {
HStack { content }
}
}
If you use a custom typeface, scale it so it responds to Dynamic Type. The API differs by framework.
Correct (SwiftUI):
extension 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))
Correct (UIKit):
let metrics = UIFontMetrics(forTextStyle: .body)
let customFont = UIFont(name: "CustomFont-Regular", size: 17)!
label.font = metrics.scaledFont(for: customFont)
label.adjustsFontForContentSizeCategory = true
Use the system font (SF Pro) unless brand requirements dictate otherwise. SF Pro is optimized for legibility on Apple displays.
Never display text smaller than 11pt. Prefer 17pt for body text. Use the caption2 style (11pt) as the absolute minimum.
Establish visual hierarchy through font weight and size. Do not rely solely on color to differentiate text levels.
Impact: HIGH
Use system-provided semantic colors that automatically adapt to light and dark modes.
Correct:
Text("Primary text")
.foregroundStyle(.primary) // Adapts to light/dark
Text("Secondary info")
.foregroundStyle(.secondary)
VStack { }
.background(Color(.systemBackground)) // White in light, black in dark
Incorrect:
Text("Primary text")
.foregroundColor(.black) // Invisible on dark backgrounds
VStack { }
.background(.white) // Blinding in Dark Mode
Define custom colors in the asset catalog with both Any Appearance and Dark Appearance variants.
// In Assets.xcassets, define "BrandBlue" with:
// Any Appearance: #0066CC
// Dark Appearance: #4DA3FF
Text("Brand text")
.foregroundStyle(Color("BrandBlue")) // Automatically switches
Always pair color with text, icons, or shapes to convey meaning. Approximately 8% of men have some form of color vision deficiency.
Correct:
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.red)
Text("Error: Invalid email address")
.foregroundStyle(.red)
}
Incorrect:
// Only color indicates the error — invisible to colorblind users
TextField("Email", text: $email)
.border(isValid ? .green : .red)
All text must meet WCAG AA contrast ratios: 4.5:1 for normal text, 3:1 for large text (18pt+ or 14pt+ bold).
Use Display P3 color space for vibrant, accurate colors on modern iPhones. Define colors in the asset catalog with the Display P3 gamut.
Use the three-level background hierarchy for depth:
systemBackground — primary surfacesecondarySystemBackground — grouped content, cardstertiarySystemBackground — elements within grouped contentChoose 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(.indigo) // All interactive elements use indigo
}
}
}
Impact: CRITICAL
Every button, control, and interactive element must have a meaningful accessibility label.
Correct:
Button(action: addToCart) {
Image(systemName: "cart.badge.plus")
}
.accessibilityLabel("Add to cart")
Incorrect:
Button(action: addToCart) {
Image(systemName: "cart.badge.plus")
}
// VoiceOver reads "cart.badge.plus" — meaningless to users
Ensure VoiceOver reads elements in a logical order. Use .accessibilitySortPriority() to adjust when the visual layout doesn't match the reading order.
VStack {
Text("Price: $29.99")
.accessibilitySortPriority(1) // Read second (lower number = lower priority)
Text("Product Name")
.accessibilitySortPriority(2) // Read first (higher number = higher priority)
}
When the user enables Bold Text in Settings, custom-rendered text must adapt. SwiftUI text styles handle this automatically. For SwiftUI custom rendering, use @Environment(\.legibilityWeight) to apply heavier weights. UIKit code must check UIAccessibility.isBoldTextEnabled and re-query on UIAccessibility.boldTextStatusDidChangeNotification.
Correct:
// SwiftUI — standard text styles adapt automatically
Text("Section Header")
.font(.headline)
// SwiftUI — custom rendering respects legibilityWeight
@Environment(\.legibilityWeight) var legibilityWeight
var body: some View {
Text("Custom Label")
.fontWeight(legibilityWeight == .bold ? .bold : .regular)
}
Incorrect:
// Hardcoded weight ignores Bold Text preference
label.font = UIFont.systemFont(ofSize: 17, weight: .regular)
// Missing: re-query font when UIAccessibility.boldTextStatusDidChangeNotification fires
Disable decorative animations and parallax when Reduce Motion is enabled. Use @Environment(\.accessibilityReduceMotion).
Correct:
@Environment(\.accessibilityReduceMotion) var reduceMotion
var body: some View {
CardView()
.animation(reduceMotion ? nil : .spring(), value: isExpanded)
}
When the user enables Increase Contrast, ensure custom colors have higher-contrast variants. Use @Environment(\.colorSchemeContrast) to detect.
Information must be available through multiple channels. Pair visual indicators with text or accessibility descriptions.
Every custom gesture must have an equivalent tap-based or menu-based alternative for users who cannot perform complex gestures.
Ensure all interactions work with Switch Control (external switches) and Full Keyboard Access (Bluetooth keyboards). Test navigation order and focus behavior.
Impact: HIGH
Use the standard iOS gesture vocabulary: tap, long press, swipe, pinch, rotate. Users already understand these.
| 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 |
These gestures are reserved by the system and must not be intercepted:
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.
Design for touch first, but also support:
Impact: HIGH
Use the built-in button styles appropriately:
.borderedProminent — primary call-to-action.bordered — secondary actions.borderless — tertiary or inline actions.destructive role — red tint for delete/removeCorrect:
VStack(spacing: 16) {
Button("Purchase") { buy() }
.buttonStyle(.borderedProminent)
Button("Add to Wishlist") { wishlist() }
.buttonStyle(.bordered)
Button("Delete", role: .destructive) { delete() }
}
Use alerts sparingly for critical information that requires a decision. Prefer 2 buttons; maximum 3. The destructive option should use .destructive role.
Correct:
.alert("Delete Photo?", isPresented: $showAlert) {
Button("Delete", role: .destructive) { deletePhoto() }
Button("Cancel", role: .cancel) { }
} message: {
Text("This photo will be permanently removed.")
}
Incorrect:
// Alert for non-critical info — should be a banner or toast
.alert("Tip", isPresented: $showTip) {
Button("OK") { }
} message: {
Text("Swipe left to delete items.")
}
Present sheets for self-contained tasks. Always provide a way to dismiss (close button or swipe down). Use .presentationDetents() for half-height sheets.
.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])
}
Use the .insetGrouped list style as the default. Support swipe actions for common operations. Minimum row height is 44pt.
Correct:
List {
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)
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()
TabView { MessagesView() .tabItem { Label("Messages", systemImage: "message") } .badge(unreadCount) }
Place search using .searchable(). Provide search suggestions and support recent searches.
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)
}
}
}
Use context menus (long press) for secondary actions. Never use a context menu as the only way to access an action.
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:)) for operations with known durationProgressView()) for unknown durationUse the appropriate rendering mode for each symbol. Monochrome is the default; hierarchical, palette, and multicolor provide richer expression where appropriate. Always prefer the symbol rendering mode that best communicates meaning — do not default to monochrome when multicolor conveys critical state.
Correct:
// Hierarchical: single color with automatic opacity layers
Image(systemName: "person.crop.circle.fill")
.symbolRenderingMode(.hierarchical)
.foregroundStyle(.blue)
// Multicolor: system-defined color per layer (e.g., battery, weather)
Image(systemName: "battery.100percent.bolt")
.symbolRenderingMode(.multicolor)
// Palette: explicit per-layer colors
Image(systemName: "folder.badge.plus")
.symbolRenderingMode(.palette)
.foregroundStyle(.white, .blue)
Incorrect:
// Monochrome on a symbol that has meaningful multicolor layers
Image(systemName: "battery.100percent.bolt")
.foregroundColor(.gray) // loses the contextual color meaning
Match the symbol weight to adjacent text weight. Use scale variants (.small, .medium, .large) rather than resizing. The symbol weight should never appear heavier than adjacent text.
Correct:
Label("Download", systemImage: "arrow.down.circle.fill")
.font(.body.weight(.semibold))
// Symbol inherits .semibold weight automatically via Label
Incorrect:
HStack {
Image(systemName: "arrow.down.circle.fill")
.font(.system(size: 32)) // explicit size ignores type scale
Text("Download")
.font(.body)
}
Use symbolEffect for symbol state transitions. Prefer discrete effects (.bounce, .pulse) for actions and indefinite effects (.variableColor) for ongoing state. Do not use manual cross-fade between symbol names when contentTransition(.symbolEffect) is available.
Correct:
Image(systemName: isLoading ? "arrow.2.circlepath" : "checkmark.circle")
.contentTransition(.symbolEffect(.replace))
.symbolEffect(.pulse, isActive: isLoading)
Incorrect:
// Manual opacity cross-fade between symbol names
if isLoading {
Image(systemName: "arrow.2.circlepath")
} else {
Image(systemName: "checkmark.circle")
}
Impact: MEDIUM
Keep onboarding to 3 or fewer pages. Always provide a skip option. Defer sign-in until the user needs authenticated features.
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()
}
Use skeleton/placeholder views that match the layout of the content being loaded. Never show a full-screen blocking spinner.
Correct:
if isLoading {
ForEach(0..<5) { _ in
SkeletonRow() // Placeholder matching final row layout
.redacted(reason: .placeholder)
}
} else {
ForEach(items) { item in
ItemRow(item: item)
}
}
Incorrect:
if isLoading {
ProgressView("Loading...") // Blocks the entire view
} else {
List(items) { item in ItemRow(item: item) }
}
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.
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.
Only send notifications for content the user genuinely cares about. Support actionable notifications. Categorize notifications so users can control them granularly.
Provide immediate feedback for every user action:
Visual state change (button highlight, animation)
Haptic feedback for significant actions using UIImpactFeedbackGenerator, UINotificationFeedbackGenerator, or UISelectionFeedbackGenerator
Button("Complete") { let generator = UINotificationFeedbackGenerator() generator.notificationOccurred(.success) completeTask() }
If an action cannot complete immediately, acknowledge the tap at once, then show inline progress, skeletons, or partial results. Never leave the interface visually unchanged while work continues.
Impact: HIGH
Request a permission at the moment the user takes an action that needs it — never at app launch.
Correct:
Button("Take Photo") {
// Request camera permission only when the user taps this button
AVCaptureDevice.requestAccess(for: .video) { granted in
if granted { showCamera = true }
}
}
Incorrect:
// 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 }
}
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 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()
}
}
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.
Let users explore the app before requiring sign-in. Gate only features that genuinely need authentication (purchases, sync, social features).
If you track users across apps or websites, display the ATT prompt. Respect denial — do not degrade the experience for users who opt out.
Use LocationButton for actions that need location once without requesting ongoing permission.
import CoreLocationUI
LocationButton(.currentLocation) {
fetchNearbyStores()
}
.labelStyle(.titleAndIcon)
Impact: MEDIUM
Provide widgets using WidgetKit for information users check frequently. Show the most useful snapshot. Since iOS 17, widgets support interactive controls: use Button and Toggle backed by App Intents for actions users perform directly from the widget without opening the app.
// iOS 17+ interactive widget with a Button
struct TimerWidgetView: View {
let entry: TimerEntry
var body: some View {
VStack {
Text(entry.remaining, style: .timer)
.font(.title2.bold())
Button(intent: ToggleTimerIntent()) {
Label(entry.isRunning ? "Pause" : "Start",
systemImage: entry.isRunning ? "pause.fill" : "play.fill")
}
.buttonStyle(.borderedProminent)
}
}
}
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: StartWorkoutIntent(),
phrases: ["Start a workout in \(.applicationName)"],
shortTitle: "Start Workout",
systemImageName: "figure.run"
)
}
}
Index app content with CSSearchableItem so users can find it from Spotlight search.
Support the system share sheet for content that users might want to send elsewhere. Implement UIActivityItemSource or use ShareLink in SwiftUI.
ShareLink(item: article.url) {
Label("Share", systemImage: "square.and.arrow.up")
}
Use Live Activities and the Dynamic Island for real-time, time-bound events (delivery tracking, sports scores, workouts).
Save state and pause gracefully when interrupted by:
Use scenePhase to detect transitions:
@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) | TabView with .tabItem | Bottom tab bar, SF Symbols |
| Hierarchical drill-down | NavigationStack | Large title on root, inline on children |
| Self-contained task | .sheet | Swipe to dismiss, cancel/done buttons |
| Critical decision | .alert | 2 buttons preferred, max 3 |
| Secondary actions |
Use this checklist to audit an iPhone app for HIG compliance:
Font.custom(_:size:relativeTo:) in SwiftUI or UIFontMetrics in UIKit).destructive roleThese are common mistakes that violate the iOS Human Interface Guidelines. Never do these:
Hamburger menus — Use a tab bar. Hamburger menus hide navigation and reduce feature discoverability by up to 50%.
Custom back buttons that break swipe-back — If you replace the back button, ensure the swipe-from-left-edge gesture still works via NavigationStack.
Full-screen blocking spinners — Use skeleton views or inline progress indicators. Blocking spinners make the app feel frozen.
Splash screens with logos — The launch screen must mirror the first screen of the app. Branding delays feel artificial.
Requesting all permissions at launch — Asking for camera, location, notifications, and contacts on first launch guarantees most will be denied.
Hardcoded font sizes — Use text styles. Hardcoded sizes ignore Dynamic Type and accessibility preferences, breaking the app for millions of users.
Using only color to indicate state — Red/green for valid/invalid excludes colorblind users. Always pair with icons or text.
Alerts for non-critical information — Alerts interrupt flow and require dismissal. Use banners, toasts, or inline messages for tips and non-critical information.
Hiding the tab bar on push — Tab bars should remain visible throughout navigation within a tab. Hiding them disorients users.
Ignoring safe areas — Using .ignoresSafeArea() on content views causes text and buttons to disappear under the notch, Dynamic Island, or home indicator.
Weekly Installs
510
Repository
GitHub Stars
283
First Seen
Feb 1, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
codex450
opencode441
gemini-cli423
github-copilot390
claude-code384
cursor369
前端设计技能指南:避免AI垃圾美学,打造独特生产级界面
36,100 周安装
.contextMenu| Long press; must also be accessible elsewhere |
| Scrolling content | List with .insetGrouped | 44pt min row, swipe actions |
| Text input | TextField / TextEditor | Label above, validation below |
| Selection (few options) | Picker | Segmented for 2-5, wheel for many |
| Selection (on/off) | Toggle | Aligned right in a list row |
| Search | .searchable | Suggestions, recent searches |
| Progress (known) | ProgressView(value:total:) | Show percentage or time remaining |
| Progress (unknown) | ProgressView() | Inline, never full-screen blocking |
| One-time location | LocationButton | No persistent permission needed |
| Sharing content | ShareLink | System share sheet |
| Haptic feedback | UIImpactFeedbackGenerator | .light, .medium, .heavy |
| Destructive action | Button(role: .destructive) | Red tint, confirm via alert |
Non-dismissable modals — Every modal must have a clear dismiss path (close button, cancel, swipe down). Trapping users in a modal is hostile.
Custom gestures without alternatives — A three-finger swipe for undo is unusable for many people. Provide a visible button or menu item as well.
Tiny touch targets — Buttons and links smaller than 44pt cause mis-taps, especially in lists and toolbars.
Stacked modals — Presenting a sheet on top of a sheet on top of a sheet creates navigation confusion. Use navigation within a single modal instead.
Dark Mode as an afterthought — Using hardcoded colors means the app is either broken in Dark Mode or light mode. Always use semantic colors.