SwiftUI 自定义 Liquid Glass 分段控件:首次挂载动画异常与稳定实现
tags:
- SwiftUI
- macOS
- Liquid-Glass
- Animation
- Debugging
#SwiftUI #macOS #Liquid-Glass #Animation #Debugging
问题现象
一个放在 macOS Toolbar 中的自定义分段控件具有类似 iOS Liquid Glass 的选中胶囊移动效果,但会出现以下现象:
- 冷启动后第一次进入页面,选中胶囊的过渡明显拉长、缩放或跨越多个分段。
- 切换到其他页面再返回后,动画恢复正常。
- 普通的系统原生 segmented
Picker没有同样的问题,但视觉动效也更接近 macOS 原生风格。
一句话概括:旧实现把「移动同一个选中指示器」写成了「移除旧玻璃、创建新玻璃并让两者形变」,首次 Toolbar 布局又放大了这条形变路径。
旧实现的链路
旧实现可以抽象为:
ToolbarItem(placement: .principal) { GlassEffectContainer(spacing: largeBlendDistance) { HStack(spacing: smallLayoutGap) { ForEach(tabs) { tab in Button(tab.title) { selection = tab } .glassEffect( selection == tab ? .regular.interactive() : .identity ) .glassEffectID( selection == tab ? "selected-tab" : nil, in: namespace ) } } } .animation(.smooth, value: selection) }
假设选中项从 A 切换到 B,状态变化是:
切换前: A = regular + selected-tab B = identity + nil 切换后: A = identity + nil B = regular + selected-tab
因此系统执行的并不是简单平移,而是:
A 上的玻璃效果消失 ↓ 根据 glassEffectID 寻找对应目标 ↓ B 上出现新的玻璃效果 ↓ GlassEffectContainer 尝试让两个形状 morph
GlassEffectContainer(spacing:) 的 spacing 是玻璃形状开始融合的距离阈值,不是普通布局间距。该值如果远大于内部 HStack 的实际间距,原本相隔很远的形状也可能过早参与融合,从而放大拉伸效果。
为什么冷启动最明显
冷启动时,多个层级会同时创建和布局:
创建窗口 ↓ 布局 NavigationSplitView ↓ 计算 Toolbar 和 .principal 项目的可用尺寸 ↓ 创建自定义分段控件与 Namespace ↓ 建立 Liquid Glass 的出现、消失与几何关系
此时 Toolbar 可能仍在从临时建议尺寸过渡到最终尺寸。如果自定义玻璃效果同时发生出现、消失或 morph,系统转场可能会把尚未稳定的几何状态纳入动画,于是第一次交互出现夸张拉伸。
切换页面后,原控件通常会离开视图树,它的 Namespace 和形变状态随之销毁。再次返回时,窗口、分栏和 Toolbar 已经稳定,新建控件第一次测量就更接近最终尺寸,因此后续动画正常。
需要注意:Apple 没有公开 SwiftUI 内部如何缓存每一帧几何信息。因此,「首次布局中的哪一帧被用于转场」属于基于生命周期和复现规律的推断;可确认的代码问题是选中变化确实触发了玻璃效果的移除、创建和 morph。
.principal 是根因吗
不是。它是容易暴露问题的环境,而不是根因。
根因:把选中项移动实现成玻璃效果的出现、消失和形变 放大因素:过大的玻璃融合距离 触发环境:Toolbar 首次挂载和 .principal 的尺寸协商
判断依据很简单:修复后的实现仍然可以放在 ToolbarItem(placement: .principal) 中,只要不再走旧的玻璃 morph 路径,问题就可以消失。
新实现:保持一个玻璃胶囊,只移动位置
新的思路是让每个分段只提供几何位置:
HStack(spacing: segmentGap) { ForEach(tabs) { tab in Button(tab.title) { selection = tab } .matchedGeometryEffect( id: tab, in: namespace, isSource: true ) } }
然后在整个控件背后放置唯一、持续存在的玻璃胶囊:
.background { Color.clear .glassEffect(.regular.interactive()) .matchedGeometryEffect( id: selection, in: namespace, isSource: false ) } .animation( reduceMotion ? nil : .smooth(duration: 0.2), value: selection )
新的动画链路变成:
唯一的玻璃胶囊始终存在 ↓ selection 改变 ↓ matchedGeometryEffect 读取目标分段的位置 ↓ 把同一个胶囊移动到目标位置
关键区别:
旧实现:销毁旧玻璃 → 创建新玻璃 → 执行 Liquid Glass morph 新实现:保留同一个玻璃 → 只改变其几何位置
新实现可以删除:
- 每个分段上的
.regular与.identity动态切换。 - 共享的
glassEffectID。 - 只为融合多个玻璃效果而存在的
GlassEffectContainer。 - 随分段数量增长的玻璃融合阈值。
原生 Picker 能否替代
macOS 的原生写法是:
Picker("Section", selection: $selection) { ForEach(tabs) { tab in Text(tab.title).tag(tab) } } .pickerStyle(.segmented)
它可以获得系统提供的外观、可访问性和平台适配,但 SwiftUI 没有公开 API 用来保证或配置 iOS 风格的选中胶囊滑动、形变速度和曲线。macOS 的原生 segmented control 通常使用更克制的选中态切换。
选择建议:
- 优先稳定性和平台一致性:使用原生
.segmented。 - 明确需要跨平台一致的滑动胶囊:使用「单一持久指示器 +
matchedGeometryEffect」。 - 不建议为普通选中切换重新引入多个玻璃形状之间的出现、消失和 morph。
验证方式
这类问题不能只验证「切回来以后正常」,应覆盖真正的首次挂载路径:
- 完全退出应用并冷启动。
- 默认页面首次显示后立即连续切换分段。
- 切换到其他页面再返回,对比两种生命周期。
- 测试不同数量的分段,特别是最少和最多的情况。
- 测试不同窗口宽度以及分栏调整后的行为。
- 开启「减少动态效果」并确认大幅移动动画被禁用或弱化。
- 除了编译、格式化和静态检查,还要确认可运行进程并人工观察真实窗口。
对于纯视觉合成问题,单元测试通常无法验证动画轨迹。更有价值的是保留一个最小复现:NavigationSplitView + ToolbarItem(.principal) + 自定义玻璃分段控件,并反复执行冷启动测试。
通用排障经验
- 先区分「控件状态异常」与「动画表达方式异常」。数据选中值正确,不代表视图身份和转场关系正确。
- 当 Bug 只在第一次出现、重建后消失时,优先检查首次挂载、布局协商、视图身份和 Namespace 生命周期。
- 不要把视觉参数调小误认为修复。缩短时长或减小 spacing 可能只是在隐藏症状。
- 如果交互语义是「一个物体移动」,实现中也应尽量保持一个物体持续存在。
- 系统 Toolbar、导航转场和自定义玻璃动画会叠加;自定义效果越复杂,越需要冷启动和真实窗口验证。
参考资料
相关文章
在 macOS 原生应用中实现 OIDC 登录:从协议到落地
最近我在一个 macOS 桌面应用里从零实现了完整的 OIDC 登录:Authorization Code + PKCE、系统浏览器授权、Keychain 持久化、token 惰性刷新与轮换、userinfo 用户资料——全程没有引入任何第三方认证库,只用 Apple 平台自带的组件。本文把这段实现整理成一份通用的工程指南:协议背景、设计决策及其理由、端到端流程、踩过的构建系统与调试陷阱,以及一份可以直接照着做的检查清单。
DMT Summary
Raspberry: 客户端Swift + 前端 -> 全栈,26Fall NYU MSCS,AI Coding 探索中,爱好iOS开发。如果你对开发(尤其是比较冷门的iOS相关领域),以及留学美国等等感兴趣,欢迎和我一起交流技术、实习、黑客松相关、升学以及未来在美生活等等。联系WeChat: ms2855190178
Sparkle + GitHub Releases 集成指南
1. 生成密钥对