SWiftUI之Layout基础篇

7,671 阅读10分钟

在SwiftUI中的layout思想,跟UIKit中的布局有点不太一样,本篇文章主要讲解一些SwiftUI中最常见的布局玩法,这些布局相关的规则是非常基础的,但了解这些技术又是十分必须的。

本文主要涵盖一下几点内容:

  • frame
  • GeometryReader
  • Alignment Guide
  • Preference
  • Stacks
  • Spacer
  • layoutPriority

布局法则

下边3个法则是SwiftUI布局的最基本的法则,面对任何布局的时候,只要想想这3个法则,就能明白为什么UI会是这样一种效果:

  1. 父view为子view提供一个建议的size
  2. 子view根据自身的特性,返回一个size
  3. 父view根据子view返回的size为其进行布局

举个简单的🌰:

struct ContentView: View {
    var body: some View {
        Text("Hello, world")
            .border(Color.green)
    }
}
截屏2020-07-26 下午2.13.29.png

ContentView作为Text的父view,它为Text提供一个建议的size,在本例中,这个size为全屏幕的尺寸,然后Text根据自身的特性,返回了它实际需要的size,注意:Text的特性是尽可能的只使用必要的空间,也就是说能够刚好展示完整文本的空间,然后ContentView根据Text返回的size在其内部对Text进行布局,在SwiftUI中,容器默认的布局方式为居中对齐。

我们在接下来的讲解中,会反复地使用上述的3种基本布局法则。

frame

在UIKit中,Frame算是一种绝对布局,它的位置是相对于父view左上角的绝对坐标。但SwiftUI中,frame这个modifier的概念完全不同。

我们先看个🌰:

struct ContentView: View {
    var body: some View {
        Text("Hello, world")
            .background(Color.green)
            .frame(width: 200, height: 50)
    }
}

理想的显示效果是这样的:

企业微信截图_a1a594cd-a812-490e-98b4-6cefe31d60c8.png

但实际效果却是这样的:

企业微信截图_9df916c9-ad0a-4c15-94bc-3cc1d9ebdee6.png

在上边的代码中,.background并不会直接去修改原来的Text,而是在Text图层的下方新建了一个新的view,在SwiftUI中中,View是廉价的。

如果我们从布局的3个法则考虑这个问题,就会非常简单,.frame起的作用就是提供一个建议的size,在本例中,frame为background提供了一个(200, 50)的size,background还需要去问它的child,也就是Text, Text返回了一个自身需要的size,于是background也返回了Text的实际尺寸,这就造成了绿色背景跟文本同样大小的效果。

知道了这个布局的过程,我们就明白了,要想得到理想的效果,需要修改一下上边的代码:

struct ContentView: View {
    var body: some View {
        Text("Hello, world")
            .frame(width: 200, height: 50)
            .background(Color.green)
    }
}

我们只是调整了frame和background的顺序,就实现了这个功能,请大家仔细思考下,这是为什么?为了节省篇幅,我们就不做过多的解释了,值得注意的是各个View不同的特性,像Text,会返回自身需要的size,像Shape,则会返回父view建议的size,在实际布局时,需要考虑这些不同特性的影响。

SwiftUI关于frame的定义有两种,第一种是:

func frame(width: CGFloat? = nil, height: CGFloat? = nil, alignment: Alignment = .center) -> some View

width和height都可以为nil,如果为nil,就直接使用父view的size,这里的alignment是什么意思呢?我们先看下边的代码:

struct ContentView: View {
    var body: some View {
        HStack {
            Text("Good job.")
                .background(Color.orange)
        }
        .frame(width: 300, height: 200, alignment: .topLeading)
        .border(Color.green)
    }
}

企业微信截图_317ddf24-6a3f-4223-8cad-4377210c87f0.png

frame中的alignment会对其内部的views做整体的对齐处理,在平时的开发中,如果你发现,在frame中设置了alignment,但并没有起作用,主要原因是外边的容器,比如说HStack,VStack等他们自身的尺寸刚好等于其子views的尺寸,这种情况下的alignment效果都是一样的。

那么frame的第二个定义如下:

 public func frame(minWidth: CGFloat? = nil, idealWidth: CGFloat? = nil, maxWidth: CGFloat? = nil, minHeight: CGFloat? = nil, idealHeight: CGFloat? = nil, maxHeight: CGFloat? = nil, alignment: Alignment = .center) -> some View

这个函数的参数比较多,但总体分为3类:

  • minWidth,idealWidth,maxWidth
  • minHeight,idealHeight,maxHeight
  • alignment

关于min和max的内容,大家看下边的这张关系图就可以了:

4

简单讲一讲idealWidth和idealHeight,按照字面意思,ideal是理想的意思,那么当我们为某个view设置了idealWidth后会怎样呢?

struct ContentView: View {
    var body: some View {
        Text("Good job.")
            .frame(idealWidth: 200, idealHeight: 100)
            .border(Color.green)
    }
}

运行后,我们发现,Text并没有使用我们给出的ideal尺寸:

企业微信截图_65b6d3d1-34df-442c-a367-1f902dbdb0d5.png

实际上,这个ideal必须跟.fixedSize(horizontal: true, vertical: true)一起使用才行:

  • horizontal:表示固定水平方向,也就是idealWidth
  • vertical: 表示固定垂直方向,也就是idealHeight

我们再次验证一下:

struct ContentView: View {
    var body: some View {
        HStack {
            Text("horizontal")
                .frame(idealWidth: 200, idealHeight: 100)
                .fixedSize(horizontal: true, vertical: false)
                .border(Color.green)
            
            Text("vertical")
                .frame(idealWidth: 200, idealHeight: 100)
                .fixedSize(horizontal: false, vertical: true)
                .border(Color.green)
            
            Text("horizontal & vertical")
                .frame(idealWidth: 200, idealHeight: 100)
                .fixedSize(horizontal: true, vertical: true)
                .border(Color.green)
        }
    }
}

企业微信截图_17a77f7e-6c18-4877-81a7-e906f19e8058.png

这项技术,在真实的开发中很有用,我们可以直接固定死某个view的尺寸,不会因为外部条件的改变而改变。如果想了解更多关于frame的详细信息,可以范围SwiftUI之frame详解

GeometryReader

我们已经知道了,修改frame,就相当于修改了父view建议的size,然后,子view会非常聪明的根据这个size做一些事情,但是,这个size到目前为止还是隐性的,所谓的隐性表示我们不能显式的获取到这个size。

如果想显式的得到这个建议的size,就需要使用GeometryReader,我们先看一个例子:

Kapture 2020-07-26 at 15.27.31.gif

struct ContentView: View {
    @State private var w: CGFloat = 100
    @State private var h: CGFloat = 100
    
    var body: some View {
        VStack {
            GeometryReader { geo in
                Text("w: \(geo.size.width, specifier: "%.1f") \n h: \(geo.size.height, specifier: "%.1f")")
            }
            .frame(width: w, height: h)
            .border(Color.green)
            
            Slider(value: self.$w, in: 10...300)
                .padding(.horizontal, 30)
        }
        
    }
}

我们动态的改变父view的width,Text能够通过GeometryReader获取到这个size。这就是GeometryReader的核心功能之一:获取到父view的size

那么,这项技术有什么实际意义呢? 我的回答是非常有意义,GeometryReader在你以后的开发中一定会用到很多,我们再举一个常用的例子:

企业微信截图_e3f49222-f7f8-4e1a-9c5c-7ddaf886cc2d.png

struct ContentView: View {
    var body: some View {
        HStack() {
            Spacer()
            
            MyProgress()
                .frame(width: 100, height: 100)
            
            Spacer()
            
            MyProgress()
                .frame(width: 150, height: 150)
            
            Spacer()
            
            MyProgress()
                .frame(width: 300, height: 300)
            
            Spacer()
        }
    }
}
struct MyProgress: View {
    var body: some View {
        GeometryReader { geo in
            Circle()
                .stroke(Color.green, lineWidth: min(geo.size.width, geo.size.height) * 0.2)
                
        }
    }
}

这个例子中,Progress的宽度需要根据父view的宽度做一个计算,这仅仅是GeometryReader的一个简单的应用。

GeometryReader的另一个比较强大的功能是它的frame(in),它能够让我们获取到某个view相对某个坐标空间的bounds,

struct ContentView: View {
    var body: some View {
        VStack {
            Spacer()
            
            ForEach(0..<5) { _ in
                GeometryReader { geo in
                    Text("coordinateSpace: \(geo.frame(in: .named("MyVStack")).minY) global: \(geo.frame(in: .global).minY)")
                }
                .frame(height: 20)
                .background(Color.green)
            }
            
            Spacer()
        }
        .frame(height: 300)
        .border(Color.green)
        .coordinateSpace(name: "MyVStack")
    }
}

image.png

可以看出,相对于.named("MyVStack").global,得到的minY的值是不同的,GeometryReader的这个功能在实际应用中也非常强大,比如,实现下边这样的效果:

Simulator Screen Shot - iPhone 11 Pro Max - 2020-07-08 at 14.27.19.png

Kapture 2020-07-08 at 14.54.02.gif

想了解更多GeometryReader的内容,可以访问SwiftUI之GeometryReader

Alignment Guide

相信大家在代码中的很多地方会用到.leading,在SwiftUI中,用到对齐的地方一共有下边几种:

alignment-confusion.png

这张图片覆盖了对齐所有的使用方式,现在大家可能是一脸茫然,但读完剩下的文章后,再回过头来看这张图片,就会发现,这张图片实在是太经典了,毫不夸张的说,你以后在SwiftUI中使用alignment guide的时候,头脑中一定会浮现出这张图片。

我们对上边的几个概念做个简单的介绍:

  • Container Alignment: 容器的对齐方式主要有2个目的,首先它定义了其内部views的隐式对齐方式,没有使用alignmentGuides() modifier的view都使用隐式对齐,然后定义了内部views中使用了alignmentGuides() 的view,只有参数与容器对齐参数相同,容器才会根据返回值计算布局
  • Alignment Guide:如果该值和Container Alignment的参数不匹配,则不会生效
  • Implicit Alignment Value:通常来说,隐式对齐采用的值都是默认的值,系统通常会使用和对齐参数相匹配的值
  • Explicit Alignment Value:显式对齐跟隐式对齐相反,是我们自己用程序明确给出的返回值
  • Frame Alignment:表示容器中views的对齐方式,把views看作一个整体,整体偏左,居中,或居右
  • Text Alignment:控制多行文本的对齐方式

我们这篇文章,不会详细的介绍上述的这些概念,如果兴趣,可以访问SwiftUI之AlignmentGuides,我们通过一个简单的例子,讲解一下.alignmentGuide的用法。

假设,我们需要实现下边这样的效果:

企业微信截图_78860947-1765-49bc-9dbc-c7e0783fea4b.png

代码应该是这样的:

struct ContentView: View {
    var body: some View {
        Image(systemName: "cloud.bolt.fill")
            .resizable()
            .frame(width: 50, height: 50)
            .padding(10)
            .foregroundColor(.white)
            .background(RoundedRectangle(cornerRadius: 5).foregroundColor(Color.green.opacity(0.8)))
            .addVerifiedBadge(true)
    }
}

extension View {
    func addVerifiedBadge(_ isVerified: Bool) -> some View {
        ZStack(alignment: .topTrailing) {
            self

            if isVerified {
                Image(systemName: "circle.fill")
                    .foregroundColor(.red)
                    .offset(x: 10, y: -10)
            }
        }
    }
}

在addVerifiedBadge中,我们使用offset来实现了小红点的位置偏移,同样的,我们也可以使用.alignmentGuide来实现相同的效果,

extension View {
    func addVerifiedBadge(_ isVerified: Bool) -> some View {
        ZStack(alignment: .topTrailing) {
            self

            if isVerified {
                Image(systemName: "circle.fill")
                    .foregroundColor(.red)
                    .alignmentGuide(.top) { (d) -> CGFloat in
                        d[VerticalAlignment.center]
                    }
                    .alignmentGuide(.trailing) { (d) -> CGFloat in
                        d[HorizontalAlignment.center]
                    }
            }
        }
    }
}

使用.alignmentGuide的一个最大的优势是,我们可以获取view的维度信息,比如,上边代码中的参数d。

alignmentGuide是一项十分强大的技术,特别建议大家去看一下SwiftUI之AlignmentGuides,总之,在alignmentGuide的一个核心思想就是设置对齐方式。

Preference

上边讲解的布局思想基本上都是子view相关的,在真实开发场景中,往往一个父view需要知道其内部子view的一些信息,对于继承链来说,我们把这类问题归结为祖先获取子孙信息的问题。

我们先看看这类问题怎样的?

Kapture 2020-07-09 at 14.41.20.gif

在这个例子中,绿圈会移动到点击的数字上边,实现原理很简单,只要知道这些数字的bounds信息,我们就能实现上述的功能,这里就用到了Preference相关的知识,其核心思想有以下2点:

  • 设置PreferenceKey和自定义的PreferenceData,把子view的信息绑定到PreferenceData上
  • 父view根据PreferenceKey获取到所有子view的PreferenceData

如何设置PreferenceKey和PreferenceData呢?下边的代码基本上都是固定用法:

struct NumberPreferenceValue: Equatable {
    let viewIdx: Int
    let rect: CGRect
}
struct NumberPreferenceKey: PreferenceKey {
    typealias Value = [NumberPreferenceValue]
    static var defaultValue: [NumberPreferenceValue] = []
    static func reduce(value: inout [NumberPreferenceValue], nextValue: () -> [NumberPreferenceValue]) {
        value.append(contentsOf: nextValue())
    }
}

父view如何获取打到这些数据呢?通过.onPreferenceChange来获取

var body: some View {
    ZStack(alignment: .topLeading) {
        ...

        VStack {
            ...
        }
    }
    .onPreferenceChange(NumberPreferenceKey.self) { preferences in
        for pre in preferences {
            self.rects[pre.viewIdx] = pre.rect
        }
    }
    .coordinateSpace(name: "ZStackSpace")

说实话,Preference技术学习起来真的是非常简单,它包含了几种变种,比如anchorPreference,我们可以直接获取到子view的anchor,一个具体的例子就是:

Kapture 2020-07-13 at 19.09.22.gif

Preference最最核心的思想就是父view能够获取到其内部子view绑定的信息,理解了这一点,就能够自由发挥想象了。

在我之前写的文章中,还演示了另外三种用途:

  • 实时监听子view的信息:Kapture 2020-07-14 at 19.17.30.gif

  • 绘制二叉树:Kapture 2020-07-15 at 10.09.37.gif

  • 下拉刷新:Kapture 2020-07-20 at 9.45.36.gif

想了解更多的朋友,可以访问下边这些文章;

SwiftUI之View Tree(PreferenceKey)

SwiftUI之View Tree(AnchorPreferences)

SwiftUI之View Tree实战1

SwiftUI之View Tree 实战2(绘制二叉树)

SwiftUI之View Tree 实战3(下拉刷新)

Stacks

在本小节中,我们只讲解VStack,HStackZStack,至于SwiftUI2.0中新出现的Stacks,我们在后边的文章中再专门讲解。

VStack是一个纵向布局的容器,在没有其他约束条件的情况下,它的布局特性表现为:尽量满足子views的布局要求,并且自身最终的布局size取决于子views的size。

    var body: some View {
        VStack(spacing: 10) {
           Text("Hello, World!")
            .background(Color.orange)
            
           Text("Hello, World!")
            .background(Color.red)
        }
        .border(Color.green)
        
    }

企业微信截图_04fb1fbb-c1ff-4a15-b2c4-e7ee38bf688a.png

HStack是一个横向布局的容器,他的特性跟HStack相同:

    var body: some View {
        HStack(spacing: 10) {
           Text("Hello, World!")
            .background(Color.orange)
            
           Text("Hello, World!")
            .background(Color.red)
        }
        .border(Color.green)
    }

企业微信截图_0375decd-b242-4118-a50a-50c4adf784e2.png

ZStack是一个按层次布局的容器,后边加入的view在前边view的上层,他的特性跟HStack和VStack相同:

    var body: some View {
        ZStack {
            Color.orange
                .frame(width: 100, height: 50)
            
           Text("Hello, World!")
            .border(Color.red)
        }
        .border(Color.green)
    }

企业微信截图_efcf46a9-176b-4625-8339-fb04cdba3ff1.png

这3个容器是开发中最经常使用的,但其核心思想十分简单,就是对容器内的views按照自身的规则进行布局,可以设置一定的间距和对齐方式。

Spacer

Spacer需要更Stacks配合使用,Spacer的特性是尽可能的在某个方向上占据更多的空间,这里有一个方向的概念,比如,在VStack中使用Spacer,则会在垂直的方向上占据更多的空间,反之,在HStack中,会在横向的空间中占据更多的空间。

    var body: some View {
        VStack {
            Color.orange
                .frame(width: 100, height: 50)

            Text("Hello, World!")
                .border(Color.red)

            Spacer()
        }
        .border(Color.green)
    }
image.png

可以看出,此时,VStack的高度是整个屏幕安全区域的高度了,Spacer占据了垂直方向剩余的全部空间,但是它并不能改变横向上的宽度。

layoutPriority

大家有没有想过,在容器中布局的各个view,他们的优先级是怎样的呢? 我们以HStack为例,如果它内部有两个优先级相同的view,那么结果就是他们平分VStack的空间:

    var body: some View {
        HStack {
            Color.orange

            Text("窗前明月光,疑是地上霜")
                .border(Color.red)
        }
        .frame(width: 200, height: 100)
        .border(Color.green)
    }

企业微信截图_4852298c-b409-4615-88be-b4d2a5c7412b.png

可以看出,Text并不会有更高的优先级,我们可以使用layoutPriority来修改view的优先级,比如,我们把Text的优先级设置高一点点:

    var body: some View {
        HStack {
            Color.orange

            Text("窗前明月光,疑是地上霜")
                .border(Color.red)
                .layoutPriority(1)
        }
        .frame(width: 200, height: 100)
        .border(Color.green)
    }

企业微信截图_f4a23874-9027-42cf-ac50-538d84cc3856.png

由于200的宽度只能容纳下Text,因此,无法显示左边的Color,我们把Text的优先级设成了1即可,没必要设置一个很大的值,默认情况下,view的优先级都为0。

总结

本篇文章只是SwiftUI布局入门级的内容,我写的一些文章不会是那种非常入门的类型,在后续的文章中,我会带领大家进一步深入的了解SwiftUI布局相关的知识,希望大家能够喜欢。

SwiftUI集合:FuckingSwiftUI