SwiftUI geometryGroup() 指南:从原理到实践

在 WWDC 2023 中,苹果为 SwiftUI 添加了一个新的修饰器:geometryGroup()。它可以解决一些之前无法处理或处理起来比较困难的动画异常。本文将介绍 geometryGroup() 的概念、用法,以及在低版本 SwiftUI 中,在不使用 geometryGroup() 的情况下如何处理异常。

原文发表在我的博客 fatbobman.com 。 由于技术文章需要不断的迭代,当前耗费了不少的精力在不同的平台之间来维持文章的更新。故从 2024 年起,新的文章将只发布在我的博客上

geometryGroup() 的官方定义

对于 geometryGroup(),苹果提供了一份详细但不易理解的 文档 解释:

geometryGroup()

Isolates the geometry (e.g. position and size) of the view from its parent view.

By default SwiftUI views push position and size changes down through the view hierarchy, so that only views that draw something (known as leaf views) apply the current animation to their frame rectangle. However in some cases this coalescing behavior can give undesirable results; inserting a geometry group can correct that. A group acts as a barrier between the parent view and its subviews, forcing the position and size values to be resolved and animated by the parent, before being passed down to each subview.
geometryGroup()

将视图的几何属性(例如位置和大小)与其父视图隔离开来。

默认情况下,SwiftUI 视图会将位置和大小的变化沿视图层级向下传递,以至于只有绘制内容的视图(称为叶子视图)将当前动画应用到它们的框架矩形上。然而在某些情况下,这种聚合行为可能会导致不希望的结果;插入一个几何组可以纠正这种情况。几何组充当父视图与其子视图之间的屏障,迫使位置和大小的值由父视图解析和动画化,然后再传递给每个子视图。

swift 复制代码
VStack {
    ForEach(items) { item in
        ItemView(item: item)
            .geometryGroup()
    }
}

不知道你怎么看这个文档和附带的代码片段,至少在我初次接触时,很难通过它来理解 geometryGroup() 的真正用途。因为文档遗漏了最主要的部分:"然而在某些情况下,这种聚合行为可能会导致不希望的结果( However in some cases this coalescing behavior can give undesirable results )"。

那么,具体在哪些情况下会发生这种情况呢?

In Some Cases

为了更好地理解 geometryGroup() 的实际作用,我们需要创建一个因父视图的几何属性发生变化而导致的非预期的子视图呈现,以便弄清楚文档中的"在某些情况下"到底指的是什么情况。

swift 复制代码
struct ContentView: View {
    @State var toggle = false
    var size: CGSize {
        toggle ? .init(width: 300, height: 300) : .init(width: 200, height: 200)
    }

    var body: some View {
        VStack {
            Button("Toggle") {
                toggle.toggle()
            }
            TopLeadingTest1(show: toggle)
                .frame(width: size.width, height: size.height)
                .animation(.smooth(duration: 1), value: toggle)
        }
    }
}

struct TopLeadingTest1: View {
    let show: Bool
    var body: some View {
        Color.red
            .overlay(alignment: .topLeading) {
                if show {
                    Circle()
                        .fill(.yellow)
                        .frame(width: 20, height: 20)
                }
            }
    }
}

这是一段非常简单的代码,当 toggle 的状态改变时,TopLeadingTest1 的尺寸会发生变化。同时(toggle 状态改变时),我们还在 TopLeadingTest1( 红色矩形)的 topLeading 位置,创建了一个黄色的圆形。

运行后,我们将获得如下的效果:

结果似乎是对的,又不完全正确。当 toggle 状态发生改变时,红色矩形按照预期以动画方式进行了缩放。黄色圆形最终也出现在红色矩形放大后的左上角位置。然而,这是否符合我们的预期效果呢?

我认为,对于许多开发者来说,他们更希望黄色的圆形能够像红色矩形一样,通过动画的方式从原始的 topLeading 位置移动到放大后的 topLeading 位置。

那么,geometryGroup() 能够帮助实现这个效果吗?

swift 复制代码
var body: some View {
    VStack {
        Button("Toggle") {
            toggle.toggle()
        }
        TopLeadingTest1(show: toggle)
            .geometryGroup()  // add geometryGroup between TopLeadingTest and frame
            .frame(width: size.width, height: size.height)
            .animation(.smooth(duration: 1), value: toggle)
    }
}

问题解决了。那么是什么导致了出现了非预期的结果,geometryGroup() 又是如何纠正了这一问题呢?

出现异常的原因

我们可以通过分析 toggle 状态发生改变后,每个视图的行为来查找原因。

  • toggle 状态发生变化,由 false 变为 true。
  • .animation(.smooth(duration: 1), value: toggle) 这行代码创建了一个包含本次状态变化对应动画信息(.smooth(duration: 1))的 transaction,并将其沿着视图分支向下传播。
  • frame 的设置进行了调整,尺寸从 200 x 200 变为 300 x 300。由于 transaction 包含了动画信息,因此这次改变是有动画效果的。
  • TopLeadingTest1 根据从父视图 frame 接收到的建议尺寸变化,根据其默认布局形态(充满全部可用空间)改变了自身的大小。
  • Shape(红色矩形)符合 Animatable 协议,在调整尺寸时,查看当前 transaction 并获取对应的动画信息(动画曲线函数),因此这次改变也是有动画效果的。
  • overlay 中,由于 show 的变化,将创建一个新的视图(if show)即黄色圆形。
  • 当 SwiftUI 在 overlay 中布局黄色圆形时(topLeading),此时红色矩形的尺寸(尽管仍在以动画的形式逐渐扩大)已经是调整后的 300 x 300。
  • SwiftUI 将黄色圆形放置在放大后的红色矩形的 topLeading 位置。
  • 黄色圆形的默认过渡效果是 opacity,在创建黄色圆形时,SwiftUI 检查当前 transaction 并获取当前的动画信息。
  • 黄色圆形以渐变的方式出现在 300 x 300 的 topLeading 位置。

上述每个过程的执行都严格且完美地遵循了 SwiftUI 的布局和动画规则。唯一让我们不满意的是,在创建黄色圆形时(布局它的位置时),它被放置在放大后的红色矩形的 topLeading 位置上。

这是因为在 SwiftUI 中,每个可动画视图根据 transaction 中的信息自行决定自身的动画行为。在创建黄色圆形时,它无法获得状态改变前的 topLeading 位置信息,因此无法满足我们的要求。

本节涉及到 transaction 以及 SwiftUI 动画的一些内部运行机制。您可以阅读 掌握 Transaction,实现 SwiftUI 动画的精准控制SwiftUI 的动画机制了解更多的内容

geometryGroup() 的作用

那么为什么添加了 geometryGroup() 后,问题就解决了呢?根据文档的描述:迫使位置和大小的值由父视图解析和动画化,然后再传递给每个子视图( forcing the position and size values to be resolved and animated by the parent, before being passed down to each subview)。

以上面的示例来说,在添加了 geometryGroup() 后,父视图( frame )并不是一次性的将自身几何属性的改变状态传递给了子视图,而是将这些变化动画化了后,持续传递给子视图的。

当创建黄色圆形时,即使 show 状态已改变,父视图(frame)仍会持续传递其当前的几何信息( 动画中)。这让黄色圆形能够获得正确的布局位置。因此,最终产生的结果就是,黄色圆形从我们预期的 200 x 200 的 topLeading 处,以动画的形式移动到了 300 x 300 的 topLeading 位置。

由此可见,geometryGroup() 中 Group 的含义为父视图统一处理并动画化其几何属性变化后,再传递给子视图。子视图不再各自独立处理上述信息。

出现 "Some Cases" 的条件

至此,我们就可以将官方文档中 "In some cases" 的条件补充完整:

  • 父视图的几何属性发生改变,且改变是动画化的
  • 在父视图改变的同时( 几何属性的变化 ),子视图因此变化( 几何信息或导致几何信息变化的状态变化)而创建了新的视图

换句话说,当子视图在父视图的几何属性发生变化时,如果子视图在自身中创建了新的视图,由于新视图无法获取到变化之前的几何信息,因此会导致布局出现意料之外的情况。

geometryGroup() 确保子视图在统一的几何信息环境中,以实现预期的布局效果。它为子视图提供了一个连续的几何信息更新过程。

总结上述条件后,我们就很容易创建出其它会导致意外行为的代码。

例如:

swift 复制代码
struct DynamicGridTest1: View {
    var body: some View {
        GeometryReader { proxy in
            let count = Int(proxy.size.width / 50)
            Grid(horizontalSpacing: 0, verticalSpacing: 0) {
                ForEach(0 ..< count, id: \.self) { _ in
                    GridRow {
                        ForEach(0 ..< count, id: \.self) { _ in
                            Rectangle()
                                .fill(.blue)
                                .border(.yellow, width: 2)
                                .frame(width: 50, height: 50)
                        }
                    }
                }
            }
        }
        .clipped()
    }
}

struct ContentView: View {
    @State var toggle = false
    var size: CGSize {
        toggle ? .init(width: 300, height: 300) : .init(width: 200, height: 200)
    }

    var body: some View {
        VStack {
            Button("Toggle") {
                toggle.toggle()
            }
            ZStack(alignment: .bottomTrailing) {
                Color.green.frame(width: 300, height: 300)
                DynamicGridTest1()
                    .frame(width: size.width, height: size.height)
                    .animation(.smooth(duration: 1), value: toggle)
            }
        }
    }
}

frame (父视图)的尺寸发生变化后,GeometryReader 所获得的尺寸也会相应地改变。新创建的 Grid 单元格会直接放置在尺寸变化后的位置。因此会导致出现非预期的结果。

在添加了 geometryGroup() 后。

swift 复制代码
DynamicGridTest1()
    .geometryGroup()
    .frame(width: size.width, height: size.height)

新创建的单元格将根据父视图持续传递进来的几何信息,获得正确的布局位置。

老版本 SwiftUI 该怎么办

只要我们能破坏 "Some Cases" 的构成条件,就能避免类似的非预期行为。

  • 在父视图几何信息发生变化时,不要同时在子视图中创建新的内容
  • 如果一定要在变化时为子视图增加新元素( 比如上面基于 GeometryReader 的示例,可以将所需元素在父视图变化前便让其存在,通过透明度来调整其可见性 )

例如,在较低版本的 SwiftUI 中,我们可以修改上面的示例一的代码,以避免出现非预期的行为:

swift 复制代码
struct TopLeadingTest2: View {
    let show: Bool
    var body: some View {
        Color.red
            .overlay(alignment: .topLeading) {
                Circle()
                    .fill(.yellow)
                    .frame(width: 20, height: 20)
                    .opacity(show ? 1 : 0)  // change visibilty by opacity
            }
    }
}

示例二修改起来稍微麻烦一些,但原理也是一样的:

swift 复制代码
struct DynamicGridTest2: View {
    private let max = 20
    var body: some View {
        Color.clear
            .overlay(alignment: .topLeading) {
                GeometryReader { proxy in
                    let count = Int(proxy.size.width / 50)
                    Grid(horizontalSpacing: 0, verticalSpacing: 0) {
                        ForEach(0 ..< max, id: \.self) { r in
                            GridRow {
                                ForEach(0 ..< max, id: \.self) { c in
                                    Rectangle()
                                        .fill(.blue)
                                        .border(.yellow, width: 2)
                                        .frame(width: 50, height: 50)
                                        .opacity((r >= count || c >= count) ? 0 : 1)
                                }
                            }
                        }
                    }
                }
            }
            .clipped()
    }
}

小插曲

在写这篇文章时,我创建了一个更加简单的代码,结果也出现了非预期的呈现。

swift 复制代码
struct TextTest1: View {
    let toggle: Bool
    var body: some View {
        Text(toggle ? "Hello" : "World")
    }
}

struct ContentView: View {
    @State var toggle = false
    var size: CGSize {
        toggle ? .init(width: 300, height: 300) : .init(width: 200, height: 200)
    }

    var body: some View {
        VStack {
            Button("Toggle") {
                toggle.toggle()
            }
            TextTest1(toggle: toggle)
                .frame(width: size.width, height: size.height)
                .animation(.smooth(duration: 1), value: toggle)
        }
    }
}

这个问题是从 iOS 16 开始出现的,而在更低版本中,文字的位置是正常的。从代码来看,Text(toggle ? "Hello" : "World") 应该能够保持一个视图标识的稳定(也就是不应该创建新的 Text)。然而,根据实际效果分析,很可能与 iOS 16 引入的 contentTransition 修饰器有关。在 SwiftUI 内部,将上述的三元运算符调整为类似以下代码的形式:

swift 复制代码
if toggle {
    Text("Hello")
} else {
    Text("World")
}

在 iOS 17 中,我们可以通过 geometryGroup() 来避免上述问题。对于 iOS 16,在文字变化较多且较大的情况下,应尽量避免在父视图几何信息调整时切换文字内容。

总结

在本文中,我们深入探讨了 SwiftUI 中 geometryGroup() 的重要性和实用性。通过实际的示例,我们看到了 geometryGroup() 在处理复杂的视图层级和同步动画时的强大功能。它不仅提供了对动画和布局的精细控制,而且确保了视图之间的一致性和流畅性。在实际开发中,尤其是面对复杂动画和布局的场景时,理解并正确使用 geometryGroup() 是至关重要的。

geometryGroup() 为我们提供了一个避免在个别情况下出现布局异常的能力。这是 SwiftUI 开发团队在完成了基本的布局功能后,腾出精力,进一步改善细节的一个表现。同时,我们也希望苹果能够在官方文档中能够提供更加清晰示例,以提高开发者学习新 API 的效率。

订阅我的电子周报 Fatbobman's Swift Weekly,你将每周及时获取有关 Swift、SwiftUI、CoreData 和 SwiftData 的最新文章和资讯。

原文发表在我的博客 fatbobman.com

欢迎订阅我的公众号:【肘子的Swift记事本】

相关推荐
一丝晨光2 天前
继承、Lambda、Objective-C和Swift
开发语言·macos·ios·objective-c·swift·继承·lambda
KWMax2 天前
RxSwift系列(二)操作符
ios·swift·rxswift
Mamong2 天前
Swift并发笔记
开发语言·ios·swift
小溪彼岸3 天前
【iOS小组件】小组件尺寸及类型适配
swiftui·swift
Adam.com3 天前
#Swift :回调地狱 的解决 —— 通过 task/await 来替代 nested mutiple trailing closure 来进行 回调的解耦
开发语言·swift
Anakki3 天前
【Swift官方文档】7.Swift集合类型
运维·服务器·swift
KeithTsui4 天前
集合论(ZFC)之 联合公理(Axiom of Union)注解
开发语言·其他·算法·binder·swift
東三城4 天前
【ios】---swift开发从入门到放弃
ios·swift
文件夹__iOS7 天前
[SwiftUI 开发] @dynamicCallable 与 callAsFunction:将类型实例作为函数调用
ios·swiftui·swift