返回文章列表

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 布局又放大了这条形变路径。

旧实现的链路

旧实现可以抽象为:

swift
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,状态变化是:

text
切换前:
A = regular + selected-tab
B = identity + nil

切换后:
A = identity + nil
B = regular + selected-tab

因此系统执行的并不是简单平移,而是:

text
A 上的玻璃效果消失
        
根据 glassEffectID 寻找对应目标
        
B 上出现新的玻璃效果
        
GlassEffectContainer 尝试让两个形状 morph

GlassEffectContainer(spacing:)spacing 是玻璃形状开始融合的距离阈值,不是普通布局间距。该值如果远大于内部 HStack 的实际间距,原本相隔很远的形状也可能过早参与融合,从而放大拉伸效果。

为什么冷启动最明显

冷启动时,多个层级会同时创建和布局:

text
创建窗口
  
布局 NavigationSplitView
  
计算 Toolbar  .principal 项目的可用尺寸
  
创建自定义分段控件与 Namespace
  
建立 Liquid Glass 的出现、消失与几何关系

此时 Toolbar 可能仍在从临时建议尺寸过渡到最终尺寸。如果自定义玻璃效果同时发生出现、消失或 morph,系统转场可能会把尚未稳定的几何状态纳入动画,于是第一次交互出现夸张拉伸。

切换页面后,原控件通常会离开视图树,它的 Namespace 和形变状态随之销毁。再次返回时,窗口、分栏和 Toolbar 已经稳定,新建控件第一次测量就更接近最终尺寸,因此后续动画正常。

需要注意:Apple 没有公开 SwiftUI 内部如何缓存每一帧几何信息。因此,「首次布局中的哪一帧被用于转场」属于基于生命周期和复现规律的推断;可确认的代码问题是选中变化确实触发了玻璃效果的移除、创建和 morph。

.principal 是根因吗

不是。它是容易暴露问题的环境,而不是根因。

text
根因:把选中项移动实现成玻璃效果的出现、消失和形变
放大因素:过大的玻璃融合距离
触发环境:Toolbar 首次挂载和 .principal 的尺寸协商

判断依据很简单:修复后的实现仍然可以放在 ToolbarItem(placement: .principal) 中,只要不再走旧的玻璃 morph 路径,问题就可以消失。

新实现:保持一个玻璃胶囊,只移动位置

新的思路是让每个分段只提供几何位置:

swift
HStack(spacing: segmentGap) {
    ForEach(tabs) { tab in
        Button(tab.title) {
            selection = tab
        }
        .matchedGeometryEffect(
            id: tab,
            in: namespace,
            isSource: true
        )
    }
}

然后在整个控件背后放置唯一、持续存在的玻璃胶囊:

swift
.background {
    Color.clear
        .glassEffect(.regular.interactive())
        .matchedGeometryEffect(
            id: selection,
            in: namespace,
            isSource: false
        )
}
.animation(
    reduceMotion ? nil : .smooth(duration: 0.2),
    value: selection
)

新的动画链路变成:

text
唯一的玻璃胶囊始终存在
        
selection 改变
        
matchedGeometryEffect 读取目标分段的位置
        
把同一个胶囊移动到目标位置

关键区别:

text
旧实现:销毁旧玻璃  创建新玻璃  执行 Liquid Glass morph
新实现:保留同一个玻璃  只改变其几何位置

新实现可以删除:

  • 每个分段上的 .regular.identity 动态切换。
  • 共享的 glassEffectID
  • 只为融合多个玻璃效果而存在的 GlassEffectContainer
  • 随分段数量增长的玻璃融合阈值。

原生 Picker 能否替代

macOS 的原生写法是:

swift
Picker("Section", selection: $selection) {
    ForEach(tabs) { tab in
        Text(tab.title).tag(tab)
    }
}
.pickerStyle(.segmented)

它可以获得系统提供的外观、可访问性和平台适配,但 SwiftUI 没有公开 API 用来保证或配置 iOS 风格的选中胶囊滑动、形变速度和曲线。macOS 的原生 segmented control 通常使用更克制的选中态切换。

选择建议:

  • 优先稳定性和平台一致性:使用原生 .segmented
  • 明确需要跨平台一致的滑动胶囊:使用「单一持久指示器 + matchedGeometryEffect」。
  • 不建议为普通选中切换重新引入多个玻璃形状之间的出现、消失和 morph。

验证方式

这类问题不能只验证「切回来以后正常」,应覆盖真正的首次挂载路径:

  1. 完全退出应用并冷启动。
  2. 默认页面首次显示后立即连续切换分段。
  3. 切换到其他页面再返回,对比两种生命周期。
  4. 测试不同数量的分段,特别是最少和最多的情况。
  5. 测试不同窗口宽度以及分栏调整后的行为。
  6. 开启「减少动态效果」并确认大幅移动动画被禁用或弱化。
  7. 除了编译、格式化和静态检查,还要确认可运行进程并人工观察真实窗口。

对于纯视觉合成问题,单元测试通常无法验证动画轨迹。更有价值的是保留一个最小复现:NavigationSplitView + ToolbarItem(.principal) + 自定义玻璃分段控件,并反复执行冷启动测试。

通用排障经验

  • 先区分「控件状态异常」与「动画表达方式异常」。数据选中值正确,不代表视图身份和转场关系正确。
  • 当 Bug 只在第一次出现、重建后消失时,优先检查首次挂载、布局协商、视图身份和 Namespace 生命周期。
  • 不要把视觉参数调小误认为修复。缩短时长或减小 spacing 可能只是在隐藏症状。
  • 如果交互语义是「一个物体移动」,实现中也应尽量保持一个物体持续存在。
  • 系统 Toolbar、导航转场和自定义玻璃动画会叠加;自定义效果越复杂,越需要冷启动和真实窗口验证。

参考资料

相关文章