GeometryReader 自 SwiftUI 诞生之初就存在,它在许多场景中扮演着重要的角色。但是,从一开始就有开发者对其持负面态度,以为应尽量防止运用。特别是在最近几回 SwiftUI 更新中新增了一些能够替代 GeometryReader 的 API 后,这种观念进一步加强。本文将对 GeometryReader 的“常见问题”进行剖析,看看它是否真的如此不堪,以及那些被批判为“不符预期”的表现,是否其实是因为开发者的“预期”本身存在问题。

原文宣布在我的博客wwww.fatbobman.com 。 因为技术文章需求不断的迭代,当时耗费了不少的精力在不同的平台之间来保持文章的更新。故从 2024 年起,新的文章将只发布在我的博客上

对 GeometryReader 的一些批判

开发者对 GeometryReader 的批判首要集中在以下两个观念:

  • GeometryReader 会损坏布局:这种观念以为,因为 GeometryReader 会占用悉数可用空间,因而或许会损坏全体的布局规划。
  • GeometryReader 无法获取正确的几许信息:这种观念以为,在某些状况下,GeometryReader 无法获取精确的几许信息,或许在视图未发生改动(视觉上)的状况下,其获取的信息或许不安稳。

此外,有些观念以为:

  • 过度依靠 GeometryReader 会导致视图布局变得死板,失去了 SwiftUI 的灵活性优势。
  • GeometryReader 打破了 SwiftUI 声明式编程的理念,使得需求直接操作视图结构,更挨近指令式编程。
  • GeometryReader 更新几许信息时资源耗费较大,或许会引发不必要的重复核算和视图重建。
  • 运用 GeometryReader 需求编写很多的辅助代码来核算和调整结构,这会增加编码量,降低代码的可读性和可维护性。

这些批判并非全无道理,其间相当一部分现已通过新的 API 在 SwiftUI 版别更新后得到了改善或解决。但是,关于 GeometryReader 损坏布局、无法获取正确信息的观念,一般是因为开发者对 GeometryReader 的了解不足和运用不当引起的。接下来,咱们将针对这些观念进行剖析和探讨。

在本文宣布之前,我发起了一个 投票 询问咱们对 GeometryReader 的观点,从成果来看,对其持负面印象的份额较高。

GeometryReader :好东西仍是坏东西?

GeometryReader 是什么

在咱们深入探讨上述负面观念之前,咱们首要需求了解 GeometryReader 的功用以及规划这个 API 的原因。

这是苹果官方文档关于 GeometryReader 的界说:

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

一个容器视图,依据其本身巨细和坐标空间界说其内容。

严格来讲,我并不彻底赞同上述描绘。这并非因为存在事实上的过错,而是这种表述或许会引起用户的误解。实际上,”GeometryReader” 这个名字更契合其规划目标:一个几许信息读取器

确切来说,GeometryReader 的作用首要是获取父视图的巨细、frame 等几许信息。官方文档中的“界说其内容( defines its content )”这一表述简略让人误以为 GeometryReader 的首要功用是自动影响子视图,或许说其获取的几许信息首要用于子视图,但实际上,它更应被视为一个获取几许信息的东西。这些信息是否运用到子视图彻底取决于开发者。

假如一开始就把它规划成下面这样的方法,也许就能防止对它的误解和滥用。

@State private proxy: GeometryProxy
Text("Hello world")
    .geometryReader(proxy: $proxy)

假如改为基于 View Extension 的方法,咱们能够将 geometryReader 的作用描绘为:它供给了其所运用的视图的巨细、frame 等几许信息,是视图获取本身几许信息的有效手法。这种描绘能够有效地防止几许信息首要运用于子视图的误解。

关于为什么不选用 Extension 的方法,规划者或许考虑了以下两个要素:

  • 通过 Binding 的方法向上传递信息,并不是当时官方 SwiftUI API 的首要规划方法。
  • 将几许信息传递到上层视图,或许会引起不必要的视图更新。而向下传递信息,能够确保更新只在 GeometryReader 的闭包中进行。

GeometryReader 是布局容器吗,它的布局逻辑是什么?

是,可是其行为有些与众不同。

当时,GeometryReader 以一个布局容器的方法存在,其布局规则如下:

  • 它是一个多视图容器,其默许堆叠规则相似于 ZStack
  • 将父视图的主张尺度( Proposed size )作为本身的需求尺度( Required Size )回来给父视图
  • 将父视图的主张尺度作为本身的主张尺度传递给子视图
  • 将子视图的原点(0,0)置于 GeometryReader 的原点方位
  • 其理想尺度( Ideal Size)为 (10,10)

假如不考虑获取几许信息的功用,一个 GeometryReader 的布局行为与以下的代码很挨近。

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

大致等于:

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

简略来说,GeometryReader 会占用父视图供给的一切空间,并将一切子视图的原点与容器的原点对齐(即放置在左上角)。这种十分规的布局逻辑是我不推荐将其直接用作布局容器的原因之一。

GeometryReader 不支持对齐指南的调整,因而上面的描绘运用了原点。

但是,这并不意味着不能将 GeometryReader 作为视图容器运用。在某些状况下,它或许比其他容器更适合。例如:

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

在制作 Path 时,GeometryReader 供给的信息(尺度,原点)正好满意咱们的需求。因而,关于需求充溢空间且选用原点对齐方法的子视图,GeometryReader 作为布局容器十分适宜。

GeometryReader 将彻底无视子视图提出的需求尺度,在这一点上,它的处理方法与 overlay 和 background 对待子视图的方法共同。

在上面临 GeometryReader 的布局规则描绘中,咱们指出了它的 ideal size 是(10,10 )。或许有些读者不太了解其含义,ideal size 是指当父视图给出的主张尺度为 nil 时(未指定模式),子视图回来的需求尺度。假如对 GeometryReader 的这个设定不了解,或许会在某些场景下,开发者会感觉 GeometryReader 并没有如预期那样充溢一切空间。

例如,执行以下代码,你只能得到一个高度为 10 的矩形:

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

GeometryReader :好东西仍是坏东西?

这是因为 ScrollView 在向子视图提交主张尺度时,其处理逻辑与大多数布局容器不同。在非翻滚方向上,ScrollView 会向子视图供给该维度上的悉数可用尺度。而在翻滚方向上,它向子视图供给的主张尺度为 nil。因为 GeometryReader 的 ideal size 为 (10,10),因而,在翻滚方向上,其回来给 ScrollView 的需求尺度即为 10。在这点上,GeometryReader 的行为与 Rectangle 共同。因而,或许会有开发者以为 GeometryReader 并没有依照预期充溢悉数的可用空间。但实际上,它的显现成果是彻底正确的,这便是正确的布局成果。

因而,在这种状况下,一般咱们只会运用具有明确值维度的尺度( 主张尺度有值 ),并以此为来核算另一维度的尺度。

例如,假如咱们想在 ScrollView 中以 16:9 的份额显现图片(即便图片本身的份额与此不符):

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

GeometryReader :好东西仍是坏东西?

首要,咱们运用 GeometryReader 获取 ScrollView 供给的主张宽度,并依据这个宽度核算出所需的高度。然后,通过 frame 调整 GeometryReader 向 ScrollView 提交的需求尺度高度。这样,咱们就能得到期望的显现成果。

在这个演示中,Image 正好满意了之前提出的充溢空间且原点对齐的要求,因而直接运用 GeometryReader 作为布局容器是彻底没有问题的。

本章节包含了许多关于 SwiftUI 的尺度和布局的常识。假如你对此还不太了解,主张你继续阅览以下文章:SwiftUI 布局 —— 尺度(上)SwiftUI 布局 —— 尺度(下)SwiftUI 布局 —— 对齐

为什么 GeometryReader 无法获取正确的信息

一些开发者或许会抱怨,GeometryReader 无法获取正确的尺度(总是回来 0,0),或许回来异常的尺度(比如负数),导致布局过错。

为此,咱们首要需求了解 SwiftUI 的布局原理。

SwiftUI 的布局是一个洽谈进程。父视图向子视图供给主张尺度,子视图回来需求尺度。父视图是否依据子视图的需求尺度来放置子视图,以及子视图是否依据父视图给出的主张尺度来回来需求尺度,彻底取决于父视图和子视图的预设规则。比如,关于 VStack ,它会在笔直维度上,分别向子视图发送具有明确值的主张尺度、未指定的主张尺度、最大主张尺度以及最小主张尺度的信息,并取得子视图在不同主张尺度下的需求尺度。VStack 会结合视图的优先级,它的父视图给其的主张尺度,在摆放时对子视图提出终究的主张尺度。

在一些复杂的布局场景中,或许在某些设备或体系版别中,布局或许需求通过几轮的洽谈才干取得终究安稳的成果,尤其是当视图需求依靠 GeometryReader 供给的几许信息来从头确定自己的方位和尺度时。因而,这或许导致 GeometryReader 在取得安稳成果之前,不断向子视图发送新的几许信息。假如咱们仍然运用上文代码中的信息获取方法,那么就无法取得变更后的信息:

.onAppear {
    width = proxy.size.width
}

因而,正确的获取信息的方法为:

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

这样,即便数据发生改动,咱们也能继续更新数据。一些开发者表明,在屏幕方向发生改动时,无法获取新的信息,原因也是如此。task(id:) 一起涵盖了 onAppearonChange 的场景,是最可靠的数据获取方法。

别的,在某些状况下,GeometryReader 有或许回来尺度为负数的数据。假如直接将这些负数数据传递给 frame,就或许会呈现布局异常(在调试状况下,Xcode 会用紫色的提示正告开发者)。因而,为了进一步防止这种极端状况,能够在传递数据时,将不契合要求的数据过滤掉。

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

因为 GeometryProxy 并不契合 Equatable 协议,一起也为了尽或许的削减因信息更新而导致的视图从头评价,开发者应该只传递当时需求的信息。

至于如何传递获取的几许信息(例如上文中运用的 @State 或是通过 PreferenceKey),则取决于开发者的编程习气和场景需求。

一般,咱们会在 overlaybackground 中运用 GeometryReader + Color.clear 来获取并传递几许信息。这既确保了信息获取的准确性(尺度、方位与要获取的视图彻底共同),也不会在视觉上形成额外的影响。

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

留意:假如想通过 PreferenceKey 传递信息,最好在 overlay 中进行。因为在某些体系版别中,从 background 传递的数据无法被 onPreferenceChange 获取到。

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
}

在某些状况下,通过 GeometryReader 获取的值或许会使你堕入无尽的循环,然后导致视图的不安稳和功能损失,例如:

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

严格来说,这个问题的本源在于 Text。因为默许字体的宽度不是固定的,所以无法形成一个安稳的尺度洽谈成果。解决方法很简略,能够通过添加.monospaced()或运用固定宽度的字体。

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

字符颤动的示例来自于 SwiftUI-Lab 的 Safely Updating The View State 这篇文章。

GeometryReader 的功能问题

只要了解 GeometryReader 获取几许信息的机遇,就能了解其对功能的影响。作为一个视图,GeometryReader 只能在被评价、布局和烘托后,才干将获取的数据传递给闭包中的代码。这意味着,假如咱们需求利用其供给的信息进行布局调整,必须先完成至少一轮的评价、布局和烘托进程,然后才干获取数据,并依据这些数据从头调整布局。这个进程将导致视图被屡次从头评价和布局。

因为前期的 SwiftUI 缺少了 LazyGrid 等布局容器,开发者只能通过 GeometryReader 来完成各种自界说布局。当视图数量较多时,这将会导致严重的功能问题。

自从 SwiftUI 弥补了一些之前缺失的布局容器后,GeometryReader 对功能的大规模影响现已有所减轻。特别是在答应自界说契合 Layout 协议的布局容器后,上述的问题已基本解决。与 GeometryReader 不同,满意 layout 协议的布局容器能够在布局阶段就获取到父视图的主张尺度和一切子视图的需求尺度。这样能够防止因为重复传递几许数据导致的很多视图的重复更新。

但是,这并不意味着在运用 GeometryReader 时没有需求留意的事项。为了进一步削减 GeometryReader 对功能的影响,咱们需求留意以下两点:

  • 只让少数视图受到几许信息改动的影响
  • 仅传递所需的几许信息

以上两点契合咱们优化 SwiftUI 视图功能的一贯准则,即操控状况改动的影响规模。

用 SwiftUI 的方法进行布局

因为对 GeometryReader 的负面观点,一些开发者会测验寻觅其他方法以防止运用它。不过,咱们是否想过,其实在很多场景中,GeometryReader 本来就并非最优解。与其说防止运用,到不如说用愈加 SwiftUI 的方法来进行布局。

GeometryReader 常用于需求限定份额的场景,例如让视图占有可用空间的 25% 宽度,或许像上文中依据给定的高宽比来核算高度。在处理相似需求时,咱们应优先选用更契合 SwiftUI 的思维方法来考虑布局计划,而非依靠某个特定的几许数据进行核算。

例如,咱们能够运用以下代码来满意上文中限定图片高宽比的需求:

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

通过 aspectRatio 创立一个契合高宽比的基底视图,然后将 Image 放置在 overlay 中。此外,因为 overlay 支持设置对齐指南,比起 GeometryReader,它能够更方便地调整图片的对齐方位。

别的,GeometryReader 经常用于依照必定份额分配两个视图的空间。关于这类需求,也能够通过其他手法处理(以下代码完成了宽度的 40% 和 60% 的分配,高度则取决于最高的子视图):

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

GeometryReader :好东西仍是坏东西?

不过,单纯就依照必定份额将两个视图置于特定空间( 无视子视图尺度 )中这个需求而言,GeometryReader 至今仍是最优解之一。

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

GeometryReader :好东西仍是坏东西?

本章并不是在暗示开发者应防止运用 GeometryReader,而是在提示开发者,SwiftUI 还有许多其他的布局手法。

请阅览 用 SwiftUI 的方法进行布局在 SwiftUI 中完成视图居中的若干种方法 两篇文章,以了解面临同一个需求,SwiftUI 有多种布局手法。

里子和体面:不同的尺度数据

在 SwiftUI 中,有一些 modifier 是在布局之后,在烘托层面临视图进行的调整。在 SwiftUI 布局 —— 尺度( 下 ) 一文中,咱们探讨过有关尺度的“里子和体面”的问题。比如下面的代码:

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

在布局时,Rectangle 的需求尺度为 100 x 100,但在烘托阶段,通过scaleEffect的处理,终究将呈现一个 220 x 220 的矩形。因为scaleEffect是在布局之后调整的,因而即便创立一个契合 Layout 协议的布局容器,也无法获悉其烘托尺度。在这种状况下,GeometryReader 就发挥了它的作用。

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)

GeometryProxysize 属性回来的是视图的布局尺度,而通过 frame.size 回来的则是终究的烘托尺度。

visualEffect:无需运用 GeometryReader 也能获取几许信息

考虑到开发者经常需求获取局部视图的 GeometryProxy,而不断地封装 GeometryReader 又显得过于繁琐,因而在 WWDC 2023 中,苹果为 SwiftUI 添加了一个新的 modifier:visualEffect

visualEffect 答应开发者在不损坏当时布局的状况下(不改动其先人和后代)直接在闭包中运用视图的 GeometryProxy,并对视图运用某些特定的 modifier。

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

visualEffect 仅答应契合 VisualEffect 协议的 modifier 被运用于闭包当中,以确保安全和作用。简略来说,SwiftUI 让只作用于“体面”( 烘托层面)的 modifier 契合了 VisualEffect 协议,禁止在闭包中运用一切能对布局形成影响的 modifier( 例如:frame、padding 等)。

咱们能够通过以下代码,创立一个visualEffect的粗糙仿制版别(没有限制可运用的 modifier 类型):

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

visualEffect 进行比较:

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

GeometryReader :好东西仍是坏东西?

总结

随着 SwiftUI 功用的不断完善,直接运用 GeometryReader 的状况或许会越来越少。但是,毫无疑问,GeometryReader 仍是 SwiftUI 中一个重要的东西。开发者需求正确地将其运用于适当的场景。

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

原文宣布在我的博客wwww.fatbobman.com

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