用途
Fluid Interfaces 是一套设计与审查原则,用于打造「流畅、手势化、符合物理直觉」的界面。它既可用于审查已有的手势/动效代码(找出违反原则的 file:line),也可用于从零设计新的交互。
其来源为 Apple WWDC 2018 第 803 场演讲《Designing Fluid Interfaces》(演讲者为 Human Interface 团队的 Chan Karunamuni、Nathan de Vries、Marcos Alonso,他们构建了 iPhone X 的手势 UI)。完整文字记录见 references/transcript.md。
核心论点:当界面感觉像是用户心智与身体的延伸,而不是在操作一台计算机时,它才是流畅的。这与帧率无关——60fps 的界面仍可能感觉「不对劲」。流畅来自让界面行为匹配我们身体与心智真实的运动方式:连续、可改向、并响应真实的物理动量。
8 条核心原则
-
即时响应。 触摸与反应之间任何可感知的延迟都会破坏直接感。要审查每一次点击、按压和拖拽起始的延迟——不只是滑动。延迟会通过 debounce 计时器、setTimeout、动画队列,或等待手势完全结束才给反馈等方式悄悄混入。
-
允许持续改向与打断。 用户应能在手势中途改变主意,而无需等待当前动画/手势结束。应通过加速度突变来检测方向变化,而不是速度阈值加计时器——手指运动突然减速/反向,比「等待速度低于 X 持续 Y 毫秒」检测得更快。如果新手势无法打断进行中的手势,即为违规。
-
保持空间一致性。 对象应以对称、一致的路径进入和离开屏幕(模拟现实世界中的物体恒存性)。如果某物从右侧进入,就应从右侧退出——而不是从底部。对称性被破坏(例如从右侧推入、向下弹出)会被读作「把它送走」而非「返回」,令人迷失方向。
-
沿手势方向给出提示。 中间/按压状态应沿同一轨迹朝最终状态视觉生长(例如控制中心模块在弹开前朝手指方向鼓起)。这使用户能在完成手势前预测结果。
-
保持触摸轻量,但放大输出。 短而轻的触摸输入(一次轻甩)应产生放大的结果(长距离滚动)。不要为大的屏幕效果要求大幅、费力的身体动作——应根据位置/速度/力度构建「惯性剖面」,并在手指抬起后将其延伸为放大、保持动量的运动。
-
在边界与交接处使用橡皮筋效果。 绝不要在边缘硬停——应柔和抵抗并回弹(橡皮筋),使界面可被理解为「仍在响应」,而非冻结或损坏。这也适用于两个被跟踪行为之间的交接(例如 dock → 应用面板):它们应以平滑曲线交接,而不是突然交换所有权。
-
设计平滑的运动帧,而不只是高帧率。 固定帧率下的快速运动,如果相邻帧之间视觉变化过大,会产生频闪。缓解方式:提高帧率、添加运动模糊,或使用运动拉伸(沿运动方向的弹性挤压/拉伸——用于 iPhone X 应用启动图标动画)。要点:不只是 fps,而是帧与帧之间变化了什么。
-
设计行为,而非预设动画。 现实世界的运动不是预先决定的动画曲线——它是物体物理属性与作用力(你的触摸、重力、摩擦力)之间持续的协商。凡是手势驱动运动之处,优先使用持续运行的动态行为(弹簧),而非一次性定时动画。
弹簧优于定时动画
定时/基于时长的动画在固定窗口内把控制权交给设计师,然后交还——这种「呼叫-响应」模式在用户想要中途打断或改向时就会失效。基于弹簧(弹性)的行为始终在运行,可在任意瞬间被改向/重定向,这正是让「持续打断」(原则 2)真正可实现的原因。
不要直接暴露 mass/stiffness/damping——那些是物理课参数,不是设计师友好的参数。
应改为暴露两个旋钮:
- damping(0–100%): 过冲程度。100% = 无过冲(临界阻尼),0% = 永远振荡。
- response: 数值接近目标的速度。(技术术语:阻尼比 + 频率响应——可查阅精确换算,例如 iOS UISpringTimingParameters / SwiftUI .spring(response:dampingFraction:)。)
描述弹簧行为时避免使用「duration」一词——它会强化错误的心智模型(弹簧永远不会「结束」,它只是静止,直到有东西再次移动它)。
默认使用 100% damping(无弹跳)。弹跳必须被「挣得」:
- 只有当驱动手势本身带有动量时(一次甩出的关闭,而非点击)才添加过冲。无手势动量的点击触发式显现不应弹跳——零动量触发上的弹跳读作噪声,而非反馈。
- 弹跳也可功能性使用,作为「需要更大力度/意图」的提示(例如一个按钮抖动以教会「按更用力才能激活」)。
- 在整个应用/系统中保持弹跳一致——行为应感觉像一家人。学会一个行为应能迁移到其他行为。
动量投影(甩向角落问题)
当手势结束时(例如把一个浮动元素甩向若干目标区域之一——「端点」),不要仅按位置吸附到最近端点。那会忽略速度,使交互感觉在与用户对抗——你几乎要拖到目标处才会被识别。
应改为使用释放速度与减速率把位置向前投影(复用 UIScrollView 风格的减速率,以匹配用户从滚动中已有的肌肉记忆),然后选择最接近该投影(想象中、从未渲染的)位置的端点:
func project(initialVelocity: CGFloat, decelerationRate: CGFloat) -> CGFloat {
// UIScrollView.DecelerationRate.normal ≈ 0.998
return (initialVelocity / 1000) * decelerationRate / (1 - decelerationRate)
}
// projectedPosition = currentPosition + project(velocity, decelerationRate)
// -> pick nearest endpoint to projectedPosition, not to currentPosition
这可以推广到位置之外——它也适用于缩放和旋转投影。任何「释放时沉降到最近状态」的交互(吸附点、角落停靠、速度可变的翻页轮播)都应投影动量,而不只是检查当前位置。
手势机制清单
- 点击: 触摸按下时立即高亮;触摸抬起前不确认动作。给点击目标在可见边界之外额外的命中边距。支持「手指滑出取消,滑回重新确认」。
- 滑动/拖拽: 在确认滑动前要求一个小的移动阈值(迟滞,iOS 上约 10pt),以与点击区分。跟踪触摸相对于抓取对象位置的偏移——绝不要在手指下重新居中对象。保留位置/速度的短历史,使释放使用真实的近期速度,而非陈旧/最终的采样。
- 一对一跟踪: 拖拽期间内容必须与触摸精确同步移动——任何偏差(丢帧、在拖拽期间而非释放后施加阻尼)都会立即、本能地被察觉。
- 连续反馈: 每一次交互——不只是滑动——都应全程可见地响应,而不只在结束时。避免只在完成时触发的手势识别器(例如 UISwipeGestureRecognizer);优先使用全程流式提供位置/速度/压力/尺寸的识别器(原始触摸、pan/压力识别器)。
- 多个竞争手势: 从触摸开始就并行识别所有可能的手势(例如列表中的滚动 vs. 点击预览),只在意图明确后取消落败者——不要等一个手势完全解决再考虑其他,那会增加延迟。延迟有时不可避免(例如点击 vs. 双击需要等待双击窗口结束)——应显式标记这些位置,而不是默默接受。
教会用户使用手势界面
手势可供性并非自明,因此要低成本地教会它们:
- 视觉线索/惯例: 被裁切的内容边缘(详见下文)、页面圆点、抓取手柄——学一次,处处可识别。
- 把交互元素提升到自己的视觉平面(阴影/z 轴抬升)以示意「这可抓取」(例如开关的旋钮)。可与 axiom-haptics 配合以搭配触觉线索。
- 把一个离散动画与等价手势配对,使一个教会另一个(例如一个可点击的「×」把标签页向左滑出,也教会用户自己可以向左滑动它)。
- 显式说明——谨慎使用,只用于应用中处处重复的手势;绝不用于罕用手势(不会被记住)。
- 为玩耍而设计,而非为说明而设计。 调校良好的流畅界面会邀请人摆弄——人们是「发现」它,而不是「学习」它。审查新手势时问:随便戳一戳它,能否在没有文档的情况下教会你它的规则?
依据本技能审查代码时
以 file:line + 违反了 8 条原则或清单中的哪一项 + 具体的用户可见症状来报告发现(例如「第 42 行的拖拽处理器在手指下重新居中元素——破坏了相对偏移跟踪,因此被拖拽元素在抓取时会跳动」)。不要标记与上述原则无关的风格性动画曲线选择——本技能关注交互物理,而非一般动画品味(那应使用 12-principles-of-animation 或 make-interfaces-feel-better)。