肘子的 Swift 记事本

GeometryReader: Blessing or Curse?

Published on

Get weekly handpicked updates on Swift and SwiftUI!

GeometryReader has been present since the birth of SwiftUI, playing a crucial role in many scenarios. However, from the very beginning, some developers have held a negative attitude towards it, believing it should be avoided as much as possible. Especially after the recent updates of SwiftUI added some APIs that can replace GeometryReader, this view has further strengthened. This article will dissect the “common problems” of GeometryReader to see if it is really so unbearable, and whether those performances criticized as “not meeting expectations” are actually due to problems with the developers’ “expectations” themselves.

Some Criticisms of GeometryReader

The main criticisms developers have towards GeometryReader focus on the following two points:

  • GeometryReader destroys the layout: This view holds that, since GeometryReader will occupy all available space, it might ruin the overall layout design.
  • GeometryReader can’t get the correct geometry information: This view holds that, in certain situations, GeometryReader may not be able to obtain precise geometry information, or the information it obtains may be unstable when the view doesn’t change visually.

In addition, some people believe that:

  • Over-reliance on GeometryReader can make the view layout rigid, losing the flexibility advantage of SwiftUI.
  • GeometryReader breaks the concept of SwiftUI’s declarative programming, requiring direct operation of the view framework, which is closer to imperative programming.
  • GeometryReader consumes a lot of resources when updating geometric information, which may cause unnecessary repetitive calculations and view reconstruction.
  • Using GeometryReader requires a lot of auxiliary code to calculate and adjust the framework, which increases the amount of coding, and reduces the readability and maintainability of the code.

These criticisms are not entirely unreasonable, and a considerable part of them has been improved or resolved through the new API in the SwiftUI version update. However, the view that GeometryReader disrupts the layout and cannot obtain correct information is usually caused by the developer’s insufficient understanding and improper use of GeometryReader. Next, we will analyze and discuss these views.

Before publishing this article, I posted a tweet asking for everyone’s opinion on GeometryReader. Based on the responses, there seems to be a higher proportion of negative impressions towards it.

Difficult to use, try to avoid 28.8%, Very useful 9.3%, Love-hate relationship 34.7%, What is GeometryReader? 27.1%

image-20231104120125753.png

What is GeometryReader?

Before we delve into the negative views mentioned above, we first need to understand the function of GeometryReader and the reason for designing this API.

This is Apple’s official documentation for GeometryReader:

A container view that defines its content as a function of its own size and coordinate space.

Strictly speaking, I don’t fully agree with the above description. This is not because there are factual errors, but because this statement may lead to user misunderstanding. In fact, the name “GeometryReader” is more in line with its design goal: a geometry information reader.

To be precise, the main function of GeometryReader is to obtain the size, frame and other geometric information of the parent view. The phrase “defines its content” in the official document is likely to make people mistakenly think that the main function of GeometryReader is to actively influence the subview, or that the geometric information it obtains is mainly used for the subview. However, it should be seen as a tool for obtaining geometric information. Whether this information should be applied to the subview is entirely up to the developer.

Perhaps if it had been designed in the following way from the beginning, misunderstandings and misuse of it could have been avoided.

Swift
@State private proxy:GeometryProxy

Text("Hello world")
    .geometryReaer(proxy:$proxy)

If we change to a View Extension-based approach, the role of geometryReader can be described as: It provides the size, frame, and other geometric information of the view it is applied to. It is an effective way for the view to get its own geometric information. This description can effectively prevent the misunderstanding that geometric information is mainly applied to subviews.

As for why not to adopt the Extension approach, the designer might have considered the following two factors:

  • Passing information upward through Binding is not the primary design method of the current official SwiftUI API.
  • Passing geometric information to the upper view may cause unnecessary view updates. Passing information downward can ensure that updates only occur in the GeometryReader’s closure.

Is GeometryReader a layout container? What is its layout logic?

Yes, but its behavior is somewhat different.

Currently, GeometryReader exists as a layout container, with the following layout rules:

  • It is a multi-view container, with its default stacking rules similar to ZStack
  • It returns the parent view’s proposed size as its own required size to the parent view
  • It passes the parent view’s proposed size as its own proposed size to the child view
  • It places the child view’s origin (0,0) at the origin of the GeometryReader
  • Its ideal size (Ideal Size) is (10,10)

If you ignore the function to get geometric information, the layout behavior of a GeometryReader is very close to the following code.

Swift
GeometryReader { _ in
  Rectangle().frame(width:50, height:50)
  Text("abc").foregroundStyle(.white)
}

It is basically equivalent to the following code:

Swift
ZStack(alignment: .topLeading) {
    Rectangle().frame(width: 50, height: 50)
    Text("abc").foregroundStyle(.white)
}
.frame(
    idealWidth: 10,
    maxWidth: .infinity,
    idealHeight: 10,
    maxHeight: .infinity,
    alignment: .topLeading
)

Simply put, GeometryReader will occupy all the space provided by the parent view, and aligns the origin of all child views with the origin of the container (i.e., placed in the upper left corner). This unconventional layout logic is one of the reasons why I do not recommend using it directly as a layout container.

GeometryReader does not support the adjustment of alignment guides, so the above description uses the origin.

However, this does not mean that GeometryReader cannot be used as a view container. In some cases, it may be more suitable than other containers. For example:

Swift
struct PathView: View {
    var body: some View {
        GeometryReader { proxy in
            Path { path in
                let width = proxy.size.width
                let height = proxy.size.height

                path.move(to: CGPoint(x: width / 2, y: 0))
                path.addLine(to: CGPoint(x: width, y: height))
                path.addLine(to: CGPoint(x: 0, y: height))
                path.closeSubpath()
            }
            .fill(.orange)
        }
    }
}

When drawing a Path, the information provided by GeometryReader (dimensions, origin) perfectly meets our needs. Therefore, for subviews that need to fill the space and align with the origin, GeometryReader is very suitable as a layout container.

GeometryReader completely ignores the request size of the subview. In this respect, it treats subviews in the same way as overlay and background.

In the above description of the layout rules of GeometryReader, we pointed out that its ideal size is (10,10 ). Some readers may not understand its meaning. The ideal size refers to the request size returned by the subview when the parent view’s proposed size is nil (unspecified mode). If you don’t understand this setting of GeometryReader, in some scenarios, developers may feel that GeometryReader does not fill all the space as expected.

For example, if you execute the following code, you can only get a rectangle with a height of 10:

Swift
struct GeometryReaderInScrollView: View {
    var body: some View {
        ScrollView {
            GeometryReader { _ in
                Rectangle().foregroundStyle(.orange)
            }
        }
    }
}

https://cdn.fatbobman.com/image-20231030192917562.png

This is because ScrollView’s logic in proposing sizes to child views is different from most layout containers. In the non-scrolling direction, ScrollView provides all available sizes on that dimension to the child view. However, in the scrolling direction, the proposed size it provides to the child view is nil. Since the ideal size of the GeometryReader is (10,10), the required size it returns to ScrollView in the scroll direction is 10. At this point, the behavior of GeometryReader is consistent with Rectangle. Therefore, some developers might think that GeometryReader does not fill up all the available space as expected. But in fact, its display result is completely correct, this is the correct layout result.

Therefore, in this situation, we usually only use the size with definite value dimensions (explicit size mode, proposed size has values) to calculate the size of another dimension.

For example, if we want to display pictures in ScrollView at a ratio of 16:9 (even if the ratio of the picture itself does not conform to this):

Swift
struct GeometryReaderInScrollView: View {
    var body: some View {
        ScrollView {
            ImageContainer(imageName: "pic")
        }
    }
}

struct ImageContainer: View {
    let imageName: String
    @State private var width: CGFloat = .zero
    var body: some View {
        GeometryReader { proxy in
            Image("pic")
                .resizable()
                .aspectRatio(contentMode: .fill)
                .onAppear {
                    width = proxy.size.width
                }
        }
        .frame(height: width / 1.77)
        .clipped()
    }
}

https://cdn.fatbobman.com/image-20231030200535483.png

First, we use GeometryReader to get the proposed width provided by ScrollView and calculate the required height based on this width. Then, we adjust the required size height that GeometryReader submits to ScrollView through frame. In this way, we can get the desired display result.

In this demonstration, the Image perfectly meets the previous requirement of filling the space and aligning the origin, so it is completely fine to use GeometryReader as a layout container directly.

This section includes a lot of knowledge about the size and layout of SwiftUI. If you are not very familiar with this, I suggest you continue to read the following articles: SwiftUI Layout: The Mystery of Size, SwiftUI Layout: The Mystery of Size, Alignment in SwiftUI: Everything You Need to Know.

Why can’t GeometryReader get the correct information?

Some developers might complain that GeometryReader cannot obtain the correct size (always returns 0,0), or returns an abnormal size (such as negative numbers), leading to layout errors.

For this, we first need to understand the layout principles of SwiftUI.

SwiftUI’s layout is a negotiation process. The parent view provides a proposed size to the child view, and the child view returns a required size. Whether the parent view places the child view based on its required size, or the child view returns a required size based on the proposed size provided by the parent view, depends entirely on the predefined rules of the parent and child views. For example, for VStack, it sends information about explicit, unspecified, maximum, and minimum proposed sizes in the vertical dimension to its child views and obtains the required sizes of the child views at different proposed sizes. VStack combines the priority of the views and the proposed size given by its parent view to provide a final proposed size to the child views during layout.

In some complex layout scenarios, or in certain devices or system versions, the layout may require multiple rounds of negotiation to achieve a stable result, especially when views need to rely on the geometry information provided by GeometryReader to re-determine their position and size. Therefore, this may cause GeometryReader to continuously send new geometry information to the child views until a stable result is obtained. If we still use the information retrieval method in the previous code, we won’t be able to obtain the updated information.

Swift
.onAppear {
    width = proxy.size.width
}

Therefore, the correct way to obtain information is:

Swift
.task(id: proxy.size.width) {
    width = proxy.size.width
}

In this way, even if the data changes, we can keep updating the data. Some developers have reported that they are unable to retrieve new information when the screen orientation changes. This is why. task(id:) covers both onAppear and onChange scenarios and is the most reliable way to get data.

Also, in some cases, GeometryReader may return negative dimensions. If these negative numbers are directly passed to frame, it may result in layout abnormalities (Xcode will warn developers in purple during debugging). Therefore, to further avoid this extreme situation, you can filter out the data that does not meet the requirements when passing the data.

Swift
.task(id: proxy.size.width) {
    width = max(proxy.size.width, 0)
}

Since GeometryProxy does not conform to the Equatable protocol, and in order to minimize the re-evaluation of views caused by information updates, developers should only pass the currently needed information.

As for how to pass the obtained geometric information (for example, the @State used in the above or through PreferenceKey), it depends on the developer’s programming habits and scene requirements.

Usually, we use GeometryReader + Color.clear in overlay or background to obtain and pass geometric information. This not only ensures the accuracy of information acquisition (size, position is completely consistent with the view to be obtained), but also does not cause additional visual impact.

Swift
extension View {
    func getWidth(_ width: Binding<CGFloat>) -> some View {
        modifier(GetWidthModifier(width: width))
    }
}

struct GetWidthModifier: ViewModifier {
    @Binding var width: CGFloat
    func body(content: Content) -> some View {
        content
            .background(
                GeometryReader { proxy in
                    let proxyWidth = proxy.size.width
                    Color.clear
                        .task(id: proxy.size.width) {
                            $width.wrappedValue = max(proxyWidth, 0)
                        }
                }
            )
    }
}

Note: If you want to pass information through PreferenceKey, it’s best to do it in overlay. Because in some system versions, data passed from background cannot be obtained by onPreferenceChange.

Swift
struct GetInfoByPreferenceKey: View {
    var body: some View {
        ScrollView {
            Text("Hello world")
                .overlay(
                    GeometryReader { proxy in
                        Color.clear
                            .preference(key: MinYKey.self, value: proxy.frame(in: .global).minY)
                    }
                )
        }
        .onPreferenceChange(MinYKey.self) { value in
            print(value)
        }
    }
}

struct MinYKey: PreferenceKey {
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = nextValue()
    }
    static var defaultValue: CGFloat = .zero
}

In some cases, the values obtained through GeometryReader may cause you to fall into an endless loop, resulting in unstable views and performance losses, for example:

Swift
struct GetSize: View {
    @State var width: CGFloat = .zero
    var body: some View {
        VStack {
            Text("Width = \(width)")
                .getWidth($width)
        }
    }
}

https://cdn.fatbobman.com/unstable-Text-by-GeometryReader_2023-10-30_22.16.35.2023-10-30%2022_17_21.gif

Strictly speaking, the root of this problem lies in Text. Since the default font width is not fixed, it can’t form a stable dimension negotiation result. The solution is simple, it can be solved by adding .monospaced() or using a fixed-width font.

Swift
Text("Width = \(width)")
    .monospaced()
    .getWidth($width)

The example of character jitter comes from the article Safely Updating The View State on SwiftUI-Lab.

Performance issues with GeometryReader

Understanding when the GeometryReader gets geometric information can help you understand its impact on performance. As a view, GeometryReader can only pass the acquired data to the code in the closure after it has been evaluated, laid out, and rendered. This means that if we need to use the information it provides to adjust the layout, we must first complete at least one round of evaluation, layout, and rendering processes, then acquire the data, and adjust the layout based on these data. This process will cause the view to be re-evaluated and laid out multiple times.

Due to the lack of layout containers such as LazyGrid in early SwiftUI, developers could only implement various custom layouts through GeometryReader. When the number of views is large, this will cause serious performance issues.

Since SwiftUI has supplemented some previously missing layout containers, the large-scale impact of GeometryReader on performance has been alleviated. Especially after allowing custom layout containers that comply with the Layout protocol, the above problems have been basically solved. Unlike GeometryReader, layout containers that meet the layout protocol can obtain the proposed size of the parent view and the required size of all subviews at the layout stage. This can avoid a large number of view updates caused by the repeated transmission of geometric data.

However, this does not mean that there are no precautions when using GeometryReader. In order to further reduce the impact of GeometryReader on performance, we need to pay attention to the following two points:

  • Only let a few views be affected by the changes in geometric information
  • Only pass the required geometric information

The above two points are in line with our consistent principle of optimizing the performance of SwiftUI views, that is, controlling the scope of the impact of state changes.

Layout in the SwiftUI way

Due to the negative views on GeometryReader, some developers try to find other ways to avoid using it. However, have you ever thought that in many scenarios, GeometryReader is not the best solution. Instead of avoiding it, why not use a more SwiftUI way to layout.

GeometryReader is often used in scenarios where the ratio needs to be limited, such as making the view occupy 25% of the available space, or calculating the height based on the given aspect ratio as mentioned above. When dealing with similar requirements, we should prioritize thinking about layout solutions in a way that aligns with SwiftUI’s mindset, rather than relying on specific geometric data for calculations.

For example, we can use the following code to meet the requirement of limiting the aspect ratio of the image mentioned above:

Swift
struct ImageContainer2: View {
    let imageName: String
    var body: some View {
        Color.clear
            .aspectRatio(1.77, contentMode: .fill)
            .overlay(alignment: .topLeading) {
                Image(imageName)
                    .resizable()
                    .aspectRatio(contentMode: .fill)
            }
            .clipped()
    }
}

struct GeometryReaderInScrollView: View {
    var body: some View {
        ScrollView {
            ImageContainer2(imageName: "pic")
        }
    }
}

Create a base view that meets the aspect ratio through aspectRatio, and then place the Image in the overlay. In addition, because overlay supports setting alignment guides, it can adjust the alignment of the image more conveniently than GeometryReader.

In addition, GeometryReader is often used to allocate space for two views in a certain proportion. For such requirements, there are also other methods to handle it. The following code implements the allocation of 40% and 60% width (the height depends on the tallest subview):

Swift
struct FortyPercent: View {
    var body: some View {
        Grid(horizontalSpacing: 0, verticalSpacing: 0) {
            // placeholder
            GridRow {
                ForEach(0 ..< 5) { _ in
                    Color.clear.frame(maxHeight: 0)
                }
            }
            GridRow {
                Image("pic")
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .gridCellColumns(2)
                Text("Fatbobman's Swift Weekly").font(.title)
                    .gridCellColumns(3)
            }
        }
        .border(.blue)
        .padding()
    }
}

https://cdn.fatbobman.com/image-20231031103955150.png

However, in terms of simply placing two views in a specific space according to a certain proportion (ignoring the size of the subviews), GeometryReader is still one of the best solutions (the overall height of the Grid solution is determined by the subviews).

Swift
struct RatioSplitHStack<L, R>: View where L: View, R: View {
    let leftWidthRatio: CGFloat
    let leftContent: L
    let rightContent: R
    init(leftWidthRatio: CGFloat, @ViewBuilder leftContent: @escaping () -> L, @ViewBuilder rightContent: @escaping () -> R) {
        self.leftWidthRatio = leftWidthRatio
        self.leftContent = leftContent()
        self.rightContent = rightContent()
    }

    var body: some View {
        GeometryReader { proxy in
            HStack(spacing: 0) {
                Color.clear
                    .frame(width: proxy.size.width * leftWidthRatio)
                    .overlay(leftContent)
                Color.clear
                    .overlay(rightContent)
            }
        }
    }
}

struct SplitHStackDemo: View {
    var body: some View {
        RatioSplitHStack(leftWidthRatio: 0.25) {
            Rectangle().fill(.red)
        } rightContent: {
            Color.clear
                .overlay(
                    Text("Hello World")
                )
        }
        .border(.blue)
        .frame(width: 300, height: 60)
    }
}

https://cdn.fatbobman.com/image-20231104145027741.png

This chapter is not implying that developers should avoid using GeometryReader, but is reminding developers that SwiftUI has many other layout methods.

Please read the articles Layout in SwiftUI Way and Several Ways to Center Views in SwiftUI to understand that SwiftUI has multiple layout methods for the same requirement.

Appearance and Essence: The size may not be the same

In SwiftUI, some modifiers are adjustments made to the view at the rendering level after layout. In the article SwiftUI Layout — Cracking the Size Code, we have discussed the issue of the “Appearance and Essence” of dimensions. For example, the following code:

Swift
struct SizeView: View {
    var body: some View {
        Rectangle()
            .fill(Color.orange.gradient)
            .frame(width: 100, height: 100)
            .scaleEffect(2.2)
    }
}

During layout, the required size for Rectangle is 100 x 100, but at the rendering stage, after being processed by scaleEffect, it will eventually present a 220 x 220 rectangle. Since scaleEffect is adjusted after the layout, even if you create a layout container that complies with the Layout protocol, you cannot know its rendering size. In this case, GeometryReader plays its role.

Swift
struct SizeView: View {
    var body: some View {
        Rectangle()
            .fill(Color.orange.gradient)
            .frame(width: 100, height: 100)
            .printViewSize()
            .scaleEffect(2.2)
    }
}

extension View {
    func printViewSize() -> some View {
        background(
            GeometryReader { proxy in
                let layoutSize = proxy.size
                let renderSize = proxy.frame(in: .global).size
                Color.clear
                    .task(id: layoutSize) {
                        print("Layout Size:", layoutSize)
                    }
                    .task(id: renderSize) {
                        print("Render Size:", renderSize)
                    }
            }
        )
    }
}

// OUTPUT:
Layout Size: (100.0, 100.0)
Render Size: (220.0, 220.0)

The size property of GeometryProxy returns the layout size of the view, whereas the frame.size returns the rendered size of the view.

visualEffect: You can obtain geometric information without using GeometryReader

Considering that developers often need to get GeometryProxy of local views, and it’s too cumbersome to continuously encapsulate GeometryReader, therefore, in WWDC 2023, Apple added a new modifier to SwiftUI: visualEffect.

visualEffect allows developers to directly use the GeometryProxy of the view in the closure without disrupting the current layout (without changing its ancestors and descendants) and apply some specific modifier to the view.

Swift
var body: some View {
    ContentRow()
        .visualEffect { content, geometryProxy in
            content.offset(x: geometryProxy.frame(in: .global).origin.y)
        }
}

visualEffect only allows modifiers that comply with the VisualEffect protocol to be used within the closure, ensuring safety and effect. Simply put, SwiftUI makes modifiers that only act on the “appearance” (rendering level) comply with the VisualEffect protocol and prohibits the use of all modifiers that can affect the layout (for example: frame, padding, etc.) in closures.

We can create a rough imitation version of visualEffect (without limiting the types of modifiers that can be used) with the following code:

Swift
public extension View {
    func myVisualEffect(@ViewBuilder _ effect: @escaping @Sendable (AnyView, GeometryProxy) -> some View) -> some View {
        modifier(MyVisualEffect(effect: effect))
    }
}

public struct MyVisualEffect<Output: View>: ViewModifier {
    private let effect: (AnyView, GeometryProxy) -> Output
    public init(effect: @escaping (AnyView, GeometryProxy) -> Output) {
        self.effect = effect
    }

    public func body(content: Content) -> some View {
        content
            .modifier(GeometryProxyWrapper())
            .hidden()
            .overlayPreferenceValue(ProxyKey.self) { proxy in
                if let proxy {
                    effect(AnyView(content), proxy)
                }
            }
    }
}

struct GeometryProxyWrapper: ViewModifier {
    func body(content: Content) -> some View {
        content
            .overlay(
                GeometryReader { proxy in
                    Color.clear
                        .preference(key: ProxyKey.self, value: proxy)
                }
            )
    }
}

struct ProxyKey: PreferenceKey {
    static var defaultValue: GeometryProxy?
    static func reduce(value: inout GeometryProxy?, nextValue: () -> GeometryProxy?) {
        value = nextValue()
    }
}

Compare with visualEffect:

Swift
struct EffectTest: View {
    var body: some View {
        HStack {
            Text("Hello")
                .font(.title)
                .border(.gray)

            Text("Hello")
                .font(.title)
                .visualEffect { content, proxy in
                    content
                        .offset(x: proxy.size.width / 2.0, y: proxy.size.height / 2.0)
                        .scaleEffect(0.5)
                }
                .border(.gray)
                .foregroundStyle(.red)

            Text("Hello")
                .font(.title)
                .myVisualEffect { content, proxy in
                    content
                        .offset(x: proxy.size.width / 2.0, y: proxy.size.height / 2.0)
                        .scaleEffect(0.5)
                }
                .border(.gray)
                .foregroundStyle(.red)
        }
    }
}

https://cdn.fatbobman.com/image-20231031145420378.png

Summary

With the continuous improvement of SwiftUI features, the direct use of GeometryReader may become less and less. However, without a doubt, GeometryReader is still an important tool in SwiftUI. Developers need to correctly apply it to the appropriate scene.

I'm really looking forward to hearing your thoughts! Please Leave Your Comments Below to share your views and insights.

Fatbobman(东坡肘子)

I'm passionate about life and sharing knowledge. My blog focuses on Swift, SwiftUI, Core Data, and Swift Data. Follow my social media for the latest updates.

You can support me in the following ways