Skip to content

第93天 项目 18 第二部分

今天,我们将继续视图布局技术专题的学习,探索我们可用的最强大的布局视图之一:GeometryReader。它允许我们在运行时读取视图的大小和位置,并在这些值随时间变化时持续读取它们。

我知道这听起来可能没什么特别的,但它为实现多种出色的效果打开了大门,这些效果不仅视觉效果出众,而且只需一两行代码就能实现。没错,就是一两行——一旦你理解了GeometryReader的工作原理,我真心希望你能花些时间去尝试!

正如英国诗人威廉·布莱克曾经说过的:“获取知识的真正方法是实践”,所以如果你真的想让这些知识牢记在心,就应该多动手尝试!

今天你需要学习四个主题,通过这些主题,你将了解框架、坐标空间、GeometryReader等更多内容。

  • 使用GeometryReader调整图像大小以适应屏幕
  • 理解GeometryReader内部的框架和坐标
  • 使用GeometryReader实现ScrollView效果
  • 使用visualEffect()和scrollTargetBehavior()实现ScrollView效果

如果你做出了一些有趣的效果,试着录制一段视频并分享到网上——这不仅是一种很好的督促自己的方式,也能向别人展示你的进步!

使用GeometryReader调整图像大小以适应屏幕

作者:Paul Hudson 2024年2月21日

SwiftUI允许我们创建具有精确大小的视图,如下所示:

swift
Image(.example)
    .resizable()
    .scaledToFit()
    .frame(width: 300, height: 300)

如果我们想要固定大小的视图,这些代码非常好用,但很多时候,我们希望图像能在一个或两个维度上自动缩放,以占据更多屏幕空间。也就是说,与其硬编码宽度为300,我们真正想说的是“让这个图像占据屏幕宽度的80%”。

一种选择是使用containerRelativeFrame()修饰符,我们在项目8中已经介绍过它。但SwiftUI还为我们提供了一个专门用于此工作的类型,即GeometryReader,它的功能非常强大。

我们很快会更详细地介绍GeometryReader,但现在我们将用它来完成一项任务:确保我们的图像占据其容器宽度的一定百分比。

GeometryReader和我们使用过的其他视图一样,都是一种视图,但在创建它时,会向我们传递一个GeometryProxy对象供我们使用。通过这个对象,我们可以查询环境信息:容器有多大?我们的视图位于什么位置?是否有任何安全区域插入?等等。

从原理上讲,这似乎很简单,但在实际使用中,你需要谨慎使用GeometryReader,因为它会自动扩展以占据布局中的可用空间,然后将其自身内容定位在左上角对齐。

例如,我们可以创建一个宽度为屏幕80%、高度固定为300的图像:

swift
GeometryReader { proxy in
    Image(.example)
        .resizable()
        .scaledToFit()
        .frame(width: proxy.size.width * 0.8, height: 300)
}

你甚至可以从图像中移除height参数,如下所示:

swift
GeometryReader { proxy in
    Image(.example)
        .resizable()
        .scaledToFit()
        .frame(width: proxy.size.width * 0.8)
}

我们已经向SwiftUI提供了足够的信息,它可以自动计算出高度:它知道原始宽度、目标宽度,也知道内容模式,因此能够理解图像的目标高度与目标宽度之间的比例关系。

现在,你可能想知道这与使用containerRelativeFrame()有什么不同。问题在于,containerRelativeFrame()对“容器”的定义非常精确:它可能是整个屏幕,可能是NavigationStack,也可能是ListScrollView等,但它不会将HStackVStack视为容器。

这在栈中使用视图时会产生问题,因为无法使用containerRelativeFrame()轻松地对它们进行细分。例如,下面的代码将两个视图放置在HStack中,其中一个视图被赋予固定宽度,另一个视图使用容器相对框架:

swift
HStack {
    Text("IMPORTANT")
        .frame(width: 200)
        .background(.blue)

    Image(.example)
        .resizable()
        .scaledToFit()
        .containerRelativeFrame(.horizontal) { size, axis in
            size * 0.8
        }
}

这种布局方式效果会很糟糕,因为containerRelativeFrame()会读取整个屏幕的宽度作为其尺寸,这意味着尽管屏幕中有200点的空间被文本视图占据,图像仍然会占据屏幕宽度的80%。

另一方面,使用GeometryReader则能正确地对空间进行细分:

swift
GeometryReader { proxy in
    Image(.example)
        .resizable()
        .scaledToFit()
        .frame(width: proxy.size.width * 0.8)
}

当然,这也带来了另一个问题:现在图像对齐到了GeometryReader的左上角!

幸运的是,这个问题很容易解决。如果你想在GeometryReader内部居中显示一个视图,而不是左上角对齐,可以添加第二个框架,使其填充容器的整个空间,如下所示:

swift
GeometryReader { proxy in
    Image(.example)
        .resizable()
        .scaledToFit()
        .frame(width: proxy.size.width * 0.8)
        .frame(width: proxy.size.width, height: proxy.size.height)
}

理解GeometryReader内部的框架和坐标

作者:Paul Hudson 2024年2月21日

SwiftUI的GeometryReader允许我们使用其尺寸和坐标来确定子视图的布局,它是在SwiftUI中创建一些最出色效果的关键。

在使用GeometryReader时,你应该始终牢记SwiftUI的三步布局系统:父视图为子视图提议一个尺寸,子视图根据该尺寸确定自身的尺寸,父视图再根据子视图的尺寸将其放置在合适的位置。

在最基本的用法中,GeometryReader的作用是让我们读取父视图提议的尺寸,然后使用该尺寸来操作我们的视图。例如,我们可以使用GeometryReader让文本视图无论其内容如何,都占据所有可用宽度的90%:

swift
struct ContentView: View {
    var body: some View {
        GeometryReader { proxy in
            Text("Hello, World!")
                .frame(width: proxy.size.width * 0.9)
                .background(.red)
        }
    }
}

传入的proxy参数是一个GeometryProxy对象,它包含父视图提议的尺寸、已应用的所有安全区域插入,以及一个我们稍后将介绍的用于读取框架值的方法。

GeometryReader有一个可能会让你一开始感到困惑的副作用:它返回的视图具有灵活的首选尺寸,这意味着它会根据需要扩展以占据更多空间。如果你将GeometryReader放置在VStack中,然后在其下方放置一些文本,就可以看到这种效果,如下所示:

swift
struct ContentView: View {
    var body: some View {
        VStack {
            GeometryReader { proxy in
                Text("Hello, World!")
                    .frame(width: proxy.size.width * 0.9, height: 40)
                    .background(.red)
            }

            Text("More text")
                .background(.blue)
        }
    }
}

你会看到“More text”被推到了屏幕的最底部,因为GeometryReader占据了所有剩余的空间。要更直观地看到这一点,可以为GeometryReader添加background(.green)修饰符,这样你就能清楚地看到它的大小。注意:这是“首选”尺寸,而不是“绝对”尺寸,这意味着它的大小仍会根据其父视图而灵活变化。

当需要读取视图的框架时,GeometryProxy提供了一个frame(in:)方法,而不是简单的属性。这是因为“框架”的概念包含X和Y坐标,而这些坐标在孤立情况下没有意义——你是想要视图相对于整个屏幕的绝对X和Y坐标,还是相对于其父视图的X和Y坐标呢?

SwiftUI将这些选项称为“坐标空间”,其中上述两种情况分别被称为全局空间(测量视图相对于整个屏幕的框架)和局部空间(测量视图相对于其父视图的框架)。我们还可以通过将coordinateSpace()修饰符附加到视图上来创建自定义坐标空间——该视图的所有子视图随后都可以读取相对于该坐标空间的框架。

为了演示坐标空间的工作原理,我们可以在各种栈中创建一些示例视图,将自定义坐标空间附加到最外层视图上,然后在其中一个内部视图上添加onTapGesture,以便打印出该视图在全局空间、局部空间和自定义坐标空间中的框架信息。

尝试以下代码:

swift
struct OuterView: View {
    var body: some View {
        VStack {
            Text("Top")
            InnerView()
                .background(.green)
            Text("Bottom")
        }
    }
}

struct InnerView: View {
    var body: some View {
        HStack {
            Text("Left")
            GeometryReader { proxy in
                Text("Center")
                    .background(.blue)
                    .onTapGesture {
                        print("全局中心:\(proxy.frame(in: .global).midX) x \(proxy.frame(in: .global).midY)")
                        print("自定义中心:\(proxy.frame(in: .named("Custom")).midX) x \(proxy.frame(in: .named("Custom")).midY)")
                        print("局部中心:\(proxy.frame(in: .local).midX) x \(proxy.frame(in: .local).midY)")
                    }
            }
            .background(.orange)
            Text("Right")
        }
    }
}

struct ContentView: View {
    var body: some View {
        OuterView()
            .background(.red)
            .coordinateSpace(name: "Custom")
    }
}

运行这段代码时得到的输出取决于你使用的设备,以下是我得到的结果:

  • 全局中心:191.33 x 440.60
  • 自定义中心:191.33 x 381.60
  • 局部中心:153.66 x 350.63

这些数值大多不同,希望你能全面了解这些框架的工作方式:

  • 全局中心X坐标为191,意味着geometry reader的中心距离屏幕左边缘191个点。
  • 全局中心Y坐标为440,意味着geometry reader的中心距离屏幕上边缘440个点。这个位置不在屏幕正中央,因为顶部的安全区域比底部的大。
  • 自定义中心X坐标为191,意味着geometry reader的中心距离拥有“Custom”坐标空间的视图(在我们的例子中,由于我们在ContentView中附加了该坐标空间,所以是OuterView)的左边缘191个点。这个数值与全局位置相同,因为OuterView在水平方向上从屏幕的一端延伸到另一端。
  • 自定义中心Y坐标为381,意味着geometry reader的中心距离OuterView的上边缘381个点。这个数值小于全局中心Y坐标,因为OuterView没有延伸到安全区域。
  • 局部中心X坐标为153,意味着geometry reader的中心距离其直接容器的左边缘153个点。
  • 局部中心Y坐标为350,意味着geometry reader的中心距离其直接容器的上边缘350个点。

你需要使用哪种坐标空间,取决于你想要解决的问题:

  • 想知道视图在屏幕上的位置?使用全局空间。
  • 想知道视图相对于其父视图的位置?使用局部空间。
  • 想知道视图相对于其他某个视图的位置?使用自定义空间。

使用GeometryReader实现ScrollView效果

作者:Paul Hudson 2024年2月21日

当我们使用GeometryProxyframe(in:)方法时,SwiftUI会根据我们指定的坐标空间计算视图的当前位置。然而,当视图移动时,这些数值会发生变化,SwiftUI会自动确保GeometryReader保持更新。

之前,我们使用DragGesture将宽度和高度存储为@State属性,因为这样可以根据拖动量调整其他属性,从而创建出色的效果。但是,通过GeometryReader,我们可以动态地从视图的环境中获取数值,并将其绝对位置或相对位置应用到各种修饰符中。更棒的是,你可以根据需要嵌套geometry reader,这样一个可以读取更高层级视图的几何信息,另一个可以读取视图树中更低层级视图的几何信息。

为了尝试使用GeometryReader实现一些效果,我们可以创建一个旋转的螺旋效果:在垂直滚动视图中创建50个文本视图,每个文本视图都具有无限的最大宽度,以便占据整个屏幕空间,然后根据它们自身的位置应用3D旋转效果。

首先,创建一个包含文本视图的基本ScrollView,这些文本视图具有不同的背景颜色:

swift
struct ContentView: View {
    let colors: [Color] = [.red, .green, .blue, .orange, .pink, .purple, .yellow]

    var body: some View {
        ScrollView {
            ForEach(0..<50) { index in
                GeometryReader { proxy in
                    Text("第\(index)行")
                        .font(.title)
                        .frame(maxWidth: .infinity)
                        .background(colors[index % 7])
                }
                .frame(height: 40)
            }
        }
    }
}

要应用螺旋式旋转效果,请在background()修饰符正下方添加以下rotation3DEffect()

swift
.rotation3DEffect(.degrees(proxy.frame(in: .global).minY / 5), axis: (x: 0, y: 1, z: 0))

当你再次运行代码时,会看到屏幕底部的文本视图是翻转的,屏幕中央的文本视图旋转了大约90度,而屏幕最顶部的文本视图则保持正常状态。更重要的是,当你在滚动视图中滚动时,这些文本视图都会随着你的滚动动作而旋转。

这是一个很棒的效果,但也存在一个问题:只有当视图位于屏幕最顶部时,它们才能恢复到正常的方向,这使得文本很难阅读。为了解决这个问题,我们可以应用一个更复杂的rotation3DEffect(),减去主视图高度的一半,但这意味着需要使用第二个GeometryReader来获取主视图的大小:

swift
struct ContentView: View {
    let colors: [Color] = [.red, .green, .blue, .orange, .pink, .purple, .yellow]

    var body: some View {
        GeometryReader { fullView in
            ScrollView {
                ForEach(0..<50) { index in
                    GeometryReader { proxy in
                        Text("第\(index)行")
                            .font(.title)
                            .frame(maxWidth: .infinity)
                            .background(colors[index % 7])
                            .rotation3DEffect(.degrees(proxy.frame(in: .global).minY - fullView.size.height / 2) / 5, axis: (x: 0, y: 1, z: 0))
                    }
                    .frame(height: 40)
                }
            }
        }
    }
}

添加这段代码后,视图将在屏幕中央附近恢复到正常方向,看起来效果会更好。

我们可以使用类似的技术创建CoverFlow风格的滚动矩形:

swift
struct ContentView: View {   
    var body: some View {
        ScrollView(.horizontal, showsIndicators: false) {
            HStack(spacing: 0) {
                ForEach(1..<20) { num in
                    GeometryReader { proxy in
                        Text("数字 \(num)")
                            .font(.largeTitle)
                            .padding()
                            .background(.red)
                            .rotation3DEffect(.degrees(-proxy.frame(in: .global).minX) / 8, axis: (x: 0, y: 1, z: 0))
                            .frame(width: 200, height: 200)
                    }
                    .frame(width: 200, height: 200)
                }
            }
        }
    }
}

所有这些代码都能很好地工作,我相信你会认同这些效果很有趣。但它们确实不太容易实现。幸运的是,SwiftUI提供了更好的选择——让我们接下来看看这些选择……

使用visualEffect()和scrollTargetBehavior()实现ScrollView效果

作者:Paul Hudson 2024年2月21日

之前,我们介绍了如何使用GeometryReader根据视图在屏幕上的位置创建不同的效果。这些代码都能正常工作,你肯定也会在很多应用中看到它们的身影,但SwiftUI提供了一些更便捷的替代方案,使用起来会容易得多。

首先,让我们再看一下之前的一段代码——这段代码创建了一个简单的CoverFlow风格效果,我们可以水平滑动来查看在3D空间中移动的视图:

swift
ScrollView(.horizontal, showsIndicators: false) {
    HStack(spacing: 0) {
        ForEach(1..<20) { num in
            GeometryReader { proxy in
                Text("数字 \(num)")
                    .font(.largeTitle)
                    .padding()
                    .background(.red)
                    .rotation3DEffect(.degrees(-proxy.frame(in: .global).minX) / 8, axis: (x: 0, y: 1, z: 0))
                    .frame(width: 200, height: 200)
            }
            .frame(width: 200, height: 200)
        }
    }
}

这段代码使用GeometryReader读取滚动视图中每个视图的位置,但我们需要添加一个明确的宽度和高度,以防止GeometryReader自动扩展到占据所有可用空间。

SwiftUI为我们提供了一个名为visualEffect()的替代方案,它有一个非常明确的用途和一个非常明确的限制:它允许我们应用改变视图外观的效果,实际上这意味着它不能执行任何影响视图实际布局位置或框架的操作。

这个修饰符的工作方式非常有趣:我们向它传递一个要执行的闭包,它会向我们提供要修改的内容以及该内容的GeometryProxy。我们要修改的内容就是我们的视图,但我们不能像平时那样随意应用任何修饰符——再次强调,我们不能执行任何影响视图布局位置的操作。

幸运的是,这仍然为我们留下了很多可以使用的修饰符,其中一些可能会让你感到惊讶——我们可以使用rotationEffect()rotation3DEffect(),甚至offset(),因为尽管它们会影响视图的绘制方式,但不会改变视图的框架。

因此,我们可以使用visualEffect()重写上述代码,如下所示:

swift
ScrollView(.horizontal, showsIndicators: false) {
    HStack(spacing: 0) {
        ForEach(1..<20) { num in
            Text("数字 \(num)")
                .font(.largeTitle)
                .padding()
                .background(.red)
                .frame(width: 200, height: 200)
                .visualEffect { content, proxy in
                    content
                        .rotation3DEffect(.degrees(-proxy.frame(in: .global).minX) / 8, axis: (x: 0, y: 1, z: 0))
                }

        }
    }
}

虽然代码只缩短了一点点,但这是一个比使用GeometryReader更简洁的解决方案,因为我们不再需要添加第二个frame()修饰符来防止视图占据整个屏幕——这个滚动视图可以与SwiftUI布局的其他部分共存,而不会产生问题。

现在我们的代码已经简洁了很多,但只需再添加两个修饰符,就能让这个效果变得更好。

第一个修饰符是scrollTargetLayout(),我们将它应用到HStack上。这会告诉SwiftUI,我们希望将这个HStack内部的每个视图都设为滚动目标——即在滚动过程中被视为重要的元素。

第二个修饰符是.scrollTargetBehavior(.viewAligned),我们将它应用到ScrollView上。这会告诉SwiftUI,应该让这个滚动视图在所有滚动目标之间平滑移动,而我们刚才已经将HStack内部的每个视图定义为滚动目标了。

如果你将这两个修饰符结合使用,结果会非常好:我们现在可以在文本视图之间平滑滚动,而且每当我们松开手指时,SwiftUI会自动确保有一个视图对齐到左边缘。

本站使用 VitePress 制作