SwiftUI

SwiftUI 是一个用户界面工具包,可让我们以声明性方式设计应用程序。SwiftUI 可在 iOS 13、macOS 10.15、tvOS 13、watchOS 6、visionOS 1 或这些平台的任何未来更高版本上运行。

声明式 UI 比命令式 UI 好理解。在命令式用户界面中,我们可能会在单击按钮时调用一个函数,并在该函数内读取一个值并显示标签,我们会根据发生的情况定期修改用户界面的外观和工作方式。命令式 UI 会导致各种各样的问题,其中大部分都与状态有关,我们需要跟踪代码所处的状态,并确保我们的用户界面正确反映该状态。

相比之下,声明式 UI 可以立即告诉 iOS 应用程序的所有可能状态。我们可能会说,如果我们已登录,则显示欢迎消息;但如果我们已注销,则显示登录按钮。我们不需要手动编写代码在这两种状态之间移动——这是丑陋的、命令式的工作方式!

所谓声明式:就是我们不会手动让 SwiftUI 组件显示和隐藏,我们只是告诉它希望它遵循的规则,并让 SwiftUI 确保这些规则得到执行。


基本代码解读

// 通过 import 导入需要的框架、功能
import SwiftUI

// 创建了一个名为 ContentView 的新结构,并让它符合 View 协议
// View 来自 SwiftUI,是你想在屏幕上绘制的任何内容都必须采用的基本协议
// 所有文本、按钮、图像等都是视图,包括你自己定义的其他布局
struct ContentView: View {

		// View 协议只有一个要求,即有一个名为 body 的计算属性,它返回 some View
		// 您可以(并且将会)向视图结构添加更多属性和方法,但 body 是唯一需要的
		// 这里使用了模糊返回类型:some View 。这意味着它将返回符合 View 协议的内容,这就是我们的布局
		
		// var body: some View { } ,这里 body 是计算型属性,每次调用时系统会跑一遍里面的程序。后面的大括号理解是个闭包
		// 既然是计算属性,它里面也可以定义其他变量,例如自定义绑定数据,就是在它里面定义 Binding

    var body: some View {

				// VStack是个函数,中间()包含的 alignment 是函数的参数
				// 其实它还有一个最后的参数 content,因为是最后的参数,所以写成了闭包。把content函数写到()外面。
				// 因为最后一个参数是函数,就可以省略该参数,并把函数写到括号外面
        VStack() {

						// 对象,只有一行代码的时候,其实是省略了 return
						// 详细写法应该是 return Text("Welcome to my first App")
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundStyle(.tint)
            Text("Hello, world!")

						ZStack(alignment:top) {
							Text("emoji")
				    }
        }
				//视图修饰符,实际也是执行一个方法,结果是返回一个新的视图
        .padding()
    }
}

//预览代码,不会包含在最后的编译成App的代码中
#Preview {
    ContentView()
}

<aside> 💡 SwiftUI 中的文件名不要起的跟常用的 View 的名字一样,例如一个文件名叫TabView的文件,里面用了TabView(){ … }这个视图,会莫名其妙报错。

</aside>


作为视图的 View

视图基本概念

视图为什么是结构体:

在以前的 UIKit 中,视图用的都是类,而在 swiftUI中,视图用的是结构体。

body 里可以返回多个视图:

如果直接从 body 属性发回多个视图,而不是将它们包装在一个堆栈中,会发生什么情况?如果返回多个视图,Swift 会默默地将一个名为 @ViewBuilder 的特殊标记赋予 body 属性。这样做的效果是以静默方式将多个视图包装在一个叫 TupleView 的容器中,因此,即使看起来我们发回了多个视图,它们也会合并为一个 TupleView 视图。

var body: some View {
    Text("haha")
    Text("haha")
    Text("haha")
    ...
}

视图的扩张收缩

收缩型视图 和 扩张型视图:当涉及视图的大小和布局时,可以观察到两种行为:

ZStack {

		//这个色块就会填满屏幕
		Color.gray

		//这个Vstack只会占用需要的面积
		VStack(spacing: 20) {
		    Text("ZStack")
		}
}

视图可组装性

SwiftUI 的视图都是可组装的,视图里面可以嵌入另外的视图。这样有利于整理视图的层次结构。

例如当有不少页面的视图模块非常相似的时候,我们就可以抽象出它们的共同结构,简化代码。在 Xcode 里按住 command 键,点击想要抽出 Extracted 的元素, 并给子视图命名即可。

// 1. 提取出相同布局的 View 的模型,抽象成子视图,并定义参数
struct PricingView: View {

	var title: String
	var price: String
	var textColor: Color
	var bgColor: Color
	
	var body: some View {
	    VStack {
	        Text(title)
	            .font(.system(.title, design: .rounded))
	            .fontWeight(.black)
	            .foregroundColor(textColor)
	        Text(price)
	            .font(.system(size: 40, weight: .heavy, design: .rounded))
	            .foregroundColor(textColor)
	        Text("per month")
	            .font(.headline)
	            .foregroundColor(textColor)
	    }
	    .background(bgColor)
	}

}

// 2. 然后在父视图中实作它,同时提供子视图需要的参数值:
PricingView(title: "Basic", price: "$9", textColor: .white, bgColor: .purple)
PricingView(title: "Pro", price: "$19", textColor: .black, bgColor: Color(red: 240/255, green: 240/255, blue: 240/255))

// 3. 如何处理不一定有的元素 Optionals
// 例如 Aview 和 Bview 布局基本相似,但是可能有个元素 X 在 A 里出现,在 B 里是隐藏的
// 这时我们还是可以抽象出子视图的类,将元素 X 声明成 Optionals 类型的变量
var icon: String?

// 4. 然后在使用时需要解包
if icon != nil {
	Image(systemName: icon!)
	    .font(.largeTitle)
	    .foregroundColor(textColor)
}

// 或者使用 map 解開 optional 也是一种方式:
icon.map({
	Image(systemName: $0)
		.font(.largeTitle)
		.foregroundColor(textColor)
})

<aside> 💡 注意:Swift 通常使用 if let 来检查 optional 是否有值, 并解开它。不过在 SwiftUI 中不能使用 if let,你會得到錯誤。在 SwiftUI 中處理 optional 的方式是檢查 optional 是否存在一個非空值,即 if icon != nil 关于具体 Optional 的解包方式,查看: 可选值 Optional

</aside>


作为协议的 View

View 协议是 SwiftUI 的核心,任何东西都可以遵循它并开始参与布局,只需几行简单的代码。

遵循 View 协议,唯一要实现的是必须具有 body 计算属性,该属性返回某种符合 View 协议的东西。当返回模糊类型(使用 some 关键字)时,所有可能的返回类型必须全部属于同一类型。

Some 模糊类型

模糊类型(Opaque Types)关键字 some 指定返回模糊类型。代码 some View 指的模糊类型是 View。“opaque” 意思是“难以理解或不可能理解”,因为模糊类型隐藏了值的类型信息和实现细节,这肯定会使其“难以或不可能理解”,但仍然可用。

当 iOS 使用此视图来绘制屏幕时,它不知道在本例中返回的是 Text 类型还是其他的。只需知道某些 View 正在返回并且可以使用它来绘制屏幕就可以了。

// 事实上明确 body 的视图类型,像下面这样写是允许的
// 但大部分情况,返回的视图是很复杂的组合,很难去写它的类型,所以这时统一用 some View 就省事了
var body: Text { Text("Hello, world!") }

// 像这样的代码时不允许的,因为返回类型不明确(又没有使用 some)
var body: View {
		if Bool.random() {
				Text("Success!")
		} else {
		    Image(systemName: "x.circle")
		}
}

@ViewBuilder 视图构建器

body 属性一般只能精确返回一个 View;但其实它也能返回多个 View ,Swift会在背后以静默的方式将多个视图包装在 TupleView 的容器中,用户不需要感知。这就得益于 @ViewBuilder 视图构建器。

作为 View 协议的一部分,@ViewBuilder 视图构建器会自动加到 body 属性上,它可以在编译时让视图转化为类型安全的视图。对于上面随机返回 Text 或 Image 的例子,@ViewBuilder 实际上不是发回两个视图,而是发回类似 _ConditionalContent 之类的东西,它能满足 some View

由于 @ViewBuilder 构建器是通过 View 协议附加的,因此像这样的代码不起作用:

struct ContentView: View {
    var status: some View {
        if Bool.random() {
            Text("Success!")
        } else {
            Image(systemName: "x.circle")
        }
    }
    var body: some View {
        status
    }
}

该 status 属性不会自动应用 @ViewBuilder,因此尝试返回两种不同类型的视图不会以静默方式转换为 _ConditionalContent ,该代码不会编译。如果想修复这个问题,可以参考后面介绍的方法。但应该注意:将逻辑打包到视图中是一个坏主意,而且它不会以某种方式成为一个好主意,因为你已将布局划分为属性。


视图用作属性

对视图使用单独的属性,是分解代码的好方法。其具体做法是指:将一个视图创建为自己视图的属性,然后在布局中使用该属性。它不仅有助于避免重复,还可以从 body 属性中编写复杂代码。也让代码更清晰。

<aside> 💡 @ViewBuilder 里面不能放 var 变量,解决办法可以把 var 放到 body 的外面定义。

</aside>

struct ContentView: View {

		// 例如,我们可以创建两个这样的文本视图作为属性
    let motto1 = Text("Draco dormiens")
    let motto2 = Text("nunquam titillandus")
    
    // Swift 不允许创建一个引用其他存储属性的存储属性,因为它会在创建对象时引起问题。但是如果需要,可以创建计算属性,如下所示:
    var spells: some View {
		    VStack {
		        Text("Lumos")
		        Text("Obliviate")
		    }
		}
		
    var body: some View {
        VStack {
            motto1
	            .foregroundStyle(.red)
            motto2
	            .foregroundStyle(.blue)
        }
    }
}

<aside> 💡 以上代码有一个要求:即创建的任何自定义属性 motto1motto2,都必须返回相同的视图类型,如果它可能是不同类型(例如有时返回 Text ,有时返回 Image),那就会报错。

</aside>

解决视图类型不确定的问题

为了解决 “如何返回不同类型视图” 的这个问题,有以下几种做法:

1. 用 Group 进行封装

首先可以将判断逻辑包装在 Group 中,放在 Group 标签中的内容会自动应用 @ViewBuilder

struct ContentView: View {

		// 这是一个视图属性,必须确保每次返回的类型一样
    var tossResult: some View {
		    
		    // 将返回的视图包装在 Group 中,它将自动应用 @ViewBuilder 转换,确保一致
        Group {
            if Bool.random() {
                Image("laser-show")
            } else {
                Text("Better luck next time")
            }
        }
        .frame(width: 400, height: 300)
    }

    var body: some View {
        VStack {
            Text("Coin Flip")
                .font(.largeTitle)
            tossResult
        }
    }
    
}

2. 用 AnyView 类型擦除器

使用 AnyView 可以有效地迫使 Swift 忘记 AnyView 内的具体类型,让它们看起来像是同一件事。不过,这会带来性能成本,因此不要经常使用它。虽然 GroupAnyView 都能实现相同的结果,但在两者之间最好使用 Group

struct ContentView: View {

    var tossResult: some View {
        if Bool.random() {
            return AnyView(Image("laser-show").resizable().scaledToFit())
        } else {
            return AnyView(Text("Better luck next time").font(.title))
        }
    }

    var body: some View {
        VStack {
            Text("Coin Flip")
                .font(.largeTitle)
            tossResult
                .frame(width: 400, height: 300)                
        }
    }
    
}

3. 手动应用 @ViewBuilder

你还可以手动应用 @ViewBuilder 转换,只需要在视图属性前加上 @ViewBuilder 关键词即可。

struct ContentView: View {
    @ViewBuilder var tossResult: some View {
        if Bool.random() {
            Image("laser-show")
                .resizable()
                .scaledToFit()
        } else {
            Text("Better luck next time")
                .font(.title)
        }
    }
    var body: some View {
        VStack {
            Text("Coin Flip")
                .font(.largeTitle)
            tossResult
                .frame(width: 400, height: 300)                
        }
    }
}

这确实有效。但实际上,当你向一个视图添加了如此多的逻辑,以至于认为 “应该把它分拆成单独的 @ViewBuilder 属性”,那么可能你更应该把代码分解成一个个单独视图。这也是更常用的办法:将视图分解为更小的视图,然后根据需要将它们组合在一起。这有助于分解逻辑和布局,并且还有一个好处是使您的视图在应用程序的其他地方更可重用。大多数情况下这是最有效的解决方案。具体查看:视图可组装性