SwiftUI geometryGroup() Guide: From Theory to Practice

Published on

Get weekly handpicked updates on Swift and SwiftUI!

At WWDC 2023, Apple introduced a new modifier for SwiftUI called geometryGroup(). It addresses some animation anomalies that were previously difficult to handle or couldn’t be handled at all. This article will introduce the concept and usage of geometryGroup(), as well as how to handle anomalies in older versions of SwiftUI without using geometryGroup().

Official Definition of geometryGroup()

For geometryGroup(), Apple provides a detailed but not easily understandable documentation explanation:

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.

Swift
VStack {
    ForEach(items) { item in
        ItemView(item: item)
            .geometryGroup()
    }
}

For me, it was difficult to understand the true purpose of geometryGroup() when I first encountered this document. This is because the document omits the most important part: “However, in some cases, this coalescing behavior can give undesirable results.”

So, in which specific situations does this happen?

In Some Cases

In order to better understand the actual function of geometryGroup(), we need to create an unexpected rendering of a subview caused by changes in the geometric properties of the parent view, in order to clarify what the documentation means by “in some cases”.

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

This is a very simple code, when the state of toggle changes, the size of TopLeadingTest1 will change. At the same time (when the toggle state changes), we also create a yellow circle at the topLeading position of TopLeadingTest1 (the red rectangle).

After running, we will get the following effect:

https://cdn.fatbobman.com/geometryGroup-demo1_2023-11-27_08.17.48.2023-11-27%2008_18_55.gif

The result seems to be correct, yet not entirely accurate. When the toggle state changes, the red rectangle scales as expected with an animation. The yellow circle also ends up in the top-left corner of the enlarged red rectangle. However, does this align with our desired effect?

I believe that for many developers, they would prefer the yellow circle to move from its original topLeading position to the enlarged topLeading position, just like the red rectangle, using an animation.

So, can geometryGroup() help achieve this effect?

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

https://cdn.fatbobman.com/geometryGroup-demo2_2023-11-27_08.25.22.2023-11-27%2008_29_25.gif

The problem has been resolved. So, what caused the unexpected result, and how did geometryGroup() fix this issue?

The reason for the occurrence of the exception.

We can find the cause by analyzing the behavior of each view after the toggle state changes.

  • toggle status changes from false to true.
  • The line of code .animation(.smooth(duration: 1), value: toggle) creates a transaction that contains animation information (.smooth(duration: 1)) for the current state change and propagates it down the view hierarchy.
  • The frame is adjusted, changing the size from 200 x 200 to 300 x 300. Since the transaction includes animation information, this change has an animated effect.
  • TopLeadingTest1 changes its size based on the proposed size change received from the parent view frame, according to its default layout behavior (filling all available space).
  • The Shape (red rectangle) conforms to the Animatable protocol, so when adjusting the size, it checks the current transaction and retrieves the corresponding animation information (animation curve function), resulting in an animated effect.
  • In the overlay, a new view (yellow circle) is created based on the change in show.
  • When SwiftUI lays out the yellow circle in the overlay (at topLeading), the size of the red rectangle (still gradually expanding with animation) has already been adjusted to 300 x 300.
  • SwiftUI places the yellow circle at the topLeading position of the enlarged red rectangle.
  • The default transition effect for the yellow circle is opacity, so when creating the yellow circle, SwiftUI checks the current transaction and retrieves the current animation information.
  • The yellow circle appears in a gradient manner at the topLeading position of the 300 x 300 size.

Each step described above was executed strictly and perfectly following SwiftUI’s layout and animation rules. The only thing that dissatisfied us was that, when creating the yellow circle (placing it in its position), it was placed at the topLeading position of the enlarged red rectangle.

This is because in SwiftUI, each animatable view determines its own animation behavior based on the information in the transaction. When creating the yellow circle, it is unable to obtain the topLeading position information before the state change, thus unable to meet our requirements.

This section covers the internal workings of transactions and SwiftUI animations. You can read The Secret to Flawless SwiftUI Animations: A Deep Dive into Transactions and Demystifying SwiftUI Animation: A Comprehensive Guide.

The purpose of geometryGroup()

So why does adding geometryGroup() solve the problem? According to the documentation: it forces the position and size values to be resolved and animated by the parent, before being passed down to each subview.

In the example above, when geometryGroup() is added, the parent view (frame) doesn’t immediately pass its changes in geometry properties to the subviews. Instead, it animates these changes and continuously passes them down to the subviews.

When creating the yellow circle, even if the show state has changed, the parent view (frame) continues to pass its current geometry information (during the animation). This allows the yellow circle to obtain the correct layout position. Therefore, the end result is that the yellow circle moves from the expected topLeading position of 200 x 200 to the topLeading position of 300 x 300 in an animated manner.

It can be seen that the Group in geometryGroup() represents the parent view that will handle and animate the changes in its geometry properties uniformly, and then pass them on to the child views. The child views no longer handle the above information separately.

Conditions for “Some Cases”

So far, we have completed the conditions for “In some cases” in the official documentation:

  • The geometric properties of the parent view have changed, and the changes are animated.
  • While the parent view is changing (in terms of geometric properties), the child view also changes (either in terms of geometric information or due to changes in the state that cause geometric information to change), resulting in the creation of a new view.

In other words, when the geometric properties of the parent view change and the child view creates a new view within itself, unexpected layout situations occur because the new view cannot access the geometric information before the changes.

geometryGroup() ensures that the child views operate in a consistent geometric environment, achieving the expected layout effects. It provides a continuous process of updating geometric information for the child views.

After summarizing the above conditions, it becomes easy to create other code that can lead to unexpected behavior.

For example:

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

https://cdn.fatbobman.com/geometryGroup-demo3_2023-11-27_09.48.31.2023-11-27%2009_49_04.gif

When the size of the frame (parent view) changes, the size obtained by GeometryReader will also change accordingly. The newly created grid cells will be placed directly in the updated position after the size change. This can lead to unexpected results.

After adding geometryGroup(),

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

https://cdn.fatbobman.com/geometryGroup-demo4_2023-11-27_09.52.07.2023-11-27%2009_53_12.gif

The newly created cells will obtain the correct layout position based on the geometry information continuously passed from the parent view.

What to do with old versions of SwiftUI

As long as we can break the condition for the formation of “Some Cases”, we can avoid similar unexpected behaviors.

  • When the geometric information of the parent view changes, do not create new content in the child view at the same time.
  • If it is necessary to add new elements to the child view during the changes (for example, in the above example based on GeometryReader), you can make the required elements exist before the parent view changes and adjust their visibility through opacity.

For example, in older versions of SwiftUI, we can modify the code in the first example above to avoid unexpected behavior:

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

The second example is a bit more complicated to modify, but the principle is the same:

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

Update:transformEffect(.identity)

On Reddit, Ne1nLives provided me with a new solution: In older versions of SwiftUI, you can use transformEffect(.identity) to achieve a similar effect as geometryGroup.

Swift
DynamicGridTest1()
    .transformEffect(.identity) // keep the original geometry information
    .frame(width: size.width, height: size.height)

transformEffect(.identity) actually applies a “no transformation” transformation to the view. This does not change the visual appearance of the view, but it may affect its behavior within the view hierarchy.

For example, in the provided example code, when applying .transformEffect(.identity), its effect is to keep the layout and position of the child view unchanged at the first moment of the state change. This essentially provides the newly created view (created during the state change) with the original geometry information of the parent view. Since the child view still animates based on the information in the transaction, we will see a similar rendering effect as with geometryGroup.

Although .transformEffect(.identity) can simulate certain effects of geometryGroup() in some specific scenarios, it is not a comprehensive replacement.

One key functionality of geometryGroup is to create a boundary that isolates the geometry attributes of the view, such as position and size, between the parent view and the child view. This means that with geometryGroup(), the layout and animation of the child view can be handled independently of the parent view.

Therefore, geometryGroup() is suitable for handling more complex and specific scenarios involving layout isolation and animation coordination, while .transformEffect(.identity) is more of a strategy to maintain the stability of child view layout in specific cases.

Little Incident

While writing this article, I created a simpler code, and unexpectedly encountered some issues.

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

https://cdn.fatbobman.com/geometryGroup-demo5_2023-11-27_10.10.26.2023-11-27%2010_11_10.gif

This issue started occurring from iOS 16, while in lower versions, the position of the text is normal. From the code perspective, Text(toggle ? "Hello" : "World") should be able to maintain a stable view identity (i.e., it should not create a new Text). However, based on the analysis of the actual effect, it is likely related to the contentTransition modifier introduced in iOS 16. Internally in SwiftUI, the ternary operator mentioned above is adjusted to a form similar to the following code:

Swift
if toggle {
    Text("Hello")
} else {
    Text("World")
}

In iOS 17, we can use geometryGroup() to avoid the aforementioned issue. For iOS 16, in cases where there are frequent and significant changes in text, it is advisable to avoid switching text content when adjusting the geometry information of the parent view.

Summary

In this article, we have delved into the importance and practicality of geometryGroup() in SwiftUI. Through practical examples, we have seen the powerful capabilities of geometryGroup() in handling complex view hierarchies and synchronized animations. It not only provides fine control over animations and layouts, but also ensures consistency and smoothness between views. Understanding and correctly using geometryGroup() is crucial, especially in real-world development scenarios involving complex animations and layouts.

geometryGroup() provides us with the ability to avoid layout anomalies in individual cases. This is a manifestation of the SwiftUI development team’s focus on improving details after completing the basic layout functionality. At the same time, we also hope that Apple can provide clearer examples in the official documentation to improve the efficiency of developers learning new APIs.

Weekly Swift & SwiftUI insights, delivered every Monday night. Join developers worldwide.
Easy unsubscribe, zero spam guaranteed