SwiftUI geometryGroup() Guide: From Theory to Practice

fatbobman ( 东坡肘子)
The Swift Cooperative
11 min readNov 29, 2023
Photo by Gabriel Gusmao on Unsplash

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

In light of the fact that my blog, Fatbobman’s Blog, now offers all articles in English, starting from April 1, 2024, I will no longer continue updating articles on Medium. You are cordially invited to visit my blog for more content.

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.

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”.

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:

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?

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

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:

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

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

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

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

Don’t miss out on the latest updates and excellent articles about Swift, SwiftUI, Core Data, and SwiftData. Subscribe to Fatbobman’s Swift Weekly and receive weekly insights and valuable content directly to your inbox.

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:

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:

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.

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.

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

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 contentTransitionmodifier introduced in iOS 16. Internally in SwiftUI, the ternary operator mentioned above is adjusted to a form similar to the following code:

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.

If you found this article helpful or enjoyed reading it, consider making a donation to support my writing. Your contribution will help me continue creating valuable content for you.
Donate via Patreon, Buy Me aCoffee or PayPal.

Want to Connect?

@fatbobman on Twitter.

--

--

fatbobman ( 东坡肘子)
The Swift Cooperative

Blogger | Sharing articles at https://fatbobman.com | Publisher of a weekly newsletter on Swift at http://https://weekly.fatbobman.com