###### tags: `iOS` `Swift` `SwiftUI`
# SwiftUI Getting Started
這是在 iOS13 Xcode出現的新功能,可以用聲明性語法,輕鬆的建立 UI 元件
包含對齊、顏色等屬性,甚至適用於復雜的概念,如動畫
而且在更改元件時,可以進行即時預覽
## 建立第一個 SwiftUI
建立 SwiftUI 檔,輸入以下程式碼,開啟預覽圖(右上角Canvas)
```kotlin=
import SwiftUI
struct ContentView: View {
var body: some View {
Text("Hello World")
}
}
//預覽圖的程式碼
#if DEBUG
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
#endif
```
:::info
#if DEBUG 指令,在 app 發線上版本時,編譯器會刪除這些程式碼
:::
點擊 Resume 提交,會在右側開啟模擬器,即可看到畫面

如果 Run 起來,會發現他有一樣的結果,但是程式是怎麼知道起始畫面在哪裏呢?
原因就在 SceneDelegate.swift 檔中,這是 iOS13 新推出的一個生命週期
AppDelegate 完成 APP 啟動後,交接給 SceneDelegate
它的 ==scene(_:willConnectTo:options:)== 將會被調用,然後進行 UI 配置
以下程式碼,就是畫面的進入點
```kotlin=
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// Create the SwiftUI view that provides the window contents.
let contentView = ContentView(isSayHello: <#Bool#>)
// Use a UIHostingController as window root view controller.
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: contentView)
self.window = window
window.makeKeyAndVisible()
}
}
```
==some==
先回到 body 這個變數,型別是 some View,一開始可能會很陌生
讓我們先從 View 開始,View 是 SwiftUI 最核心的協議,代表了屏幕上元素的描述
這個協議含有 associatedtype
```kotlin=
public protocol View : _View {
associatedtype Body : View
var body: Self.Body { get }
}
```
帶有 associatedtype 的協議不能作為類型使用,只能作為類型約束使用
```kotlin=
// Error
func createView() -> View {}
// OK
func createView<T: View>() -> T {}
```
因此不能寫出這樣的程式碼
```kotlin=
// Error,含有 associatedtype 的 protocol View 只能作為類型約束使用
struct ContentView: View {
var body: View {
Text("Hello World")
}
}
```
想要 Swift 自動推斷出 View.Body 的類型,需要明確指出 body 的真正的類型,例如:
```kotlin=
struct ContentView: View {
var body: Text {
Text("Hello World")
}
}
```
雖然可以明確指出 body 的類型,但有些麻煩
- 每次修改 body 的返回時,都需要手動更改返回類型
- 新建一個 View 時,需要考慮是什麼類型
- 其實開發者只關心返回的是不是 View,而對實際上它是什麼類型不感興趣
some View 使用了 Swift 5.1 的 Opaque return types 特性
它向編譯器作出保證,每次 body 得到的一定是某個確定且遵守 View 協議的類型
但請編譯器網開一面,不要细究具體的類型,但只能返回單一確定類型,所以以下程式碼不行
```kotlin=
var body: some View {
if someCondition {
return Text("Hello World")
} else {
return Button(action: {}) {
Text("Tap me")
}
}
}
```
這是編譯期間的特性,在保證 associatedtype protocol 的功能的前提下
使用 some 能抹消具體的類型,這特性用在 SwiftUI 上簡化了撰寫難度
讓不同 View 聲明的語法更統一
## 新增元件
新增一個 Image,並在 Assets.xcassets 加入圖片
```kotlin=
struct ContentView: View {
@State var isSayHello = true
var body: some View {
Image("rain").resizable().frame(width: 100, height: 100)
Text("Hello World")
}
}
```
這樣子會發現編譯器報錯,<font color = "Tomato">Function declares an opaque return type, but has no return statements in its body from which to infer an underlying type</font>
==Stack 組合法==
這時候就牽扯到上面提到的 some View,只能返回單一確定的類型,而 Image 和 Text 是不同類型
那這樣到底該怎麼組合不同的元件呢?答案是使用 Stack
```kotlin=
struct ContentView: View {
@State var isSayHello = true
var body: some View {
VStack {
Image("rain").resizable().frame(width: 100, height: 100)
Text("Hello World")
}
}
}
```
Stack 分成水平的 HStack、垂直的 VStack,將元件包成一個 View 就可以組合多個元件
這個原理跟 ViewBuilder 有關,研究 VStack 的原始碼會發現有一個 @ViewBuilder 標記的 content
這表示 content 在被使用前,會按照 ViewBuilder 中合適的 buildBlock 進行 build 後再使用
ViewBuilder 有很多 buildBlock 方法,他們負責把閉包中的 View 轉換成 TupleView,並返回
```kotlin=
@inlinable public init(alignment: HorizontalAlignment = .center, spacing: CGFloat? = nil, @ViewBuilder content: () -> Content)
```
==TupleView 組合法、Group 組合法==
但 ViewBuilder 只能實現最多十個參數的 buildBlock,因此在 VStack 中放超過十個 View
編譯器就會不太高興,但十個應該夠。如果還不行,可以考慮直接使用 TupleView 來合併 View
```kotlin=
TupleView<(Text, Text)>(
(Text("Hello"), Text("Hello"))
)
```
```kotlin=
Group {
Text("Hello")
Text("Hello")
}
```
:::info
Text 元件可以互相合併,例如 Text("123") + Text("456"),最後會顯示 123456
:::
## 宣告屬性
用屬性的狀態決定如何顯示、更新畫面,這樣能更容易實現資料與畫面的同步
==有預設值==
在 struct 中直接宣告變數
```kotlin=
struct ContentView: View {
var isSayHello = true
var body: some View {
Text("Hello World")
}
}
```
==無預設值==
需要在使用時 (ex: SceneDelegate、PreviewProvider),進行初始化
- 宣告
```kotlin=
struct ContentView: View {
var isSayHello: Bool
var body: some View {
Text("Hello World")
}
}
```
- 使用
```kotlin=
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView(isSayHello: true)
}
}
```
==進行屬性修改==
宣告完後,想要對屬性進行修改,會產生 <font color=Tomato>Cannot assign to property: 'self' is immutable</font> 錯誤
因為 SwiftUI 的 view 通常以 struct 定義,就像 struct ContentView: View
而 struct 是 value type,因此我們無法在 computed property body 裡修改它的屬性
只要加上 <font color=Tomato>@State</font> 就可以解決問題囉!
```kotlin=
@State var isSayHello = true
```
==範例實作==
@State 可讓宣告的屬性進行修改,而屬性被修改後,body 就會重新執行,刷新頁面
加上 private 可以讓屬性更安全,如果不加上,在外面讀取時會造成崩潰
```kotlin=
struct ContentView: View {
@State private var isRain = true
var body: some View {
VStack {
if isRain {
Image("rain")
.resizable()
.frame(width: 100, height: 100)
Text("我們淋著大雨不知何時才能放晴")
} else {
Image("sun")
.resizable()
.frame(width: 100, height: 100)
Text("太陽公公出來了,他對我呀笑呀笑")
}
Button("今天天氣如何 ?") {
self.isRain = Bool.random()
}
}
}
}
```
:::info
以 struct 定義的 ContentView 是 value type,為何加了 @State 就能修改它的 property ?
這是因為加了 @State 後,SwiftUI 將在背後另外產生空間儲存 property 的內容
它不再儲存在 ContentView 裡,因此我們可以修改它的內容。
@State 實現的機制跟 Swift 的 property wrapper 有關,可以進一步 google 研究相關的說明
:::
## 元件綁定資料
接下來我們把上面範例的 Button 改成 Toggle,並讓 Toggle 跟 isRain 綁定
開關打開時 isOn 變成 true,關閉時 isOn 自動變成 false,同時 isRain 跟著變動
由於 isRain 是 @State,所以數值改動時,會讓畫面更新,顯示更改後的天氣
```kotlin=
Toggle("今天下雨嗎 ?", isOn: $isRain)
```
SwiftUI 透過型別 `Binding<Value>` 實現 binding 溝通機制,Value 代表綁定的資料型別
在 @State property 前加上 $,即可取得它的 binding,<font color="Tomato">而 $ 修飾後就會變成引用類型</font>
## 元件外觀
### 基礎設計
這節要來修改一下元件的外觀,在 SwiftUI 中,元件的外觀可以直接用屬性來表示
例如:製作一個有粗體、標題大小、文字顏色、內部間距、邊框的 Text 元件

```kotlin=
Text("Hello World")
.fontWeight(.bold)
.font(.title)
.foregroundColor(.purple)
.padding()
.border(Color.purple, width: 5)
```
製作有圓角的矩形 
```kotlin=
Text("Hello World")
.fontWeight(.bold)
.font(.title)
.foregroundColor(.purple)
.padding()
.overlay(RoundedRectangle(cornerRadius: 20)
.stroke(Color.purple, lineWidth: 5))
```
使用另一個形狀物件來繪製邊框。例如:Capsule 取代 RoundedRectangle
製作膠囊形狀 
```kotlin=
Text("Hello World")
.fontWeight(.bold)
.font(.title)
.foregroundColor(.purple)
.padding()
.overlay(Capsule(style: .continuous)
.stroke(Color.purple, lineWidth: 5))
```
試著指定 StrokeStyle,讓元件邊框變成虛線 
```kotlin=
Text("Hello World")
.fontWeight(.bold)
.font(.title)
.foregroundColor(.purple)
.padding()
.overlay(Capsule(style: .continuous)
.stroke(Color.purple, style: StrokeStyle(lineWidth: 5, dash: [10])))
```
製作內部填滿的美麗元件 
```kotlin=
Text("Hello World")
.fontWeight(.bold)
.font(.title)
.padding()
.background(Color.purple)
.foregroundColor(.white)
.padding(10)
.border(Color.purple, width: 5)
```
:::info
上面的原理是怎麼辦到的呢?可以從程式碼中看到有兩個 padding
第一個 padding() 是文字與背景的內距,第二個 padding(10) 是背景與邊框的內距
能把這簡單想成由三個東西組成,一個是自帶內距的元件、一個是 padding、另一個是邊框
把這三者組合就構成了這個美麗的元件
:::
那有圓角該怎麼做? 
一樣的道理先做出有自帶圓角的元件、再來是 padding、最後是圓角邊框
```kotlin=
Text("Hello World")
.fontWeight(.bold)
.font(.title)
.padding()
.background(Color.purple)
.cornerRadius(40)
.foregroundColor(.white)
.padding(10)
.overlay(
RoundedRectangle(cornerRadius: 40)
.stroke(Color.purple, lineWidth: 5))
```
:::danger
因為自帶圓角的元件是自己定義 cornerRadius,所以用 Capsule 膠囊形狀
可能造成外框與內部形狀不一致的情況,所以上述範例採用圓角矩形代替
:::
錯誤的使用結果,仔細看就能看出差異 
### 進階設計
製作帶有圖片的按鈕 
```kotlin=
Button(action: {
print("Delete tapped!")
}) {
HStack {
Image(systemName: "trash")
.font(.title)
Text("Delete")
.fontWeight(.semibold)
.font(.title)
}
.padding()
.foregroundColor(.white)
.background(Color.red)
.cornerRadius(40)
}
```
製作有漸變色背景的按鈕 
將上個範例的 .background(Color.red) 修改成以下程式碼
```kotlin=
.background(LinearGradient(gradient: Gradient(colors: [Color.red, Color.blue]),
startPoint: .leading, endPoint: .trailing))
```
:::info
可以修改 startPoint、endPoint,分別為 .top、.bottom,即為上往下漸變
:::
接下來自定義顏色,並添加陰影 
在上個範例的 .cornerRadius(40) 下方加上以下程式碼
```kotlin=
.shadow(radius: 5.0)
//.shadow(color: .gray, radius: 20.0, x: 20, y: 10) //可定義陰影顏色、位置
```
創建適應螢幕寬度的按鈕 
在上個範例的 .padding() 下方加上以下程式碼
```kotlin=
.frame(minWidth: 0, maxWidth: .infinity)
```
若希望適應螢幕寬度並與螢幕有間距 
在上個範例的 .shadow(radius: 5.0) 下方加上以下程式碼
```kotlin=
.padding(.horizontal, 20)
```
## 元件、風格複用
有些元件常常一起使用,或是元件的風格都相同,此時就需要重複利用,避免冗余的程式碼
以下將制定複用的結構,讓組合的元件、特定的風格,可以重複使用
### 自訂複用元件
在 ContentView.swift 中建立一個 Text 與 TextField 結合的元件
```kotlin=
struct LabelTextField : View {
var label: String
var placeHolder: String
@State var text: String = ""
var body: some View {
VStack(alignment: .leading) {
Text(label)
.font(.headline)
TextField(placeHolder, text: $text)
.textFieldStyle(PlainTextFieldStyle())
.padding(.all)
.background(Color(red: 176.0/255.0, green: 196.0/255.0, blue: 222.0/255.0, opacity: 1.0))
.cornerRadius(20)
}.padding(.horizontal, 15)
}
}
```
然後在 ContentView 使用
```kotlin=
struct ContentView: View {
var body: some View {
List {
VStack {
LabelTextField(label: "NAME", placeHolder: "Enter your name")
LabelTextField(label: "POSITION", placeHolder: "Enter your position", text: "Student")
}
.listRowInsets(EdgeInsets())
}
}
}
```
完成後可以得到這樣的畫面 
### 自訂複用風格
在 ContentView.swift 中建立一個 ViewModifier(視圖修改器),將風格屬性加入
```kotlin=
struct PrimaryLabel: ViewModifier {
func body(content: Content) -> some View {
content
.font(.title)
.foregroundColor(.purple)
.padding()
.overlay(Capsule(style: .continuous)
.stroke(Color.purple, style: StrokeStyle(lineWidth: 5, dash: [10])))
}
}
```
然後在 ContentView 建立元件並套用風格
```kotlin=
struct ContentView: View {
var body: some View {
VStack {
Text("Hello World")
.fontWeight(.bold)
.modifier(PrimaryLabel())
Text("Hello World")
.fontWeight(.bold)
.modifier(PrimaryLabel())
}
}
}
```
完成後就能得到這樣的畫面 
:::danger
有些風格屬性是無法用 ViewModifier 套用的,例如:.fontWeight(.bold)
這樣就必須在使用元件時添加,不過總歸來說,還是節省了很多程式碼
:::
## 動畫
SwiftUI 讓製作動畫變得更容易,有兩種方法可以進行動畫:animation()、withAnimation()
它們使用的時機點不太一樣,但方法是相似的
- animation():讓單個 View 進行動畫
- withAnimation():當狀態值改變時,進行動畫
### animation()
添加以下程式碼
```kotlin=
struct ContentView: View {
@State private var isShowing = false
var body: some View {
VStack {
Toggle(isOn: $isShowing.animation()) {
Text("Show the text")
}
if isShowing {
Text("Hello World")
}
}
}
}
```
完成後就會有很平滑的效果 
### withAnimation()
添加以下程式碼
```kotlin=
struct ContentView: View {
@State private var isShowing = false
var body: some View {
VStack {
Button(action: {
withAnimation {
self.isShowing.toggle() //讓變數值相反
}
}) {
Image(systemName: "chevron.right.circle")
.imageScale(.large)
.rotationEffect(.degrees(isShowing ? 450 : 0))
.scaleEffect(isShowing ? 1.5 : 1)
.padding()
}
if isShowing {
Text("Hello World")
}
}
}
}
```
完成後就會有很酷的旋轉效果 
### 進階動畫範例
旋轉、調整大小這些都是基本的動畫,接下來要用 AnyTransition 進行進階動畫
使用自定義的轉場,讓元件有淡入淡出的效果
添加以下程式碼
```kotlin=
struct ContentView: View {
@State private var isShowing = false
//對顯示、消失制定不同的動畫
var transition: AnyTransition {
let insertion = AnyTransition.move(edge: .trailing)
.combined(with: .opacity)
let removal = AnyTransition.move(edge: .leading)
.combined(with: .opacity)
return .asymmetric(insertion: insertion, removal: removal)
}
var body: some View {
VStack {
Button(action: {
withAnimation {
self.isShowing.toggle() //讓變數值相反
}
}) {
Image(systemName: "chevron.right.circle")
.imageScale(.large)
.rotationEffect(.degrees(isShowing ? 540 : 0))
.scaleEffect(isShowing ? 1.5 : 1)
.padding()
}
if isShowing {
Text("Hello World")
.transition(transition)
}
}
}
}
```
完成後元件就有淡入淡出的效果 
## 多樣化預覽
預覽是 SwiftUI 的特色,但需要搭配 MacOS 10.15 beta 跟 Xcode beta7 才能用
而預覽是在右側開啟模擬器,這樣的功能加速了開發者刻 UI 的速度,同時避免發生錯誤
預覽的使用方式在本文前已經說明,接下來要介紹它獨特的功能
### 自訂模擬器環境
預覽可以讓開發者自訂環境,例如:手機字體超大、暗黑模式、導覽畫面
這樣的方式,可以模擬出在各種情況下 SwiftUI 所呈現的樣貌
在要預覽的 SwiftUI 中,加入以下程式碼,並 Resume 提交
```kotlin=
#if DEBUG
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
Group {
ContentView()
.environment(\.sizeCategory, .accessibilityExtraExtraExtraLarge)
ContentView()
.environment(\.colorScheme, .dark)
NavigationView {
ContentView()
}
}
}
}
#endif
```
### 各種版本模擬器
預覽能設定開啟的模擬器版本,例如:iPhone XR
也可以一次開啟多個模擬器、多個裝置,例如:iPad
在要預覽的 SwiftUI 中,加入以下程式碼,並 Resume 提交
```kotlin=
#if DEBUG
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ForEach(["iPhone SE", "iPhone XS Max", "iPad Pro (12.9-inch)"], id: \.self) { deviceName in
ContentView()
.previewDevice(PreviewDevice(rawValue: deviceName))
.previewDisplayName(deviceName)
}
}
}
#endif
```
## 參考文章
[初探 SwiftUI 概念](https://onevcat.com/2019/06/swift-ui-firstlook/)
[APPCODA 元件外觀](https://www.appcoda.com.tw/swiftui-border/?fbclid=IwAR1cVTBjF7p5E03qT8XXegMje-54k1A4crRgNM2kHlqbYPwY_LmCnrGT86o)
[更多的元件外觀 原文](https://www.appcoda.com/swiftui-buttons/)
[SwiftUI 技巧](https://www.hackingwithswift.com/quick-start/swiftui/swiftui-tips-and-tricks)
[理解 SwiftUI 的屬性裝飾器@State, @Binding, @ObservedObject, @EnvironmentObject](https://juejin.im/post/5d625c01f265da03cd0a8a58)
[SwiftUI cheat sheet](https://fuckingswiftui.com/)
## 未完成
controller 結合