75befdfbb0af95afdac0dbcdf8160bec
SwiftUI 自定义视图

Session 213 - Building Custom Views with SwiftUI
作者:CoderAFI
校对:冬瓜

前言

SwiftUI 可以说是苹果近几年来在技术方面做出最大胆的创新,界面布局完全抛弃了 StoryboardAutolayout ,采用了最流行的声明式的界面语言(DSL),再加上 Canvas 的实时预览功能,在开发体验方面可谓是指数级提升。

在日常的开发工作中,大家每天都要自定义视图,根据设计稿画一些曲线,或者实现一些特定的动画效果, SwiftUI 能否帮助开发者高效的解决这些问题呢?答案是肯定的, SwiftUI 已经为开发者提供了一套完备的 API 实现,就等你来一探究竟啦,😀。

下面本文会从三方面与大家分享自定义视图方面的知识:

  • 内置控件
  • 布局原理
  • 图形绘制

内置控件


下图列出了 SwiftUI 开发者文档 中常用的系统控件,可以看出 UIKit 中常用的控件在 SwiftUI 中都已经提供。

布局原理


1. 布局流程

首先我们来看一个最基本的 SwiftUI 视图布局代码,如下:

import SwiftUI

struct ContentView : View {
    var body: some View {
        Text("Hello World!")
    }
}

其中包含了一个 Text,然后用一个 ContentView 来包含 Text,最终 SwiftUI 内部会在 Window 上用一个 RootView 再把 ContentView 包含起来,对应的视图 DSL 结构如下:

RootView → ContentView → Text

有了视图结构的 DSL ,那么 SwiftUI 会直接读取 DSL 内部描述信息并收集起来,然后转换成基本的图形单元,最终交给底层 MetalOpenGL 渲染出来,所以基本视图绘制流程如下:

DSL信息读取 → 收集并转换绘制信息 → 组装图形单元 → Metal(OpenGL) 渲染

现在,让我们有浅入深聚焦到一个点,在 SwiftUI 中每个元素是如何确定位置和大小的呢?实际上完成该工作需要三步:

  • 父视图为子视图提供预估尺寸大小
  • 子视图计算自己的实际尺寸大小
  • 父视图根据自身尺寸和子视图的尺寸以及属性,计算子视图的位置信息,布局展示

其中在第二步子视图计算自身尺寸的时候,SwiftUI 提供了三种设置尺寸的方式:

  • 无需设置,根据内容自行计算,例如 Text
  • 手动设置 frame(x, y, width, height)
  • 设置 aspectRatio 宽高比,例如 Image

在这里值得一提的是,SwiftUI 会将开发者代码中引起模糊的坐标设定,自动对齐到附近清晰的像素点,大大提高视图界面的清晰度(在 UIKit 中也有相应的处理),效果如下对比图:

现在我们已经明确了一个基本界面布局流程,下面我们来看一个比较复杂的界面布局,代码如下:

import SwiftUI

struct ContentView : View {
    var body: some View {
        Text("Avocado Toast")
            .padding(10)
            .background(Color.green)
    }
}

最终视图结构如下:

从上面的视图结构图可以看出,为 Text 设置的 backgroundpadding 属性,都出现在视图结构中,这似乎与 UIKit 的设计思路相矛盾了。

SwiftUI 中这些属性的设置在内部都会用一个虚拟的 View 来承载,然后在布局的时候就会按照上面示例的布局流程,一层层 View 的计算布局下来,这样做的目的主要是方便底层在设计渲染函数时更容易做到 monomorphic call,省去无用的分支判断,提高效率。

同时 SwiftUI 中也是支持 frame 设定的,但也不会像 UIKit 中那样作用于当前元素,在内部也是形成一个虚拟的 View 来承载 frame 设定,在布局过程中进行 frame 计算最终显示出想要的结果。

总之在 SwiftUI 中给一个 View 设置属性,已经不是为当前元素提供约束,而是用一系列容器来包含当前元素,为后续布局计算做准备。

2. Stacks

SwiftUI 中提供了 HStack、VStack 来方便横向和纵向排列的元素布局,这与 AndroidLinearLayout 以及 UWP 中的 StackPanel 非常类似,以下图例,我们看下如何使用 Stacks来实现。

该界面总体在横向可以分为左右两部分,可以用一个 HStack 包含,左边和右边各自可以用一个 VStack 包含,内部的 Avocado Toast 文本和 图片 又是一个左右布局,可以再用一个 HStack 包含,这种布局思路,感觉跟 HTMLFlexbox 布局很像,用起来那叫一个爽,最终实现代码如下:

import SwiftUI

struct ContentView : View {
    var body: some View {
        HStack {
            VStack {
                Text("★★★★★")
                Text("5 stars")
                }.font(.caption)
            VStack {
                HStack {
                    Text("Avocado Toast").font(.title)
                    Spacer()
                    Image("20x20_avocado")
                }
                Text("Ingredients: Avocado, Almond Butter, Bread, Red Pepper Flakes")
                    .font(.caption).lineLimit(1)
            }
        }
    }
}

需要注意的是,为了满足苹果自身的设计规范,SwiftUI 会在视图上附加一些默认的间距,常见的有:

  • Stack 容器之间会有默认边距
  • 文本的底边和底边之间会有间距
  • 文本底部和控件边缘也会有边距

所以默认做好的视图界面,很有可能是不满足团队的 UI 设计稿要求的,当然,SwiftUI 团队也考虑到了这点,让开发者可以自定义边距,示例代码如下:

VStack(spacing: 20) {
   Text("★★★★★")
   Text("5 stars")
}.font(.caption)

利用 SwiftUI Stacks 容器布局的界面,在国际化的 App 中,会识别不同语言的展示习惯, 自动帮助开发者切换布局方向(在 UIKit 也有相关实现),如下图:

通过以上特性可以看出 Stacks 的使用非常方便,但是在开发中有时候会让一些超大的视图截断显示,或者按比例显示等,所以我们要更深层的了解下 Stacks 是如何给每个子元素分配尺寸的。

我们以下面视图为例,可以看出整体布局是一个 HStack ,我们假设宽度为 W1, 那么子元素的尺寸计算步骤如下:

  • Stack 根据子元素的个数和系统自带的元素边距,进行平均分配计算一个预估分配宽度( W1 - ( S1 + S2 ) ) / 3
  • 找到具备明确大小的元素,在这里就是 Image 元素,假设大小为 W2
  • 计算剩余宽度 W1 - W2 - (S1 + S2) 并根据剩余元素个数再次平均分配宽度 W1 - W2 - (S1 + S2) ) / 2
  • 然后从左到右依次,将平均分配的宽度与元素实际计算的宽度匹配,如果分配宽度足够则全部显示,若不够则默认会截断显示,这里假设 Delicious 文本的宽度为 W3
  • 最后剩下 Avocado Toast 文本部分,HStack 最后为它剩下的宽度为 W1 - W2 - W3 - (S1 + S2),再用这部分宽度与文本的实际计算宽度匹配,宽度足够则全部显示,不够则截断
top Created with Sketch.