---
# System prepended metadata

title: SwiftUI Getting Started
tags: [Swift, SwiftUI, iOS]

---

###### 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 提交，會在右側開啟模擬器，即可看到畫面

![](https://hackmd.io/_uploads/SkRdiP0Vh.png)

如果 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 元件
![](https://hackmd.io/_uploads/S1ucoD042.png)

```kotlin=
Text("Hello World")
    .fontWeight(.bold)
    .font(.title)
    .foregroundColor(.purple)
    .padding()
    .border(Color.purple, width: 5)
```

製作有圓角的矩形 ![](https://hackmd.io/_uploads/BJpnoDAE3.png)

```kotlin=
Text("Hello World")
    .fontWeight(.bold)
    .font(.title)
    .foregroundColor(.purple)
    .padding()
    .overlay(RoundedRectangle(cornerRadius: 20)
        .stroke(Color.purple, lineWidth: 5))
```

使用另一個形狀物件來繪製邊框。例如：Capsule 取代 RoundedRectangle
製作膠囊形狀 ![](https://hackmd.io/_uploads/B18yhPCNh.png)

```kotlin=
Text("Hello World")
    .fontWeight(.bold)
    .font(.title)
    .foregroundColor(.purple)
    .padding()
    .overlay(Capsule(style: .continuous)    
        .stroke(Color.purple, lineWidth: 5))
```

試著指定 StrokeStyle，讓元件邊框變成虛線 ![](https://hackmd.io/_uploads/rJubhwRV3.png)

```kotlin=
Text("Hello World")
    .fontWeight(.bold)
    .font(.title)
    .foregroundColor(.purple)
    .padding()
    .overlay(Capsule(style: .continuous)
        .stroke(Color.purple, style: StrokeStyle(lineWidth: 5, dash: [10])))
```

製作內部填滿的美麗元件 ![](https://hackmd.io/_uploads/ryymhw0Nh.png)

```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、另一個是邊框
把這三者組合就構成了這個美麗的元件
:::

那有圓角該怎麼做？ ![](https://hackmd.io/_uploads/ryO42D0Eh.png)
一樣的道理先做出有自帶圓角的元件、再來是 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 膠囊形狀
可能造成外框與內部形狀不一致的情況，所以上述範例採用圓角矩形代替
:::

錯誤的使用結果，仔細看就能看出差異 ![](https://hackmd.io/_uploads/rJrI3vC43.png)

### 進階設計
製作帶有圖片的按鈕 ![](https://hackmd.io/_uploads/SyH_2vA43.png)

```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)
}
```

製作有漸變色背景的按鈕 ![](https://hackmd.io/_uploads/BJe92wRE2.png)
將上個範例的 .background(Color.red) 修改成以下程式碼

```kotlin=
.background(LinearGradient(gradient: Gradient(colors: [Color.red, Color.blue]), 
    startPoint: .leading, endPoint: .trailing))
```

:::info
可以修改 startPoint、endPoint，分別為 .top、.bottom，即為上往下漸變
:::

接下來自定義顏色，並添加陰影 ![](https://hackmd.io/_uploads/HkFo3PC42.png)
在上個範例的 .cornerRadius(40) 下方加上以下程式碼

```kotlin=
.shadow(radius: 5.0)
//.shadow(color: .gray, radius: 20.0, x: 20, y: 10) //可定義陰影顏色、位置
```

創建適應螢幕寬度的按鈕 ![](https://hackmd.io/_uploads/r1Ba3DAV2.png)
在上個範例的 .padding() 下方加上以下程式碼

```kotlin=
.frame(minWidth: 0, maxWidth: .infinity)
```

若希望適應螢幕寬度並與螢幕有間距 ![](https://hackmd.io/_uploads/Hkok6wRN2.png)
在上個範例的 .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())
        }
    }
}
```

完成後可以得到這樣的畫面 ![](https://hackmd.io/_uploads/ByEZawREh.png)

### 自訂複用風格
在 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())
        }
    }
}
```

完成後就能得到這樣的畫面 ![](https://hackmd.io/_uploads/S174pD0E2.png)

:::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")
            }
        }
    }
}
```

完成後就會有很平滑的效果 ![](https://i.imgur.com/aTasUJn.gif)

### 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")
            }
        }
    }
}
```

完成後就會有很酷的旋轉效果 ![](https://i.imgur.com/66ScT1H.gif)

### 進階動畫範例
旋轉、調整大小這些都是基本的動畫，接下來要用 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)
            }
        }
    }
}
```

完成後元件就有淡入淡出的效果 ![](https://i.imgur.com/XCZGG05.gif)

## 多樣化預覽
預覽是 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 結合
