跳到主要内容

SwiftUI @ViewBuilder 的魔法

· 阅读需 5 分钟

定义

先看一下ViewBuilder的定义,实际上这是一个@resultBuilder 的 struct。

@resultBuilder public struct ViewBuilder {

public static func buildBlock() -> EmptyView

public static func buildBlock<Content>(_ content: Content) -> Content where Content : View
}

@resultBuilder 属性封装具体用法查看官方文档

用于函数参数的用法

下面是一个简单的例子,将 @ViewBuilder 用于参数

func contextMenu<MenuItems: View>(@ViewBuilder menuItems: () -> MenuItems) -> some View

在调用的时候可以指定多个 View,而且不需要逗号分割,

myView.contextMenu {
Text("Cut")
Text("Copy")
Text("Paste")
if isSymbol {
Text("Jump to Definition")
}
}

多个Text是因为 buildBlock 的多参数重载实现,最多到 C9:

static func buildBlock<C0, C1>(C0, C1) -> TupleView<(C0, C1)>
static func buildBlock<C0, C1, C2>(C0, C1, C2) -> TupleView<(C0, C1, C2)>
static func buildBlock<C0, C1, C2, C3>(C0, C1, C2, C3) -> TupleView<(C0, C1, C2, C3)>
static func buildBlock<C0, C1, C2, C3, C4>(C0, C1, C2, C3, C4) -> TupleView<(C0, C1, C2, C3, C4)>
static func buildBlock<C0, C1, C2, C3, C4, C5>(C0, C1, C2, C3, C4, C5) -> TupleView<(C0, C1, C2, C3, C4, C5)>
static func buildBlock<C0, C1, C2, C3, C4, C5, C6>(C0, C1, C2, C3, C4, C5, C6) -> TupleView<(C0, C1, C2, C3, C4, C5, C6)>
static func buildBlock<C0, C1, C2, C3, C4, C5, C6, C7>(C0, C1, C2, C3, C4, C5, C6, C7) -> TupleView<(C0, C1, C2, C3, C4, C5, C6, C7)>
static func buildBlock<C0, C1, C2, C3, C4, C5, C6, C7, C8>(C0, C1, C2, C3, C4, C5, C6, C7, C8) -> TupleView<(C0, C1, C2, C3, C4, C5, C6, C7, C8)>
static func buildBlock<C0, C1, C2, C3, C4, C5, C6, C7, C8, C9>(C0, C1, C2, C3, C4, C5, C6, C7, C8, C9) -> TupleView<(C0, C1, C2, C3, C4, C5, C6, C7, C8, C9)>

上面的if支持是因为ViewBuilder实现了buildEither(second:) 静态方法,还有其他更多的写法,比如For循环。

用于返回值的用法

先来梳理一下问题,当你创建一个函数,返回类型是View时,如果编译器不能在编译阶段就确定类型,那就会出现泛型无法推断类型的编译错误。

比如下面的例子,只能在运行期才能确定返回值类型。

func showTextOrImage(isImage: Bool) -> some View {

if !isImage {
Text("This is a title")
.foregroundColor(.red)
}
else {
Image(systemName: "square.and.arrow.up")
.foregroundColor(.blue)
}
}

有几种方式解决这个问题,核心就是再包一层,比如容易想到的就是自定义一个 View:

struct ShowTextOrImage: View {
let isImage: Bool

var body: some View {
if !isImage {
Text("This is a title")
.foregroundColor(.red)
}
else {
Image(systemName: "square.and.arrow.up")
.foregroundColor(.blue)
}
}
}

这种方式不好的地方就是需要另写一个 struct,更好的方式是在 struct 内部通过函数就可以得到需要的 View,我们可以使用Group来实现:

// 使用 Group 包装以下
func groupDemo(isImage: Bool) -> some View {
Group {
if !isImage {
Text("This is a title")
.foregroundColor(.red)
}
else {
return AnyView(Image(systemName: "square.and.arrow.up")
.foregroundColor(.blue))
}
}
}

或者 转成AnyView擦除类型具体的类型:

// AnyView 擦除类型
func anyViewDemo(isImage: Bool) -> some View {
if !isImage {
return AnyView(Text("This is a title")
.foregroundColor(.red))
}
else {
return AnyView(Image(systemName: "square.and.arrow.up")
.foregroundColor(.blue))
}
}

最后一种方式就是使用 @ViewBuilder 属性封装,也可以达到目的。

@ViewBuilder
func viewBuilderDemo(isImage: Bool) -> some View {
if !isImage {
Text("This is a title")
.foregroundColor(.red)
}
else {
Image(systemName: "square.and.arrow.up")
.foregroundColor(.blue)
}
}

这里不会报错的原因,也是@resultBuilder的作用,因为ViewBuilder实现了buildEither(second:),支持 if-else 语法

用于属性

当你想实现一个自定义的VStack时,可以这么做:

struct CustomVStack<Content: View>: View {
let content: () -> Content

var body: some View {
VStack {
// custom stuff here
content()
}
}
}

但是这种方式只能接收单个View,无法传入多个 View:

CustomVStack {
Text("Hello")
Text("Hello")
}

为了达到原生VStack的效果,就必须增加一个构造函数:

init(@ViewBuilder content: @escaping () -> Content) {
self.content = content
}

每次定义容器 View 时,都得这么写的话就很啰嗦,所以有人向官方提建议,看是否能把@ViewBuilder直接用于属性。

最终这个提案通过了,发布在 Swift 5.4 版本:

struct CustomVStack<Content: View>: View {
@ViewBuilder let content: Content

var body: some View {
VStack {
content
}
}
}

其他

一些开源的@resultBuilder实现: