###### 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 結合