SwiftUI

让GeometryReader来解决吧

多数情况下,SwiftUI 会做施展它的布局魔法 ,生活对于我们来说是如此美妙 。不过,也有很多时候,我们需要对自定义的视图拥有更多的掌控。在这些时候,我们有几种工具 可以利用。第一个需要我们去探索的就是 GeometryReader

父级视图想要什么?

当你给自定义视图编码时,你通常不需要担心它周围的环境和它的尺寸。举个例子,假设你需要创建一个绘制矩形的视图,你只要绘制一个 Rectangle 就好了。它会以父级视图指定的尺寸和位置被绘制。

在下面的例子中,我们有一个尺寸为 150×100 的 VStack,它首先放置了一个 Text 在顶部,然后剩余的空间被 MyRectangle() 占据。这个视图是如此顺从,它准确地绘制了我们给它的蓝色。一个像素不多,一个像素不少:

struct ContentView : View {
    var body: some View {
        
        VStack {
            
            Text("Hello There!")
            MyRectangle()
            
        }.frame(width: 150, height: 100).border(Color.black)

    }
}

struct MyRectangle: View {
    var body: some View {
        Rectangle().fill(Color.blue)
    }
}

如你所见,视图MyRectangle()并不关心尺寸,它只做一件事,绘制一个矩形,由 SwiftUI 来搞清楚它的父级视图需要在哪里,以多大的尺寸来摆放它。在这个例子中,包含它的VStack正是这样的父级视图。

如果你想要了解更多关于 parents 是如何决定它们的 children 的尺寸和位置的信息,我强烈建议你观看 2019 WWDC session 237 (Building Custom Views with SwiftUI).

在很多情况下,这样就足够了。不过,正如 session 237 里提到的,父级视图建议尺寸和位置,但子视图究竟要如何以及在哪绘制是由它自己决定的。如果子视图对于提供给它的建议不满意,它会忽略建议。

举个例子,如果你希望你的自定义视图以父级视图建议的尺寸的一半来绘制矩形,并且希望它被放置在距离右边缘 5 个点的位置,那就这么干,没有人能阻止你 。

那么我们在这里要怎么编写代码呢?实际上,这其实不复杂,让 GeometryReader 来解决吧

子视图做了什么?

让我们看一下Apple 提供的关于 GeometryReader 的文档:

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

相对于其他几乎都是 “No overview available” 的文档,这个解释已经算很详细了。

那这句话是什么意思呢?简单来讲,GeometryReader 就是另外一种视图。惊不惊喜?在 SwiftUI 中,几乎所有东西都是视图。在下面的例子中,GeometryReader 让你定义了它的内容。 但是与其他视图不同。你可以拿到一些你在其他视图中拿不到的信息,并借助它们做一些你自己的事情。

上面说到我们要绘制一个大小是父级视图建议大小一半的视图,并且摆放在距离建议区域右边缘 5 个点的地方。有了 GeometryReader,这就很简单了:

struct ContentView : View {
    var body: some View {
        
        VStack {
            
            Text("Hello There!")
            MyRectangle()
            
        }.frame(width: 150, height: 100).border(Color.black)

    }
}

struct MyRectangle: View {
    var body: some View {
        GeometryReader { geometry in
            Rectangle()
                .path(in: CGRect(x: geometry.size.width + 5,
                                 y: 0,
                                 width: geometry.size.width / 2.0,
                                 height: geometry.size.height / 2.0))
                .fill(Color.blue)
            
        }
    }
}

GeometryProxy

你仔细看上面的例子,会发现一个包含 geometry 变量的闭包,这变量是一个 GeometryProxy 类型。我们可以通过 Inspecting the View Tree (检视视图树) 这篇文章去了解更多相关内容。

在 GeometryProxy 类中有两个计算型属性,一个方法,和一个下标 getter。

public var size: CGSize { get }
public var safeAreaInsets: EdgeInsets { get }
public func frame(in coordinateSpace: CoordinateSpace) -> CGRect
public subscript<T>(anchor: Anchor<T>) -> T where T : Equatable { get }

第一个属性很直白,正如在例子中看到的,size属性是父级视图建议的大小。我们可以兑现它,也可以不这么。记住,这取决于子视图。

GeometryProxy 把safeAreaInsets也暴露给了我们。

frame方法暴露给我们父级视图建议区域的矩形,可以借助传入.local, .global, .named()来获取不同的坐标空间。本地和全局坐标空间不用多解释,.named()用来获取一个被命名的坐标空间。我们可以通过名称来获取其他视图的坐标空间。Inspecting the View Tree 这篇文章介绍了具体的使用方法。

最后,我们可以通过下标取值来获取一个锚点。激起你的好奇心了对不对? 这个是 GeometryReader 的一个相当炫酷的功能,同时这也是一个笨重的主题,我在Inspecting the View Tree的第二部分有讲解。目前,你需要知道的是,借助下标,你可以获取视图树中任何子视图的尺寸和位置。是不是很强大?记得回来学习更多关于它的知识。

吸收另一个视图的 Geometry

GeometryReader 功能已经非常强大,但如果它被结合 .background() 或者 .overlay() modifier 来使用,功能就会更加强大。

在我见过的大多数教程里,background 都是以最简单方式使用的。举个例子:Text("hello").background(Color.red)。第一眼看,我们会以为Color.red是一个颜色参数,其实它并不是,Color.red是又一个视图!它唯一的功能就是把父级视图建议给它的区域填充成红色。因为父级视图是背景,而背景修改了Text,所以建议Color.red填充的区域就是Text("hello")所占据的区域。是不是很优雅?

.overlay modifier 的道理一样,只不过它不是给被修改的视图绘制背景,而是叠在前面绘制。

你可能意识到你可以把任意视图塞进 .background(),不单是 Color()。为了演示我们可以如何利用这一点,我们将结合 GeometryReader,画一个矩形背景,同时给矩形的角指定不同的圆角半径:

具体实现如下:

struct ContentView : View {
    var body: some View {
        
        HStack {
            
            Text("SwiftUI")
                .foregroundColor(.black).font(.title).padding(15)
                .background(RoundedCorners(color: .green, tr: 30, bl: 30))
            
            Text("Lab")
                .foregroundColor(.black).font(.title).padding(15)
                .background(RoundedCorners(color: .blue, tl: 30, br: 30))
            
        }.padding(20).border(Color.gray).shadow(radius: 3)
    }
}

struct RoundedCorners: View {
    var color: Color = .black
    var tl: CGFloat = 0.0
    var tr: CGFloat = 0.0
    var bl: CGFloat = 0.0
    var br: CGFloat = 0.0
    
    var body: some View {
        GeometryReader { geometry in
            Path { path in
                
                let w = geometry.size.width
                let h = geometry.size.height
                
                // 确保圆角半径不会超出界限
                let tr = min(min(self.tr, h/2), w/2)
                let tl = min(min(self.tl, h/2), w/2)
                let bl = min(min(self.bl, h/2), w/2)
                let br = min(min(self.br, h/2), w/2)
                
                path.move(to: CGPoint(x: w / 2.0, y: 0))
                path.addLine(to: CGPoint(x: w - tr, y: 0))
                path.addArc(center: CGPoint(x: w - tr, y: tr), radius: tr, startAngle: Angle(degrees: -90), endAngle: Angle(degrees: 0), clockwise: false)
                path.addLine(to: CGPoint(x: w, y: h - br))
                path.addArc(center: CGPoint(x: w - br, y: h - br), radius: br, startAngle: Angle(degrees: 0), endAngle: Angle(degrees: 90), clockwise: false)
                path.addLine(to: CGPoint(x: bl, y: h))
                path.addArc(center: CGPoint(x: bl, y: h - bl), radius: bl, startAngle: Angle(degrees: 90), endAngle: Angle(degrees: 180), clockwise: false)
                path.addLine(to: CGPoint(x: 0, y: tl))
                path.addArc(center: CGPoint(x: tl, y: tl), radius: tl, startAngle: Angle(degrees: 180), endAngle: Angle(degrees: 270), clockwise: false)
                }
                .fill(self.color)
        }
    }
}

另外,我们还可以应用相同的视图,但是采用 0.5 的透明度,应用在文本上面,用 .overlay()实现:

Text("SwiftUI")
    .foregroundColor(.black).font(.title).padding(15)
    .overlay(RoundedCorners(color: .green, tr: 30, bl: 30).opacity(0.5))
Text("Lab")
    .foregroundColor(.black).font(.title).padding(15)
    .overlay(RoundedCorners(color: .blue, tl: 30, br: 30).opacity(0.5))

鸡与蛋的问题

当你开始使用 GeometryReader,你很快会发现所谓的鸡与蛋的问题。因为 GeometryReader 需要提供所有的可用空间给子视图,它首先需要尽可能多地占据空间。然后,如我们知道的,子视图可能决定使用更少的空间。结果,GeometryReader 还是尽可能地保持大。

你可能会想,一旦子视图确定了需要的空间,可以强制 GeometryReader 相应地缩小。但这样就会导致子视图根据 GeometryReader 重新计算新的尺寸… 一个循环就产生了。

所以,究竟是哪个在先呢?是子级依赖父级的尺寸,还是父级依赖子级的尺寸? 但你面对这种问题时,你可能需要退回去重新思考你的方案,创造力是脱离这种困境的关键。不过谁又知道呢,也许 GeometryReader 在这里并不适用于解决这种布局问题。幸运的是,GeometryReader 并不是唯一的布局工具。你可以从我的下一篇文章 Preferences and Anchor Preferences 里找到例子。’

总结

今天我们了解到我们可以利用 GeometryReader 来构建可感知自身在屏幕上的尺寸和位置的自定义视图。我们还了解到一些关于从其他视图“窃取”几何信息,为我们所用的有用提示。