mcpField
S
swiftui-design-principles

swiftui-design-principles

github.com/arjitj2设计与 UI321.3k 次安装32 周前更新
代码仓库

关于 swiftui-design-principles

swiftui-design-principles 为 SwiftUI、WidgetKit 和原生 Apple UI 的构建与修改提供设计规范,面向界面开发者。内容涵盖间距、字体、颜色、组件、导航和小组件等实践。

主要功能

  1. 01采用一致的间距网格
  2. 02建立清晰的字体层级
  3. 03优先使用系统语义颜色
  4. 04遵循原生组件与布局模式
  5. 05覆盖 WidgetKit 尺寸与刷新策略

适用场景

  1. 01构建或调整 SwiftUI 界面
  2. 02设计 WidgetKit 小组件
  3. 03统一应用与小组件的视觉规范

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/arjitj2/swiftui-design-principles --skill swiftui-design-principles

安装后在 Claude Code 中输入/swiftui-design-principles确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

AGENTS.md
LICENSE
README.md
metadata.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.regulariOS 标准正文大小
区块标题(大写)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 加独立数据
  • 预览和导出共用相同的平移/缩放/裁剪/布局几何模型
  • 自定义页眉不会重复计算顶部安全区域内边距
  • 无滚动编辑器界面的高度预算通过集中式布局模型管理
  • 多模式编辑器一次只显示一个重点设置区域,而非同时显示所有控件

评论

支持 Markdown