关于 swiftui-design-principles
swiftui-design-principles 为 SwiftUI、WidgetKit 和原生 Apple UI 的构建与修改提供设计规范,面向界面开发者。内容涵盖间距、字体、颜色、组件、导航和小组件等实践。
主要功能
- 01采用一致的间距网格
- 02建立清晰的字体层级
- 03优先使用系统语义颜色
- 04遵循原生组件与布局模式
- 05覆盖 WidgetKit 尺寸与刷新策略
适用场景
- 01构建或调整 SwiftUI 界面
- 02设计 WidgetKit 小组件
- 03统一应用与小组件的视觉规范
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ arjitj2/ swiftui-design-principles --skill swiftui-design-principles安装后在 Claude Code 中输入/swiftui-design-principles确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
AGENTS.mdLICENSEREADME.mdmetadata.json详细介绍
这项技能总结了通过对比精致、达到生产质量的 SwiftUI 应用与制作粗糙的应用所提炼出的设计原则。这里的模式体现了让应用感觉“恰到好处”的关键,也说明了为什么有些应用的边距、间距和文字大小看起来总是“不对劲”。
构建或修改 SwiftUI 界面、WidgetKit 小组件或任何 Apple 原生 UI 时,都应遵循这些原则。
核心理念
克制胜过装饰。 每个像素都必须物有所值。精致的应用使用更少的颜色、更少的字体大小、更少的间距值和更少的文字,并始终保持一致。过度设计视觉元素(自定义渐变、装饰性边框、定制分隔线)会造成视觉噪声。原生组件和系统颜色则能营造和谐感。
注意力有限。 UI 文案要比你认为所需的更简短。与其在标题、副标题、正文和页脚反复解释,不如使用一个清晰的标题和一段精炼的辅助说明。如果屏幕需要说明理由,就把它放在一个有明确用途的位置,而不是散落在整个页面中。
1. 间距系统:使用一致的网格
关键: 使用以 4 或 8 为基数的网格间距值。切勿使用随意的数值。
允许的间距值
4, 8, 12, 16, 20, 24, 32, 40, 48
不佳(随意的数值会造成视觉不协调)
// WRONG - these numbers have no relationship to each other
.padding(.bottom, 26)
.padding(.bottom, 34)
.padding(.bottom, 36)
HStack(spacing: 18)
.padding(14)
良好(使用一致网格中的数值)
// RIGHT - predictable rhythm the eye can follow
.padding(.horizontal, 20)
.padding(.top, 8)
Spacer().frame(height: 32)
HStack(spacing: 4) // or 8, 12, 16
.padding(.vertical, 12)
.padding(.horizontal, 16)
标准内边距设置
- 内容外侧内边距:水平 16-20pt
- 主要区块之间:垂直 24-32pt
- 分组组件内部:4-12pt
- 卡片/行内部内边距:垂直 12-16pt,水平 16pt
2. 字体排印:通过字重而非仅靠字号建立层级
原则
使用更少的字体大小,并通过清晰的字重差异建立层级。大字号使用较轻字重,小字号使用中等或常规字重。这样能营造精致感,而非视觉混乱。
推荐的字号比例(适用于以数据为主的应用)
| 角色 | 大小 | 字重 | 说明 |
|---|---|---|---|
| 主视觉数字 | 36-42pt | .light | 大而轻盈,优雅而不厚重 |
| 次要统计数据 | 20-24pt | .light | 与主视觉数字使用相同的字重系列,但字号更小 |
| 正文/切换项标签 | 15pt | .regular | iOS 标准正文大小 |
| 区块标题(大写) | 11pt | .medium | 配合字距/字母间距 |
| 说明文字/副标题 | 11-13pt | .regular | 次要信息 |
不佳(字号过多,字重不一致)
// WRONG - 7 different sizes with no clear system
.font(.system(size: 60, weight: .ultraLight)) // hero
.font(.system(size: 44, weight: .regular)) // stat (too close to hero)
.font(.system(size: 31, weight: .ultraLight)) // percent symbol (odd ratio)
.font(.system(size: 18, weight: .regular)) // label (too big for a toggle)
.font(.system(size: 14, weight: .regular)) // header
.font(.system(size: 13, weight: .regular)) // another header
.font(.system(size: 12, weight: .regular)) // button (too small to read)
良好(层级清晰,字号更少)
// RIGHT - 5 sizes, clear purpose for each
.font(.system(size: 42, weight: .light, design: .monospaced)) // hero
.font(.system(size: 24, weight: .light, design: .monospaced)) // stat value
.font(.system(size: 15, weight: .regular, design: .monospaced)) // body
.font(.system(size: 14, weight: .regular, design: .monospaced)) // secondary
.font(.system(size: 11, weight: .medium, design: .monospaced)) // label
字体设计一致性
选择一种字体设计,并在所有地方统一使用,包括应用和小组件:
// If using monospaced, use it everywhere
design: .monospaced // app views, widgets, lock screen -- all of them
// NEVER mix designs between app and widgets
// BAD: .monospaced in app, .rounded in lock screen widget
字母间距(字距)
最多使用 2 种数值,且只用于大写标签:
.tracking(1.5) // section labels: "NOTIFICATIONS", "DAY", "LEFT"
.tracking(3) // navigation/toolbar titles
切勿使用 3 种或更多不同的字距值,例如 kerning(4)、kerning(4.5)、kerning(5)——它们之间的差异难以察觉,但这种不一致会在潜意识中留下印象。
标识符的数字格式
年份及其他固定标识符不应按区域设置进行分组格式化。
// RIGHT - stable, non-grouped identifier text
Text(String(year)) // "2026"
Text(year, format: .number.grouping(.never))
// WRONG - locale grouping can render "2,026"
Text("\(year)")
3. 颜色:优先使用系统语义颜色,而非硬编码值
原则
使用 SwiftUI 的语义颜色系统。它会自动适配浅色/深色模式和辅助功能,并呈现原生外观。硬编码颜色并手动设置多个透明度值,会带来维护难题,也显得不自然。
不佳(硬编码白色,并使用十几种透明度值)
// WRONG - impossible to maintain, doesn't adapt to light mode
Color.black.ignoresSafeArea() // forced dark
Color.white.opacity(0.08) // ring background
Color.white.opacity(0.09) // divider
Color.white.opacity(0.3) // year text
Color.white.opacity(0.32) // stat label
Color.white.opacity(0.42) // percent symbol
Color.white.opacity(0.44) // toggle tint
Color.white.opacity(0.72) // button text
Color.white.opacity(0.88) // toggle label
Color.white.opacity(0.9) // stat value
Color.white.opacity(0.94) // ring fill
良好(语义系统颜色)
// RIGHT - adapts automatically, looks native, easy to maintain
Color(.systemBackground) // main background
Color(.secondarySystemBackground) // card/group backgrounds
Color(.separator) // dividers (with optional opacity)
Color.primary // primary text and UI elements
.foregroundStyle(.secondary) // secondary text
.foregroundStyle(.tertiary) // labels, captions
需要使用透明度时
将其限制为 2-3 种数值,并明确各自用途:
.opacity(0.15) // subtle background strokes
.opacity(0.3) // separator lines
// That's it. If you need more, you're probably hardcoding what semantic colors handle.
4. 组件尺寸:按比例设置,而非过度放大
进度环/圆形指示器
// App main view: 200x200 with thin stroke
.frame(width: 200, height: 200)
Circle().stroke(..., lineWidth: 3)
// Widget (systemSmall): 90x90, same stroke
.frame(width: 90, height: 90)
Circle().stroke(..., lineWidth: 3)
// WRONG: oversized ring with thick inconsistent strokes
.frame(width: 260, height: 260) // too large, dominates screen
Circle().stroke(..., lineWidth: 9) // background
Circle().stroke(..., lineWidth: 8) // fill -- WHY different from background?
描边宽度一致性
同一元素的背景描边和前景描边始终使用相同的 lineWidth:
// RIGHT
Circle().stroke(background, lineWidth: 3)
Circle().trim(from: 0, to: fraction).stroke(fill, lineWidth: 3)
// WRONG - creates visual misalignment
Circle().stroke(background, lineWidth: 9)
Circle().trim(from: 0, to: fraction).stroke(fill, lineWidth: 8)
列表行和切换行
// RIGHT - natural sizing with proper padding
Toggle(isOn: $value) {
Text(title)
.font(.system(size: 15, weight: .regular, design: .monospaced))
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
// WRONG - fixed oversized height
HStack {
Text(label)
.font(.system(size: 18)) // too big for a toggle label
Spacer()
Toggle("", isOn: $isOn)
.labelsHidden() // why hide the label? Use Toggle properly
}
.frame(height: 70) // way too tall
5. 分组内容与卡片:采用系统模式
不佳(过度设计的自定义卡片)
// WRONG - custom gradient, overlay border, huge corner radius
VStack { ... }
.padding(.vertical, 4) // too tight
.background(
RoundedRectangle(cornerRadius: 22) // too round
.fill(LinearGradient( // unnecessary gradient
colors: [Color(white: 0.10), Color(white: 0.085)],
startPoint: .topLeading, endPoint: .bottomTrailing
))
)
.overlay(
RoundedRectangle(cornerRadius: 22)
.stroke(Color.white.opacity(0.08), lineWidth: 1) // decorative border
)
良好(原生分组样式)
// RIGHT - simple, native, works in light and dark mode
VStack(spacing: 0) {
row1
Divider().padding(.leading, 16)
row2
Divider().padding(.leading, 16)
row3
}
.background(Color(.secondarySystemBackground))
.clipShape(.rect(cornerRadius: 10))
分组内容的关键规则
- 圆角半径:卡片/分组使用 10pt(符合 iOS 系统样式)。绝不要使用 22pt 及以上。
- 分隔线:使用系统
Divider(),并添加.padding(.leading, 16),以符合 iOS 标准内缩。绝不要构建自定义分隔线结构体。 - 卡片内边距:垂直方向为 12-16pt,水平方向为 16pt。绝不要使用 4pt 的垂直内边距。
- 背景:
Color(.secondarySystemBackground)——标准卡片绝不要使用自定义渐变。
6. 导航:使用 NavigationStack
// RIGHT - proper navigation with minimal toolbar
NavigationStack {
ScrollView {
content
}
.toolbar {
ToolbarItem(placement: .principal) {
Text("Title")
.font(.system(size: 13, weight: .medium, design: .monospaced))
.tracking(3)
.foregroundStyle(.tertiary)
}
}
.navigationBarTitleDisplayMode(.inline)
}
// WRONG - no navigation structure, just a ZStack
ZStack {
Color.black.ignoresSafeArea()
ScrollView {
VStack {
Text("2026").font(...) // manually placed "title"
content
}
}
}
7. WidgetKit:使用原生组件
圆形锁屏小组件
// RIGHT - use Gauge, it's purpose-built for this
Gauge(value: entry.fraction) {
Text("")
} currentValueLabel: {
Text("\(Int(entry.percentage))%")
.font(.system(size: 12, weight: .medium, design: .monospaced))
}
.gaugeStyle(.accessoryCircular)
.containerBackground(.fill.tertiary, for: .widget)
// WRONG - manual circle drawing for lock screen
ZStack {
Circle().stroke(Color.primary.opacity(0.18), lineWidth: 4)
Circle().trim(from: 0, to: progress).stroke(...)
Text(percentText)
.font(.system(size: 14, weight: .bold, design: .rounded)) // wrong font design!
}
矩形锁屏小组件
// RIGHT - use Gauge with linearCapacity
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(year).font(.system(size: 13, weight: .semibold, design: .monospaced))
Spacer()
Text(percentage).font(.system(size: 13, weight: .medium, design: .monospaced))
.foregroundStyle(.secondary)
}
Gauge(value: fraction) { Text("") }
.gaugeStyle(.linearCapacity)
.tint(.primary)
HStack {
Spacer()
Text("\(dayOfYear)/\(totalDays)")
.font(.system(size: 11, weight: .regular, design: .monospaced))
.foregroundStyle(.secondary)
}
}
.containerBackground(.fill.tertiary, for: .widget)
// WRONG - custom GeometryReader progress bar
GeometryReader { proxy in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 2).fill(Color.primary.opacity(0.16))
RoundedRectangle(cornerRadius: 2).fill(Color.primary)
.frame(width: max(2, proxy.size.width * progress))
}
}
.frame(height: 6)
小组件背景
// RIGHT
.containerBackground(.fill.tertiary, for: .widget)
// WRONG - hardcoded color
.containerBackground(.black, for: .widget)
小组件类型覆盖
支持所有相关类型——不要遗漏常见类型:
.supportedFamilies([
.accessoryCircular, // lock screen circle
.accessoryRectangular, // lock screen rectangle
.accessoryInline, // lock screen inline text
.systemSmall, // home screen small
.systemMedium, // home screen medium
.systemLarge, // home screen large
])
跨类型的视觉一致性
中型和大型主屏幕小组件应采用相同的结构布局:
- 标题:左侧显示年份,右侧显示百分比
- 中间:进度条
- 页脚:右对齐显示
day/total
除非尺寸有硬性限制,否则不要为每种类型重新设计层级结构。
主屏幕小组件务必显式设置内部内边距,避免内容在圆角边缘附近被裁切:
.padding(.horizontal, 12)
.padding(.vertical, 12)
小组件内存预算(硬性限制)
小组件扩展的内存预算很紧(通常约为 30 MB)。如果使用过多嵌套视图构建密集型可视化,可能会因 EXC_RESOURCE 而被终止。
// RIGHT - draw dense dot grids in one pass
Canvas { context, size in
// draw 365/366 dots here
}
// WRONG - hundreds of nested subviews (high memory overhead)
LazyVGrid(columns: columns) {
ForEach(1...366, id: \.self) { day in
ZStack { Circle(); partialFillLayer }
}
}
时间线刷新(匹配数据粒度)
// RIGHT - refresh at midnight for day-level data
let tomorrow = calendar.startOfDay(for: calendar.date(byAdding: .day, value: 1, to: now)!)
Timeline(entries: [entry], policy: .after(tomorrow))
// RIGHT - periodic refresh for time-of-day dependent percentages/partial fills
let refresh = Calendar.current.date(byAdding: .minute, value: 15, to: now)!
Timeline(entries: [entry], policy: .after(refresh))
// WRONG - minute-level refresh for static daily data
let tooFrequent = Calendar.current.date(byAdding: .minute, value: 1, to: now)!
Timeline(entries: [entry], policy: .after(tooFrequent))
8. 交互元素
切换开关
// RIGHT - use Toggle with its built-in label, tint with a single accent color
Toggle(isOn: $value) {
Text(title)
.font(.system(size: 15, weight: .regular, design: .monospaced))
}
.tint(.green)
// WRONG - hidden label with manual HStack layout
HStack {
Text(label).font(.system(size: 18))
Spacer()
Toggle("", isOn: $isOn)
.labelsHidden()
.tint(Color.white.opacity(0.44)) // low-contrast tint
}
互斥选项
选项互斥时(例如每日/每周/每月频率),应使用一个选中值,而不是三个独立的切换开关。
// RIGHT - single source of truth
enum Cadence: String, CaseIterable { case daily, weekly, monthly }
@State private var cadence: Cadence = .daily
ForEach(Cadence.allCases, id: \.rawValue) { option in
Button {
cadence = option
} label: {
HStack {
Image(systemName: cadence == option ? "checkmark.circle.fill" : "circle")
Text(option.rawValue.capitalized)
}
}
}
// RIGHT - one preview action when content is shared
Button("Preview") { sendPreview() }
// WRONG - independent toggles allow contradictory state
Toggle("Daily", isOn: $daily)
Toggle("Weekly", isOn: $weekly)
Toggle("Monthly", isOn: $monthly)
数字变化时的动画过渡
// Add to any Text that displays a changing numeric value
Text(String(format: "%.2f", percentage))
.contentTransition(.numericText())
9. 交互式编辑器:集中管理几何信息和状态
交互式编辑器(拼贴、裁剪、画布、媒体取景工具、布局选择器)需要比普通表单更严格的状态和布局管理。
展示状态
根据负载状态展示编辑器流程,而不是使用单独的 Bool 加上独立管理的数据。
// RIGHT - presentation only happens when payload exists
@State private var activeCropRequest: CropRequest?
.sheet(item: $activeCropRequest) { request in
CropEditor(request: request)
}
// WRONG - the sheet can open before the backing data is ready
@State private var showCropEditor = false
@State private var selectedImage: UIImage?
.sheet(isPresented: $showCropEditor) {
if let selectedImage { CropEditor(image: selectedImage) }
}
共享几何模型
如果应用会实时预览平移/缩放/裁剪/布局,之后再导出结果,则预览和渲染应使用同一个共享几何模型。
// RIGHT - one source of truth for bounds and transforms
let normalized = EditorGeometry.normalizedAdjustment(adjustment, imageSize: image.size, slotSize: slotSize)
let drawRect = EditorGeometry.drawRect(for: image.size, in: slotRect, adjustment: adjustment)
// WRONG - preview and export each invent their own math
let previewOffset = ...
let exportOffset = ...
如果用户可以缩小到足以看到背景,这就必须是共享几何模型有意支持的行为,而不是仅限编辑器的例外情况。
手势协调
点击、长按拖动和捏合并不是彼此独立的功能。在 SwiftUI 中,除非明确建模它们之间的关系,否则这些手势会相互竞争。
- 使用单一交互状态来表示当前激活的图块/卡片/画布项目。
- 确定哪些手势具有优先级,以及哪些手势可以同时运行。
- 在选中项变化时,有意识地重置临时手势状态。
- 优先采用连贯统一的状态机,避免使用与各个手势绑定、零散分布的布尔值。
固定的编辑器布局
如果屏幕必须禁止滚动,则应自上而下划分垂直空间,使用少数几个命名区域:
- 标题
- 画布区域
- 设置区域
- 底部工具栏
将尺寸计算集中在一处。不要让每个子视图自行设定高度。
自定义标题和安全区域
如果用自定义标题替代系统导航栏:
- 明确父视图是否已经遵循安全区域。
- 不要习惯性地添加
safeAreaInsets.top;重复计算会产生明显的空白区域。 - 保持自定义标题简洁。它应像导航栏元素,而不是完整的内容区块。
设置面板
编辑器包含多个配置模式(Layout、Border、Ratio、Background 等)时,一次只显示一个活动设置面板,而不是将所有控件都堆叠在屏幕上。
这样可以让画布在视觉上更突出,也更便于理解每组控件。
10. 数据模型:在应用和小组件之间共享
// RIGHT - one model used everywhere
struct YearProgress {
// shared calculation logic
static func current() -> YearProgress { ... }
}
// Used by both ContentView and widget TimelineProvider
// If percentage is shown as live progress, include time-of-day in shared math
let dayProgress = elapsedInCurrentDay / totalSecondsInDay
let elapsedDays = Double(dayOfYear - 1) + dayProgress
let fraction = elapsedDays / Double(totalDays)
// WRONG - separate snapshot structs with duplicated date math
struct YearProgressSnapshot { ... } // in app
struct YearProgressWidgetSnapshot { ... } // in widget extension (duplicated!)
11. 快速检查清单
发布任何 SwiftUI 视图前,请确认:
- 所有间距值均来自网格(4、8、12、16、20、24、32)
- 字体大小不超过 5 种
- 始终使用同一种字体设计(包括小组件)
- 颜色使用语义化系统颜色,而非带透明度的硬编码值
- 背景和前景描边使用相同的 lineWidth
- 卡片使用
Color(.secondarySystemBackground),圆角半径为 10pt - 分隔线使用系统
Divider(),并添加前导内边距 - 开关行使用 Toggle 内置的标签(而非
.labelsHidden()) - 锁定屏幕小组件使用
Gauge(而非手动画圆) - 小组件背景使用
.containerBackground(.fill.tertiary, for: .widget) - 不需要分组时,年份/标识符文本不会按区域设置进行分组
- 字符间距/字距调整最多使用 2 种值
- 使用 NavigationStack(而非裸 ZStack)
- 时间线刷新频率与数据粒度相符(午夜刷新或定期刷新)
- 大型/密集的小组件视觉元素使用
Canvas或类似的轻量渲染方式 - 中型和大型小组件布局系列采用一致的层级结构和内部内边距
- 互斥选项使用单个选中值(而非多个开关)
- 当界面暗示实时进度时,百分比应包含时段信息
- 不使用
minimumScaleFactor变通方案——应改进布局 - 交互式编辑器根据载荷状态呈现,而非使用
Bool加独立数据 - 预览和导出共用相同的平移/缩放/裁剪/布局几何模型
- 自定义页眉不会重复计算顶部安全区域内边距
- 无滚动编辑器界面的高度预算通过集中式布局模型管理
- 多模式编辑器一次只显示一个重点设置区域,而非同时显示所有控件