SwiftUI设计iPhone Duo

iPhone Duo 各姿态间的过渡动画

展开、合上、旋转和弯折时 iPhone Duo 为你做了哪些系统动效,它留给 App 处理的唯一变化,以及如何为那项变化添加动画。来自对真实 App 的逐帧录屏。

whiten2026年10月10日 · 更新于 2026年10月11日阅读约 12 分钟10 个来源

展开 iPhone Duo,屏幕上的画面仿佛凝固在空间中,唯有机身玻璃在周围转动。这是系统底层所做的工作,它也引出了每款适配 App 迟早都要面对的问题:当设备变换姿态时,App 自身究竟应该为哪些变化做动画?Apple 人机界面指南中关于动效只有一条原则,且并未提供任何专门的 API。本文通过录屏解答这一问题:电影票根收藏 SwiftUI App Flicit 在 Apple 的 iPhone Duo 模拟器中经历每次姿态切换,在学会如何做动效的前后,被逐帧录制比对。

简短的答案是:极少,且只有一件事。借助编程智能体适配 iPhone Duo 的指南提供了制作这些录屏所需的工具包;姿态指南则详细介绍了各姿态本身。

要点

  • 开合与旋转均由系统接管。 系统在高斯模糊快照下将 App 在两块屏幕间交接,并通过自身的旋转动效转动窗口。App 在底层完成尺寸调整:单次排版直接落地新布局,保留用户所在位置与状态,不要添加任何自己的动效。
  • 展开状态下弯折手机归你处理。 窗口尺寸保持不变,没有任何遮罩掩盖变化,布局在折痕处所做的任何调整,用户都会亲眼目睹。
  • 最好的弯折是什么都不动。 在弯折前便根据系统未激活的折痕区域完成折痕排版,书本形态下屏幕上便不会发生任何位移。
  • 当某个姿态拥有独立专用布局时,为布局切换做动画,且仅在弯折时触发:即折痕在激活或静止间切换、同时窗口保持原有尺寸。切勿将动画绑定到姿态本身,否则它会叠在系统旋转动画之上播放。
  • 为新布局构建的视图起初图像为空白。 在首帧绘制已解码的位图,否则每次切换都会暴露占位图。
  • 录屏验证。 截图无法发现跳变、空白帧或占位图闪烁。

系统为你做了哪些动效

开合过程

系统使用 App 的高斯模糊快照遮盖外屏与内屏之间的交接,快照模糊程度随机身运动而变化。Empirical Health 对该效果的逆向重建描述为:内容被固定在空间之中,而玻璃在其周围翻转——靠近铰链处清晰,越靠近运动边缘越模糊,内屏起初较暗,随展开过程逐渐变亮。两块屏幕拥有完全一致的宽高比,因此画面不会发生跳变。

在底层,没有视图被销毁重建。在 Frank Schlegel 记录的 Apple 小组实验室(Group Labs)笔记中,全屏 App “通常在另一块屏幕上继续运行并调整大小,而不是被销毁重建”;而在 Anton Gubarenko 整理的开发者论坛回复中,则表述为 “铰链与屏幕过渡表现为尺寸类别(Size Class)和 Trait 的变更”。在 Flicit 的录屏中,App 在模糊快照仍未褪去时便单步到位地完成了新布局。此时它所做的任何动作都不会被看见,因此在此处添加动画没有任何意义。

用户真正能看到的是他们是否依然停留在之前的位置。这才是此处的关键任务:同一个屏幕、同样的滚动位置、刚刚正在输入的文字。

旋转

在展开状态下将手机在横置与纵向立起之间旋转属于屏幕旋转,系统自带旋转动画。在旋转完成时,Flicit 的票库(Library)已经完成了竖屏排版。之后唯一发生位移的是:转回横屏时,其底部标签栏逐渐淡出,侧边栏(Side Rail)逐渐淡入——这是旋转完成后的短暂交接,而非叠加在旋转过程之上的额外动效。这在视觉上自然流畅。如果将弹性动画(Spring)绑定到姿态上,并在系统仍在旋转窗口时触发,效果就会大打折扣。

弯折

将展开的手机部分折起,窗口尺寸保持不变。这里既没有模糊快照,也没有屏幕旋转:唯一的变化是折痕区域从静止变为激活态,你的布局为此所做的任何调整都会在众目睽睽之下发生。在模拟器中,倾斜背离视线的那一侧折页在铰链运动时会暗化并模糊,并在铰链停止运动的瞬间恢复清晰。

双手捧着半开的 iPhone Duo,主屏幕铺满内屏
处于半折状态。此处没有任何遮罩掩盖变化:无论布局在折痕处做什么调整,手持设备的人都会亲眼目睹。图片:Apple

系统自身的组件在弯折时已经会自动调整。警告框(Alert)、菜单、浮层(Popover)与表单(Sheet)会围绕折痕重新定位,分屏浏览(Split View)也会平衡其分栏宽度。其余的部分则由你决定。

Apple 的原则:微调为先

人机界面指南明确指出:“Avoid extreme layout changes as people fold the device. Move only what’s necessary to keep elements visible and easy to tap. Controls that disappear or shift dramatically are harder to find and track, so favor small adjustments over rearrangement.”(在用户折叠设备时,避免极端的布局变动。仅移动必要的元素以保证其可见且易于点击。消失或大幅位移的控件更难找寻和跟踪,因此优先做小幅调整而非重新排布。)

关于布局的 Tech Talk 以“位移(displacement)”为名补充了细节。将元素移动到符合其用途的区域,但不要远离其原有位置,因为距离会割裂两者之间的视觉联系。将相关的组件一同移动。连续滚动的正文内容(文章、信息流、文档、列表)应留在原处,因为移动它们“会打断连续性”。连移动方向都有考量:像书本一样半折时,警告框移向靠后的一侧,“更接近设备合上时它们出现的位置”。

还有两个事实框定了动画的形式。铰链数据“非常适合驱动交互或动效”,而布局则归属于保留区域(Region)与 Arrangement API。并且系统没有提供任何用于对姿态变换做动画的公共 API。onHingeChange 仅报告状态和角度,不含速度;唯一带有 animated: 参数的折叠 API 是 UIKit 的 UIArrangementViewController,用于手动切换排列方式。

最好的弯折是什么都不动

Flicit 最初的录屏捕捉到了一处并非由系统引起的动效:在弯折时,票库的票根列表列在折痕变为激活态的瞬间突兀跳动对齐至折痕,而在完全展平时又跳回原位。修复手段并非添加动画。使用 .includeInactive 请求折痕区域,系统便会在手机仍完全平放时报告折痕未来的走向,从而让双栏排版从一开始就将间距对齐在该位置:

let divisions = proxy.reservedRegions(kind: .division, options: .includeInactive)
let crease = divisions.first { $0.isActive }?.frame            // 半折状态
    ?? divisions.first { !$0.isActive }?.frame                 // 平放状态:未来折痕所在位置

在两种姿态下间距均对齐该线后,Flicit 横向弯折的录屏中便除了系统的模糊之外什么位移都没有了。折痕指南介绍了该 API,智能体指南的书本姿态一节则提供了防止旋转导致的陈旧区域污染布局的校验逻辑。尽可能这么做:最好的微调就是不做调整。

仅对弯折做动画,且仅在弯折时触发

有些姿态值得拥有独立的专用布局。将展开的手机纵向立起并折至约 110°,Flicit 的票根页面便一分为二:立起的上半屏呈现横向票样,平放的下半屏承载翻页操作台。电影详情页同样拆分,上方为海报与简介,下方为评分与操作按钮。

竖拿并半折的 iPhone Duo:立起的上半部分是横向形式的《F1:狂飙飞车》票根,车手画成了像素,平放的下半部分是面向银幕的座位图,H12 座位用墨迹标出
笔记本形态:与平放时完全不同的布局,因此两者之间的切换值得做动画。图片:Flicit

在添加任何动效之前,两个页面在两个方向上均以单帧硬切切换布局。这正是人机指南所警告的那类剧烈变动,但笔记本布局有其存在的理由:在立起的半屏上,横向票样的面积大约是纵向票样的三倍。可以柔化的是切换动作本身。

问题在于何时做动画。姿态可以通过多种方式改变,而其中只有一种是没有系统遮盖的。开合与旋转均会改变窗口尺寸;弯折则不会。因此弯折的判定标准是:折痕在激活或静止间切换、同时窗口保持原有尺寸。将窗口度量为受追踪视图加上其 Inset 安全边距,因为弯折可能会为页面注入边距。首次读取也不属于弯折:折痕保留区域是在首轮布局之后到达的,为它们添加动画会导致页面在 App 启动时滑入。

struct FoldStage: Equatable {
    var seam: CGRect?               // 激活的 division 保留区域;平放或合上时为 nil
    var contentSize: CGSize = .zero // 受追踪的视图尺寸,位于安全边距内
    var insets = EdgeInsets()

    var windowSize: CGSize {
        CGSize(width: contentSize.width + insets.leading + insets.trailing,
               height: contentSize.height + insets.top + insets.bottom)
    }

    /// 折痕在激活或静止间切换,同时窗口保持尺寸不变。
    func isBend(to next: FoldStage) -> Bool {
        (seam == nil) != (next.seam == nil) && windowSize.width > 0
            && abs(windowSize.width - next.windowSize.width) <= 0.5
            && abs(windowSize.height - next.windowSize.height) <= 0.5
    }
}

在窗口根节点的唯一位置读取 stage,并在该处裁决新读数是否需要执行动画:

@Environment(\.accessibilityReduceMotion) private var reduceMotion

func publish(_ next: FoldStage) {
    guard next != stage else { return }
    guard stage.isBend(to: next), !reduceMotion else {
        stage = next                    // 开合、旋转:由系统处理
        return
    }
    withAnimation(.interpolatingSpring(mass: 0.9, stiffness: 280, damping: 24)) {
        stage = next
    }
}

下方的每个子视图均从环境中读取 stage,而那些因姿态改变布局的视图无需了解细节即可自然继承弯折动画。通过 if let leaves = … { laptop } else { pager } 切换布局的页面获得 SwiftUI 默认的淡入淡出过渡。Flicit 的票根页面现在在双向切换中大约耗时 120 毫秒。

竖拿并弯成笔记本形态的 iPhone Duo 的三帧:Flicit 的《F1:狂飙飞车》票根先像平放时那样完整竖立,接着整张票根淡出,横向票样和座位图淡入,最后横向票样留在立起的上半部分,下方座位图上的 H12 座位用墨迹标出
同一段录屏中的三帧,首尾相隔约五分之一秒。弯成笔记本形态后,票根页先保持平放时的布局,直到折痕激活,再以淡入淡出切换到笔记本布局。图片:Flicit

在数据源头做裁决,切勿在各个子视图中处理。如果使用针对姿态值的 .animation(_:value:),它会在姿态每次变化时触发,包括手机在弯折状态下发生旋转,此时动画会叠加在系统旋转之上。Flicit 从书本形态旋转至笔记本形态的录屏显示,笔记本布局在旋转结束时单步就位,这正是正确的表现。

在首帧绘制已解码的图像

淡入淡出暴露了硬切所掩盖的第二个瑕疵。切入的新布局是全新构建的,其图像起初为空白。即使图片早已解码完成,异步加载的图片视图也会展示一帧或多帧的占位图,然后才淡入真实图像。在 Flicit 的录屏中,每次弯折时票根副券黑屏、电影海报灰屏均持续了 60 到 100 毫秒。

将图片视图最后绘制的位图保存在一份可同步读取的小缓存中,在展示占位图前先查缓存:

  • 优先按完全匹配的请求查找:相同文件、相同像素尺寸与缩放倍率解码所得像素完全一致,新视图在首帧即可将其绘制出来,不会露出任何破绽。
  • 其次按图片查找:同张图片的已有最大解码图可作为垫底图,只要其尺寸达到所需尺寸的一半以上。尚未绘制过的尺寸(如手机首次弯折时的副券)否则依然会闪烁。短暂的轻微模糊远胜于露出一块占位图。
  • 让视图自身的异步加载照常进行,并在完成后无淡入地替换垫底图,因为两者是同一张图。

如果业务逻辑需要读取位图像素,需校验其在垫底图下依然正常运作。Flicit 的海报票根标签颜色取自海报在 24×24 像素下的采样,因此同张图片在不同尺寸下能得到相同的色彩;录屏证明当精准解码替换垫底图时,标签颜色没有发生任何跳变。有了这套缓存,Flicit 录屏中的任何切换无论首次还是后续均不再露出占位图。

这不仅对折叠屏有益,对整个 App 都有帮助:任何因已在屏幕上的图片而重建的视图都能立即绘制出内容。

当淡入淡出闪烁时,改用硬切

Flicit 的电影详情页并不能像票根页那样优雅地淡入淡出。其退出的旧布局在单帧内直接消失而不是渐变褪去;展平手机时,切入的新页面则在裸白纸底上淡入约 40 毫秒(两到三帧的空白页)。改用提前展现新页面的 Ease-Out 曲线也无法消除这一空白。该页面的两套布局均基于 ScrollView 构建,而票根页的手动排版 Pager 却能正常渐隐渐显;录屏所证止于此,更深的原因暂未探明。

空白闪烁比硬切更难以忍受。电影详情页退出了弯折动画,在海报早已绘制完成的前提下以单帧利落硬切:

.transaction(value: isLaptopLayout) { $0.animation = nil }

务必为每一个因姿态切换布局的页面逐一录屏。一个页面的淡入淡出正常,并不保证下一个页面也是如此。

跟随铰链连续运动的动效

上述所有内容均属于两个状态之间的离散切换。有些动效则应持续跟随铰链运动:随着立起半屏一同倾斜的表单、随弯折翘起的票根副券。这正是铰链角度的用途。根据 Gubarenko 收集的论坛回复,系统对 onHingeChange 不保证任何“更新频率、角度增量、范围或精度”,因此应将每次更新视为状态变更,而非动画的逐帧驱动源。在动效需要保持平滑的地方,另一条论坛回复建议“朝着最新角度驱动一个弹簧动画”。Flicit 通过柔和的弹簧使立起半屏上的票根副券随角度倾斜数度,并在“减弱动态效果”开启时保持垂直。铰链指南详细介绍了将角度读作位置、速度或捕捉点(Snap)的方案。

保持用户的浏览位置与状态

动效是姿态变换可见的一半;另一半则是确保用户状态不丢失。论坛回复与小组实验室(Group Labs)对此观点一致。在几何形态变化期间保持视图标识(View Identity)与数据流稳定。不要防抖:将“保留区域、安全区域与边界”视为“声明式 UI 的普通输入”。并警惕基于 Size Class 的 if 分支,“除非将共享状态提升至这些分支之上,否则在屏幕切换期间可能会销毁局部视图状态”。开源项目 meow-ios 正是出于此原因将一个分区的状态提升到稳定的父级:该分区在折叠时改变了容器,若不提升会导致状态重置并意外弹出已压栈的页面。

Flicit 的电影详情页展示了切换布局的局限:其两套布局属于不同的 ScrollView,因此切换前滚动的浏览位置无法带入新布局。

逐帧录屏验证

截图只能显示布局最终落地的形态。它们无法展现跳变、空白帧、占位图或意外漏出的游走动效,而本文列出的所有瑕疵均集中在这些瞬间。智能体指南的工具包可通过脚本驱动模拟器弯折,并录制其中一块屏幕:

python3 duo.py orient upright
python3 duo.py record --out films/ --name laptop path:1.0:180:110 wait:2.5 path:1.0:110:180 wait:2   # 进入笔记本形态并返回展平
ffmpeg -i films/laptop-inner.mp4 -fps_mode passthrough -enc_time_base 1/1000 -frame_pts 1 frames/%06d.png

录制工具仅在屏幕发生变化时才输出帧,因此需以直通模式提取原汁原味的帧;每个文件以其毫秒时间戳命名。要找到值得审查的关键帧,将每帧与前一帧比对并按差异大小排序。单次大幅度差异是硬切,一连串细微差异则是淡入淡出:

prev=; for f in frames/*.png; do
  [ -n "$prev" ] && echo "$f $(compare -metric RMSE "$prev" "$f" null: 2>&1)"; prev=$f
done | sort -t'(' -k2 -g -r | head

然后查看每个关键时刻附近的画面。重点排查:系统模糊褪去后仍在位移的布局、原本有图片的地方闪出占位图、两套布局之间的空白帧,以及在旋转过程中发生位移的任何元素。在修改任何代码之前也先录制一段,这样你才能分清哪些动效是系统自带的。

模拟器的两项特性在这里十分关键。折痕在铰链停止大约一秒后才变为激活状态,因此在录屏中布局切换显示在弯折之后,而非弯折途中;在真机上很可能会在弯折中途、手机仍在手中转动时触发。而在旋转之后,保留区域可能需要数秒才能收敛稳定:Flicit 的一段录屏在结束时手机已转回横屏但仍显示笔记本布局,数秒后的静态截图则恢复正常。在认定录屏所见为 Bug 之前,请务必给录制留出足够的等待尾部。

速查表

姿态变换 驱动来源 App 的应对策略
合上 ↔ 展开 系统高斯模糊快照 单步落地新布局;保留用户所在位置
横置 ↔ 立起 系统屏幕旋转 同样单步就位;切勿绑定姿态动画
平放 ↔ 半折 无:App 的变化完全可见 尽可能不产生位移;否则为布局切换做动画
铰链运动中 无 仅做视觉特效,通过弹簧平滑跟随角度
目标 可行的方案
让书本形态不产生二次位移 从一开始便将间距对齐在未激活折痕保留区域(.includeInactive)之上
准确识别弯折而非其他变换 折痕在激活或静止间切换,且窗口(视图加安全边距)尺寸保持不变
为弯折添加动画 在发布 stage 的源头做裁决:仅为弯折包裹 withAnimation,“减弱动态效果”时不执行动画
让两套布局优雅淡入淡出 针对姿态使用 if/else;弯折事务将赋予其默认的渐变过渡
新布局首帧不闪现占位图 在首帧绘制缓存位图:完全匹配的请求优先,否则使用尺寸达标一半以上的已解码图
淡入淡出会闪烁的页面 .transaction(value:) { $0.animation = nil }:改用硬切,并提前备好已解码位图
排查录屏中的跳变时刻 使用 -fps_mode passthrough 导出帧,再用 compare -metric RMSE 逐帧比对

本指南的局限

本文全部内容均在 Apple 的 iPhone Duo 模拟器(iOS 27.1,版本号 24A94232)中录制,而 iPhone Duo 定于 10 月 23 日发售。真机上的折叠系统动效与模拟器的模糊效果可能会有出入,折痕可能在弯折中途而非停止后一秒激活,因此在录屏上调优的淡入淡出在真机手持体验中可能会显得偏快或多余。电影详情页的滚动视图为何会在单帧内直接脱落而不是渐隐渐褪,深层原因暂未探明。文中的时间数据均来自单一 App 在单台 Mac 上的实测:票根页淡入淡出耗时 120 毫秒,修复前占位图闪烁持续 60 到 100 毫秒。

继续阅读

App · 设计阅读约 3 分钟

iPhone Duo 分屏浏览:两个 App 并排使用

iPhone Duo 是第一台能同时显示两个 App 的 iPhone。分屏浏览怎么用、有哪些限制,以及哪些 App 为共享屏幕做了设计。

10月6日阅读指南