为 SwiftUI 组件的显示与隐藏添加动画

了解如何使用 .transition() 修饰器为组件的显隐添加动画,并理解为什么使用 if 控制组件的显隐时,在某些场景下无法正常工作?

为 SwiftUI 组件的显示与隐藏添加动画

在之前的文章中,我们已经详细介绍过 SwiftUI 中的 .transition().animation() 修饰器,以及 withAnimation() 函数的作用与使用场景:

为 SwiftUI 组件添加过渡动画效果
了解如何通过 withAnimation、transition 和 Animation 等 SwiftUI 工具,为视图的状态变化和组件的显示与隐藏添加过渡动画,让用户界面更加自然和动感。

这篇文章我们重点介绍,使用 .transition() 为组件的显隐添加动画时,一些常见错误以及最佳实践方案。


以下代码是一个常见的场景:

  • 当满足特定条件时,会显示 ReimbursementProgressView 视图(报销状态)
  • 不满足条件,则不显示报销状态组件
// MARK: 报销进度
if isReimbursementEnabled
    && record.reimbursementStatus != .none
{
    ReimbursementProgressView(record: record)
}

默认情况下,组件的显示和隐藏会直接发生,没有过渡动画:

0:00
/0:03

演示使用「MONO 记账」,在 App Store 搜索即可找到。

避免将 .animation() 添加到 If 语句内部

可以使用 .transition() 搭配 .animation() 为组件的显隐添加动画:

// MARK: 报销进度
if isReimbursementEnabled
    && record.reimbursementStatus != .none
{
    ReimbursementProgressView(record: record)
        .animation(
            .default, value: record.reimbursementStatus
        )
        .transition(.opacity)
}

但实际运行,你会发现代码并未按照预期工作。原因是 .animation() 修饰器在视图层级中的应用位置不正确。

0:00
/0:03

.animation() 修饰器必须应用到包含条件逻辑的父视图上,它告诉 SwiftUI 为父视图中的视图层级变化设置动画。也就是说,.animation() 必须放到 if 语句外部

使用 Stack 包裹条件内容

一种简单的解决方案是,用 Stack 或其他容器包裹条件内容,然后将 .animation 修饰器应用到父视图。

例如,使用 VStack 包裹:

VStack(spacing: 0) {
    if isReimbursementEnabled
        && record.reimbursementStatus != .none
    {
        ReimbursementProgressView(record: record)

            .transition(.opacity)
    }
}.animation(
    .spring(duration: 1), value: record.reimbursementStatus
)

这样修改之后,动画就可以正常生效了:

0:00
/0:05

Stack 组件占用格外空间问题

使用 Stack 组件时,即使某些子组件未显示,也可能导致布局中占用额外的空间。这通常是因为 Stack 会为其子组件之间的间距(spacing)预留空间,即使这些子组件没有内容:

一种常见场景是 Stack 嵌套,例如:

VStack(spacing:16){
    VStack(spacing: 0) {
        if isReimbursementEnabled
            && record.reimbursementStatus != .none
        {
            ReimbursementProgressView(record: record)

                .transition(.opacity)
        }
    }.animation(.spring(duration: 1), value: record.reimbursementStatus)
}

在这个例子中,即使内部的 VStack 没有显示任何内容,由于外部 VStack 设置了 spacing: 16,父布局仍会为组件之间的间距预留空间。

要解决这个问题,需要避免无意义的嵌套。如果内部 VStack 没有特殊作用,可以直接将 if 条件内的内容移到外层 VStack,减少多余的布局层级。

这样优化之后,就不会有占据空间的问题:

VStack(spacing:16){
    if isReimbursementEnabled
        && record.reimbursementStatus != .none
    {
        ReimbursementProgressView(record: record)

            .transition(.opacity)
    }
}
.animation(.spring(duration: 1), value: record.reimbursementStatus)

最佳实践

将 .animation() 放在视图的最外层

.transition() 和 .animation() 的使用位置会直接影响动画的表现和效果,建议遵循以下原则:

  • .transition() 应放在具体的组件上。这样可以明确地为某个组件的显示和隐藏定义过渡效果。
  • .animation() 应放在页面的最外层。将 .animation() 修饰器应用到整个页面的最外层,可以确保所有同层级组件的动画同步进行,从而避免不必要的 UI 冲突。

如果在页面的某个子组件中使用 .animation(),会为该组件的显示或隐藏添加动画效果,但同层级的其他组件却不会同步动画。这样可能会导致视觉上的不一致。例如:

VStack(spacing:16){
    // 其他组件

    VStack(spacing: 0) {
        if isReimbursementEnabled
            && record.reimbursementStatus != .none
        {
            ReimbursementProgressView(record: record)
                .transition(.opacity)
        }
    }.animation(.spring(duration: 1), value: record.reimbursementStatus)

    // 其他组件
}
0:00
/0:04

在上述代码中,虽然 ReimbursementProgressView 的显示和隐藏具有动画效果,但当该组件消失时,其它同层级组件的变化不会同步动画,可能导致突兀的 UI 显示效果。

如果将 .animation() 添加到该页面的最外层,当一个组件显示或消失时,其他组件也会自动应用动画。在视觉效果上会更流畅:

VStack(spacing:16){
    // 其他组件

    VStack(spacing: 0) {
        if isReimbursementEnabled
            && record.reimbursementStatus != .none
        {
            ReimbursementProgressView(record: record)
                .transition(.opacity)
        }
    }

    // 其他组件
}
.animation(.spring(duration: 1), value: record.reimbursementStatus)
0:00
/0:05