在 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 视图会将方位和巨细的改动沿视图层级向下传递,以至于只要绘制内容的视图(称为叶子视图)将当时动画应用到它们的框架矩形上。但是在某些状况下,这种聚合行为可能会导致不希望的成果;刺进一个几许组可以纠正这种状况。几许组充当父视图与其子视图之间的屏障,迫使方位和巨细的值由父视图解析和动画化,然后再传递给每个子视图。

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() 的实践作用,咱们需求创立一个因父视图的几许特点发生改动而导致的非预期的子视图出现,以便弄清楚文档中的“在某些状况下”究竟指的是什么状况。

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 方位,创立了一个黄色的圆形。

运行后,咱们将取得如下的作用:

SwiftUI geometryGroup() 攻略:从原理到实践

成果似乎是对的,又不完全正确。当 toggle 状况发生改动时,赤色矩形按照预期以动画方法进行了缩放。黄色圆形终究也出现在赤色矩形扩大后的左上角方位。但是,这是否符合咱们的预期作用呢?

我认为,对于许多开发者来说,他们更希望黄色的圆形可以像赤色矩形相同,经过动画的方法从原始的 topLeading 方位移动到扩大后的 topLeading 方位。

那么,geometryGroup() 可以帮助完成这个作用吗?

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)
    }
}

SwiftUI geometryGroup() 攻略:从原理到实践

问题处理了。那么是什么导致了出现了非预期的成果,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() 保证子视图在一致的几许信息环境中,以完成预期的布局作用。它为子视图供给了一个接连的几许信息更新过程。

总结上述条件后,咱们就很简略创立出其它会导致意外行为的代码。

例如:

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)
            }
        }
    }
}

SwiftUI geometryGroup() 攻略:从原理到实践

frame (父视图)的尺度发生改动后,GeometryReader 所取得的尺度也会相应地改动。新创立的 Grid 单元格会直接放置在尺度改动后的方位。因而会导致出现非预期的成果。

在添加了 geometryGroup() 后。

DynamicGridTest1()
    .geometryGroup()
    .frame(width: size.width, height: size.height)

SwiftUI geometryGroup() 攻略:从原理到实践

新创立的单元格将依据父视图持续传递进来的几许信息,取得正确的布局方位。

老版别 SwiftUI 该怎么办

只要咱们能损坏 “Some Cases” 的构成条件,就能防止相似的非预期行为。

  • 在父视图几许信息发生改动时,不要一起在子视图中创立新的内容
  • 如果必定要在改动时为子视图增加新元素( 比方上面依据 GeometryReader 的示例,可以将所需元素在父视图改动前便让其存在,经过透明度来调整其可见性 )

例如,在较低版别的 SwiftUI 中,咱们可以修正上面的示例一的代码,以防止出现非预期的行为:


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
            }
    }
}

示例二修正起来略微费事一些,但原理也是相同的:

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()
    }
}

小插曲

在写这篇文章时,我创立了一个愈加简略的代码,成果也出现了非预期的出现。

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)
        }
    }
}

SwiftUI geometryGroup() 攻略:从原理到实践

这个问题是从 iOS 16 开始出现的,而在更低版别中,文字的方位是正常的。从代码来看,Text(toggle ? "Hello" : "World") 应该可以坚持一个视图标识的稳定(也就是不应该创立新的 Text)。但是,依据实践作用剖析,很可能与 iOS 16 引入的 contentTransition 修饰器有关。在 SwiftUI 内部,将上述的三元运算符调整为相似以下代码的形式:

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记事本】