关于 swiftui-microinteractions
用于生成可编译的完整 SwiftUI 动画文件,采用 legendary-Animo 风格。面向构建或编辑交互动效与视觉效果的开发者,并提供意图推断、资源检查、触觉反馈和项目文件修改规则。
主要功能
- 01生成完整 SwiftUI 动画文件
- 02将设计描述映射为动画模式
- 03支持手势物理效果与定时触觉反馈
- 04扫描现有图像资源
- 05将修改限制在项目文件内
适用场景
- 01创建动画 SwiftUI 按钮或滑块
- 02构建带吸附阈值的拖动交互
- 03制作 SF Symbols 或 Canvas 动效
- 04编辑现有 SwiftUI 动画
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ iamvishal16/ swiftui-microinteractions --skill swiftui-microinteractions安装后在 Claude Code 中输入/swiftui-microinteractions确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
LICENSECHANGELOG.mdREADME.md详细介绍
生成完整、可编译的 SwiftUI 动画文件,采用 legendary-Animo 风格。不使用占位符。不添加 TODO 注释。
适用场景
- 使用高级物理效果构建 SwiftUI 按钮、手势、滑块或液态效果
- 需要让触觉反馈与动画阶段正确同步
- 创建具有阻力、吸附或阈值触发效果的拖动交互
- 为 SF Symbols 添加描绘、呼吸、弹跳、替换或可变颜色效果动画(iOS 17–26)
- 构建 Canvas 加载器,沿形状轮廓绘制彗星轨迹(无穷符号、星形、多边形……)
- 将平面网格元素变形成伪 3D 旋转形态(圆筒、球体、螺旋),再变回原样
- 构建卡片揭晓包(滑动撕开、3D 翻转卡片、交错填充统计数据)或 Canvas 力量效果展示
- 构建引导式聚焦导览 / 教练标记,引导用户逐一查看实际控件(变暗遮罩、镂空区域、指向式提示框)
- 编辑现有的 SwiftUI 动画文件
范围与安全
- 仅在用户当前项目内写入:新建或编辑的
.swift(或.metal)文件,以及必要时该项目的project.pbxproj。不得写入工作目录以外的位置。 - 不访问网络、不安装软件包、不运行 Shell 脚本。在编辑模式下,只修改指定文件。
模式检测
- 编辑模式:提示中包含“edit”“update”“add X to”“change”或
.swift文件名 → 先读取文件,只修改用户要求的内容,然后将文件写入磁盘覆盖原文件 - 创建模式:其他情况 → 生成新的完整文件并写入磁盘
意图推断(在生成代码前运行)
生成任何代码前,先解析提示中未明确说明的细节,并将其补全为默认值。输出一个 🔍 Inferred from prompt: 区块,让用户查看并覆盖自动补全的内容。只输出实际推断出的条目(不包括用户已明确说明的内容)。
步骤 0 — 规范化设计师词汇
在运行后续步骤之前,将提示词中的设计师用语转换为技能可理解的术语。这样,即使是非技术提示词(“灵动的卡片,带有令人期待的撕裂感”),也能产出与完整指定的提示词相同的结果。
动画感觉词 → 物理效果预设:
| 设计师用语 | 对应效果 |
|---|---|
| 灵动 / 清脆 / 紧凑 | UI pop 0.35/0.6 |
| 弹跳 / 弹簧感 / 弹性 | Snap 0.35/0.5 |
| 拉伸 / 橡胶感 / 摇摆 | 3 阶段橡皮筋(参见 iOS 26 部分) |
| 融化 / 漂浮 / 柔和 / 轻柔 | 慢速变形 0.6/0.8 |
| 僵硬 / 精确 / 机械 | 精密刚性 interpolatingSpring(220, 22) |
| 线性 / 平稳 / 恒定 | .linear(duration:) |
运动原理词 → 原型 / 动画信号:
| 设计师用语 | 含义 |
|---|---|
| 预备动作 | 主动作前的预拉伸阶段 |
| 后续动作 | 在稳定动画中加入过冲 |
| 挤压与拉伸 | 撞击时的缩放形变(x 轴扩展,y 轴压缩) |
| 舞台调度 / 错开 | 为每个元素设置偏移 .animation(delay:) |
| 脉动 / 呼吸 | .breathe isActive 循环 |
| 涟漪 | 从点击位置径向扩散的 Circle 缩放 |
| 泛起 | Liquid Toggle 原型、metaball 填充 |
| 摆动 / 抖动 | .wiggle value: — 错误 / 拒绝信号 |
| 弹出 | 快速 .scaleEffect 过冲后弹回 |
| 变形 | Glass Morph 原型或形状插值 |
| 液态铬 / 熔融金属 / 水银 / 拉丝钢 | Metal Shader — 可缝合的 .colorEffect(参见 Metal Shaders 部分) |
| 全息 / 虹彩 / 油膜 / 箔片 / 棱镜 | Metal Shader — 在 .colorEffect 中使用菲涅耳彩虹效果 |
| 等离子 / 熔岩 / 星云 / 极光 / 流体墨水 | Metal Shader — 在 .colorEffect 中使用 FBM 场 |
UI 元素名称 → SwiftUI 基元:
| 设计师用语 | 也称为 | SwiftUI |
|---|---|---|
| 底部工作表 / 托盘 / 抽屉 / 面板 | 上拉面板、模态工作表 | .sheet + .presentationDetents |
| 模态框 / 对话框 / 覆盖层 / 灯箱 | 全屏工作表 | .sheet(全屏)或 .fullScreenCover |
| 弹出框 / 工具提示 / 标注 / 气泡 | 悬停卡片 | .popover |
| Toast / 消息栏 / 轻提示 / 横幅 | 内嵌通知 | 自定义覆盖层 + 自动关闭 .task |
| 警报 / 警告 / 确认 | 对话框 | .alert 或 .confirmationDialog |
| 卡片 / 磁贴 / 表面 | 面板 | RoundedRectangle + 材质 |
| 标签芯片 / 标签 / 药丸形标签 / 徽章 | 筛选标签 | 自定义 Capsule |
| FAB / 浮动操作按钮 | 浮动按钮 | 在 ZStack 中绝对定位的 Button |
| 分段控件 / 药丸形选择器 / 标签栏 | 切换组 | Picker(.segmented) 或自定义药丸形控件 |
| 骨架屏 / 闪烁效果 / 占位符 | 加载占位符 | redacted(.placeholder) 或动画透明度 |
| 微调器 / 加载器 / 旋转指示器 | 活动指示器 | ProgressView |
| 上下文菜单 / 长按菜单 | 按住菜单 | .contextMenu |
| 操作表 / 底部操作菜单 | 选项工作表 | .confirmationDialog |
步骤 1 — 符号 → 效果查询
如果提示词中提到了某个符号(或按主题描述了符号),且未指定动画效果,则应用此表:
| 符号 / 主题 | 类型 | 自动效果 |
|---|---|---|
signature、pencil、pencil.line、scribble、lasso | 笔画 | .drawOn 自动循环 |
checkmark、heart(轮廓)、star(轮廓)、wifi、antenna.radiowaves | 笔画 | .drawOn 自动循环 |
heart.fill、star.fill、circle.fill | 填充 | .bounce value:(填充形状上的 drawOn 不可见) |
circle.dotted、rays、waveform | 动态结构 | .variableColor.iterative.reversing 或 .breathe |
arrow.clockwise、arrow.2.circlepath、gear | 旋转 | .rotate(无限循环,isActive) |
bell、bell.fill | 离散动作 | .bounce value: |
第 2 步 — 展示模式与交互模式
- 未描述用户操作(没有“轻点以”“提交时”“当用户……”等表述)→ 展示模式:使用
.task自动循环驱动。符号会自行播放动画,无需轻点。 - 描述了用户操作 → 仅在该操作触发后播放动画。不要添加自动循环。
第 3 步 — 应用默认值约定
使用默认值约定(见下文)处理所有未指定的 UI 元素。
第 4 步 — 输出推断日志
🔍 Inferred from prompt:
Vocab → "<designer word>" → <normalized term> (only if Step 0 fired)
Symbol → <name> (<stroke|fill>) → <effect>
Mode → showcase (auto-loop) | interaction (tap/submit)
Container → .sheet (Apple default) | inline | none
Controls → Clear · Done | none
默认值约定
未提及 = Apple HIG 默认值。 提示词中未提及的任何 UI 元素都默认使用 Apple 系统原语;如果有合适的系统原语,绝不自行创建自定义模态框、叠加层或容器。
| 元素 | Apple 默认值 | 何时升级处理 |
|---|---|---|
| 容器 | .sheet(isPresented:) | 提示词指定了自定义模态框 / 全屏 |
| 关闭 | 在 .toolbar(.confirmationAction) 中使用 Button("Done") | 提示词指定了自定义按钮 |
| 清除 / 重置 | 在 .toolbar(.cancellationAction) 中使用 Button("Clear") | 仅限绘图 / 签名场景 |
| 工作表内背景 | 系统工作表背景(不使用自定义深色填充) | 提示词明确指定了深色 / 自定义背景 |
| 导航 | NavigationStack | 多步骤流程或暗示了标题 |
| 加载状态 | .progressView() 或符号 .variableColor | 提示词指定了特定加载器形状 |
绘图 / 签名场景规则——如果提示词包含“签名”“绘制”“草图”或“手写”,则自动应用:
- 容器:
.sheet(isPresented:) - 工具栏:
Button("Clear")(.cancellationAction)+Button("Done")(.confirmationAction) - 工作表内不使用自定义深色背景(显示系统工作表表面即可)
资源扫描(生成任何依赖图像的动画之前运行)
如果动画涉及图像、照片或卡片,请先扫描现有资源:
-
按以下顺序检查这些路径:
legendary-Animo/Res/Assets.xcassets/Images/Assets.xcassets/Images/Assets.xcassets/
-
列出
.imageset文件夹名称——去掉.imageset后缀,得到Image("name")字符串 -
如果找到 → 直接使用。输出:
🖼️ Assets found: photo1, photo2 … (using in file) -
如果未找到 → 在彩色
RoundedRectangle中使用Image(systemName: "photo.fill")。在末尾添加资源说明。
绝不臆造资源名称。仅引用确认存在于磁盘上的名称。
触觉反馈——先检查项目
添加触觉反馈代码之前,请先检查项目中是否存在 HapticFeedback.swift:
- 如果找到 → 直接调用
HapticFeedback.lightImpact()等。不要重新声明该结构体。 - 如果未找到 → 直接内联调用
UIImpactFeedbackGenerator/UISelectionFeedbackGenerator,并在顶部添加import UIKit。
何时添加触觉反馈,何时跳过
在以下情况下添加触觉反馈:
- 手势设有会触发不可逆操作的阈值(拖动删除、滑动关闭)→ 越过阈值时使用
heavyImpact - 切换控件在两个有意义的状态之间切换 → 确认时使用
mediumImpact - 确认破坏性操作(删除、清除、重置)→ 使用
heavyImpact - 滚动条或拨盘在离散数据点之间移动 → 每步使用
selectionChanged - 玻璃形变或 metaball 融合/分离完成 → 使用
mediumImpact
在以下情况下跳过触觉反馈:
- 纯符号展示,自动循环且没有用户交互
- 加载指示器或环境背景动画(星星、漂浮粒子)
- 提示词完全没有描述用户操作
手势弧线的触觉反馈梯度:
lightImpact— 开始拖动/触摸屏幕mediumImpact— 越过阈值/进行到一半heavyImpact— 确认操作/确认销毁selectionChanged— 滚动离散值/步进
阈值触觉反馈应使用门控并重新布防——每次越过阈值都触发,而非只触发一次。 拖动越过关闭/确认阈值、随后退回,再次越过时,每次都应触发振动,而不只是第一次。一个从未重置的简单“已触发”布尔值,在整个手势期间只会触发一次。进入时启用门控,退出时重新布防:
@State private var didCrossThreshold = false
// in onChanged:
if !didCrossThreshold, y > threshold {
didCrossThreshold = true
impact(.medium) // fires on every fresh crossing
} else if didCrossThreshold, y <= threshold {
didCrossThreshold = false // rearm — retreating past the line resets it
}
// reset both didCrossThreshold and any didStartDrag flag in onEnded
对触摸屏幕时的 lightImpact 使用相同的门控(无需重新布防,因为只会发生一次),并设置一个较小的死区(y > 2),避免手势刚开始时的亚像素抖动导致过早触发。
TimelineView 触觉反馈——根据阶段枚举触发,绝不根据时钟触发。 .animation 调度的 body 每秒会重新运行约 60 次,因此在原始时间值上(或 clock >= tLiftEnd 之类的测试上)使用 .sensoryFeedback(trigger:),会在越过边界后的每一帧都触发,而不是只在越过边界时触发。将时钟映射为小型 Equatable 阶段枚举(.flat / .lifting / .spinning / .settling),并根据该枚举触发——“阶段已改变”只会在每个边界发生一次。根据时钟而非轻点时的 Bool 推导阶段:轻点只能表明形变开始,只有时钟才能表明形变已完成,而通常最值得标记的是形变到达的时刻。对于不应触发反馈的阶段,从闭包返回 nil。
SourceKit 将 HapticFeedback 误报为错误——始终忽略:
在 Carousels/ 或 Animations/ 目录下编写的文件中,SourceKit 会报告 Cannot find 'HapticFeedback' in scope。这不是真正的错误——SourceKit 会单独分析新文件,因此看不到模块中的其他成员。HapticFeedback.swift 已在 Sources 构建阶段注册;真正的编译器可以正确解析它。不要添加 import UIKit,不要重新声明该结构体,也不要修改代码。
样式规则
背景: 独立使用 Color(white: 0.06).ignoresSafeArea() · 项目内使用 Color("BgColor").ignoresSafeArea()
玻璃表面(iOS 26 之前): 将 .ultraThinMaterial 或 .thickMaterial 裁剪为指定形状 · 轨道背景使用 .white.opacity(0.06),并加上 1pt 的 .white.opacity(0.08) 描边
不透明度级别: 极淡 0.06–0.08 · 柔和 0.12 · 非活动 0.3 · 次要 0.5 · 活跃 0.8–0.9 · 完全不透明 1.0
颜色: 以白色和不透明度为主 · 青色 Color(red: 0.45, green: 0.65, blue: 1.0) · 绿色 Color(red: 0.55, green: 0.95, blue: 0.75) · 金色/评分色 Color(red: 1.0, green: 0.84, blue: 0.35) · 深色背景上不要使用 .blue/.green/.red
光泽叠层(摄影卡片): 在海报/照片卡片上添加对角光泽,可呈现高级玻璃质感,同时不改动图像本身——叠加一个 LinearGradient(colors: [.white.opacity(0.18), .clear], startPoint: .topLeading, endPoint: .center),将其裁剪为卡片形状,并使用 .blendMode(.softLight)。.softLight(而非 .screen)能让高光足够柔和,不会冲淡下方的照片。
图像着色的环境阴影(光晕): 在海报/照片卡片下方添加普通黑色 .shadow,看起来只是常见的厚重感,而非光线效果。要让阴影看起来像是来自特定作品本身,可在卡片后方复制图像,并进行强烈模糊和调暗:
.background {
Image(item.imageName)
.resizable().scaledToFill()
.frame(width: cardWidth, height: cardHeight)
.clipShape(cardShape)
.blur(radius: 34)
.opacity(0.65)
.offset(y: 22)
.scaleEffect(0.96) // slightly smaller so the blurred edge doesn't peek past the card
}
这样会产生柔和的彩色光晕,而不是中性的投影——仅用于主视觉卡片(牌组正面、选中项),不要用于每一行,因为这会重复渲染图像。适用于任何以图像为背景的卡片——海报、专辑封面、产品照片。
渐变: 仅使用两种颜色 · 色块填充 [.white, Color(white: 0.88)] · 进度条 [cyan, green]
浅色主题
上述样式规则以深色主题为主。对于浅色界面(白色背景上的仪表板、卡片、工作表),层次感来自色调差异,而非阴影——高级浅色设计会避免在堆叠表面之间使用投影。
色调层级——通过亮度区分表面:
| 角色 | 值 |
|---|---|
| 屏幕背景 | Color(white: 0.92)(或渐变 0.93 → 0.87) |
| 卡片/行表面 | Color.white |
| 内嵌区域(图标圆形、字段) | Color(white: 0.95) |
| 轨道/分隔线 | Color(white: 0.88) |
| 墨色(主要文本) | Color(white: 0.10) |
| 柔和色(次要文本) | Color(white: 0.55) |
规则:
- 堆叠的浅色表面之间不要使用
.shadow——白色行置于0.92的背景上,本身就显得凸起。阴影仅用于单个悬浮的主要元素(例如深色 CTA),不要用于每一行。 - 强调色必须加深,以便在白色背景上保持对比度——例如青色
Color(red: 0.30, green: 0.52, blue: 0.95),与深色背景使用的青色相反。 - 深色胶囊形 CTA(填充
Color(white: 0.10),白色标签)是浅色主题中玻璃按钮的对应样式。
自适应(同时支持浅色和深色)
屏幕必须跟随系统外观时,绝不要硬编码 .white/.black 或强制设置 .preferredColorScheme。所有内容都应基于 @Environment(\.colorScheme) 和语义颜色:
@Environment(\.colorScheme) private var scheme
private var isDark: Bool { scheme == .dark }
- 文本 →
.primary/.secondary(自动反转)。将所有.white.opacity(x)替换为.primary.opacity(x)/.secondary。 - 毛玻璃卡片——官方方式(iOS 26): 使用真正的 Liquid Glass API,并将其着色为深色,而不是用材质伪造毛玻璃——
.glassEffect(.regular.tint((isDark ? .black : .white).opacity(0.28)), in: shape)能保留真实的镜面反光和折射,同时呈现深色(或浅色)的通知表面。通过if #available(iOS 26.0, *)进行版本判断;在 iOS 18–25 上回退到.ultraThinMaterial,并叠加适配色彩方案的色调(isDark ? .black.opacity(0.45) : .white.opacity(0.35))。(未经着色的glassEffect看起来是浅色的——要呈现深色表面,务必着色;在 iOS 26 上不要使用材质。) - 渐变 / 描边 / 阴影 → 根据
isDark分支处理(例如深色模式使用海军蓝背景,浅色模式使用蓝白背景;阴影在深色模式下为0.35,浅色模式下为0.12)。 - 强调色渐变 → 深色模式下调亮,浅色模式下加深,以确保强调色在两种背景上都清晰可见。
- 存储的
let无法读取环境值——将依赖色彩方案的值设为计算属性var。
iOS 26 Liquid Glass
当提示词中写着“Liquid Glass”时 → 务必使用真正的效果。 两条不可妥协的要求:
- 使用真实 API,绝不使用仿制效果。 在 iOS 26 上使用真正的
.glassEffect(.regular)(或用于可按压控件的.regular.interactive())。不要用接近不透明的填充作为主要表面来伪造效果——Capsule().fill(.ultraThinMaterial)或浓重的.glassEffect(.regular.tint(.white.opacity(0.5)))看起来像扁平的胶囊按钮,而不是玻璃。.ultraThinMaterial仅用于 iOS 26 以下的回退方案,并通过if #available(iOS 26.0, *)进行版本判断。 - 将繁杂、鲜艳的内容放在玻璃背后,这样才能真正呈现折射和镜面反光——例如图片网格、照片墙或鲜艳的渐变。在纯色或浅色背景上,Liquid Glass 几乎不可见,看起来就像普通材质;这是“玻璃”效果看起来不对的首要原因。(若要单独展示效果,使用图片平铺网格作为背景是最稳妥的方式。)
.regular 与 .clear: .glassEffect(.regular, in:) 是默认的磨砂强度;如果工具栏或控件位于特别丰富、繁杂、高对比度的内容(如海报、照片背景)之上,且 .regular 的磨砂效果会过度削弱图像细节,可使用 .glassEffect(.clear, in:)。.clear 能让背景的更多色彩和细节透出来。
绝不要在正在拖动或快速动画的视图上,为随之移动的胶囊按钮使用实时 .glassEffect()(例如卡片正在滑动时的元信息胶囊,或滑块拇指上的徽标)。玻璃会持续重新采样背景;对于快速移动的元素,这种重新采样会让玻璃明显地膨胀或脉动,看起来不稳定——这不是细微的 bug,而是明显的故障效果。对于附着在主动移动内容上的元素,应改用普通的静态半透明填充(在相同形状中使用 Color.black.opacity(0.45));真正的玻璃只用于静止或缓慢移动的元素。
在 iOS 26 及更高版本上使用 .glassEffect(),较旧的操作系统版本则回退到 .ultraThinMaterial。务必将两种路径都封装在 @ViewBuilder 辅助方法中,使其共用同一个调用位置:
@ViewBuilder
private func glassShape(radius: CGFloat) -> some View {
if #available(iOS 26.0, *) {
RoundedRectangle(cornerRadius: radius, style: .continuous)
.fill(.clear)
.glassEffect(in: RoundedRectangle(cornerRadius: radius, style: .continuous))
} else {
RoundedRectangle(cornerRadius: radius, style: .continuous)
.fill(.ultraThinMaterial)
.overlay(
RoundedRectangle(cornerRadius: radius, style: .continuous)
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1)
)
}
}
适用于任意形状——药丸形、胶囊形、圆形。若目标版本包含 iOS 26,为新组件硬编码 .ultraThinMaterial。
着色玻璃(有色状态)
对于破坏性操作或强调色按钮,使用内置的 tint API——绝不要在玻璃上叠加有色形状:
.glassEffect(.regular.tint(dangerColor), in: Circle())
危险红:Color(red: 0.87, green: 0.32, blue: 0.42) · 强调蓝:Color(red: 0.45, green: 0.65, blue: 1.0)。
GlassEffectContainer — 形变融合
当多个玻璃元素需要通过 iOS 26 的元球效果融合与分离时(例如胶囊形元素形变为独立圆形),请将它们放入 GlassEffectContainer 中:
@Namespace private var glassNS
GlassEffectContainer(spacing: 18) {
HStack(spacing: 28) { // see spacing trap below
capsule
.glassEffect(in: Capsule())
.glassEffectID("capsule", in: glassNS)
if isExpanded {
circle
.glassEffect(.regular.tint(.red), in: Circle())
.glassEffectID("circle", in: glassNS)
}
}
}
融合阈值间距陷阱 — GlassEffectContainer(spacing:) 是融合阈值,不是视觉内边距。任意两个玻璃元素只要间距小于该值,就会始终在视觉上融合成一团,即使处于静止状态也一样。布局间距必须大于 containerSpacing:
| containerSpacing | HStack spacing | 结果 |
|---|---|---|
| 18 | 10 | ❌ 永久融合 — 静止时边缘也会变形 |
| 18 | 28 | ✅ 静止时形状清晰,仅在过渡期间产生元球形变 |
规则:为留出安全余量,layoutSpacing > containerSpacing + 6pt。
边缘变形陷阱
绝不要对单个玻璃元素应用带偏移锚点(.leading / .trailing)的 .scaleEffect(x:y:anchor:) — 锚点边缘的亚像素渲染会造成明显的边缘变形,即使缩放比例为 1.0 也一样。请改用作用于整个容器的橡皮筋拉伸:
GlassEffectContainer(spacing: 18) { ... }
.scaleEffect(x: 1.0 + stretch * 0.05, y: 1.0 - stretch * 0.025, anchor: .center)
另外:不要在 .glassEffect(in: Capsule()) 上再添加多余的 .clipShape(Capsule()) — glassEffect 已经会裁剪。
三阶段橡皮筋切换
对于点击时会弹开或融合的玻璃元素,叠加 3 种动画,营造真实橡皮筋的效果:
private func toggle() {
HapticFeedback.mediumImpact()
// Phase 1 — pre-stretch (rubber tensions)
withAnimation(.spring(response: 0.28, dampingFraction: 0.55)) {
stretchAmount = 1.0
}
// Phase 2 — morph fires at peak tension
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
withAnimation(.spring(response: 0.55, dampingFraction: 0.72)) {
isExpanded.toggle()
}
}
// Phase 3 — release snap-back with overshoot
DispatchQueue.main.asyncAfter(deadline: .now() + 0.28) {
withAnimation(.spring(response: 0.45, dampingFraction: 0.55)) {
stretchAmount = 0
}
}
}
玻璃效果演示的浅色背景
为展示 iOS 26 的玻璃效果,请使用浅色渐变背景 — 深色背景会遮挡折射效果。独立演示:
LinearGradient(colors: [Color(white: 0.97), Color(white: 0.88)],
startPoint: .topLeading, endPoint: .bottomTrailing)
.ignoresSafeArea()
SF Symbols 动画(iOS 17–26)
自动选择效果: 请参阅上文“意图推断 § 符号 → 效果查找”。该表会根据符号名称自动映射到正确的效果 — 除非要覆盖默认效果,否则无需在提示词中指定
.drawOn、.breathe等效果。
.drawOn — 仅限 iOS 26(SF Symbols 7)
isActive 的含义是反向的。 这是最常见的错误:
| isActive | 视觉状态 | 播放内容 |
|---|---|---|
true | 隐藏(绘制前) | 首次渲染时不播放任何内容 |
false | 可见 | drawOn 描绘出笔画 |
false → true | 隐藏 | 自动播放反向效果(draw-off) |
正确模式 — 始终将状态变量命名为表示“隐藏”:
@State private var isDrawSymbolHidden = true // starts hidden
Image(systemName: "checkmark")
.symbolEffect(.drawOn, options: .speed(0.5), isActive: isDrawSymbolHidden)
.task {
try? await Task.sleep(for: .seconds(0.5))
while !Task.isCancelled {
isDrawSymbolHidden.toggle() // false → draws on; true → draws off
try? await Task.sleep(for: .seconds(2.0))
}
}
.drawOn 的符号选择很重要:
heart.fill、star.fill— 填充形状,没有描边路径。drawOn 看起来会从隐藏状态瞬间跳到填充状态。不适合展示笔迹描绘效果。checkmark、heart(轮廓)、signature、wifi、star(轮廓)— 基于描边,能清晰展示描绘过程。
不要在同一张图像上叠加 .drawOn 和 .drawOff。它们会互相冲突,导致符号一直不可见。
.drawOn 只会在状态转换时播放 — 不会在视图出现时播放。 isActive: 会在视图可见时,于绑定值发生变化时描绘路径。如果视图出现时已经处于可见状态,它不会重新播放。由此有两点需要注意:
- “绘制一个……演示”/“为……添加动画” → 默认自动播放。 如果提示要求符号自行绘制或播放动画(例如展示效果、提示或加载状态),请使用上面的
.task循环驱动,让它自行描绘。只有当提示明确描述了用户操作(“轻点以签名”“提交时”)时,才将其设为必须通过轻点或状态变化触发。当用户想要自动播放的演示时,却使用交互触发,是 drawOn 构建看起来“失效”的最常见原因 — API 虽然存在且使用正确,但效果始终不会触发。 - 如果由轻点驱动,绘图区域在首次轻点前会是空的。 这是正确行为,不是错误 — 但请确认这符合提示的要求。
验证陷阱 — 切勿通过预先将状态设为目标值来验证状态转换。 在初始化时设置 @State private var isDrawSymbolHidden = false,会让符号一开始就完全绘制好,且没有状态转换 — 因此截图会显示完整符号,造成“运行正常”的假象。这验证的是布局,而非动画。要实际验证描绘效果:启动并运行自动循环(或模拟轻点),然后截取描绘过程中的画面(部分笔画)。截图中出现完整绘制的符号,无法证明绘制动画是否播放过。这适用于所有 isActive:/value: symbolEffect,而不只是 drawOn。
其他符号效果(iOS 17+)
// Discrete — fires once per value change (user actions, additions)
.symbolEffect(.bounce, value: count)
.symbolEffect(.wiggle, value: errorTrigger)
// Indefinite — runs while active (states: recording, loading, scanning)
.symbolEffect(.breathe, isActive: isRecording)
.symbolEffect(.variableColor.iterative.reversing, isActive: isLoading)
.symbolEffect(.rotate, isActive: isSyncing)
// Content transition — swapping symbols on state toggle (always needs withAnimation)
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
.contentTransition(.symbolEffect(.replace))
// trigger with: withAnimation(.smooth) { isPlaying.toggle() }
循环驱动方式 — 使用 .task,不要使用 Timer:
.task {
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(interval))
count += 1 // or toggle state
}
}
视图消失时,.task 会自动取消。Timer 会造成泄漏。
弹簧预设
| 观感 | 值 |
|---|---|
| 猛然弹出 / 回弹 | .spring(response: 0.35, dampingFraction: 0.5) |
| UI 弹出 | .spring(response: 0.35, dampingFraction: 0.6) |
| 标准稳定 | .spring(response: 0.4, dampingFraction: 0.65) |
| 物理效果稳定 | .spring(response: 0.45, dampingFraction: 0.7) |
| 缓慢变形 | .spring(response: 0.6, dampingFraction: 0.8) |
| 精准、刚性 | .interpolatingSpring(stiffness: 220, damping: 22) |
| 表盘 / 拖动 | .interactiveSpring(response: 0.3, dampingFraction: 0.7) |
切勿使用不带参数的 .spring() — 始终明确指定 response 和 dampingFraction。如果用户提供了数值,请原样使用。观感词映射:“有弹性” → 0.4/0.5,“利落” → 0.35/0.6,“融化” → 0.6/0.8。
原型 → 默认物理参数(提示未指定观感时使用):
| 原型 | 默认物理参数 |
|---|---|
| 符号展示 | .easeInOut(duration: 0.4) — 符号效果自身控制时长 |
| 液态开关 | UI 弹出 0.35/0.6 |
| 手势卡片 | 物理效果稳定 0.45/0.7 |
| 面板交互 | 标准稳定 0.4/0.65 |
| 数据仪表盘 | 表盘/拖动 .interactiveSpring(response: 0.3, dampingFraction: 0.7) |
| 玻璃变形 | 3 阶段橡皮筋效果(参见 iOS 26 部分) |
| 粒子 / 物理模拟 | 不使用 SwiftUI 弹簧 — 使用自定义物理循环 |
| 加载指示器 | .linear(duration: 0.8) — 切勿使用弹簧(循环播放会显得抖动) |
按压反馈 — PressableScale
每个可点击的行或按钮都应在手指按下时缩小,带来真实的按压感。使用由手势驱动的修饰符,而不是 Button/ButtonStyle——ButtonStyle.isPressed 无法在按下的瞬间触发触觉反馈,但 DragGesture(minimumDistance: 0) 可以。
private struct PressableScale: ViewModifier {
var pressedScale: CGFloat = 0.96
let action: () -> Void
@State private var isPressed = false
func body(content: Content) -> some View {
content
.scaleEffect(isPressed ? pressedScale : 1.0)
.animation(.spring(response: 0.3, dampingFraction: 0.6), value: isPressed)
.contentShape(Rectangle())
.simultaneousGesture( // simultaneous → won't block parent scroll
DragGesture(minimumDistance: 0)
.onChanged { _ in
if !isPressed { // haptic on the DOWN edge only
isPressed = true
HapticFeedback.lightImpact()
}
}
.onEnded { v in
isPressed = false
// lift-inside-to-fire: dragging off cancels, like a real UIButton
if abs(v.translation.width) < 20, abs(v.translation.height) < 20 { action() }
}
)
}
}
extension View {
func pressable(scale: CGFloat = 0.96, action: @escaping () -> Void = {}) -> some View {
modifier(PressableScale(pressedScale: scale, action: action))
}
}
其中有几条不太明显的规则:
- 使用
.simultaneousGesture(而不是.gesture),这样ScrollView/List中的行仍可滚动。 - 只在按下时触发触觉反馈(
if !isPressed)——不要在每次onChanged更新时都触发。 - 仅在手指在元素内抬起时触发——确认手指在约 20pt 范围内抬起后再运行
action;拖离元素应取消操作。 - 圆形图标按钮的
pressedScale约为0.90,宽行约为0.965。
入场 / 显示动画
高级感屏幕会自行组装呈现。使用单个 @State 标记,在显示时依次引入子元素。
@State private var appeared = false
ForEach(Array(items.enumerated()), id: \.element.id) { i, item in
rowView(item)
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 16)
.scaleEffect(appeared ? 1 : 0.97, anchor: .top)
.animation(.spring(response: 0.5, dampingFraction: 0.82)
.delay(0.10 + Double(i) * 0.07), value: appeared)
}
.onAppear { appeared = true } // trigger once — NEVER init appeared = true
- 将
opacity + offset(y:) + scaleEffect(anchor:.top)组合起来,营造轻柔的上升并稳定落位效果;每个索引延迟0.07s会呈现层叠效果。 - 在
.sheet内部,@State会在每次呈现时重置——因此每次打开工作表都会重新触发,无需额外代码就能实现“自行组装”的入场效果。 - 验证时的陷阱(与
.drawOn相同):切勿将标记初始化为目标值,否则转场不会播放。
自定义转场 — 橡皮筋式入场
.transition(.scale) 只会从其缩放值插值到 1.0——因此无法让元素以超大尺寸入场后再回弹。要实现“以更宽的尺寸入场,再像橡皮筋一样回弹至实际大小”的弹出效果,请编写自定义修饰符转场,并使用低阻尼(弹跳感强)的弹簧驱动,使其产生过冲:
private struct WidthPopModifier: ViewModifier {
let active: Bool
func body(content: Content) -> some View {
content
.scaleEffect(x: active ? 1.16 : 1.0, y: active ? 1.07 : 1.0, anchor: .top) // anisotropic → wider
.offset(y: active ? -34 : 0)
.opacity(active ? 0 : 1)
}
}
private extension AnyTransition {
static var widthPop: AnyTransition {
.modifier(active: WidthPopModifier(active: true), identity: WidthPopModifier(active: false))
}
}
// usage — the surrounding withAnimation's spring governs the transition:
withAnimation(.spring(response: 0.5, dampingFraction: 0.55)) { items.insert(item, at: 0) }
// on the view: .transition(.asymmetric(insertion: .widthPop, removal: .move(edge: .top).combined(with: .opacity)))
- 非等比缩放(
x > y)会让效果看起来像是宽度拉伸,而不是整体放大。 - 橡皮筋效果来自弹簧阻尼,而非转场:
dampingFraction ≈ 0.55会产生过冲;≈ 0.8则会平稳落位。数值越低,弹跳感越强。 - 对于内置转场无法表达的任何入场效果,
.modifier(active:identity:)都是通用方案。
数据仪表板模式
- 动画比例条——胶囊轨道 + 填充部分,显示时从宽度 0 开始增长,数据变化时再次弹出动画:
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(track).frame(height: 6)
Capsule().fill(ink.opacity(0.85))
.frame(width: geo.size.width * (appeared ? share : 0), height: 6)
.animation(.spring(response: 0.6, dampingFraction: 0.85), value: appeared)
.animation(.spring(response: 0.5, dampingFraction: 0.8), value: selection)
}
}.frame(height: 6)
- 用
.numericText实现数字递增效果——不只是用于货币。由分段选择器控制的醒目指标发生变化时,可通过.contentTransition(.numericText(value: Double(total)))+.animation(..., value: period)滚动数字。将分段切换与selectionChanged触觉反馈及滑动指示器搭配使用(参见“标签栏模式”)。
状态与代码规则
- 默认:所有手势跟踪都使用纯
@State· 仅当值必须自动重置时才使用@GestureState - 常量:在结构体层级使用 camelCase 命名的
private let(2–5 个值)——切勿使用 SCREAMING_SNAKE_CASE - 液态代谢球:在
Canvas中渲染球体,并使用.addFilter(.alphaThreshold)+.addFilter(.blur)——参见 Canvas Metaball 一节。(较旧的替代方案:黑底白色圆形 +.blur+.contrast(50)+.blendMode(.screen)。) - 多阶段序列:使用带有重叠延迟的
DispatchQueue.main.asyncAfter链式调用
繁重的单项计算应放在普通函数中,而不是 ViewBuilder 闭包里。 SwiftUI 会将 ViewBuilder 主体作为一个整体表达式进行类型检查,因此,在 ForEach 闭包中串联几十个包含三角函数 / pow 的 let,会让类型检查耗时呈指数级增长——慢到足以让 Xcode Previews 构建 thunk 超时,而完整应用构建仍能成功。将每项的几何计算放入 private func layoutFor(_ item:) -> ItemLayout,并返回一个小型结构体;普通函数会逐条语句进行类型检查。将不随帧变化的值(单元格大小、中心点、半径)提升到上下文结构体中,并在每次 GeometryReader 处理时只构建一次,而不是为每个项重复计算。
累计 rotation3DEffect 的反向翻转。 (headline) Y 轴上的 rotation3DEffect 会在每个 180° 的奇数倍角度下镜像所有内部内容——文本、图像和叠加层。当卡片在多次翻转中累积旋转角度(例如,每次“下一张卡片”旋转 180°)时,每隔一张卡片上的内容就会反向显示。解决方法:计算 let halfTurns = Int(round(angle / 180)),并在内部内容上、rotation3DEffect 之前应用 .scaleEffect(x: halfTurns % 2 != 0 ? -1 : 1)。用户看不到这个反向翻转,因为 3D 旋转本身已经镜像了视图——两者会相互抵消,内容始终能正确显示。
用 SeededRNG 生成确定性的 Canvas 粒子。 在 Canvas 渲染闭包(或任何 @ViewBuilder)中使用 CGFloat.random(),每一帧都会生成不同的值,导致粒子闪烁并瞬移。请改用简单的带种子 xorshift RNG(struct SeededRNG { var state: UInt64; mutating func unit() -> CGFloat })。种子在每帧之间保持不变,因此位置稳定;在事件发生时(新的闪电击中、新的撞击)更改种子以重新生成图案。对于非 Canvas 的 ForEach 主体,通过普通函数预先计算静态粒子数组——绝不要在 @ViewBuilder 中调用 .random()。
稳定的 ForEach 标识。 优先使用 ForEach(Array(items.enumerated()), id: \.element.id),而不是 ForEach(items.indices, id: \.self)——基于索引的标识会导致 SwiftUI 在数组重排时拆除并重建视图,破坏正在进行的动画并浪费视图标识。
ZStack 布局陷阱——务必同时应用以下两条规则:
当 ZStack 具有固定的 .frame(height:),且包含 LazyVGrid、List 或任何较高的组件时:
- 仅在需要时有条件地渲染较高的子视图(
if isExpanded || childVisible) - 为 ZStack 添加
.clipped()
仅依赖 .opacity(0) 并不能防止溢出——组件仍会在框架之外渲染,修改 height 常量也不会产生任何视觉效果。
浮动栏布局: 将由多个组件组成的栏(胶囊形区域 + 独立的操作按钮)构建为由独立视图组成的 HStack,而不是一个整体的 ZStack。每个组件都应有自己的玻璃背景和阴影。
Sheet 封装——外部启动器 + 私有内部内容:
当演示需要原生 .sheet 时,在同一个文件中使用两个结构体。文件中定义的 private 模型类型对两个结构体均可见——无需更改访问级别。
struct MyDemoView: View { // public — registered in ContentView
@State private var showSheet = false
var body: some View {
ZStack { Color("BgColor").ignoresSafeArea(); triggerButton }
.sheet(isPresented: $showSheet) {
MySheetContent()
.presentationDetents([.height(540)])
.presentationCornerRadius(28)
.presentationDragIndicator(.visible)
}
}
}
private struct MySheetContent: View { // private — only used as sheet body
@Environment(\.dismiss) private var dismiss
// sheet body here — X button calls dismiss()
}
数值文本过渡——拆分货币前缀:
在 "$173.11" 这样的货币字符串上使用 .contentTransition(.numericText(value:)),数字滚动时 $ 可能会闪动。将前缀和数字拆分为单独的 Text 视图——仅数字部分应用过渡:
HStack(alignment: .firstTextBaseline, spacing: 1) {
Text("$") // static — no transition
.font(.system(size: 32, weight: .bold))
Text(String(format: "%.2f", amount))
.font(.system(size: 44, weight: .bold))
.contentTransition(.numericText(value: amount))
.animation(.spring(response: 0.4, dampingFraction: 0.65), value: triggerValue)
}
当过渡由离散步骤驱动(卡片轮播、拨盘刻度)时:使用 selectionChanged 触觉反馈,而不是 mediumImpact——这更符合数字滚动器的感觉。
文件布局(必须遵循 MARK 顺序):
// MARK: - Model
// MARK: - Main View (tokens → @State → body → subviews → gesture → actions)
// MARK: - Supporting Shapes
// MARK: - Preview
原型目录
原型决定物理效果、触觉反馈和容器默认设置。根据提示词选择最匹配的类型;如果不确定,选择更简单的类型。
| 原型 | 提示词信号 | 容器 | 物理效果 | 触觉反馈 |
|---|---|---|---|---|
| 符号展示 | “animate”“draw”“show” + 符号名称 | 无(内嵌) | .easeInOut | 无 |
| 液体切换 | “toggle”“switch”“flood”“metaball” | 内嵌 | UI 弹出 0.35/0.6 | 切换时触发 mediumImpact |
| 手势卡片 | “drag”“pull”“rip”“swipe”“dismiss” | 内嵌 ZStack | 物理效果复位 0.45/0.7 | 轻触开始 → 强触确认 |
| 工作表交互 | “sheet”“sign”“draw”“pick”“form” | .sheet | 标准复位 0.4/0.65 | 控件上触发 selectionChanged |
| 数据仪表盘 | “chart”“graph”“metric”“scrub”“data” | 全屏 | 刻度盘/擦动 .interactiveSpring | 每步触发 selectionChanged |
| 玻璃变形 | “glass”“fuse”“morph”“capsule” | 内嵌 | 三阶段橡皮筋效果 | 变形时触发 mediumImpact |
| 粒子/物理模拟 | “gravity”“fluid”“rope”“cloth”“sand” | 全屏 | 自定义物理循环 | 触摸时触发 lightImpact |
| Metal 着色器 | “liquid chrome”“molten metal”“holographic”“plasma”“shader” | 全屏 | TimelineView 时钟(绝不使用 spring) | 触摸时触发 lightImpact |
| 加载指示器 | “loading”“spinner”“progress”“scanning” | 内嵌 | .linear(duration:) | 无 |
| 3D 对象展示 | “3D”“SceneKit”“spin the cover”“album”“boxed product” | 嵌入式或全屏 | SceneKit 装配;对驱动属性使用 SCNTransaction.disableActions(绝不使用 spring) | 每次复位时触发 selectionChanged/被动展示时无 |
| 教练标记导览 | “tour”“coach mark”“spotlight”“walkthrough”“onboarding tooltip” | 透明的 fullScreenCover,仅展示一次 | 镂空区域滑动 .spring(0.5/0.86),气泡 .opacity 交叉淡化,.easeInOut(0.25) 淡出 | 无(最多仅在点击“下一步”时触发 selectionChanged) |
| 卡包揭晓 | “pack”“tear open”“card flip”“reveal”“unbox”“foil” | 全屏 | .easeInOut 撕开 + .spring(0.6/0.7) 翻转 + 使用 TimelineView 时钟实现 Canvas 效果 | 撕开时触发强触觉反馈,翻转时触发中等触觉反馈,稀有卡牌时触发成功反馈 |
在 🎯 Archetype: 行打印确定的原型。如果用户的提示词覆盖了此表中的任何默认值,请使用用户指定的值,并在括号中注明覆盖情况。
标签栏模式
滑动选中指示器 — 使用 GeometryReader + offset(x:),绝不要手动计算位置:
GeometryReader { geo in
let tabW = geo.size.width / CGFloat(tabCount)
RoundedRectangle(cornerRadius: indicatorRadius, style: .continuous)
.fill(Color.white.opacity(0.14))
.padding(5)
.frame(width: tabW)
.offset(x: tabW * CGFloat(selectedIndex))
.animation(.spring(response: 0.35, dampingFraction: 0.65), value: selectedIndex)
}
活动指示器规格(符合 Apple Music / iOS 26 HIG):
- 填充:
Color.white.opacity(0.14)— 实色效果明显,与玻璃背景清晰区分 - 内边距:四周缩进
5pt(填满标签栏高度,距每条边缘各5pt) - 圆角半径:
outerRadius - 5(比外层胶囊更柔和,独立值约为16–18pt) - 宽度:精确为
pillWidth / tabCount— 清晰划分槽位,无需猜测
标签单元格布局(图标 + 标签,符合 HIG):
- 图标:
22pt,活动时为.semibold· 非活动时为.regular - 标签:
10–11pt,活动时为.semibold· 非活动时为.regular - 选中时二者均使用
activeColor着色 · 非活动时使用white.opacity(0.5) - 在
.frame(maxWidth: .infinity).frame(height: barHeight)内使用VStack(spacing: 4)
SF Symbol 替换转场 — 对于任何会切换状态并更改图标的按钮(例如“更多”→“关闭”、“播放”→“暂停”、“添加”→“完成”):
Image(systemName: isExpanded ? "xmark" : "ellipsis")
.contentTransition(.symbolEffect(.replace.downUp))
.animation(.spring(response: 0.35, dampingFraction: 0.6), value: isExpanded)
.replace.downUp— 旧图标缩小,新图标放大。用于打开/关闭切换。.replace.upUp— 二者均放大。用于依次向前执行的操作。- 按钮操作必须切换状态:
isExpanded ? collapse() : expand()— 绝不要隐藏按钮来显示单独的关闭按钮。 - iOS 17+ 可用。对于 legendary-Animo 无需回退处理(最低支持 iOS 16+ → 仅当目标为 iOS 16 时才使用
if #available(iOS 17, *)进行判断)。
标准高度: barHeight = 49pt(仅图标) · barHeight = 68pt(图标 + 标签)
展开式标签栏(内容驱动宽度,无需计算指示器) — 当标签栏不需要固定宽度的单元格时(例如悬浮在内容上方的玻璃胶囊,而非全屏宽度的栏),可跳过整个 GeometryReader/指示器:只有选中的标签会显示文字,因此其 Button 会自然变宽,容器也会随之重新排布。
HStack(spacing: 10) {
ForEach(NavTab.allCases, id: \.self) { tab in
let isSelected = tab == selectedTab
Button { select(tab) } label: {
HStack(spacing: 6) {
Image(systemName: tab.symbol).symbolEffect(.bounce, value: isSelected)
if isSelected { Text(tab.title) } // label only exists when selected — no width reserved
}
.foregroundStyle(.white.opacity(isSelected ? 1 : 0.6))
.padding(.horizontal, 14).padding(.vertical, 9)
}
.buttonStyle(.plain)
}
}
.padding(6)
.glassEffect(.clear, in: .capsule) // capsule autosizes to content
.animation(.spring(response: 0.4, dampingFraction: 0.8), value: selectedTab)
当标签栏悬浮在丰富内容上方,而非占满屏幕宽度时,这种方式比滑动指示器方案更简单 — 因为没有固定槽位,所以无需计算槽位。需要权衡的是:标签出现/消失时,标签的位置会发生变化,因此它呈现的是“带有一个自我标识标签的紧凑图标栏”,而非稳定的网格 — 如果标签必须保持固定位置,请改用滑动指示器方案。
轮播与分页
基于速度的分页 — 根据 predictedEndTranslation 而非原始位移设定阈值。 缓慢的短距离拖动不应触发翻页;快速轻扫则应触发 — 即使手指移动的距离很短。预测终点会考虑速度:
DragGesture(minimumDistance: 12)
.onChanged { v in dragOffset = v.translation.width * 0.5 } // 0.5 = drag resistance
.onEnded { v in
let threshold: CGFloat = 52
withAnimation(.spring(response: 0.42, dampingFraction: 0.72)) {
if v.predictedEndTranslation.width < -threshold, index < count - 1 {
index += 1; HapticFeedback.selectionChanged()
} else if v.predictedEndTranslation.width > threshold, index > 0 {
index -= 1; HapticFeedback.selectionChanged()
}
dragOffset = 0
}
}
扇形/卡片堆叠布局 — 根据每张卡片与选中索引的距离确定其位置,将相邻卡片缩小,并按接近程度设置 z 轴顺序:
let diff = CGFloat(index - selectedIndex)
card
.scaleEffect(index == selectedIndex ? 1.0 : 0.86)
.offset(x: diff * xStep + dragOffset, y: index == selectedIndex ? -10 : 14)
.zIndex(index == selectedIndex ? 10 : Double(5 - abs(index - selectedIndex)))
.animation(.spring(response: 0.42, dampingFraction: 0.72), value: selectedIndex)
- 每次切换卡片使用
selectionChanged(而非mediumImpact)— 带来清晰的逐项滑动体验。 - 对
dragOffset应用阻力系数(* 0.5),让卡片堆在手指下显得更有分量。 MeshGradient(iOS 18+,9 个控制点)可打造质感出色的卡片/球体填充效果 — 通过为控制点添加动画,营造流动的表面效果。
Coverflow/连续纵深扇形效果
上面的双状态扇形效果(selected 与 not)足以实现简单的堆叠,但**“翻阅海报/卡片”的 Coverflow 效果需要让每张卡片——而不只是紧邻的卡片——都连续地向远处退去,并且在手指仍在拖动时就开始变化,而不是等手势结束后才变化。让每个变换都由一个带符号且融合了拖动量的距离**驱动,而不是用布尔值:
private func effectiveDistance(for i: Int) -> CGFloat {
CGFloat(i - selectedIndex) - (dragOffset / cardWidth) // blends live drag in
}
private func scale(for i: Int) -> CGFloat { max(0.82, 1.0 - 0.12 * abs(effectiveDistance(for: i))) }
private func rotation(for i: Int) -> Double { Double(max(-2, min(2, effectiveDistance(for: i))) * 7) } // cap at ±14°
private func yOffset(for i: Int) -> CGFloat { abs(effectiveDistance(for: i)) * 18 }
private func opacity(for i: Int) -> Double { max(0.0, 1.0 - 0.28 * abs(effectiveDistance(for: i))) }
private func blur(for i: Int) -> CGFloat { min(4, abs(effectiveDistance(for: i)) * 2) }
card
.scaleEffect(scale(for: i))
.rotationEffect(.degrees(rotation(for: i)))
.offset(x: CGFloat(i - selectedIndex) * xStep + dragOffset, y: yOffset(for: i))
.opacity(opacity(for: i))
.blur(radius: blur(for: i))
.zIndex(-abs(effectiveDistance(for: i))) // closest to center draws on top
- 用卡片宽度除
dragOffset,而不是用一个随意设定的常量——这样可以把拖动量归一化为“移动了几张卡片的距离”,手指移动时,邻近卡片就会实时变大或缩小;松手后弹簧动画再完成剩余部分。否则,扇形效果只会在手势结束时突然更新。 - 限制旋转角度,并将缩放/不透明度限制在最低值之上(
max(-2, min(2, d))、max(0.82, …))——离开约 2 张卡片后,线性公式会让卡片翻过垂直位置,或使不透明度反转;限制取值范围可以让远处的卡片保持小巧、暗淡且模糊,而不是出现异常。 - 按距离模糊是一种低成本的景深效果——偏离中心的卡片添加 2–4pt 的模糊,比单靠缩放/不透明度更能体现“纵深感”;与真实景深渲染相比,几乎不耗费性能。
- 索引变化时使用
selectionChanged触觉反馈(参见上文的速度感知翻页);连续变换纯粹是视觉效果,不会改变触觉反馈的级别。 - **响应不对称也没问题——而且往往更符合物理直觉,尤其是在两个方向需要给人不同感受时。**距离响应曲线不必对称:对于“已经翻过”的卡片和“仍在前方”的卡片堆叠,可以采用不同的每步角度(例如,前方的卡片扇开得更平,后方的卡片则更直立),让牌堆两侧看起来像不同的实体,而不是彼此镜像。根据距离的正负来分支计算公式,而不只是根据其绝对值。
与选中项同步的沉浸式背景
要营造“浏览时整个屏幕都会随之响应”的感觉(电影海报、专辑封面、商品照片),可以在轮播视图后方交叉淡入淡出一个铺满屏幕且经过模糊处理的背景,始终与选中的卡片一致:
GeometryReader { geo in
ZStack {
ForEach(items.indices, id: \.self) { i in
Image(items[i].imageName)
.resizable().scaledToFill()
.frame(width: geo.size.width, height: geo.size.height)
.clipped()
.opacity(i == selectedIndex ? 1 : 0)
.animation(.easeInOut(duration: 0.55), value: selectedIndex)
}
}
.blur(radius: 80, opaque: true) // opaque: true — avoids edge transparency artifacts at high radius
.saturation(1.3) // blur flattens color; push saturation back up
}
- **叠放所有候选图片并交叉淡入淡出不透明度——不要替换
Image视图。**在视图树中替换图片会重新开始加载和布局,也无法交叉淡入淡出;让视图始终存在并切换其opacity,才能实现平滑动画,让.easeInOut(duration: 0.55)呈现溶解过渡效果。 - 使用
.blur(radius:opaque:)并设置opaque: true,不仅速度更快,还能避免全屏图片在较大模糊半径下使用普通.blur时产生的半透明边缘瑕疵。 - 对整个
ZStack应用一次.blur/.saturation,不要对每张图片分别应用——只需执行一次模糊处理,而不是 N 次。 - 叠加一层顶部/底部的
LinearGradient遮罩(black.opacity(0.55) → clear → black.opacity(0.70)),让标题栏和标题文字在任何照片上都清晰可读。 - 有两种淡出方式——渐变遮罩或蒙版,应根据下方内容选择。当整个背景上方都有可读内容时,使用上面的渐变遮罩(叠加在背景之上的半透明渐变)。如果背景只应占据屏幕顶部区域,并与下方纯色卡片自然衔接,则应使用
white → clear渐变对图片本身应用.mask:在其后方放置纯色Color.black,并使用.mask(LinearGradient(stops: [.init(color: .white, location: 0), .init(color: .white, location: 0.18), .init(color: .clear, location: 0.42)], startPoint: .top, endPoint: .bottom))。蒙版会显示其后的图层(真正透明,直至黑色),而渐变遮罩只会调暗图片——需要干净地衔接到纯色表面时使用蒙版;需要让文字在整张照片上清晰可读时使用渐变遮罩。
页面指示圆点(宽度可变)
用于轮播/分页视图下方的轻量级位置指示器(比 Tab Bar 滑动指示器更简单,因为没有需要对齐的标签页内容):
HStack(spacing: 7) {
ForEach(items.indices, id: \.self) { i in
Capsule()
.fill(.white.opacity(i == selectedIndex ? 0.95 : 0.3))
.frame(width: i == selectedIndex ? 22 : 7, height: 7)
}
}
.animation(.spring(response: 0.4, dampingFraction: 0.7), value: selectedIndex)
让活动圆点拉伸成胶囊形(22pt,而非 7pt 的圆形),而不只是改变颜色/不透明度,这才显得精致——相比之下,只改变颜色的圆点指示器看起来已经过时了。
滑动指示器变体(用于像素级精准居中)。 当活动胶囊必须准确落在目标圆点正中央时(例如,在中性色圆点上方显示一个按等级着色的胶囊),将所有圆点渲染为静态圆形,并叠加一个滑动的 Capsule,使用 .matchedGeometryEffect(id: "activeDot", in: namespace)——SwiftUI 会精准插值其位置。当多个圆点的活动颜色不同时(胶囊颜色随索引变化),且手动计算偏移会产生偏差时,使用此方案;当所有圆点颜色相同时,使用上面的简易逐圆点宽度方案。
选中时切换详情文本(.numericText 的轻量替代方案)
当轮播/列表的选中项会联动更新相邻文本(标题、元数据),且文本不是数字时,.numericText 并不适用——使用 .contentTransition(.opacity),并将其关联到一个随选中项变化的 .id(),而不是编写自定义转场:
Text(current.title)
.contentTransition(.opacity)
.id(current.id) // changing id forces the transition to fire
.animation(.easeInOut, value: current.id) // implicit, or wrap the mutation in withAnimation
.id()必须发生变化,不能只改变字符串——.contentTransition会在底层视图的标识发生变化时触发,因此,如果将其关联到一个稳定字符串,而该字符串在两个项目之间恰好没有变化(例如,两部电影碰巧有相同的类型标签),交叉淡入淡出就会悄无声息地跳过。应关联到项目自身的id;或者使用text + current.id作为每个字段的键(例如,可复用的“标签”子视图,用于显示同一项目的不同字段)。
原生物理效果滚动堆栈(不可见的 UIScrollView 驱动器)
由 DragGesture 驱动的堆栈(见上文“轮播与分页”)始终只是用弹簧近似惯性效果——它永远无法完全匹配 UIKit 真实的减速和橡皮筋效果。当堆栈需要拥有原生列表般的滚动手感(例如像 Music.app 或 Photos 中那样自由滚动并减速的卡片/专辑堆栈)时,应改用一个真实、不可见的 UIScrollView 来驱动它,并让它更新一个简单的 CGFloat 绑定,由完全独立的 SwiftUI 渲染堆栈读取该值来定位卡片。
private struct ScrollDriver: UIViewRepresentable {
@Binding var scrollOffset: CGFloat // expressed in "items", not points
let itemSpacing: CGFloat
let maxOffset: CGFloat
func makeUIView(context: Context) -> UIScrollView {
let sv = UIScrollView()
sv.delegate = context.coordinator
sv.decelerationRate = .normal // real momentum curve — not a spring
sv.backgroundColor = .clear
return sv
}
func updateUIView(_ sv: UIScrollView, context: Context) {
sv.contentSize = CGSize(width: sv.bounds.width, height: maxOffset * itemSpacing + sv.bounds.height)
guard !context.coordinator.isTracking, !context.coordinator.isDecelerating else { return }
let target = scrollOffset * itemSpacing
if abs(sv.contentOffset.y - target) > 0.5 {
sv.setContentOffset(CGPoint(x: 0, y: target), animated: false)
}
}
// Coordinator (UIScrollViewDelegate): scrollViewDidScroll → scrollOffset = contentOffset.y / itemSpacing
}
// The rendered card stack sits ABOVE the driver in a ZStack with .allowsHitTesting(false) on the
// stack — every touch lands on the invisible UIScrollView, which never draws any content of its own.
不太明显的规则——每一条都可能导致实际问题:
- 滚动视图不渲染任何内容。 它的
contentSize只用于定义可滚动范围并产生真实的减速/橡皮筋物理效果;实际卡片位于完全独立的图层中,并将scrollOffset作为普通浮点数读取。这种分工——由 UIKit 计算“位置”,由 SwiftUI(或嵌入 SceneKit 的卡片)绘制“内容”——是核心思路,也适用于任何需要原生滚动手感的自定义渲染堆栈。 - 每次以编程方式调用
setContentOffset前,都要检查!isTracking && !isDecelerating。 如果没有这道检查,数据往返(驱动器 →@Binding→updateUIView→setContentOffset)就会与用户的触摸操作或原生减速相互冲突,造成卡顿。只有在用户当前没有亲自驱动滚动时,才强制同步偏移量(例如外部调用“跳转到索引”时)。 - 稳定时刻的触觉反馈——四舍五入到最近的索引,并与上次触发的索引比较,而不是使用原始像素阈值:
let crossed = Int(floor(offset + 0.5)); if crossed != lastHapticIndex { tick(); lastHapticIndex = crossed }。每跨过一个项目边界(任一方向)恰好触发一次selectionChanged——由于比较的是“项目”而非点数,因此比固定像素阈值更可靠,也不受itemSpacing影响。 - 边界/橡皮筋触觉反馈——每次过度滚动只触发一次,而不是每帧触发一次。 在
contentOffset首次越过任一端点的那一帧设置boundaryFired标记,并在scrollViewWillBeginDragging中重置;否则,当用户将视图拉伸到边界之外并按住时,每次scrollViewDidScroll调用都会触发一次强烈的撞击反馈(从一次干脆的震动变成几十次)。 - 窗口化渲染——裁剪远离可见窗口的卡片。 如果有许多开销大的单项视图(SceneKit、Metal、视频),不要渲染整个堆栈:在
ForEach中使用if abs(scrollProgress) <= renderWindow { cardView }。SwiftUI 不会构建被裁剪项目的视图——这对 SceneKit 尤其重要,因为每个实例都拥有一个真实的SCNView和渲染通道。
堆叠卡片(通知堆栈)
Apple 的折叠通知堆栈:最新的卡片以完整尺寸显示在最前面,较旧的卡片从后方露出一角(根据层级缩小、偏移并调暗),轻点可展开为列表,向左滑动最前面的卡片以将其关闭,并限制卡片数量,让最旧的卡片从后方移除。
// front = index 0. Transform each card from its depth + an `expanded` flag:
let yOffset = expanded ? CGFloat(depth) * (cardHeight + 10) : CGFloat(depth) * 16 // peek step
let scale = expanded ? 1.0 : max(0.88, 1.0 - CGFloat(depth) * 0.06)
let opacity = expanded ? 1.0 : (depth < 3 ? 1.0 - Double(depth) * 0.22 : 0.0) // only ~3 peek
card.scaleEffect(scale).opacity(opacity).offset(y: yOffset).zIndex(Double(count - depth))
- 在最前面插入并限制数量:
withAnimation(.spring) { items.insert(new, at: 0); if items.count > cap { items.removeLast() } }——最新卡片弹出显示(搭配widthPop),最旧的卡片从后方淡出。 - 展开切换: 在堆栈上使用
onTapGesture,通过selectionChanged触觉反馈和.spring(0.5/0.8)动画切换expanded。 - 滑动关闭(仅限最前面的卡片): 仅当
depth == 0时启用DragGesture;对dragOffset应用橡皮筋效果,开始时触发lightImpact,超过约 120pt 时调用removeFirst()并触发heavyImpact,否则弹回原位。 - 每张卡片使用
.transition(.asymmetric(insertion: .widthPop, removal: .move(edge: .top).combined(with: .opacity)))。 - “通知”卡片表面: iOS 26 上使用原生 Liquid Glass 效果——
.glassEffect(.regular.tint((isDark ? .black : .white).opacity(0.28)), in: shape);较低版本使用.ultraThinMaterial和随配色方案调整的色调(见“浅色主题 → 自适应”)。圆角半径为 28pt,采用连续曲线,并添加柔和阴影。
自动推进卡片堆(滑动或定时器,无限循环)
类似 Tinder 的卡片堆(每日电影/歌曲/产品、故事卡片),最前面的卡片要么由用户滑走,要么由定时器自动推进,并无限循环;下一张卡片必须立即升到最前面,不能等被丢弃的卡片完全落出屏幕。
通过取模距离实现无限循环,因此 index 无需进行边界检查:
private func slot(for i: Int) -> Int { (i - index + items.count) % items.count } // 0 = front
一个 advance() 函数,两个调用方。 滑动手势(越过阈值)和 .task 自动推进定时器都会调用同一个函数,因此下落动画、触觉反馈和状态变更只定义一次,手动和自动路径不会出现行为偏差:
.task {
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(2.5))
if flying == nil { advance(fromDrag: 0) } // don't double-fire if a swipe is already mid-flight
}
}
关键陷阱——将退出的卡片与它即将离开的卡片堆解耦。 如果被丢弃的卡片仍留在渲染卡片堆的同一个 ForEach 中,更新 index(这会重新排列所有剩余卡片的深度/偏移)与为该卡片设置移除动画就会争夺相同的元素,导致卡顿或明显的视觉故障。应将下落的卡片作为独立的叠加层渲染在卡片堆循环之外,并为它设置自己的状态:
@State private var flying: Item? = nil
@State private var flyY: CGFloat = 0
@State private var flyRot: Double = 0
private func advance(fromDrag startY: CGFloat) {
flying = items[index]; flyY = startY; flyRot = tilt(for: startY)
withAnimation(.easeInOut(duration: 0.55)) { index = (index + 1) % items.count } // stack reshuffles NOW, fast
withAnimation(.easeIn(duration: 0.85)) { flyY = 1100; flyRot = 6 } // discarded card keeps falling, slower
DispatchQueue.main.asyncAfter(deadline: .now() + 0.87) { flying = nil }
}
// in the stack ForEach: skip any card whose id == flying?.id
// render the flying card separately: cardView(f).offset(y: flyY).rotationEffect(.degrees(flyRot)).zIndex(100)
- 同时发生的两件事,使用两种不同的时长——卡片堆重新排列的时长(
0.55s)刻意短于卡片下落的时长(0.85s),这样被丢弃的卡片还在背景中明显地完成退场时,下一张卡片就已经就位。这才会让人感觉是“下一张升到了最前面”,而不是“我们等旧卡片离开了”。 - 调用
advance()时,index会立即变更——下落动画不会阻塞它。 - 逐级收紧的卡片堆偏移(越靠后的层级越紧凑),比每个深度都使用固定步长更像真实的手工叠放卡片:
private func stackOffset(_ depth: Int) -> CGFloat {
var y: CGFloat = 0, step: CGFloat = 18
for _ in 0..<depth { y += step; step = max(9, step - 3) } // 18, 15, 12, 9, 9…
return -y
}
Canvas 元球(黏糊/液体挤出效果)
如果想让元素在纯 SwiftUI 中像液体一样融合与分离(不使用 iOS 26 GlassEffectContainer),例如动作从按钮中挤出的快捷拨号 FAB,可以使用 goo 滤镜在 Canvas 中渲染这些液滴:
Canvas { ctx, size in
ctx.addFilter(.alphaThreshold(min: 0.5, color: blobColor)) // hard edge after blur
ctx.addFilter(.blur(radius: 16)) // halos overlap → merge
ctx.drawLayer { layer in
layer.fill(Path(ellipseIn: fabRect), with: .color(.white))
for b in bubbles { layer.fill(Path(ellipseIn: b.rect), with: .color(.white)) }
}
}
一些不明显的规则——每一条都是真正的陷阱:
alphaThreshold会产生硬边缘。 静止时,液滴是边缘清晰的圆形;只有重叠处才会呈现黏糊效果。“液体”≠“柔和/模糊”。- 通过让图层实现
View, Animatable来为 Canvas 添加动画,并设置animatableData = progress。在 Canvas 闭包中直接读取原始的@State会导致动画跳变——Animatable 能让 SwiftUI 逐帧插值progress,并在每一步重绘黏糊效果。 - 让所有液滴的大小都大致相同。 一个全局模糊半径只能匹配一种圆形尺寸——如果气泡缩小(例如移动途中变为
0.45·r),固定模糊半径 ≈ 气泡半径,阈值处理后就会塌缩成一条线。让液滴保持接近完整尺寸(0.9–1.0·r)。 - 静止时的间距必须大于
直径 + 2·模糊半径,否则相邻液滴无法清除彼此模糊后的 alpha 值 → 永久形成水滴形。连接颈部应该只在过渡期间出现。 - 放慢形变(
spring(response ≈ 1.0))——快速弹簧动画会让整个黏糊效果一闪而过。 - Canvas 只负责视觉效果。 在其上方相同的位置放置真正的
Button,用于处理点击和图标。图标必须与液滴一起使用同一个弹簧动画移动(动画化.position),不能通过.transition在目标位置突然出现,否则运动看起来不均匀。堆叠在 FAB 上的关闭按钮需要设置.allowsHitTesting(false),避免它们拦截 FAB 的点击。
样式建议: 液体 FAB 用扁平样式(无阴影)效果最好。将液滴和图标的颜色设为参数,并根据背景反转——深色背景 → 白色液滴 + 深色字形,浅色背景 → 深色液滴 + 白色字形。+ → ✕ 只需将一个 plus 符号旋转 135°;使用 .thin 字重会让字形更显高级。
Canvas 路径加载器(轮廓描摹/彗星尾迹)
对于沿形状轮廓描摹并带有渐隐彗星尾迹的加载器(无限符号、星形、多边形、心形、玫瑰等),使用 TimelineView 时钟驱动 Canvas,并使用一组预先计算的轮廓采样点——每一帧都不要重新计算形状的数学表达式:
// Build ONCE — cache unit samples per shape (e.g. in a static dictionary).
let samples: [CGPoint] = makeSamples(for: shape) // arc-length-even points
TimelineView(.animation(minimumInterval: 1.0/60.0)) { ctx in
let progress = (ctx.date.timeIntervalSince(start) / duration).wrappedUnit
Canvas(rendersAsynchronously: true) { gc, size in
// stroke the static outline faintly …
// … then draw N trail dots, each at interpolatedPoint(in: samples,
// progress: progress - tail*trailLength), radius/opacity fading by tail.
}
}
容易忽略的规则——每一条都是实实在在的陷阱:
- 先采样,再动画;绝不逐帧求值。 将轮廓预先计算为
[CGPoint]单位采样点(并缓存)。动画只需推进一个progress头部,让它沿缓存的采样点插值。渲染器(轮廓描边和尾迹)与形状无关——添加新形状只需增加一个采样生成器,运动代码完全无需改动。这样就能将“是什么形状”和“如何运动”彻底解耦。 - 按弧长均匀采样=匀速运动。 沿轮廓按相等的弧长间隔取样,而不是按相等的参数
t取样。相等的t会在曲线较慢处堆积采样点、在较快处拉开间距,因此推进progress时,头部会明显加速/减速(多边形拐角处最明显)。弧长均匀 → 头部匀速滑动。形状的尖锐程度与运动的平滑度相互独立——轮廓中的尖角不会导致运动卡顿。 - 两种采样器,适用于两种轮廓。 用于直边、清晰顶点(星形/多边形)的折线弧长采样器,以及用于自然流畅曲线的闭合 Catmull-Rom 样条。Catmull-Rom 需要
> 3个控制点——否则回退到折线采样器。根据形状是否应该有拐角来选择。 - 形状构造方法。 N 点星形由
2N个顶点组成,外半径为1.0、内半径为~0.42,交替排列;正多边形由N个单位圆顶点组成;通过旋转偏移调整朝向(尖角朝上为-π/2,平顶六边形为+π/6)。 - 使用
TimelineView时钟,而不是弹簧或withAnimation。 加载器是一个连续循环,而不是状态转换——从time推导一切:progress = (time/duration).wrappedUnit,并从同一时钟独立计算rotation和breathe。(这是 Archetype Catalog 中 Loading Indicator → never spring 规则的 Canvas 实现。)
内容建议: 对于通用加载器展示,优先选择通用几何图形(星形、五边形、六边形、无限符号),而不是描摹品牌/商标标识——同样有高级感,又不会涉及商标风险,而且这些形状适用范围更广。
Liquid Glass 提示框与状态横幅
对于采用 Apple 风格、以 Liquid Glass 呈现的瞬时提示框/横幅(成功/错误/警告/信息/“已复制”),只有在其后方有彩色内容时,玻璃效果才明显——在提示框层后方放置图片网格(或任何鲜明的背景),这样才能看出折射效果;平淡的深色/浅色背景会掩盖该效果。
// neutral glass = maximum refraction; status color lives in the icon, NOT the glass tint
.background(glassBackground(Capsule(), tint: nil)) // .glassEffect(.regular) / .ultraThinMaterial fallback
容易忽略的规则——每一条都是真正容易踩的坑:
- 高度超过屏幕的背景必须放在
.background中,绝不能作为 ZStack 同级视图。 (headline) 内在高度超过屏幕的图片网格/LazyVGrid如果作为 ZStack 同级视图放置,会撑高容器——因此,由Spacer()锚定的控件(例如底部按钮)会被推到屏幕外,悄无声息地消失。将背景移到.background { grid }.clipped()中,让它填满屏幕,同时不会影响前景布局;这样前景就会按照实际视口进行布局。由此可知:不要在.background图层前方叠放不透明的Color,否则会将其遮住。 - 让每个提示框的玻璃效果保持一致;用图标而非表面来表示状态。 按状态为玻璃着色(
.glassEffect(.regular.tint(statusColor)))会让该提示框看起来近似纯色填充,而中性色提示框仍会折射光线,因此视觉上不一致。所有提示框表面(胶囊形和卡片形)都使用中性的.regular玻璃效果,并通过 SF Symbol 的状态颜色或小型着色图标底框来表示状态颜色。这与破坏性按钮使用有色玻璃的规则恰好相反——提示框需要保持一致,控件则需要传达状态。 - 提示框的位置会自动遵循安全区域。
.overlay(alignment: .top) { toast }会对齐到安全区域顶部,因此只需加上少量.padding(.top, 10),就能让提示框位于灵动岛下方。如果视图忽略顶部安全区域,固定的顶部间距(.padding(.top, 70))会让提示框落到刘海下方——不要写死这个值。 - 使用令牌而非单纯的延时来自动消失。 显示时设置
toast = new;约 2.5 秒后安排消失操作,但只有在toast?.id == new.id时才清除它,以免新提示框被提前关闭。在withAnimation中使用.transition(.move(edge: .top).combined(with: .opacity))实现滑入/滑出效果。 - 形状: 单行状态使用
Capsule;内容较丰富的状态使用RoundedRectangle卡片(图标底框 + 标题 + 副标题)——两者都使用相同的中性玻璃效果。
仅图标的 Liquid Glass 操作按钮(循环触发器)——一个原生 Apple 风格的玻璃按钮,每次点击都会切换图标:
Button(action: trigger) {
Image(systemName: kind.icon)
.contentTransition(.symbolEffect(.replace)) // morph to the next action
.frame(width: 72, height: 72)
}
.buttonStyle(.glass) // iOS 26 — material-circle fallback below
.buttonBorderShape(.circle)
.tint(kind.tint ?? .white)
// trigger(): show(current); withAnimation { index = (index + 1) % count }
- 只放图标——让玻璃及其折射效果成为按钮的全部;
.replace转场(在withAnimation内部驱动)才是主要交互。不要添加文字标签或自定义圆环。 - 状态的触觉反馈阶梯: 对应状态使用
notificationSuccess / Warning / Error;中性的信息/已复制状态使用lightImpact。
堆叠卡片与原位重排
一组位于父级已设置为可拖动或可长按的行内部的卡片(带有 .draggable 的列表项或 .contextMenu)。
两种陷阱,一种模式。
不要嵌套拖动。 在已启用拖动或长按的父级内部放置子级 .draggable,会让嵌套识别器争抢同一次触摸
——父级的长按和子级的拖动会在同一段手势过程中同时启动,
所以两者都不可靠。将子级拖动限制在一个小型抓手区域内并不能解决问题;父级的长按仍会从该位置触发。
- 使用离散点击控件重排,而不是拖动。 每行放置向上/向下箭头按钮(首行的 ▲ 禁用,末行的 ▼ 禁用),每次将项目移动一个位置。按钮在轻点时触发,绝不会与长按冲突,因此可以在任何卡片内重排。这与 Apple 在无法拖动时采用的备用方案相同。
- 在原位按位置持久化。 重写底层数据中的第 i 个位置(例如,在片段文本中重排 URL 令牌,同时让交错其中的正文原样保留;仅当数量匹配时才执行,否则不做任何操作,以免文本被破坏),使新顺序在重新加载后仍然保留。
通知卡组模式(折叠时露出堆叠预览 → 展开时扇形散开):将每张卡片放入 ZStack(alignment: .top),并通过一个 isExpanded 弹簧动画切换每张卡片的几何布局。
let depth = CGFloat(min(index, maxPeeks)) // 0,1,2 — only top few peek
card
.frame(height: rowHeight)
.scaleEffect(isExpanded ? 1 : 1 - depth * 0.05, anchor: .top) // peeks shrink
.brightness(isExpanded ? 0 : -Double(depth) * 0.03) // …and dim
.offset(y: isExpanded ? CGFloat(index) * rowStride // fanned list
: depth * peekStep) // stacked peek
.opacity(!isExpanded && index > maxPeeks ? 0 : 1)
.zIndex(Double(count - index))
- 折叠时,整个卡组都是一个用于展开的点击目标(覆盖一个透明的
Button);卡片展开前,底层卡片保持allowsHitTesting(false)。 - 在折叠状态下的顶层卡片上叠加玻璃质感的计数胶囊(
rectangle.stack+ N)。 - 容器高度按状态计算:展开时为
count * rowHeight + (count-1) * spacing,折叠时为rowHeight + min(count-1, maxPeeks) * peekStep。 - 展开/折叠时使用
mediumImpact;每次移动时使用selection。
Metal 着色器(.colorEffect / stitchable)
对于普通 SwiftUI 无法实现的高端 GPU 表面效果——可触碰的液态铬、全息箔、等离子、熔融金属——编写真正的 Metal 着色器,并通过 .colorEffect 应用。它是真正的、位于单独 .metal 文件中的 [[ stitchable ]] 着色器,不是用 Canvas 仿造的效果。(legendary-Animo 已经内置了约 11 个此类着色器,这是成熟的模式——请先检查是否已有 .metal 文件。)
// LiquidMetal.metal
#include <metal_stdlib>
#include <SwiftUI/SwiftUI_Metal.h>
using namespace metal;
[[ stitchable ]]
half4 liquidMetal(float2 pos, half4 color, float2 res, float time, float4 r0 /*…*/) {
float2 uv = pos / res;
// FBM height field → normal → moving light (diffuse + specular + fresnel)
return half4(half3(col), 1.0h);
}
Rectangle().foregroundStyle(.black)
.colorEffect(ShaderLibrary.liquidMetal(.float2(size), .float(time), .float4(x,y,startT,amp)))
不易察觉的规则——每一条都是实际陷阱:
- 将
.metal注册到 pbxproj 的 Sources 阶段,类型为sourcecode.metal。 (headline) 仅添加文件还不够——如果它不在 Compile Sources 中,就永远不会进入default.metallib,因此ShaderLibrary.<name>会悄无声息地渲染成黑色(不会崩溃,也没有错误)。[[ stitchable ]]函数名必须与ShaderLibrary.<name>调用完全一致。 - 前两个参数会自动提供。
.colorEffect着色器的签名为half4 fn(float2 pos, half4 color, <your uniforms…>)——SwiftUI 会填入pos/color;你的 Swift 参数列表从第一个 uniform 开始。(.distortionEffect会扭曲几何形状;.layerEffect可以采样邻近像素——需要<SwiftUI/SwiftUI_Metal.h>。) - 使用
TimelineView(.animation)时钟驱动,绝不要用弹簧动画。 着色器场是一个连续循环;将time = ctx.date.timeIntervalSince(start)作为floatuniform 传入,并在每个时钟周期重新应用.colorEffect。(与 Loading Indicator / Canvas 加载器的规则相同。) - 触摸数据不支持结构体浮点数组——请打包成固定的
float4环形缓冲区。 SwiftUIShader.Argument仅支持.float/.float2/.float3/.float4/.color/.image/.floatArray。要保留 N 个触摸涟漪,请使用 Swift 环形缓冲区,并将其限制为例如 4 个,再传入 4 个固定的.float4(x, y, startTime, amp)槽位(空槽的 amp 填 0)。拖动时保留最近几个涟漪,就能轻松获得拖尾般的液态波痕。 - 涟漪扰动的是高度场,而非颜色。 在计算法线之前,将每个衰减的环(
sin(d·k − age·ω)·exp(−age)·exp(−d))叠加到表面高度上,这样你触碰的位置就会弯曲反射——这才是营造“液态”质感的关键,而不是颜色飞溅。 - 使用非着色器回退方案(例如
AngularGradient扫掠效果)为iOS 17+(.colorEffect从 17.0 起提供),确保在 16 上仍能构建。 - **验证陷阱:**着色器可能编译并链接成功,却仍然渲染成黑色(名称不匹配、缺少 Sources 条目、参数顺序错误)。构建并查看效果——日志中的
CompileMetalFile和MetalLink只能证明编译成功,只有屏幕截图才能证明它确实渲染了。
**内容风格:**善用 Metal 的双重含义——熔融铬/水银/黄金/油膜效果既显高端又出人意料。将合金颜色作为色调对 uniform,让一个着色器适用于多种配色;通过菲涅耳效果呈现彩虹色,而非叠加平面效果。
SceneKit 3D 对象展示
如果要展示一个真正的 3D 对象(专辑封面、书籍、盒装产品),并让它随滚动或触摸倾斜/旋转,而不是用 rotation3DEffect 假装成 3D 效果,请通过 UIViewRepresentable 嵌入 SCNScene,并由 SwiftUI 状态驱动。
真正透明的 SceneKit 嵌入
SCNView 默认背景为不透明白色——若不在以下四处全部清除,它就无法在 SwiftUI 背景上呈现透明效果:
scene.background.contents = nil // the SCNScene itself
view.isOpaque = false
view.backgroundColor = .clear
view.layer.isOpaque = false
漏掉其中任意一处,对象后方仍会出现白色(或清除背景但尚未清除图层时的黑色)矩形。
驱动节点变换时避免 SceneKit 的隐式动画干扰 SwiftUI 状态
SceneKit 默认会对节点属性变化进行隐式动画。如果倾斜/旋转需要跟随持续变化的值(滚动偏移、拖动),隐式动画就会导致它滞后,并与自己的驱动逻辑相互拉扯。请在禁用 actions 的事务中封装每次驱动更新:
SCNTransaction.begin()
SCNTransaction.disableActions = true
node.eulerAngles = SCNVector3Make(tiltX * .pi / 180, tiltY * .pi / 180, 0)
SCNTransaction.commit()
这样,值就会完全按照你的时间安排更新(每次 updateUIView、每个滚动时钟周期)——动画曲线由 SwiftUI/UIKit 控制,而非 SceneKit 自带的默认设置。
盒状对象配方(6 面 SCNBox,其中一面作为渲染标签)
SCNBox 的六种材质按 SCNBox.materials 的顺序(+Z 正面、-Z 背面、+X 右侧、-X 左侧、+Y 顶部、-Y 底部)构成一个带有真实书脊的“封套”对象:正面 = 美术图,书脊面 = 动态渲染的 UIImage(通过 Core Graphics 在彩色背景上绘制标题和副标题),其余面 = 纯色边缘:
box.materials = [coverFront, edgeColor, coverRight, edgeColor, edgeColor, labelEdge]
- 图像面使用
.lightingModel = .blinn(应在灯光下产生明暗效果);纯色面使用.lightingModel = .constant(平面效果——不需要灯光计算,开销更低)。 - 书脊标签只需通过
UIGraphicsImageRenderer绘制即可,无需使用 SceneKit 文本 API。只渲染一次(而不是每帧渲染),然后将生成的UIImage传给.diffuse.contents。
灯光/摄像机配置 — “物体悬浮于空间中”的可复用默认方案
一盏定向主光加一盏环境补光,就足以打造精致的单对象展示效果——无需完整的三点布光:
keyLight.type = .directional; keyLight.intensity = 900 // tune 700–1000
keyLight.eulerAngles = SCNVector3Make(-40 * .pi/180, 25 * .pi/180, 0) // top-front-left
ambientLight.type = .ambient; ambientLight.color = UIColor(white: 0.30, alpha: 1) // fill so shadow side isn't pitch black
camera.fieldOfView = 46; camera.position = SCNVector3Make(0, 0.3, 5.0)
双模式组件:独立展示与嵌入展示
同一个对象视图应同时支持全屏展示(不透明黑色背景、完整抗锯齿)和作为多个重复实例之一嵌入滚动堆栈中(透明背景,多个实例同时渲染,因此使用开销更低的抗锯齿):
var embedded: Bool = false
// standalone: ZStack { Color.black.ignoresSafeArea(); sceneView }
// embedded: sceneView alone, background stays .clear
view.antialiasingMode = embedded ? .multisampling2X : .multisampling4X // cheaper AA when many instances render together
**验证陷阱:**如果漏掉上述任何透明度设置,SceneKit 视图即使成功编译并运行,仍可能只显示一个普通的白色/黑色矩形;如果纹理加载失败(UIImage(named:) 返回 nil,且不会报错),则可能只显示一个纯灰色盒子。请通过截图验证实际对象是否以正确的美术图和灯光效果渲染,而不只是确认视图存在于视图层级中。
从美术图中提取强调色
如果屏幕需要根据图像的主色调为自身着色(例如对象的书脊面、背景遮罩或按钮强调色),而不是使用硬编码的调色板值,请对图像的缩小版本进行采样,并按饱和度和亮度加权——不要按原始像素频率加权,因为后者通常只会选出出现最多的颜色(往往是乏味的背景灰色或黑色):
let sampleSize = 40 // downscale first — sampling a full-res image per pixel is wasteful and noisy
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: sampleSize, height: sampleSize))
// per pixel: visibility = saturation * 0.7 + brightness * 0.3
// bucket by quantized color, accumulate weight * (r,g,b), skip alpha < 0.5
// winner = bucket with highest total weight; divide its accumulated color by its weight
- 按饱和度和亮度加权,而不是按像素数量加权。 对海报/封面进行简单的“最常见颜色”处理,通常会返回乏味的灰色/黑色背景填充色。提高饱和度适中、亮度适中的像素权重,可以找到人们真正会称作“这张图的颜色”的颜色。
- 明确排除接近白色/接近黑色的异常值(跳过
brightness在0.08–0.95范围之外的像素)——否则,主要为白色或黑色的图像会将白色/黑色作为其“主色”返回。这虽然没错,但不适合作为强调色。 - 调暗接近白色的胜出颜色(
luminance > 0.85 → multiply channels by 0.75)——即使经过加权,以柔和粉彩色为主的图像仍可能选出一种填充为纯色背景时看起来像空白白板的颜色;将其缩暗,可让它明显呈现为一种颜色,而不是一片空白。 - 对比文本颜色——使用感知亮度,而不是通道平均值:
0.299·r + 0.587·g + 0.114·b > 0.55 → black text, else white。相同通道值下,绿色在人眼中比红色或蓝色更亮,因此在高饱和度的纯蓝或纯绿背景上,简单的(r+g+b)/3平均值会选错文本颜色。
实时活动与真正的 Dynamic Island(ActivityKit + WidgetKit)
应用内视图可以模拟 Dynamic Island(形变、玻璃效果、手势),但要在真正的 Dynamic Island 和锁定屏幕上呈现内容,你需要 ActivityKit + Widget Extension,其规则与应用内 SwiftUI 视图截然不同。为微交互构建模拟效果;需要在真正的灵动岛中显示时,则构建实时活动。
- 它需要单独的 Widget Extension 目标——而不是视图。 声明
struct XAttributes: ActivityAttributes(包含ContentState),在 WidgetKit extension 中添加ActivityConfiguration(for:)+DynamicIsland { }DSL,在应用的 Info.plist 中设置NSSupportsLiveActivities = true,然后由应用调用Activity.request / update / end。没有可用于搭建该目标的 CLI——请使用 Xcode 的 File → New → Target → Widget Extension(勾选“Include Live Activity”)。 - WidgetKit ≠ 应用内 SwiftUI:没有手势、没有连续动画、没有自定义弹簧效果。 (标题) 紧凑↔展开的形变由系统负责(iOS 26 中是真正的 Liquid Glass)——你只需提供各个区域的内容。交互仅限 App-Intent 按钮(由
LiveActivityIntent支持的Button(intent:)),绝不能使用DragGesture/onTapGesture。滑动条无法拖动;进度条只会在推送状态更新时前进。没有TimelineView的 60 fps 循环。因此,拖动/橡皮筋/实时均衡器等微交互应保留在应用内;灵动岛是声明式状态显示。 - 美术资源的内存预算是空白灵动岛问题的头号元凶。 (标题) 实时活动扩展的内存上限约为 30 MB。全分辨率照片(
4000×6000,解码后约 96 MB)会静默渲染失败 → 显示灰色占位框——不会崩溃,也不会报错。请将美术资源控制在约 400×400(显示尺寸很小)。这是最常见的“为什么我的灵动岛封面是空白的”问题。 - 展开区域会裁切内容——保持紧凑。
DynamicIslandExpandedRegion集合(leading / trailing / center / bottom)的高度有限;封面 + 标题 + 进度条 + 时间 + 大型播放控制行会溢出,并在底部被裁切。使用较小的控制字体(播放按钮用.title2,跳转按钮用.body)、缩紧间距,并将已用时间/−剩余时间放在进度条下方,不要作为悬浮的尾随数字。 - 共享的
ActivityAttributes必须同时归属两个目标。 attributes /ContentState文件会同时编译进应用(用于请求/更新)和 Widget(用于渲染)——使用一个PBXFileReference,并在每个目标的 Sources 阶段各添加一个 build-file 条目(或将其加入同步文件夹)。它会分别编译进各自的模块,因此无需import另一个模块。 - 现代 Xcode 的 Widget 目标采用文件系统同步(
PBXFileSystemSynchronizedRootGroup):放入扩展文件夹的文件会自动加入目标,删除模板示例也会自动将其移除——只有跨目标共享的文件需要显式配置。 - 灵动岛 ↔ 应用双向同步。 灵动岛的 App-Intent 按钮会直接修改正在运行的活动(
Activity<Attrs>.activities.first→update(...));应用会观察activity.contentUpdates,将灵动岛驱动的更改同步反映到自身 UI 中,使两边的isPlaying/ 进度保持一致。 - 内容审美: 黑色药丸上的真实、小尺寸专辑封面显得高级;渐变 + SF-Symbol 占位图则像是“没有封面”。
SceneKit 金属徽章——平面正面照明、抛硬币式定向、背面雕刻
可旋转的金属成就徽章(盾牌/硬币/星形):挤出的 SCNShape 边框 + 彩色珐琅 + 凸起的徽章图案,PBR 金属反射由代码生成的环境渐变。与盒状物体展示不同——这些陷阱是平坦、正面朝前的金属所特有的。
- 平坦金属表面反射的是环境,不是灯光。(标题)纯金属(
metalness 1)平面朝向摄像机时,会反射环境贴图中朝向摄像机的区域——通常很暗——因此它在深色背景上会渲染成黑色。方向性主光不会为纯金属添加漫反射,因此无法补救。解决方法:平面使用缎面金属(metalness ≈ 0.7、roughness ≈ 0.34),让色调显现;镜面铬(roughness ≈ 0.15)则留给斜面/曲面(它们能捕捉环境中明亮的顶部)。斜面徽章正面朝向摄像机时效果很好;没有这些设置,平面硬币则不然。 - 为静止/正面朝向摄像机的金属添加正面补光。 静止时,即使是缎面金属,在只有顶部光和轮廓光的情况下也会显得暗淡。添加一盏大致从正前方照射的低强度方向光,再让徽章静止时略微倾斜,使表面朝向较亮的环境上方区域,就能让正面保持明亮,并呈现居中的高光。搭配高对比度的环境渐变(顶部明亮 → 底部近乎黑色),并提高
lightingEnvironment.intensity,即可获得强烈的反射效果。 - 抛硬币式定向(不自动旋转)。 在渲染循环中驱动一个
spinAngle;未拖动时,让弹簧朝向最近的 π 倍数,并在每帧重新计算——target = round(spinAngle / .pi) * .pi。轻甩时,根据DragGesture.value.velocity为spinVel赋初值;由于每帧都会重新选择目标,惯性会带动徽章从一面翻到另一面,最后落在最近的一面(正面/反面),并略微摇晃。较低的刚度(约 50)可让轻甩越过中点,而不是弹回。 - 背面的雕刻平面必须被正面遮挡。 放在主体后方的双面纹理平面会从正面透出(从收窄的轮廓边缘露出,或透过深色珐琅显现)。将它放在不透明主体后方较远处(
z方向上越过背面),并确保它位于主体轮廓内(小于形状最宽处),这样主体从正面看时会遮挡它;只有徽章翻转后它才会出现。并且不要预先镜像背面文字——父节点的y = π翻转已经让平面的正面朝向摄像机,手动水平翻转会造成双重镜像(文字会反向)。 - 同心环硬币(Apple Move 奖项风格): 金属底座
SCNCylinder+ 彩色凹陷表面 + 凸起的SCNTorus环形凹槽 + 中央凸台,每个节点都绕 X 轴旋转π/2,使硬币平放并朝向摄像机。 - 保真度上限——程序生成与
.usdz。 挤出的SCNShape加上代码生成的环境只能近似这种效果,而 Apple 的徽章是经过制作和纹理烘焙的.usdz,带有真实 HDRI 反射。若要实现像素级一致,应加载.usdz(由 Reality Composer Pro/Blender 导出),而不是构建几何体——程序生成属于“良好且自包含”的级别,而非像素级一致的级别。
环形进度 — 修剪填充与端点圆点几何
适用于 Apple Fitness 风格的活动圆环(填充的同心渐变圆环):
Circle().stroke圆环上的起始端点圆点应以side/2为轨道半径,而不是(side − lineWidth)/2。 (标题)Circle().stroke(lineWidth:)会让描边以路径为中心(半径为side/2,并向视图框外溢出lineWidth/2)。将端点圆点放在(side − lineWidth)/2处,会让它落在圆环内侧半个带宽的位置。应以side/2为轨道半径——先.offset(y: -side/2),再围绕 ZStack 中心应用.rotationEffect(.degrees(progress * 360))——这样它才能沿着圆环移动。- 错开弹簧动画填充。 让每个圆环的
Circle().trim(from: 0, to: min(progress, 1))从 0 开始填充,并为每个圆环应用withAnimation(.spring).delay(0.08 * i);统计数字则使用.contentTransition(.numericText(value:))滚动切换。 - 超额完成时环绕并添加阴影(超过 100% 的提示)。 当
progress > 1时,在上方绘制第二段trim(0, progress − 1)圆弧,并放置一个旋转到progress * 360°、带阴影的端点圆点,让它与圆环起点重叠——这种深度提示能传达“你超额完成了目标”。 - 鲜艳的圆环渐变(粉色→红色表示移动、绿色表示锻炼、青色→蓝色表示站立)是“深色界面上绝不使用纯
.red/.green/.blue”这一规则的例外——这些是经过调校的渐变,不是浑浊的系统颜色。
Liquid Glass 标签栏 ⇄ 搜索框变形
iOS 26 的标志性设计:悬浮玻璃标签栏,其搜索按钮会变形为全宽搜索框。
- 这是两个
glassEffectID的融合与分离,而不是单个元素调整尺寸。 (标题) 为标签栏胶囊设置一个 id("bar"),为搜索圆形按钮设置另一个 id("search");切换时,移除标签栏(让它融化消失),同时让搜索元素从圆形扩展为全宽搜索框(使用相同的"search"id)。GlassEffectContainer会渲染这种流体融合效果。单个调整尺寸的元素无法表达“一个元素离开,而另一个元素扩展”。 - 等变形结束后再延迟键盘焦点。 如果在变形过程中自动聚焦搜索框,键盘会立即弹出并打断转场——应在变形弹簧动画结束后再将
@FocusState = true排入计划(例如,缓慢变形时延迟+0.6s),让圆形→搜索框的变形先播放。 - 放慢变形,让用户感受到动画(弹簧的
response ≈ 0.95),并遵守融合阈值规则(layoutGap > containerSpacing + 6),避免标签栏和搜索圆形在静止时相互渗入。 - 营造恰当的标签栏观感: 只显示图标,每个图标使用
.frame(maxWidth: .infinity)以均匀分布(不留空隙);通过在图标后方显示柔和的圆形来表示选中状态,而不是使用文本标签。
Liquid Step Slider(代谢球填充 + 跟随值气泡)
一款带有液态观感的离散步进滑块:填充轨道和滑块合为一个黏连的代谢球,值气泡则悬浮在滑块上方。(分段控件可以基于同一套引擎切换主题变体,例如深色的 ⚡“充能”岛,以及浅色的频谱“预算”轨道;滑块会发出当前所在色调的光。)
- 填充 + 滑块 = 一个代谢球,且遮罩必须是
Animatable。 在Canvas中将填充条和滑块(胶囊形或圆形)绘制为白色形状(.alphaThreshold(min: 0.5) + .blur),然后将其用作色调渐变的.mask。让这个 Canvas 视图实现View & Animatable,并将animatableData = progress,这样 SwiftUI 就会逐帧插值黏连效果——在 Canvas 内直接读取原始@State,会让滑块在步进之间跳变,而不是平滑滑动。过冲弹簧(0.5/0.55)可带来液态摇摆效果;每步触发selectionChanged,到达两端时触发mediumImpact。 - 细填充条塌缩陷阱。(标题)细填充条与粗滑块融合时,保持
blur ≤ fillHeight/2,否则alphaThreshold会吞掉细填充条,使其消失(这是代谢球规则“半径约等于模糊半径的缩小球体会塌缩成一条线”的具体情况)。fillHeight 10 → blur 5可以保留;blur 9会抹掉填充条。 - 跟随滑块的值气泡必须位于滑块自己的坐标空间中,绝不能使用
PreferenceKey。(标题)通过PreferenceKey+ 命名coordinateSpace发布滑块的坐标框架,并在屏幕层级放置气泡,首次显示时会导致位置错误:偏好值回调在布局完成后(且可能在入场动画期间)触发,因此报告的是过时坐标框架;气泡会停在滑块上,直到首次拖动才“修正”。应改为在滑块的GeometryReader内部,使用.position(x: cx, y: midY - gap)绘制气泡——这里的cx与绘制滑块时使用的值相同,因此从第一帧起位置就正确。气泡会向上溢出轨道框架(SwiftUI 不会裁剪),从而悬浮在控件上方。 - 底部停靠陷阱。 在
ZStack内的VStack中直接放置Spacer(),不会将内容固定到底部——如果没有任何布局约束强制指定高度,VStack就会收缩到内容高度并居中。应使用显式的.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)图层进行固定(并为固定的页眉单独设置.top图层),将它们作为ZStack的同级图层。 - 取色滑块。 对于频谱渐变滑块,在
progress对应的位置采样渐变,并用该颜色驱动滑块的辉光/泛光.shadow,这样拖动时滑块就会发出其所在位置的精确色调——一处小巧而贴心的设计。
Grid ⇄ Cylinder Morph(平面格点 → 旋转圆筒)
一片平面的点阵(二维码、头像墙、日历)从平面上剥离,聚拢成缓慢旋转的 3D 圆筒,停留片刻后再铺回平面——每个方向各点按一次即可。仅用三角函数和 TimelineView 实现伪 3D,无需 SceneKit。
- 从一个捏合点向外径向错开,返回时镜像错开。 (headline) 为每个点设置
pinchFraction= 它到网格中心的直线距离 ÷ 中心到角落的距离;抬起延迟pinchFraction * maxStagger,落下延迟(1 - pinchFraction) * maxStagger,两者使用同一个maxStagger。共用一个时间预算,才能让前后两段互为镜像——抬起时领先的点会在落下时落后,就像布料被捏住、提起,然后从边缘向内铺开。按行索引错开会呈现擦除效果;按欧几里得距离错开则会呈现扩散的圆环。扩散时间必须与移动时长相当(maxStagger 0.8对比morphDuration 0.9)——每行0.02(相对于 1.1 秒的整个移动过程,端到端约 0.2 秒)几乎看不出来,网格看起来就像同时移动了一样。 - 两个方向都使用五次平滑阶跃曲线。
t*t*t*(t*(t*6 - 15) + 10)在两端的一阶和二阶导数都为零:零速度能避免点看起来像被弹飞,零加速度能避免点看起来像被击中。这里优先使用它,而不是三次曲线;也优先使用它,而不是弹簧曲线——任何过冲都会让点越过位置再弹回来,看起来像被抛出,而不是被托起。 - 3D 目标需要自己的均匀格点——绝不能复用源网格的
(row, col)。 (headline) 将稀疏源(二维码、任何带孔洞的网格)逐格映射到圆柱体上,会形成参差不齐、分布斑驳的圆环。将圆柱体均匀编号(cylRows × cylCols),并按打乱但固定的顺序分配位置((row*7919 + col*104729) % 100003),这样在圆柱体成形时,找不到位置的点会散布在整个方形区域中——按二维码顺序分配位置,会让剩余点全都来自底部,源图看起来就像从下往上被擦除。 - 使用带平滑过渡的背面剔除,而不是截断式渐变。
depth = cos(colAngle)(1 = 面向观者,−1 = 绕到背面);将它输入同一个平滑阶跃函数——smoothMorph((depth + 0.10) / 0.55)——而不是线性截断函数。深度是余弦值,因此它在轮廓线上变化最快,而截断式渐变的拐点恰好落在那里:每个点先缓缓淡入,然后又以全速突然消失,每转一圈发生两次。 - 加速旋转:动画化速度的积分,而不是速度本身。 如果圆柱体第一帧就跳到全速,点会变形到每秒横扫一个屏幕宽度的位置上——虽然点能到位,却无法静止到达。逐渐提升
v = vmax(1 − e^(−t/τ)),并将它的精确积分写入角度:angle = vmax * (t − τ(1 − e^(−t/τ))),这样角度本身连续,初始速度为零。τ ≈ 1.2s时,形变结束时速度约为最终速度的四分之三。 - 由点按驱动的限幅时钟——让每行布局读取同一个
cycleTime。 用min(tForwardEnd, tFlatEnd + timeSince(expandTap))替换modulo循环(展开时),并在未展开时使用min(totalCycle, tHoldEnd + timeSince(collapseTap)):时钟只向前运行,并在每一端停驻,因此只要用户让它保持展开,停留时间就会相应延长,而几何计算中的任何一行都无需知道状态发生了变化。旋转时间从展开点按开始计时,使用单独的时钟——由cycleTime驱动的圆柱体会在抬起阶段限幅的瞬间冻结,而且必须在收拢过程中继续转动。 - 形变过程中拒绝点按。 两段会按相反顺序分配延迟,因此在运动过程中反向,会让每个点在一帧内跳到自身运动路径上的另一个位置——波形会撕裂。两个分支都应添加保护条件
timeSince(lastTap) >= morphDuration + maxStagger。 - 用剪切倾斜圆环,而不是旋转。
y += x * tan(θ)可以在不让任何点横向移动的情况下倾斜每个圆环,因此只占用垂直空间,并将全部水平空间预算留给半径和主轴偏移。主轴的倾斜、弯曲或 S 形曲线带来的横向位移,都要通过缩小半径来补偿;这三者必须以同一个主轴半长为基准测量(绝不能以屏幕宽度为基准,因为屏幕变短的手机上,屏幕宽度会失去比例)。对于 S 形主轴,手动处理符号——pow(abs(n), e) * (n < 0 ? -1 : 1)——绝不能将负底数和非整数指数一起传给pow。 - 沙漏轮廓 = 只改变半径。 保持行间距绝对均匀,并按
waist + (edge − waist) * n²缩放半径(n= 沿圆柱体从上到下的 −1…1)。平方项能让腰部的切线保持平滑,使两侧各自形成一段平滑的 C 形曲线——线性递减会在中心形成尖角。只提高边缘因子来扩大外张程度;同时提高两者只会让管体变粗,并使圆圈在已经最密集的收腰处更加拥挤。 - 边缘渐隐应以透明度为零的页面颜色淡出,绝不能使用
.clear。.clear是透明的黑色,因此向它插值会让渐变中间泛灰——应将Color(...).opacity(0)匹配到精确的背景色。将渐隐层作为.overlay添加,而不是作为 ZStack 的同级视图:overlay 的尺寸与宿主一致,不会反过来影响GeometryReader报告的高度;而忽略安全区域的 ZStack 同级视图会撑大 reader,并悄悄改变圆柱体的尺寸。让顶部和底部约 20% 的区域渐隐,也能避免外张边缘稀疏的部分看起来像散落的圆圈。
焦点引导游览(单个遮罩,镂空平滑移动)
一种会调暗屏幕、突出一个真实控件,并用工具提示指向它的引导游览,提供 Next / End tour 操作——在不同视图所拥有的控件(标签栏、FAB、卡片)之间逐步切换。
- 整个游览只呈现一次;步骤切换就是布局变化。 (headline) 每一步都呈现一次引导标记,会让每次点击 Next 看起来都像是在关闭一个屏幕——遮罩闪烁,气泡滑下又滑上。只呈现一次(使用透明背景的
fullScreenCover,并在带有disablesAnimations = true的Transaction中显示和移除),之后只在withAnimation(.spring(response: 0.5, dampingFraction: 0.86))内更改目标:镂空区域的框架会动起来,因此能在控件之间平滑移动。 - 气泡交叉淡入淡出,镂空区域平滑移动。 为每一步重新标识工具提示(
.id(message)+.transition(.opacity)),使其在原位淡出,并在新位置淡入,同时镂空区域移动。让气泡的位置随弹簧动画变化,会导致文字在移动过程中拖影。 - 离开时只淡出,绝不直接关闭。 结束游览 →
isVisible = false→.easeInOut(0.25)透明度动画 → 然后无动画地移除遮罩 → 之后执行任何后续操作。首次显示也采用相同方式:遮罩无动画地出现,叠加层自行淡入。 - 全局坐标框架,在所有界面元素上方收集。 锚点通过
PreferenceKey报告proxy.frame(in: .global)(遮罩有自己的窗口)。在屏幕根视图上附加onPreferenceChange:位于ScrollView外的标签栏或 FAB,不会传递到滚动内容上的读取器。锚定可点击控件,而不是包住它的全宽容器。 - 两个
GeometryReader。 内层的.ignoresSafeArea()使其空间与全局框架相匹配;它随后会报告零内边距,因此要从保留安全区域的外层读取器读取safeAreaInsets。 - 气泡放在空间较宽的一侧,并在安全区域内测量。
spaceAbove = minY − safeTop,spaceBelow = height − safeBottom − maxY,哪边更大就放在哪边。将 x 限制在 16pt 边距内,然后根据目标的 midX 落在气泡上的位置设置指示箭头(<0.33为左侧,>0.67为右侧),这样即使气泡贴边,箭头仍能指向控件。将气泡锚定到朝向目标的一侧,并限制在距目标gap的框架内,这样高度不定的文本就不会遮住镂空区域。 - 镂空区域适配。 圆形控件(“+”、标签栏中央按钮)的半径应 ≥ 其边长的一半;文本目标需要分别设置 x/y 内边距(约 10 × 6pt),因为其框架紧贴字形。尚未布局的目标应跳过——绝不突出显示零矩形;点击 Next 时,应从实际显示的步骤继续,而不是从存储的索引 + 1 继续。
- 遮罩不响应,镂空区域可交互。 点击遮罩不执行任何操作(使用
contentShape+ 空点击处理),避免意外丢失卡片。遮罩无法透传触摸事件,因此要在镂空区域上覆盖一个近乎透明的命中区域,在淡出后执行控件的实际操作,并在用户返回时从下一步继续。 - 结束游览是轻量操作。 使用粗体、下划线、约 0.8 白色且不带装饰的样式,放在胶囊形主按钮旁边;在 0.8 黑色遮罩上,主按钮无需投影。仅当承载游览的屏幕位于最前方时才运行游览(上方没有弹出层、推入的屏幕或付费墙),否则它会锚定到过期的框架。
输出(创建模式)
逐条输出以下进度信息:
⚙️ swiftui-microinteractions v1.25.1
🖼️ Assets: <found: name1, name2… · or · none found, using placeholders>
🎯 Archetype: <archetype name>
⚡ Physics: <spring preset and why — one phrase>
🎮 Haptics: <2–3 haptic moments>
🏗️ State: <tier> — <@State var names>
✍️ Writing <FileName>.swift…
步骤 1 — 将文件写入磁盘:
- 轮播图 → 如果
legendary-Animo/Carousels/存在,则写入该目录;否则,如果Carousels/存在,则写入该目录;否则写入当前工作目录 - 其他 → 如果
legendary-Animo/Animations/存在,则写入该目录;否则,如果Animations/存在,则写入该目录;否则写入当前工作目录
输出:✅ Saved to <full relative path>
步骤 2 — 注册到 Xcode 项目:
在当前项目中查找 .xcodeproj(最多向下查找几层)。
如果找到 .xcodeproj → 先检查是否存在文件系统同步组。如果 .pbxproj 包含 PBXFileSystemSynchronizedRootGroup(Xcode 16+,objectVersion >= 77),则放入源目录的文件会自动编译——无需编辑项目文件。输出:
📦 File-system-synchronized project — no .pbxproj edit needed.
File placed in: <directory>
否则,像 Xcode 一样将新文件添加到该项目的 project.pbxproj 中:添加一个 PBXFileReference、一个 PBXBuildFile、一个与同组相邻文件相同组(Carousels 或 Animations)的子项,以及一个位于应用目标 Sources 构建阶段的条目。参照同组中的现有文件来保持格式,并使用全新的 24 位十六进制 ID。只编辑该 project.pbxproj 文件。输出:
📦 Registered in Xcode project: <xcodeproj name>
└─ PBXFileReference ✓
└─ PBXBuildFile ✓
└─ Group child ✓
└─ Sources phase ✓
如果没有找到 .xcodeproj → 输出:
⚠️ No Xcode project found. Add the file manually:
File path: <full path>
In Xcode: File → Add Files to "<ProjectName>"
or drag into the Navigator → check "Add to target"
步骤 3 — ContentView 注册代码片段(```swift 代码块,手动粘贴):
date: 字段使用今天的实际日期——不要硬编码过去的日期。
DemoItem(
row: RowView(icon: "💧", title: "Title Here", desc: "Feature · feature · feature"),
destination: wrappedDestination { YourView() },
date: "<today's date e.g. June 11, 2026>",
hasDateHeader: true
)
步骤 4 — 资源说明(仅在使用了占位符时):
ASSET NOTES:
- Add portrait images to Assets.xcassets/Images/ named: photo1, photo2…
→ Recommended size: 400×600pt, portrait ratio
- Remove placeholder RoundedRectangle once real assets are added
输出(编辑模式)
编辑前输出:
📂 Reading <FileName>.swift…
✏️ Applying: <one-line summary of change>
✍️ Writing updated file…
覆盖现有路径中的文件,然后输出:
✅ Updated <full relative path>
随后输出一个 更改内容项目符号列表,说明修改了什么以及原因。 (编辑时无需更新 pbxproj——文件已注册。)