SwiftUI

Table of Contents

1 Recommending Reading

這些都只是為了選修課臨時整理的教材,內容深度與廣度都有限,建議有學習野心的同學還是多多去讀其他的資料:

2 iOS app 的開發界面: UIKit v.s. SwiftUI

2.1 UIKit 與 SwiftUI 的差異性

2.1.1 系統需求

UIKit 是從 Xcode1 就一直存在的 Framework;而 SwiftUI 則是 2019/6 WWDC 所發表的全新用來繪製 UI 的 Framework。因此,SwiftUI 必須搭配 iOS13+ 和 MacOS10.15+。1

2.1.2 底層語言

UIKit 底層仍為 Objecitve-C;而 SwiftUI 則是完完全全用 Swift 打造的 Framework。

2.1.3 語法簡潔度

SwiftUI 產生一顯示文字的元件更精簡潔了。

2.1.4 Declarative vs Imperative Programming

  • imperative programming:告訴電腦如何做(HOW)來得到我們想要的結果(WHAT),如 Java, C++, PHP, C#, Swift
  • declarative programming:告訴電腦我們想要的結果(WHAT),讓電腦決定如何做(HOW),如 SwiftUI

2.1.5 跨平台

跨平台指的非跨 Android(但希望有那麼一天是可以支援的😀)。跨平台指的是使用 SwiftUI 所開發的專案,可以同時支援 macOS、watchOS、tvOS 等系統。引用一句 WWDC2019 SwiftUI 演講者所說的一句話。

Learn once, apply everywhere.

2.1.6 Automatic Preview

這是此次 SwiftUI 最大的亮點之一,所謂 Automatic Preview,意思指的是即時預覽,即我們一邊調整程式碼的同時,也可以立即看到修改後的結果。

2.1.7 自動支援進階功能

SwiftUI 本身即支援 Dynamic Type、Dark Mode、 Localization 等等。這邊特別來講一下 UIKit 和 SwiftUI 在文字設定上有關於 Dark Mode 的差異,UIKit 若是無特別指定文字的顏色(意即使用 Default 的選項),在 Light Mode 字體會是白色;相對的在 Dark Mode 即會是白色,這點跟 SwiftUI 沒有特別的差異,但是 SwiftUI 除了 Default 外,還有 Secondary,如果還不喜歡的話,還有第三個選項,就是在 Assets 自行設定 Light Mode 和 Dark Mode 分別要顯示的顏色。

differences.png

Figure 1: UIKit 與 SwiftUI 的差異性比較圖

2.2 SwiftUI vs UIKit: Benefits and Drawbacks

2.2.1 Drawbacks of SwiftUI 2

  • It supports only iOS 13 and Xcode 11. By switching to them, you abandon users of older versions of iOS, which is a radical move devoid of concern for the user. But since Apple annually updates its list of supported iOS versions, I think SwiftUI will be used more over the next two years as users install the latest iOS version.
  • It’s still very young, so there isn’t much data on Stack Overflow. This means that you can’t get much help resolving complicated issues.
  • It doesn’t allow you to examine the view hierarchy in Xcode Previews.

2.2.2 Benefits of SwiftUI 2

  • It’s easy to learn, and the code is simple and clean.
  • It can be mixed with UIKit using UIHostingController.
  • It allows you to easily manage themes. Developers can easily add dark mode to their apps and set it as the default theme, and users can easily enable dark mode. Besides, it looks awesome.
  • SwiftUI provides mechanisms for reactive programming enthusiasts with BindableObject, ObjectBinding, and the whole Combine framework.
  • It offers Live Preview. This is a very convenient and progressive way to see the results of code execution in real time without having to build. I’m not sure if it somehow affects the processor. So far, I’ve noticed a delay provoked by the use of Live Preview, but I think Apple will soon make improvements.
  • SwiftUI no longer needs Interface Builder. It was replaced by Canvas, an interactive interface editor. When writing code, the visual part in Canvas is automatically generated, and when you create visual presentation elements, they automatically appear in the code.
  • Your application will no longer crash if you forget to update the @IBOutlet association with the variable.
  • There’s no AutoLayout or related problems. Instead, you use things like HStack, VStack, ZStack, Groups, Lists, and more. Unlike AutoLayout, SwiftUI always produces a valid layout. There’s no such thing as an ambiguous or unsatisfiable layout. SwiftUI replaces storyboards with code, making it easy to create a reusable view and avoid conflicts related with the simultaneous use of one project by the development team.

3 AppDelegate v.s. SceneDelegate v.s. <AppName>App

3.1 AppDelegate -> SceneDelegate

AppDelegate 原來的職責為負責 App 的生命週期和 UI 生命週期,在 Xcode11 後,AppDelegate 將 UI 的生命週期(Scene Session)交給 SceneDelegate。原 Xcode10使用 Swift 為 User Interface 的專案 Launch 的生命週期為 AppDelegate → ViewController,而使用 SwiftUI 為 User Interface 的專案則變成為 AppDelegate → SceneDelegate → ContentView,原本應該出現在 AppDelegate 的 applicationWillEnterForeground(_:) 等相關 App 到前、背景等相關的生命週期邏輯也都移至 SceneDelegate 裡了,method 名稱 application 的前綴字也都更改為 scene 了。3

3.1.1 SceneDelegate.swift

 1: import UIKit
 2: import SwiftUI
 3: 
 4: class SceneDelegate: UIResponder, UIWindowSceneDelegate {
 5: 
 6:     var window: UIWindow?
 7: 
 8:     func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
 9:         // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
10:         // If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
11:         // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
12: 
13:         // Create the SwiftUI view that provides the window contents.
14:         let contentView = ContentView()
15: 
16:         // Use a UIHostingController as window root view controller.
17:         if let windowScene = scene as? UIWindowScene {
18:             let window = UIWindow(windowScene: windowScene)
19:             window.rootViewController = UIHostingController(rootView: contentView)
20:             self.window = window
21:             window.makeKeyAndVisible()
22:         }
23:     }
24: 
25:     func sceneDidDisconnect(_ scene: UIScene) {
26:         // Called as the scene is being released by the system.
27:         // This occurs shortly after the scene enters the background, or when its session is discarded.
28:         // Release any resources associated with this scene that can be re-created the next time the scene connects.
29:         // The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead).
30:     }
31: 
32:     func sceneDidBecomeActive(_ scene: UIScene) {
33:         // Called when the scene has moved from an inactive state to an active state.
34:         // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive.
35:     }
36: 
37:     func sceneWillResignActive(_ scene: UIScene) {
38:         // Called when the scene will move from an active state to an inactive state.
39:         // This may occur due to temporary interruptions (ex. an incoming phone call).
40:     }
41: 
42:     func sceneWillEnterForeground(_ scene: UIScene) {
43:         // Called as the scene transitions from the background to the foreground.
44:         // Use this method to undo the changes made on entering the background.
45:     }
46: 
47:     func sceneDidEnterBackground(_ scene: UIScene) {
48:         // Called as the scene transitions from the foreground to the background.
49:         // Use this method to save data, release shared resources, and store enough scene-specific state information
50:         // to restore the scene back to its current state.
51:     }
52: }
53: 
54: struct SceneDelegate_Previews: PreviewProvider {
55:     static var previews: some View {
56:         /*@START_MENU_TOKEN@*/Text("Hello, World!")/*@END_MENU_TOKEN@*/
57:     }
58: }

3.2 SceneDelegate -> <AppName>App

從程式的簡潔與效能來看,整段SceneDelegate只為了定義一個View(第76行)這其實不太合理,Apple在WWDC20中提出了一個新的solution: App.
從Xcode 12 beta開始,開啟新專案時會多一個SwiftUI App的選項,原來的SceneDelegate.swiftx己消失,取而代之的是XXXApp.swift, XXX為專案名稱。

XcodeComparison.png

Figure 2: Xcode 11 v.s. Xcode 12

3.2.1 HelloApp.swift(專案名稱為Hello)

 1: //
 2: //  NewAppApp.swift
 3: //  NewApp
 4: //
 5: //  Created by yen yung chin on 2021/2/15.
 6: //
 7: 
 8: import SwiftUI
 9: 
10: @main
11: struct HelloApp: App {
12:     var body: some Scene {
13:         WindowGroup {
14:             ContentView()
15:         }
16:     }
17: }
18: 
  • @main tells Xcode that the following struct, Hello, will be the entry point for the app. Only one struct can be marked with this attribute.
  • According to the documentation, App is a protocol that “represents the structure and behavior of an app.” HelloWorldApp conforms to this. It’s like the base view of your app — no, the app itself. You’re literally writing out what your app will look like in this struct.
  • Scene — The body of a SwiftUI View must be of type View. Similarly, the body of a SwiftUI App must be of type Scene…
  • WindowGroup is a Scene that wraps views. The view that we want to present, ContentView, is a View — not a scene. WindowGroup lets us wrap them up into a single Scene that SwiftUI can recognize and display.

資料來源:4

4 建立 SwiftUI Project

4.2 使用 SwiftUI 開啟新專案 3

  1. 首先,打開 Xcode,並點擊 Create new Xcode project。在 iOS 之下選擇 Single View App,並為專案命名。
  2. 然後在下方勾選 Use SwiftUI 的選項,如果沒有勾選該選項的話,Xcode 會自動產生 storyboard 檔案(UIKit)。
  3. Xcode 會自動幫你創建一個名為 ContentView.swif 的檔案,Xcode 會在程式碼的右邊呈現一個即時的預覽視窗(preview), 點選 resume 鈕生成預覽畫面(會花一點時間)。

4.2.1 ContentView.swift

 1: import SwiftUI
 2: 
 3: struct ContentView: View {
 4:     var body: some View {
 5:         Text("文字")
 6:     }
 7: }
 8: 
 9: struct ContentView_Previews: PreviewProvider {
10:     static var previews: some View {
11:         ContentView()
12:     }
13: }
  • 1行和 C++中的#include <iostream>同意,先匯入所需函式庫
  • 3行說明有一個 struct 名為 ContentView,這個 ContentView conform(尊循)View 這個 Protocol,這代表必須有一個 some view 或回傳一個 some view
  • 在 ContentView 中,有一個叫 body 的變數(第4行),這個 body 的回傳類型為 some view,some 為 swift 5.1 出現的新 keyword,屬於 opaque 回傳類型,代表它會回傳某些類型為 view 的值,至於實際回傳的是那一種類型的 view,swift 並不太在意
  • 5行的最前面省略了一個 return,意思是 body 這個 variable 最後會傳回一個 Text, 即,呈現在 View 上,body 只能回傳一個值,若 view 上面有許多物件,則需包含進一個 container 中,最後回傳這一個 container。
  • 9行的 ContentViewPreviews 負責產生預覧畫面。

4.3 Text

4.3.1 改變 Text 的屬性

  • 改變 component 有兩種方式:工具列、code
  • Attributes (modifier 的不同順序可能產生不同效果)
    • frame
    • foregroundColor
    • background
    • font
    • padding
    • cornerRadius
4.3.1.1 SwiftUI Inspector:
  1. on Text object (in preview screen): CMD + click
  2. select Show SwiftUI Inspector
  3. change Text, Font, Color
  4. Monitor the corresponding code changes in code window

inspector-1.gif

Figure 3: SwiftUI Inspector

4.3.1.2 Inspector frame

inspector-2.gif

Figure 4: SwiftUI Inspector

4.3.1.3 code

於 Text(“…”)後加上屬性 function 或修改其他屬性

inspector-3.gif

Figure 5: SwiftUI Change Attributes

4.4 Stack

一個以上的物件都要放在 Stack 中,Stack 可分為以下三類

  • VStack: 垂直排列
  • HStack: 水平排列
  • ZStack: 上下排列(重叠)

4.4.1 Stack 與 Stack 可相互包含,於 View 中加入 Stack 的方式有二:

4.4.1.1 由工具列 drag: Xcode 會自動加入相對的 code

vstack.gif

Figure 6: Drag component from toolbar

4.4.1.2 coding
 1: import SwiftUI
 2: 
 3: struct ContentView: View {
 4:     var body: some View {
 5:         VStack {
 6:             Text("第一行文字")
 7:             Text("第二行文字")
 8:         }
 9:     }
10: }
11: 
12: struct ContentView_Previews: PreviewProvider {
13:     static var previews: some View {
14:         ContentView()
15:     }
16: }

vstack-1.jpg

Figure 7: VStack

4.4.2 SwiftUI 撰寫原則

  • body 恆為只能 return 一物件。
  • 若有多個物件時,一定得放在 Stack 裡。

4.4.3 HStack DEMO

 1: import SwiftUI
 2: 
 3: struct ContentView: View {
 4:     var body: some View {
 5:         HStack {
 6:             VStack {
 7:                 Button("請按我") {
 8:                     print("TEST")
 9:                 }
10:                 .frame(width: 60, height: 30, alignment: .center)
11:                 .foregroundColor(.white)
12:                 .background(Color.green)
13:                 Button("別亂按") {
14:                     print("QQ")
15:                 }
16:             }
17:             VStack {
18:                 Text("第一行文字")
19:                     .frame(width: 100, height: 30, alignment: .center   )
20:                     .foregroundColor(.white)
21:                     .background(Color.orange)
22:                 Text("第二行文字")
23:                     .frame(width: 100, height: 30, alignment: .center)
24:                     .foregroundColor(.white)
25:                     .background(Color.red)
26:             }
27:         }
28:     }
29: }
30: 
31: struct ContentView_Previews: PreviewProvider {
32:     static var previews: some View {
33:         ContentView()
34:     }
35: }

hstack-1.jpg

Figure 8: HStack

5 Basic Components

5.1 Image

影像來源可以是 System Image 或自行下載/編修的影像(Customized Image)

5.1.1 使用系統內建的圖

5.1.1.1 SF Symbols 5
5.1.1.2 從 iOS 13 開始,Apple 介紹了一個名為 SFSymbols 的新功能。SF Symbols 這功能由 Apple 所設計,當中集合了 1500 多個可以在 App 之中使用的符號。3
5.1.1.4 code
 1: import SwiftUI
 2: 
 3: struct ContentView: View {
 4:     var body: some View {
 5:         VStack {
 6:             Text("System Image")
 7:                 .font(.headline)
 8:                 .foregroundColor(.orange)
 9:             Image(systemName: "icloud")
10:             .resizable()
11:             .scaledToFit()
12:             .frame(width: 100, height: 80, alignment: .center)
13:         }
14:     }
15: }
16: 
17: struct ContentView_Previews: PreviewProvider {
18:     static var previews: some View {
19:         ContentView()
20:     }
21: }
5.1.1.5 Demo

image-1.jpg

Figure 9: Images-1

5.1.2 使用自己的圖

  1. Drag image into Project folder Assets.xcassets
  2. Add following code
1: Image("ImageName") //file name in Assets.xcassets
2:   .resizable()
3:   .scaledToFit()
4:   .frame(width: 200, height: 160, alignment: .center)

5.1.3 Image Attributes

 1: import SwiftUI
 2: 
 3: struct ContentView: View {
 4:     var body: some View {
 5:         VStack {
 6:             Text("Albert Camus")
 7:                 .font(.title)
 8:                 .foregroundColor(.white)
 9:                 .background(Color.orange)
10:             Image("Albert-Camus")
11:                 .resizable()
12:                 .scaledToFill()
13:                 .frame(width: 200, height: 200, alignment: .center)
14:                 .clipShape(Circle())
15: 
16:         }
17:     }
18: }
19: 
20: struct ContentView_Previews: PreviewProvider {
21:     static var previews: some View {
22:         ContentView()
23:     }
24: }

image-2.jpg

Figure 10: Images-2

5.2 Button

5.2.1 語法

1: //...
2: Button("Title") {
3:     //action
4: }
5: 
6: Button(action: <#T##() -> Void#>, label: <#T##() -> _#>)
7: //...

5.2.2 將變數加入 View 中

 1: struct ContentView: View {
 2:     var title = "Hello SWiftUI"
 3: 
 4:     var body: some View {
 5:         VStack {
 6:             Text(verbatim: title)
 7:                 .padding(4)
 8:                 .foregroundColor(.white)
 9:                 .background(Color.gray)
10:         }
11:     }
12: }

5.2.3 如何於程式中改變 title 的值來改變 View 的顯示內容?

解決方案: @State
如以下範例:

 1: struct ContentView: View {
 2:     @State var title = "Hello SWiftUI"
 3: 
 4:     var body: some View {
 5:         VStack {
 6:             Text(verbatim: title)
 7:                 .padding(4)
 8:                 .foregroundColor(.white)
 9:                 .background(Color.gray)
10:         }
11:     }
12: }

View 為 struct,未加上 @State 的變數是不允許變更的(immutable);加了 @State 後,SwiftUI 將認為這個變數(或,struct 的 property)代表某種影將 View 畫面或內容的狀態,並在背後另外產生空間儲存 property 的內容,它不再儲存在 ContentView 裡,因此我們可以修改它的內容6

以 @State 宣告的 property 有個重要的特性,只要它的內容改變,畫面也會立即更新。它帶來了以下兩個好處:

  • 不用另外寫 property 內容改變時更新畫面的程式。
  • 不用擔心畫面顯示的內容跟 property 的內容不同步,比方修改了 property,但卻忘了更新畫面。

那麼,要在什麼地方去改變 title 的值?

5.2.4 範例: 按下 Button,改變 Text title

 1: struct ContentView: View {
 2:     @State var title = "Hello SWiftUI"
 3: 
 4:     var body: some View {
 5:         VStack {
 6:             Text(verbatim: title)
 7:                 .padding(4)
 8:                 .foregroundColor(.white)
 9:                 .background(Color.gray)
10:             Button("Click Me") {
11:                 self.title = "QQ"
12:             }
13:         }
14:     }
15: }

5.2.5 以 Button 開啟一個新的 View

一個 app 當然不會只有一個 View,我們可以透過 NavigationLink 來控制一系列 View 的呈現,也可以簡單的以 Button 來控制。
在下例中,按下 BUtton 後會秀出 SecondView,而 SwiftUI 控制一個新 View 的方式是透過 sheet(isPreseted)中 isPresented 的 true/false。
ContentView.swift

 1: import SwiftUI
 2: 
 3: struct ContentView: View {
 4:     @State var showSecondView = false
 5:     var body: some View {
 6:         Button(action: {
 7:             self.showSecondView = true
 8:         }, label: {
 9:             Text("打開一個新的View")
10:         }).sheet(isPresented: self.$showSecondView, content: {
11:             SecondView()
12:         })
13:     }
14: }
15: 
16: struct ContentView_Previews: PreviewProvider {
17:     static var previews: some View {
18:         ContentView()
19:     }
20: }
21: 

Button 藉由 sheet 裡的 contet 來指定要打開的是哪一個 View(第10行,在此例中目標 View 為 SecondView),而真正控制是否秀出這個 View 的變數為 showSecondView 這個 bool(第4),之所以按下 Button 後能秀出 SecondView,是因為在 Button 的 action 中,我們把 showSecondView 的值改為 true(第7行)。SecondView.swift 的內容如下:

 1: import SwiftUI
 2: 
 3: struct SecondView: View {
 4:     var body: some View {
 5:         Text("I'm second View")
 6:     }
 7: }
 8: 
 9: struct SecondView_Previews: PreviewProvider {
10:     static var previews: some View {
11:         SecondView()
12:     }
13: }
14: 

而此例執行結果為:

showSecondView.gif

Figure 11: Show New View

5.2.6 以 Button 關閉 View

在上例中,把 SecondView 關掉的方式為往下滑動螢幕,另一種關掉的方式是加上一個 Button:

 1: import SwiftUI
 2: 
 3: struct ContentView: View {
 4:     @State var showSecondView = false
 5:     var body: some View {
 6:         Button(action: {
 7:             self.showSecondView = true
 8:         }, label: {
 9:             Text("打開一個新的View")
10:         }).sheet(isPresented: self.$showSecondView, content: {
11:             SecondView(showMe: self.$showSecondView)
12:         })
13:     }
14: }
15: 
16: struct ContentView_Previews: PreviewProvider {
17:     static var previews: some View {
18:         ContentView()
19:     }
20: }
21: 

上述程式碼與前節唯一的差異在第11行,此處我們把控制 SecondView 是否出現的變數 showSecondView 傳給 SecondView。

 1: import SwiftUI
 2: 
 3: struct SecondView: View {
 4:     @Binding var showMe: Bool
 5:     var body: some View {
 6:         VStack {
 7:         Text("I'm second View")
 8:             Button(action: {
 9:                     self.showMe = false
10:             }, label: {
11:                 Text("Close me")
12:             })
13:         }
14:     }
15: }
16: 
17: struct SecondView_Previews: PreviewProvider {
18:     static var previews: some View {
19:         SecondView(showMe: .constant(false))
20:     }
21: }

在 SecondView.swift 中,以 showMe「接住」來自 ContentView 的共享變數(showSecondView)(第4行),然後在 Buuton 的 action 中將 showMe 的值改為 false(第9行),這同時也就是把 ContentView 中的 showSecondView 的值由 true 改為 false,然後 SecondView 就被關掉了。
由於 SecondView 中宣告了一個@Binding 變數,所有呼叫這個 View 的程式碼都要傳這個變數給它,例如 ContentView 中的

1: SecondView(showMe: self.$showSecondView)

同樣的,SecondView 最底下負責產生 app 預覽畫面的 SecondViewPreviews 也要提供這個參數,不過,由於它只是產生預覽畫面,與程式 h 執行無實際影響,所以我們可以隨便傳個 true/false 轉為常數給它就行,如上例中的第19行。執行結果如下:

closeView.gif

Figure 12: Close New View

6 Passing data within View: TextField

6.1 語法

1: @State private var 變數="值"
2: TextField("提示文字", text: $變數)

6.2 範例: 於 TextField 輸入資料,顯示於 Text 中

即,利用@State宣告一個可以讓Text和TextField共享的變數

 1: import SwiftUI
 2: 
 3: struct ContentView: View {
 4:     @State private var title = ""
 5: 
 6:     var body: some View {
 7:         VStack {
 8:             Text(verbatim: "Hello "+title)
 9:             HStack {
10:                 Text("Your Name: ")
11:                 TextField("請輸入姓名:", text: $title)
12: 
13:             }
14:         }
15:     }
16: }

執行結果如下

txtfield-1.jpg
在上述程式中,我們希望在 TextField 中輸入文字時就能即時改變 title 的值,便要做綁定(Binding)的動作,也就是在 TextField 中 title 前加上一個$ (第11行),由 TextField 的語法也可以看出: TextField(<titleKey: LocalizedStringKey, text: Binding<String>)。SwiftUI 便是透過「在@State property 前加上$」的方式來取得這個 property 的 binding,當第11的值一改變,第8行的 Text 也會即時跟著改變,這便是 binding 的作用。

除了 TextField 之外,SwiftUI 還有許多提供 Binding 的元件,例如:Toggle, Slider, DatePicker…。

6.3 範例 2: @state struct

當在 View 中有許多變數的值需要 Binding 時,一個一個加入@State property 有點太麻煩,此時可以將這些相關變數組合為一 struct,如下例:

1: struct User {
2:     var firstName = "Bilbo"
3:     var lastName = "Baggins"
4: }

We can now use that in a SwiftUI view by creating an @State property and attaching things to $user.firstName and $user.lastName, like this:

 1: struct ContentView: View {
 2:     @State private var user = User()
 3: 
 4:     var body: some View {
 5:         VStack {
 6:             Text("Your name is \(user.firstName) \(user.lastName).")
 7: 
 8:             TextField("First name", text: $user.firstName)
 9:             TextField("Last name", text: $user.lastName)
10:         }
11:     }
12: }

That all works: SwiftUI is smart enough to understand that one object contains all our data, and will update the UI when either value changes. Behind the scenes, what’s actually happening is that each time a value inside our struct changes the whole struct changes – it’s like a new user every time we type a key for the first or last name. That might sound wasteful, but it’s actually extremely fast.

6.4 What is @State

用途:在同一 View 中給不同元件共享(變更)變數,而且這種共享是雙向的,即,任何一端變更了變數的值,另一端都會立即更新。@tate 的相關特性如下:

  • State is a value, or a set of values, that can change over time, and that affects a view’s behavior, content, or layout. You use a property with the @State attribute to add state to a view.
  • 通過使用 @State 修飾器我們可以關聯出 View 的狀態. SwiftUI 將會把使用過 @State 修飾器的屬性存儲到一個特殊的內存區域(heap),並且這個區域和 View struct 是隔離的. 當 @State 裝飾過的屬性發生了變化,SwiftUI 會根據新的屬性值重新創建 View。7
  • Simple properties like String or Int
  • Belongs to a specific view
  • Never used outside that view
  • The wrappedValue is: anything (but almost certainly a value type).
  • What it does: stores the wrappedValue in the heap; when it changes, invalidates the View.
  • Projected value (i.e. $): a Binding (to that value in the heap).

7 Customize UI Components

SwiftUI 提供豐富的 modifier 幫助我們設計客製 UI 元件的樣式,諸如陰影,旋轉等效果皆可透過 modifier 實現,還可以搭配方便的拖曳加入相關程式碼。8

7.1 Text

7.1.1 Advanced Attributes 8

 1: struct ContentView: View {
 2:     var body: some View {
 3:         Text("Example")
 4:           .font(.title)
 5:           .fontWeight(.bold)
 6:           .foregroundColor(Color.white)
 7:           .padding(4)
 8:           .background(Color.gray)
 9:           .cornerRadius(14.0)
10:           .rotationEffect(Angle(degrees: 15))
11:           .rotation3DEffect(Angle(degrees: 30), axis: (x: 10, y: 30, z: 30))
12:           .shadow(radius: 20)
13:     }
14: }

7.1.2 Demo

adv-text-attributes.jpg

Figure 13: Text Attributes

7.2 Image

7.2.1 Advanced Attributes s9

 1: import SwiftUI
 2: 
 3: struct ContentView: View {
 4:     var body: some View {
 5:         VStack {
 6:             Text("Albert Camus")
 7:               .font(.body)
 8:               .foregroundColor(.white)
 9:               .background(Color.orange)
10:             Image("Albert-Camus")
11:               .resizable()
12:               .scaledToFill()
13:               .frame(width: 100, height: 100, alignment: .center)
14:               .clipShape(Circle())
15:             Image(systemName: "alarm.fill")
16:               .resizable()
17:               .scaledToFill()
18:               .frame(width: 100, height: 100, alignment: .center)
19:             Image("Albert-Camus")
20:               .frame(width: 100, height: 100, alignment: .center)
21:               .mask(Image(systemName: "alarm.fill")
22:                       .resizable()
23:                       .scaledToFit())
24:               .shadow(radius: 20)
25:         }
26:     }
27: }
28: 
29: struct ContentView_Previews: PreviewProvider {
30:     static var previews: some View {
31:         ContentView()
32:     }
33: }
34: 

7.2.2 Demo

image-advanced-attributes.jpg

Figure 14: Image Attributes

7.3 Button II

7.3.1 外觀控制

7.3.1.1 Advanced Attributes 10
 1: import SwiftUI
 2: 
 3: struct ContentView: View {
 4:     var body: some View {
 5:         VStack(spacing: 5.0) {
 6:             Text("Customized Button")
 7:               .font(.body)
 8:               .foregroundColor(.white)
 9:               .background(Color.orange)
10:             Button(action: {
11:                        print("Hello button tapped!")
12:                    }) {
13:                 Text("HI HI")
14:                   .fontWeight(.bold)
15:                   .font(.title)
16:                   .foregroundColor(.purple)
17:                   .padding()
18:                   .border(Color.purple, width: 5)
19:             }
20:             Button(action: {
21:                        print("Hello button tapped!")
22:                    }) {
23:                 Text("Press me")
24:                   .fontWeight(.light)
25:                   .font(.title)
26:                   .foregroundColor(.green)
27:                   .padding(5)
28:                   .overlay(
29:                     Capsule(style: .continuous)
30:                       .stroke(Color.green, style: StrokeStyle(lineWidth: 3, dash: [10]))
31:                   )
32:             }
33:         }
34:     }
35: }
36: 
7.3.1.2 Demo

customized-button-1.jpg

Figure 15: Button Attributes

7.3.2 Button v.s. @State

 1: import SwiftUI
 2: 
 3: struct ContentView: View {
 4:     @State private var a = ""
 5:     @State private var b = ""
 6:     @State private var c = "Ans:"
 7: 
 8:     var body: some View {
 9:         VStack {
10:             VStack {
11:                 Divider()
12:                 TextField("Number 1: ", text: $b)
13:                 Divider()
14:                 TextField("Number 2:", text: $a)
15:                 Divider()
16:                 Button("➕") {
17:                     let one = Int(self.a) ?? 0
18:                     let two = Int(self.b) ?? 0
19:                     self.c = "Ans: " + String(one + two)
20:                 }
21:                   .frame(width: 40, height: 30, alignment: .center)
22:                   .foregroundColor(.white)
23:                   .background(Color.green)
24:                   .font(.largeTitle)
25:                 Divider()
26:                 Text(verbatim: c)
27:                   .foregroundColor(.gray)
28:             }
29:               .frame(width: 200, height: 160, alignment: .center)
30:         }
31:     }
32: }

btn-1.jpg

Figure 16: Button

7.4 background, opacity

 1: //
 2: //  ContentView.swift
 3: //  uitest
 4: //
 5: //  Created by yen yung chin on 2020/7/29.
 6: //  Copyright © 2020 Letranger.tw. All rights reserved.
 7: //
 8: 
 9: import SwiftUI
10: 
11: struct ContentView: View {
12:     @State private var a = ""
13:     @State private var b = ""
14:     @State private var c = "Ans:"
15: 
16:     var body: some View {
17:         VStack(alignment: .center) {
18:             Text("計算機")
19:             Divider()
20:             TextField("Number 1: ", text: $b)
21:             Divider()
22:             TextField("Number 2:", text: $a)
23:             Divider()
24:             Button("➕") {
25:                 let one = Int(self.a) ?? 0
26:                 let two = Int(self.b) ?? 0
27:                 self.c = "Ans: " + String(one + two)
28:             }
29:               .frame(width: 40, height: 30, alignment: .center)
30:               .foregroundColor(.white)
31:               .background(Color.white)
32:               .font(.largeTitle)
33:             Divider()
34:             Text(verbatim: c)
35:               .foregroundColor(.black)
36: 
37: 
38:         }
39:           .padding(60)
40:           .background(Image("background").resizable().scaledToFill())
41:           .opacity(0.9)
42:     }
43: }
44: struct ContentView_Previews: PreviewProvider {
45:     static var previews: some View {
46:         ContentView()
47:     }
48: }
49: 
50: 

background-1.jpg

Figure 17: Background

8 List

8.1 What is List

SwiftUI 的列表視圖 (List View) 其實和 UIKit 的表格視圖 (Table View) 很類似,它們都是讓開發者把項目一列列地呈現,而預設設定上,每一列資料都會用分隔線 (line separator) 分開。

1: struct ContentView: View {
2:     var body: some View {
3:         List {
4:             Text("Hello world.")
5:             Text("Hello world.")
6:             Text("Hello world.")
7:         }
8:     }
9: }

8.2 準備單一 cell 格式

 1: import SwiftUI
 2: 
 3: struct ContentView: View {
 4:     var body: some View {
 5:         HStack {
 6:             Image(systemName: "book")
 7:               .resizable()
 8:               .frame(width: 30, height: 30, alignment: .center)
 9:             VStack(alignment: .leading) {
10:                 Text("Artificial Intelligence: A Modern Approach")
11:                   .multilineTextAlignment(.leading)
12:                   .foregroundColor(Color.green)
13:                 Text("Stuart Russell and Peter Norvig")
14:                   .multilineTextAlignment(.leading)
15:                   .foregroundColor(Color.orange)
16:             }
17:         }
18:     }
19: }
20: 

single-cell.jpg

Figure 18: Single cell

8.3 轉入 List 格式(靜態 List)

8.3.1 將最外層的 VStack 加入 List 中

list-1.jpg

Figure 19: List-1

8.3.2 list 語法

 1: import SwiftUI
 2: 
 3: struct ContentView: View {
 4:     var body: some View {
 5:         List(0 ..< 5) { item in
 6:             Image(systemName: "book")
 7:               .resizable()
 8:               .frame(width: 30, height: 30, alignment: .center)
 9:             VStack(alignment: .leading) {
10:                 Text("Artificial Intelligence: A Modern Approach")
11:                   .multilineTextAlignment(.leading)
12:                   .foregroundColor(Color.green)
13:                 Text("Stuart Russell and Peter Norvig")
14:                   .multilineTextAlignment(.leading)
15:                   .foregroundColor(Color.orange)
16:             }
17:         }
18:     }
19: }

8.3.3 結果

list-2.jpg

Figure 20: List-2

8.4 建立 list 來源資料(動態 List) 3

In order to handle dynamic items, you must first tell SwiftUI how it can identify which item is which. This is done using the Identifiable protocol, which has only one requirement: some sort of id value that SwiftUI can use to see which item is which. 11

 1: import SwiftUI
 2: 
 3: //建立book struct
 4: struct Book: Identifiable {
 5:     var id = UUID()
 6:     var title: String
 7:     var author: String
 8:     var image: String
 9: }
10: 
11: struct ContentView: View {
12:     var books = [
13:       Book(id: UUID(), title: "地獄藍調", author: "李查德", image: "b1"),
14:       Book(id: UUID(), title: "至死方休", author: "李查德", image: "b2"),
15:       Book(id: UUID(), title: "一觸即發", author: "李查德", image: "b3"),
16:       Book(id: UUID(), title: "索命訪客", author: "李查德", image: "b4"),
17:       Book(id: UUID(), title: "闇夜回聲 ", author: "李查德", image: "b5")]
18: 
19:     //.....
20: }

8.5 將資料連結到列表中 3

 1: import SwiftUI
 2: 
 3: //....
 4: var body: some View {
 5:     List(books) { book in
 6:         Image(book.image)
 7:           .resizable()
 8:           .frame(width: 40, height: 40, alignment: .center)
 9:         VStack(alignment: .leading) {
10:             Text(book.title)
11:               .multilineTextAlignment(.leading)
12:               .foregroundColor(Color.green)
13:             Text(book.author)
14:               .multilineTextAlignment(.leading)
15:               .foregroundColor(Color.orange)
16:         }
17:     }
18: }
19: }

8.6 結果

list-3.jpg

Figure 21: List-3

8.7 為什麼要加入 id 與 Identifiable

9 Passing data between Views #1: @Binding

用途:不同 View 間共享變數

  • @Binding is one of SwiftUI’s less used property wrappers, but it’s still hugely important: it lets us declare that one value actually comes from elsewhere, and should be shared in both places. This is not the same as @ObservedObject or @EnvironmentObject, both of which are designed for reference types to be shared across potentially many views.12
  • 有時候我們會把一個視圖的屬性傳至子節點中,但是又不能直接的傳遞給子節點,因為在 Swift 中值的傳遞形式是值類型傳遞方式,也就是傳遞給子節點的是一個拷貝過的值。但是通過 @Binding 修飾器修飾後,屬性變成了一個引用類型,傳遞變成了引用傳遞,這樣父子視圖的狀態就能關聯起來了。7
  • The wrappedValue is: a value that is bound to something else.
  • What it does: gets/sets the value of the wrappedValue from some other source.
  • What it does: when the bound-to value changes, it invalidates the View.

9.1 Time to use Binding

Bindings are all about having a single source of the truth (data)!.

  • Getting text out of a TextField
  • Using a Toggle or other state-modifying UI element
  • Finding out which item in a NavigationView was chosen.
  • Find out whether we’re being targeted with a Drag
  • Binding our gesture to the .updating function of a gesture.

9.2 Demo 1: @State v.s. @Bidning

透過@State 與@Bidning, ContentView.swift 可以將變數 switchIsOn pass 給 SwitchView.swift,而後者可以藉由更改變數值來改變 ContentView.swift 的顯示結果。

9.2.1 ContentView.swift

 1: import SwiftUI
 2: 
 3: struct ContentView: View {
 4:     @State var switchIsOn = false
 5: 
 6:     var body: some Vie {
 7:         VStack {
 8:             Text(switchIsOn ? "-_-" : "^_^")
 9:             SwitchView(switchIsOn: $switchIsOn)
10:         }
11:     }
12: }

9.2.2 SwitchView.swift

 1: import SwiftUI
 2: 
 3: struct SwitchView: View {
 4:     @Binding var swtichIsOn: Bool
 5: 
 6:     var body: some View {
 7:         Toggle(isOn: $switchIsOn, label: {
 8:             Text(switchIsOn ? "ON" : "OFF")
 9:         })
10:     }
11: }

9.3 Demo 2: Sharing multiple variable

建立一個 User struct,透過@Binding 與其他 View 共享 struct 裡的變數

9.3.1 User.swift

 1: import Foundation
 2: 
 3: struct User {
 4:     var firstName: String
 5:     var lastname: String
 6:     var VIP: Bool
 7:     init() {
 8:         firstName = "Brown"
 9:         lastname = "Charlie"
10:         VIP = false
11:     }
12: }
13: 

9.3.2 ContentView.swift

建立一個可以其他 View 共享的 struct variable (user),將 user struct 傳給 EditUser 進行編輯

 1: import SwiftUI
 2: 
 3: struct ContentView: View {
 4:     @State var showEdit: Bool = false
 5:     @State var user = User()
 6: 
 7:     var body: some View {
 8:         VStack {
 9:             Text("User: \(user.lastname) \(user.firstName)")
10:             if user.VIP {
11:                 Text("VIP")
12:             } else {
13:                 Text("Regular user")
14:             }
15:             Button("Edit") {
16:                 self.showEdit = true
17:             }.sheet(isPresented: self.$showEdit, content: {
18:                 EditUser(user: $user)
19:             })
20:         }.padding()
21:     }
22: }
23: 
24: struct ContentView_Previews: PreviewProvider {
25:     static var previews: some View {
26:         ContentView()
27:     }
28: }

9.3.3 EditUser.swift

以@Bidning 接收來自 ContentView 的 user

 1: import SwiftUI
 2: 
 3: struct EditUser: View {
 4:     @Binding var user: User
 5:     var body: some View {
 6:         VStack {
 7:         TextField("First Name", text: $user.firstName)
 8:             TextField("Last Name", text: $user.lastname)
 9:             Toggle("VIP", isOn: $user.VIP)
10:         }
11:     }
12: }
13: 
14: struct EditUser_Previews: PreviewProvider {
15:     static var previews: some View {
16:         EditUser(user: .constant(User()))
17:     }
18: }

10 Alert

目的: 跳出對話視窗

10.1 以 Button 觸發

和呼叫其他 View 一樣,仍然是以一個 bool 來控制是否秀出 Alert

 1: struct SecondView: View {
 2:     @State private var showAlert:Bool = false
 3:     var body: some View {
 4:         Button("請按我") {
 5:             showAlert = true
 6:         }.alert(isPresented: $showAlert, content: {
 7:                                              return Alert(title: Text("別亂按"))
 8:                                          })
 9:     }
10: }

10.2 以 TextField 觸發

將控制顯示的 boo 值寫在 onCommit

 1: struct SecondView: View {
 2:     @State private var title:String = ""
 3:     @State private var showAlert:Bool = false
 4:     var body: some View {
 5:         TextField("To be or not to be", text: $title, onCommit:  {
 6:                                                           showAlert = true
 7:                                                       })
 8:           .alert(isPresented: $showAlert, content: {
 9:                                               return Alert(title: Text("\(title)"))
10:                                           })
11: 
12:     }
13: }

11 Navigation between Views

11.1 Tabbed View

Tab bar 是在 App 螢幕底部出現的欄,提供了在不同的版面之間進行快速切換的途徑。Tab bar 的背景顏色是半透明,可以有調色。Tab bar 在所有螢幕尺寸都保持一樣的高度,並且在鍵盤時出現會隱藏起來。

一個 tab bar 可以包含無數個 tab,但可以能容納的數量視乎根據手機/平板的大小,以及橫、豎屏模式都會有所影響。在空間的限制下,當某些 tab 無法被顯示時,最後一個 tab 會變成“更多”(More), 通過這個 tab 可以到另一個獨立的列表頁面,那裡會列出所有無法被顯示的 tab。13

11.1.1 Create subView

 1: import SwiftUI
 2: 
 3: struct tabView: View {
 4: 
 5:     init() {
 6:         UITabBarItem.appearance().setTitleTextAttributes([.font: UIFont.systemFont(ofSize: 16) ], for: .normal)
 7:     }
 8: 
 9:     var body: some View {
10:         TabView {
11:             StoreView().tabItem {
12:                 Image(systemName: "cart.fill.badge.plus")
13:                 Text("購買")
14:             }
15:             AboutView().tabItem {
16:                 Image(systemName: "person.3")
17:                 Text("關於")
18:             }
19:             NewsView().tabItem {
20:                 Image(systemName: "message")
21:                 Text("消息")
22:             }
23:         }.accentColor(.pink)    }
24: }
25: 
26: struct StoreView: View {
27:     var body: some View {
28:         Text("商店View")
29:     }
30: }
31: 
32: struct AboutView: View {
33:     var body: some View {
34:         Text("關於View")
35:     }
36: }
37: 
38: struct NewsView: View {
39:     var body: some View {
40:         Text("消息View")
41:     }
42: }
43: 
44: struct tabView_Previews: PreviewProvider {
45:     static var previews: some View {
46:         tabView()
47:     }
48: }

執行結果如下

tabbedView.gif

Figure 22: Tabbed View DEMO

11.1.2 自訂底下的主控 tab bar

11.1.2.1 Change tabView font size
1: init() {
2:     UITabBarItem.appearance().setTitleTextAttributes([.font: UIFont.systemFont(ofSize: 14) ], for: .normal)
3: }
4: 
11.1.2.2 the color of unselected tab bar
1: init() {
2:     UITabBar.appearance().unselectedItemTintColor = UIColor.systemGray3
3: }
11.1.2.3 the color of the tab bar item : .accentColor
1: TabView {
2: 
3: }.accentColor(.pink)
11.1.2.4 the tab bar’s color: appearance()

兩種做法(尚未區分 backgroundColor 與 barTintColor 之差異)

  1. init()
    1: init() {
    2:         UITabBar.appearance().barTintColor = UIColor.systemPink
    3:     }
    
  2. onAppear()

    與 init()共用時,onAppear()優先

    1: .onAppear() {
    2:     UITabBar.appearance().barTintColor = .white
    3: }
    

11.1.3 以程式控制 tabbed view 的 subview 的切換

Users can tap the tab bar items to switch between tabs, which is automatically handled the TabView. In some use cases, you may want to switch to a specific tab programmatically. The TabView has another init method for this purpose. The method takes a state variable which associates with the tag value of the tabs.14

1: TabView(selection: $selection)

As an example, declare the following state variable in ContentView:

1: @State private var selection = 0

Here we initialize the selection variable with a value of 0, which is the tag value of the first tab item. We haven’t defined the tag value for the tab items yet. Therefore, update the code like this and attach the tag modifier for each of the tab items:

 1: import SwiftUI
 2: 
 3: struct BookDetailView: View {
 4:     var body: some View {
 5:         Text("This is the Book Detail View")
 6:     }
 7: }
 8: 
 9: struct BookOrderView: View {
10:     var body: some View {
11:         Text("購物車")
12:     }
13: }
14: 
15: struct BookAboutView: View {
16:     var body: some View {
17:         Text("About me")
18:     }
19: }
20: 
21: struct ContentView: View {
22:     init() {
23:         UITabBar.appearance().barTintColor = UIColor.systemPink
24:         UITabBar.appearance().unselectedItemTintColor = UIColor.systemGray3
25:     }
26:     @State private var selection = 0
27:     var body: some View {
28: 
29:         ZStack(alignment: Alignment(horizontal: .trailing, vertical: .top), content: {
30:             TabView(selection: $selection,
31:                     content:  {
32:                         BookDetailView().tabItem {
33:                             Image(systemName: "book.fill")
34:                             Text("Detail")
35:                         }.tag(0)
36:                         BookOrderView().tabItem {
37:                             Image(systemName: "cart.fill")
38:                             Text("Order")
39:                         }.tag(1)
40:                         BookAboutView().tabItem {
41:                             Image(systemName: "person")
42:                             Text("About")
43:                         }.tag(2)
44:                     })
45:                 .accentColor(.white)
46:             Button(action: {
47:                 selection = (selection + 1) % 3
48:                 print(selection)
49:             }, label: {
50:                 Text("Button")
51:                     .padding(3)
52:                     .background(Color.pink)
53:                     .foregroundColor(.white)
54:                     .padding(3)
55:             })
56:         })
57:     }
58: }

11.1.4 一個優秀的 Tab Bar 應該具備哪些特點?13

  1. 不會有太多的 tab
    每增加一個 tab 會減小了選擇各別的可觸區域,並增加 app 的複雜性,讓用戶更難找到所需的資料。即使“更多(More)”標籤可以展示額外的 tab,但這會需要額外的點擊步驟,而且沒有很好利用 tab bar 的有限空間。太少的 tab 也是問題,它會使你的界面感覺被分離。一般來說,在 iPhone 上應使用 3 至 5 個標籤,在 iPad 上則可稍微多幾個。
  2. 不會經常變動
    在某個 tab 的功能無法使用時, 不要移除這個 tab 或是讓它失效。如果 tab 時有時無,App 界面會變得不穩定和難以捉摸。確保所有的 tab 都可有效點擊,並且向用戶解釋目前 tab 內容不可用的原因。譬如說,當 iOS 裝置沒有歌曲時,音樂 app 的“我的音樂”tab 會顯示如何下載歌曲的說明。
  3. 應在相連接的視窗進行內容的轉換
    為了讓界面符合用戶預期,選擇一個 tab 後的作用應該直接顯示於與其 tab bar 相連的視窗,而不是螢幕上其它範圍的視窗。例如,如在左邊的 split view 選擇了一個 tab,是不會讓右半部分突然產生變化的。在 popover 中選擇的 tab 不會導致後方的視窗發生改變。
  4. 應保持 icon 的風格統一和平衡
    系統提供了一系列預先設定好、一般用途的 icon,我們也可以創建自己的 icon,確保 tab bar icon 在視覺上的風格統一和平衡。
  5. 應使用 badge 傳遞信息
    我們可以在 tab 上加上 badge (一個紅色橢圓上帶有白色數字或感嘆號的圖案) ,來暗示該 tab 或模式有新信息。

11.2.1 Navigation bar

  • 於 body 中最外層的 component 之外加入 NavigationView
  • Title: navigationBarTitle(),title 置於 NavigationView {}內 attach 在 List 上,因為 NavigationView 主要負責於不同的 View 中切換,每個 View 都會有自己的 content 與 Title。
  • DisplayMode 有 三類:
    1. large: 適用於 top level
    2. inline: 適用於 detail level
    3. automatic: 自動判斷有無 parent level
 1: import SwiftUI
 2:   ...
 3: 
 4: struct ContentView: View {
 5:     ....
 6:     var body: some View {
 7:         NavigationView {
 8:             List(books) { book in
 9:                 ...
10:                 }
11:             .navigationBarTitle(Text("書單"))
12:             .navigationBarTitleDisplayMode(.large)
13:             }
14:         }
15:     }
16: }
17: ...

navigation-1.jpg

Figure 23: Navigation bar

11.2.1.1 Hide Navigation Bar

使用 Hide 與不設定 BarTitle 的差異在於:後者仍會佔掉 Bar 的空間

 1: import SwiftUI
 2:   ...
 3: 
 4: struct ContentView: View {
 5:     ....
 6:     var body: some View {
 7:         NavigationView {
 8:             List(books) { book in
 9:                 ...
10:                 }
11:             .navigationBarTitle(Text("書單"))
12:             .navigationBarTitleDisplayMode(.large)
13:             .navigationBarHidden(true)
14:             }
15:         }
16:     }
17: }
18: ...
  1. NavigationLink
    1. Create
    2. 語法:
      1: NavigationLink(<title: StringProtocol, destination:)
      
    3. 方式
      1. Attach link in NavigationLink()

         1: struct ContentView: View {
         2:     var body: some View {
         3:         NavigationView {
         4:             VStack {
         5:                 NavigationLink("JumpToSecond", destination: SecondView())
         6:                 Text("Hello, world!")
         7:                     .padding()
         8:             }
         9:             .navigationTitle("Book List")
        10:             .navigationBarTitleDisplayMode(.large)
        11:         }
        12:     }
        13: }
        
      2. Attach link to other object(Text in this example)
       1: struct ContentView: View {
       2:     var body: some View {
       3:         NavigationView {
       4:             VStack {
       5:                 NavigationLink(destination: SecondView()) {
       6:                     Text("TextLink")
       7:                         .padding()
       8:                 }
       9:             }
      10:             .navigationTitle("Book List")
      11:             .navigationBarTitleDisplayMode(.large)
      12:         }
      13:     }
      14: }
      

      若是將 link attach 至 image,則要加上 renderingMode,否則會看不到圖,例:

       1: struct ContentView: View {
       2:     var body: some View {
       3:         NavigationView {
       4:             VStack {
       5:                 NavigationLink(destination: SecondView()) {
       6:                     Image(systemName: "myImage")
       7:                         .renderingMode(.original)
       8:                 }
       9:             }
      10:             .navigationTitle("Book List")
      11:         }
      12:     }
      13: }
      
  2. Reading

11.2.2 Create New Views

11.2.2.1 in same file
 1: import SwiftUI
 2: 
 3: struct BookDetailView: View {
 4:     var body: some View {
 5:         Text("This is the Book Detail View")
 6:     }
 7: }
 8: 
 9: struct ContentView: View {
10:     var body: some View {
11:         NavigationView {
12:             VStack {
13:                 NavigationLink(destination: BookDetailView()) {
14:                     Text("GoToDetail")
15:                 }
16:             }
17:               .navigationTitle("Book List")
18: 
19:         }
20:     }
21: }
  1. class exercise

    為上述 NavigationView 加入另外兩個 subView: OrderView, AboutView

11.2.2.2 in new file

將上述專案中的每個 subView 改為獨立 View

11.2.3 Passing parameter(單純參數傳遞)

Single way, Just pass variable from A to B.

 1: struct BookDetailView: View {
 2:     var operation: String
 3:     var body: some View {
 4:         Text("已為你\(operation)這本書")
 5:     }
 6: }
 7: 
 8: struct ContentView: View {
 9:     var body: some View {
10:         NavigationView {
11:             VStack {
12:                 NavigationLink(destination: BookDetailView(operation: "借閱")) { Text("Loan")
13:                 }
14:                 NavigationLink(destination: BookDetailView(operation: "續借")) { Text("Renew")
15:                 }
16:                 NavigationLink(destination: BookDetailView(operation: "歸還")) { Text("Return")
17:                 }
18:             }
19:               .navigationTitle("Book List")
20: 
21:         }
22:     }
23: }

11.2.4 Version-1: Navigation v.s. List v.s. NavigationLink

 1: //
 2: //  ContentView.swift
 3: //  navigation
 4: //
 5: //  Created by yen yung chin on 2020/9/27.
 6: //
 7: 
 8: import SwiftUI
 9: 
10: struct Book: Identifiable {
11:     var id = UUID()
12:     var title: String
13:     var author: String
14: }
15: 
16: struct ContentView: View {
17:     var books = [
18:         Book(title:"X的悲劇", author: "艾勒里.昆恩"),
19:         Book(title:"地獄藍調", author: "李查德"),
20:         Book(title:"東方列車謀殺案", author: "阿嘉莎‧克莉絲蒂"),
21:         Book(title:"八百萬種死法", author: "勞倫斯.卜洛克"),
22:         Book(title:"血字研究", author: "柯南道爾")
23:     ]
24: 
25:     var body: some View {
26:         NavigationView {
27:             List(books) { book in
28:                 NavigationLink(destination: DetailView(book: book) ){
29:                         bookRow(book: book)
30:                     }
31:                 }
32:             .navigationTitle("書單")
33:         }
34:     }
35: }
36: 
37: struct bookRow: View {
38:     var book: Book
39:     var body: some View {
40:         VStack {
41:             Text(book.title)
42:             Text(book.author)
43:         }
44:     }
45: }
46: 
47: struct DetailView: View {
48:     var book: Book
49:     var body: some View {
50:         VStack {
51:             Text(book.title)
52:         }
53:     }
54: }
55: 
56: struct ContentView_Previews: PreviewProvider {
57:     static var previews: some View {
58:         ContentView()
59:     }
60: }
61: 

11.2.5 Version-2:

將記錄架構(Book.swift)、List(ContentView.swift)以及 Detail(DetailView.swift)各自以獨立檔案設計。

11.2.5.1 Book.swift
 1: // 不涉及使用者界面
 2: 
 3: import Foundation
 4: 
 5: // 建立一個書籍的基本結構
 6: // 加上Identifiable以及UUID()是為了可以將書籍透過List來呈現
 7: 
 8: struct Book: Identifiable {
 9:     var id = UUID() //產生一個唯一(unique)的亂數
10:     var title: String
11:     var author: String
12:     var image: String
13: }
11.2.5.2 ContentView.swift
 1: import SwiftUI
 2: 
 3: struct ContentView: View {
 4:     // 在這裡臨生產生一個Book陣列,然後在List秀出來
 5:     var books = [
 6:         Book(id: UUID(), title: "地獄藍調", author: "李查德", image: "b1"),
 7:         Book(id: UUID(), title: "至死方休", author: "李查德", image: "b2"),
 8:         Book(id: UUID(), title: "一觸即發", author: "李查德", image: "b3"),
 9:         Book(id: UUID(), title: "索命訪客", author: "李查德", image: "b4")
10:     ]
11: 
12:     var body: some View {
13:         NavigationView {
14:             List(books) { book in
15:                 NavigationLink(destination: DetailView(thisbook: book)) {
16:                     VStack(alignment: .leading) {
17:                         Text(book.title)
18:                             .font(.title)
19:                         Text(book.author)
20:                             .font(.subheadline)
21:                     }
22:                 .navigationBarTitle("我的書單")
23:                 }
24:             }
25:         }
26:     }
27: }
28: 
29: struct ContentView_Previews: PreviewProvider {
30:     static var previews: some View {
31:         ContentView()
32:     }
33: }

Navi-List-1.png

Figure 24: 主選單

11.2.5.3 DetailView.swift
 1: import SwiftUI
 2: 
 3: struct DetailView: View {
 4:     //我希望等一下有任何人呼叫我,一定要傳書本的資料給我
 5:     var thisbook: Book
 6:     var body: some View {
 7:         VStack {
 8:             Image(thisbook.image)
 9:             Text(thisbook.title)
10:             Text(thisbook.author)
11:         }
12: 
13:     }
14: }
15: 
16: struct DetailView_Previews: PreviewProvider {
17:     static var previews: some View {
18:         // 臨時給個資料,讓preview可以秀出來就好
19: 
20:         DetailView(thisbook: Book(id: UUID(), title: "索命訪客", author: "李查德", image: "b4"))
21:     }
22: }

Navi-List-2.png

Figure 25: Detail

12 Dynamic List

12.1 DataModel

每筆記錄的基本欄位

12.1.1 BookModel.swift (Model)

1: import Foundation
2: 
3: struct BookModel: Identifiable {
4:     var id = UUID()
5:     var title: String
6:     var author: String
7: }
8: 

12.2 ViewModel

12.2.1 Book.swift

資料來源:所有書本的內容,建立一個 ObservableObject 的 class,以@Published 方式分享書籍記錄(list)

 1: import Foundation
 2: 
 3: class Book: ObservableObject{
 4:     @Published var list: [BookModel]
 5:     init(){
 6:         self.list = [
 7:             BookModel(title:"X的悲劇", author: "艾勒里.昆恩"),
 8:             BookModel(title:"地獄藍調", author: "李查德"),
 9:             BookModel(title:"東方列車謀殺案", author: "阿嘉莎‧克莉絲蒂"),
10:             BookModel(title:"八百萬種死法", author: "勞倫斯.卜洛克"),
11:             BookModel(title:"血字研究", author: "柯南道爾")
12:         ]
13:     }
14: }

12.3 主畫面

12.3.1 ContentView.swift

以@ObservedObject 的方式讀取 ViewModel 裡分享的記錄

 1: import SwiftUI
 2: 
 3: struct ContentView: View {
 4:     @ObservedObject var book = Book()
 5:     @State var showNewBook = false
 6:     var body: some View {
 7:         NavigationView {
 8:             List(book.list) { book in
 9:                 NavigationLink(destination: Text("Show Detail here...") ){
10:                     bookRow(book: book)
11:                 }
12:             }
13:             .navigationBarItems(trailing: Button("New"){
14:                 self.showNewBook = true
15:             }.sheet(isPresented: self.$showNewBook, content: {
16:                 AddNewBook(book: book, showThisView: $showNewBook, title: "", author: "")
17:             }))
18:         }
19:     }
20: }
21: 
22: 
23: struct bookRow: View {
24:     var book: BookModel
25:     var body: some View {
26:         VStack(alignment: .leading) {
27:             Text(book.title)
28:             Text(book.author)
29:         }
30:     }
31: }
32: 
33: struct ContentView_Previews: PreviewProvider {
34:     static var previews: some View {
35:         ContentView()
36:     }
37: }
38: 

12.4 加入新記錄

12.4.1 AddNewBook.swift

以@ObservedObject 的方式新增 ViewModel 裡分享的記錄

 1: import SwiftUI
 2: import Combine
 3: 
 4: struct AddNewBook: View {
 5:     @ObservedObject var book = Book()
 6:     @Binding var showThisView: Bool
 7:     @State  var title: String
 8:     @State  var author: String
 9: 
10:     var body: some View {
11:         VStack {
12:             Text("Adding New Book....")
13:             TextField("書名", text: self.$title)
14:             TextField("作者", text: self.$author)
15:             Button("Done") {
16:                 book.list.append(BookModel(title: self.title, author: self.author))
17:                 showThisView.toggle()
18:             }
19:         }
20:         Text("TEST")
21:     }
22: }
23: 
24: struct AddNewBook_Previews: PreviewProvider {
25:     static var previews: some View {
26:         AddNewBook(showThisView: .constant(true), title: "", author: "")
27:     }
28: }
29: 

12.5 加入搜尋功能

為 List 加入基本的搜尋(過濾)功能十分容易,最簡單的方式就是在 List 上方加入一個 TextField,宣告一個@State 變數 Bind 到這個 TextField,然後以這個變數做為 List 的搜尋條件。接下來就在 list 後加入 filter,這裡用到的 filter 語法為:

1: book.list.filter ({ self.filterKey.isEmpty ? true : $0.title.contains(self.filterKey.lowercased())})

裡面的 A ? B : C 意思是,如果條件 A 成立,就傳回 B,否則就傳回 C,即,如果 TextField 裡沒有搜尋值,則 filterKey.isEmpty 會成立,就不進行過濾(傳回 true),否則就傳回那些書名包含 filterKey 的記錄($0.title.contains(self.filterKey.lowercased()),另,為避免大小寫問題,一律轉為小寫

 1: import SwiftUI
 2: 
 3: struct ContentView: View {
 4:     //建立一個BookClass類別的物件,叫做book
 5:     @ObservedObject var book = BookClass()
 6:     @State var showNewbookView = false
 7:     @State var filterKey = "" //儲存搜尋內容
 8:     var body: some View {
 9:         VStack {
10:             // 這裡輸入要搜尋的關鍵字
11:             TextField("請輸入搜尋內容", text: $filterKey)
12:             NavigationView {
13:                 // 透過對list進行即時filter來過濾list的顯示結果
14:                 // 如果filterKey沒有值,就將filter條件設為true(不過濾)
15:                 List(book.list.filter ({ self.filterKey.isEmpty ? true : $0.title.contains(self.filterKey.lowercased())})) { book in
16:                     NavigationLink(destination: DetailView(thisbook: book)) {
17:                         VStack(alignment: .leading) { //cell格子
18:                             Text(book.title)
19:                                 .font(.title)
20:                             Text(book.author)
21:                                 .font(.subheadline)
22:                         }
23:                         .navigationBarTitle("我的書單")
24:                     }
25:                 }
26: 
27:             }
28:         }
29:     }
30: }
31: struct ContentView_Previews: PreviewProvider {
32:     static var previews: some View {
33:         ContentView()
34:     }
35: }
36: 

FilteredList.gif

Figure 26: 搜尋功能

13 Passing data between Views

13.1 Passing data between Views #2: @ObservedObject

13.1.1 ObservedObject 範例

前節的 Book.swift 中使用@Published 將書籍資料設定為可以其他 View 共享,與@State, @Binding 相同,@Published 為一種 Property wrapper(屬性包裝器)。SwiftUI 中幾個常見的 @ 開頭修飾,如 @State,@Binding,@Environment,@EnvironmentObject 等都是運用了 Property Wrappers 這個特性。

當你以 @State 來標註一個屬性時,SwiftUI 會自動儲存它在你的應用程式的某處。還有,使用這些屬性的視圖會自動監聽屬性值的變更。在狀態改變時,SwiftUI 會重新計算那些視圖並更新應用程式的外觀。

@Published 可以讓我們建立 observable object(如前節 Book.swift 的 list)

 1: import Foundation
 2: 
 3: class Book: ObservableObject{
 4:     @Published var list: [BookModel]
 5:     init(){
 6:         self.list = [
 7:             BookModel(title:"X的悲劇", author: "艾勒里.昆恩"),
 8:             BookModel(title:"地獄藍調", author: "李查德"),
 9:             BookModel(title:"東方列車謀殺案", author: "阿嘉莎‧克莉絲蒂"),
10:             BookModel(title:"八百萬種死法", author: "勞倫斯.卜洛克"),
11:             BookModel(title:"血字研究", author: "柯南道爾")
12:         ]
13:     }
14: }

被宣告為@Published 變數,SwiftUI 會自動監控 list,一旦其內容有所變更,則所有引用到這個 object 的 View 都會接收到通知,然後重新載入做必要的變更。至此,我們只做好了資料來源端的設定,那麼,要使用這些共享變數的 View 要如何做呢?可以先看一下前節的 ContentView.swift 或 AddNewBook.swift:

 1: import SwiftUI
 2: 
 3: struct ContentView: View {
 4:     @ObservedObject var book = Book()
 5:     @State var showNewBook = false
 6:     var body: some View {
 7:         NavigationView {
 8:             List(book.list) { book in
 9:                 NavigationLink(destination: Text("Show Detail here...") ){
10:                     bookRow(book: book)
11:                 }
12:             }
13:             .navigationBarItems(trailing: Button("New"){
14:                 self.showNewBook = true
15:             }.sheet(isPresented: self.$showNewBook, content: {
16:                 AddNewBook(book: book, showThisView: $showNewBook, title: "", author: "")
17:             }))
18:         }
19:     }
20: }
21: ///... 下略

不同於@State,在 ContentView 以及 AddNewBook.swift 中,我們以@ObservedObject 建立外部參考型態(external reference type)的變數(即之前以@Published 宣告的變數),所有以@ObservedObject 包裝的變數都必須 conform @ObservableObject protocol(即 class Book),透過此二者的連結,我們就能輕易的在不同的 View 間共享或編輯變數(如本例中的 book)

13.1.2 ObservedObject 特性

  • Can be shared across views
  • More complex properties (e.g custom type)
  • External reference type that has to be managed (Create an instance of the class, create its own properties, …)
  • Class should confrom to ObservableObject
  • @Published property wrapper used to mark properties that should force a view to refresh
  • The wrappedValue is: anything that implements the OvservableObject protocol (ViewModels basicly).
  • What is does: invalidates the View when wrappedValue does objectWillChange.send().
  • Projected value (i.e. %): a Binding (to the vars of the wrappedValue (a ViewModel)). You can bind a variable in your View to the variable in your ViewModel with @ObservedObject.
  • @ObservedObject 的用處和 @State 非常相似,從名字看來它是來修飾一個對象的,這個對象可以給多個獨立的 View 使用。如果你用 @ObservedObject 來修飾一個對象,那麼那個對象必須要實現 ObservableObject 協議,然後用 @Published 修飾對象裡屬性,表示這個屬性是需要被 SwiftUI 監聽的。7

13.1.3 @ObservedObject v.s. @ObjectBinding

另一種與@ObservedObject 有點相似的 wrapper 為@ObjectBinding,但不同的是,使用@ObservedObject 的 object 必須要有@Published wrapper;而使用@ObjectBinding 則要自已加上 didChange。@ObjectBindin 的範例如下:

 1: import SwiftUI
 2: 
 3: class User:BindableObject {
 4:    var didChange = PassthroughSubject<Void,Never>()
 5:    var username = "Saravana" { didSet { didChange.send() } }
 6:    var password = "iuwerosdkj3298" { didSet { didChange.send() } }
 7:    var email = "saravkumar.g@gmail.com" { didSet { didChange.send() } }
 8: }
 9: 
10: struct ContentView:View {
11:     @ObjectBinding var user = User()
12: 
13:     var body: some View {
14:       VStack {
15:         TextField($user.username)
16:         TextField($user.password)
17:         TextField($user.email)
18:       }
19:     }
20: }

13.2 Passing data between Views #3: @EnvironmentObject

適用時機: SwiftUI’s @EnvironmentObject property wrapper allows us to create views that rely on shared data, often across an entire SwiftUI app. For example, if you create a user that will be shared across many parts of your app, you should use @EnvironmentObject15. 其相關特性如下:

  • Similar to @ObservedObject
  • Possibility to make it available to all views through the application itself
  • If one view changes the model all views update

13.2.1 @EnvironmentObject 與@ObservedObject 的異同

13.2.1.1 相同

@EnvironmentObject has a lot in common with @ObservedObject

  • both must refer to a class that conforms to ObservableObject
  • both can be shared across many views,
  • and both will update any views that are watching when significant changes happen.
13.2.1.2 差異
  • However, @EnvironmentObject specifically means “this object will be provided from some outside entity, rather than being created by the current view or specifically passed in.15
13.2.1.3 比較
@State @ObservedObject @EnvironmentObject
Simple properties like String or Int Can be shared across views Similar to @ObservableObject
Belongs to a specific view More complex properties (e.g. custom type) Possibility to make it available to all views through the application itself
Never used outside that view External reference type that has to be managed (Create an instance of the class, create its own properties, …) If one view changes the model all views update
  Class should conform to ObservableObject  
  Published property wrapper used to make properties that should force a view to refresh  
13.2.1.4 範例

以如下 app 為例,若 View A 裡有某些 data 要在未來與 View E 共用,若是使用@ObservedObject 來進行分享,則 View A 要依序將這些 data 傳給 View B、View C、View D,最後再傳到 View E 裡。
然而,若使用@EnvironmentObject 的機制來進行分享 data,則可以在 View A 中將這些要分享的 data 置於 Swift environment 中,所有處於同一 environment 中的所有 View 都能與之共享,而不用依次傳遞。

environmentObject.png

Figure 27: @Environment 適用狀況

13.2.2 EnvironmentObject 設定方式

13.2.2.1 建立 EnvironmentObject class

UserSettings.swift (ObservableObject)

1: import SwiftUI
2: 
3: class UserSettings: ObservableObject {
4:     @Published var name = ""
5:     // use this ObservableObject as an environment object
6: }
13.2.2.2 設定 app 環境
  1. Xcode 11.X: SceneDeleate.swift

    加入變數宣告(x)及 environmentObject(x)

    1: //....
    2: 
    3: var settings: UserSettings()
    4: func scene(.......) {
    5:     //....
    6:     //let tabbedView = TabbedView()
    7:     let contentView = ContentView()
    8: }
    9: //....
    
  2. Xcode 12.X: xxxApp.swift
     1: import SwiftUI
     2: 
     3: @main
     4: struct bindingApp: App {
     5:     var settings: UserSettings()
     6:     var body: some Scene {
     7:         WindowGroup {
     8:             ContentView().environmentObject(settings)
     9:         }
    10:     }
    11: }
    
13.2.2.3 設定 Client View
  1. Read

    UserSettingsView.swift

     1: import SwiftUI
     2: 
     3: struct UserSettingsView: View {
     4:     @EnvironmentObject var settings: UserSettings
     5:     var body: some View {
     6:         VStack {
     7:             Text("My anme: \(settings.name)")
     8:             EditView()
     9:         }
    10:     }
    11: }
    
  2. Write

    EditView.swift

    1: import SwiftUI
    2: 
    3: struct EditView: View {
    4:     @EnvironmentObject var settings: UserSettings
    5:     var body: some Veiw {
    6:         TextField("Type in your name:", text: $settings.name)
    7:     }
    8: }
    

13.2.3 範例

建立 environment 物件(ShoppingCart),由各 View 共享/編輯內容

13.2.3.1 ShoppingCart.swift

情境說明:在 app 中建立一個 ShoppingCart 類別,並讓這個 class confirm ObservableObject protocol,於 class 中將要在各 View 中 share 的 property 以@Published 標示出來。

1: import Foundation
2: 
3: class ShoppingCart: ObservableObject {
4:     @Published var Title = "item"
5:     @Published var items = 0
6: }
13.2.3.2 環境設定 SceneDelegate.swift / xxxApp.swift

環境變數由 swift 環境提供,故在 SceneDelegate.swift 檔案中宣告要共享的變數(程式第7行),並於 UIHostingController function 中將之加入起始 View 中,如下列程式第16行。

  1. 舊版: SceneDelegate.swift
     1: import UIKit
     2: import SwiftUI
     3: 
     4: class SceneDelegate: UIResponder, UIWindowSceneDelegate {
     5: 
     6:     var window: UIWindow?
     7:     var cart = ShoppingCart()
     8: 
     9:     func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    10: 
    11:         let contentView = ContentView()
    12: 
    13:         // Use a UIHostingController as window root view controller.
    14:         if let windowScene = scene as? UIWindowScene {
    15:             let window = UIWindow(windowScene: windowScene)
    16:             window.rootViewController = UIHostingController(rootView: contentView.environmentObject(cart))
    17:             self.window = window
    18:             window.makeKeyAndVisible()
    19:         }
    20:     }
    21:     //....
    22: }
    
  2. 新版: xxxApp.swift

    在 WWDC20 之前,使用 SwiftUI 建立 View 必須將其包裝在 UIHostingController,Controller 被包裝在一個 UIWindow,window 在 SceneDelegate 中定義。在 WWDC20 之後,一個新的解決方案出現:App。

    在之前我們使用 AppDelegate 和 SceneDelegate 來管理生命週期,這樣很繁瑣。如今使用 App Name App.swift 文件來代替。

     1: import SwiftUI
     2: 
     3: @main
     4: struct bindingApp: App {
     5:     var cart = ShoppingCart()
     6:     var body: some Scene {
     7:         WindowGroup {
     8:             ContentView().environmentObject(cart)
     9:         }
    10:     }
    11: }
    
13.2.3.3 ContentView.swift

在每一個要使用到這個 EnvironmentObject 的 View 中以@EnvironmentObject 來建立該 class 的 instance(第6行),同時,在 previews 中也要加入 environmentObject()的 modifier(第26行),在需用到環境變數的 View(如 SecondView)被呼叫時加入 environmentObject(第17行)。

 1: import SwiftUI
 2: 
 3: struct ContentView: View {
 4: 
 5:     @State private var showCart: Bool = false
 6:     @EnvironmentObject var cart: ShoppingCart
 7: 
 8:         var body: some View {
 9:         VStack {
10:             Text(cart.Title)
11:             Spacer()
12:             Text("Item Name: \(self.cart.Title)")
13:             Text("目前數量\(self.cart.items)")
14:             Button("下單") {
15:                 self.showCart = true
16:             }.sheet(isPresented: self.$showCart, content: {
17:                 SecondView().environmentObject(self.cart)
18:             })
19:             Spacer()
20:         }
21:     }
22: }
23: 
24: struct ContentView_Previews: PreviewProvider {
25:     static var previews: some View {
26:         ContentView().environmentObject(ShoppingCart())
27:     }
28: }
13.2.3.4 SecondView

同樣的,在 SecondView 中要使用到 environment object 也要以@EnvironmentObject 來宣告利用該 class 所建立的 instance(第4行),而 SecondView 的 preview 也要加上同樣的 modifier(傳入參數)。

 1: import SwiftUI
 2: 
 3: struct SecondView: View {
 4:     @EnvironmentObject var cart: ShoppingCart
 5:     var body: some View {
 6:         VStack {
 7:             Text("\(self.cart.Title)")
 8:             Spacer()
 9:             TextField("Item Name", text: self.$cart.Title)
10:             Text("amount: \(self.cart.items)")
11:             Button("加購") {
12:                 self.cart.items += 1
13:             }
14:             Spacer()
15:         }
16:     }
17: }
18: 
19: struct SecondView_Previews: PreviewProvider {
20:     static var previews: some View {
21:         SecondView().environmentObject(ShoppingCart())
22:     }
23: }

這裡的 cart.items 在變更後,ContentView 的數量會即時更新,同樣的,ContentView 中被變更的 Title 也會即時在 SecondView 中看出結果。

13.3 Passing data between Views #4: Property Wrappers

All of these @Something statements are property wrappers. A property wrapper is actually a struct. These structs encapsulate some “template” behavior applied to the vars they wrap.

The property wrapper feature adds “syntactic sugar” to make these structs easy to create/use.“ 16

13.3.1 SwiftUI 的狀態管理

資料來源:@StateObject 和 @ObservedObject 的区别和使用
在 2019 年 SwiftUI 剛問世時,除去專門用來管理手勢的@GestureState 以外,有三個常用的和狀態管理相關的 property wrapper,它們分別是@State,@ObservedObject 和@EnvironmentObject。根據職責和作用範圍不同,它們各自的適用場景也有區別17。一般來說:

  • @State 用於 View 中的私有狀態值,一般來說它所修飾的都應該是 struct 值,並且不應該被其他的 view 看到。它代表了 SwiftUI 中作用範圍最小,本身也最簡單的狀態,比如一個 Bool,一個 Int 或者一個 String。簡單說,如果一個狀態能夠被標記為 private 並且它是值類型,那麼@State 是適合的。
  • 對於更複雜的一組狀態,我們可以將它組織在一個 class 中,並讓其實現 ObservableObject 協議。對於這樣的 class 類型,其中被標記為@Published 的屬性,將會在變更時自動發出事件,通知對它有依賴的 View 進行更新。View 中如果需要依賴這樣的 ObservableObject 對象,在聲明時則使用@ObservedObject 來訂閱。
  • @EnvironmentObject 針對那些需要傳遞到深層次的子 View 中的 ObservableObject 對象,我們可以在父層級的 View 上用.environmentObject 修飾器來將它注入到環境中,這樣任意子 View 都可以通過@EnvironmentObject 來獲取對應的對象。

這基本就是初版 SwiftUI 狀態管理的全部了。看起來對於狀態管理,SwiftUI 的覆蓋已經很全面了,那為什麼要新加一個@StateObjectproperty wrapper 呢?

13.3.2 @StateObject v.s. @ObservedObject

@ObservedObject 不管存儲,會隨著 View 的創建被多次創建。而@StateObject 保證對像只會被創建一次。因此,如果是在 View 裡自行創建的 ObservableObjectmodel 對象,大概率來說使用@StateObject 會是更正確的選擇。@StateObject 基本上來說就是一個針對 class 的@State 升級版17

13.3.2.1 程式範例
 1: struct ContentView: View {
 2:     @State private var showRealName = false
 3:     var body: some View {
 4:         VStack {
 5:             Button("學號/姓名") {
 6:                 showRealName.toggle()
 7:             }
 8:             Text("學生: \(showRealName ? "202010101" : "王小明")")
 9:             ScoreBoad().padding(.top, 10)
10:         }
11:     }
12: }
13: 
14: class ScoreModel: ObservableObject {
15:     init() {print("ScoreModel Created")}
16:     @Published var score: Int = 40
17: }
18: 
19: struct ScoreBoad: View {
20:     @ObservedObject var scoreModel = ScoreModel()
21:     @State private var pass = false
22: 
23:     var body: some View {
24:         VStack {
25:             Button("加10分") {
26:                 scoreModel.score += 10
27:                 if scoreModel.score >= 60 {
28:                     pass = true
29:                 }
30:             }
31:             Text("分數: \(scoreModel.score)")
32:             Text("及格? \(pass ? "YES" : "NO")")
33:             GradeText(scoreModel: scoreModel).padding(.top, 10)
34:         }
35:     }
36: }
37: 
38: struct GradeText: View {
39:     @ObservedObject var scoreModel: ScoreModel
40: 
41:     var body: some View {
42:         if scoreModel.score >= 90 {
43:             return Text("等級:A")
44:         } else if scoreModel.score >= 80 {
45:             return Text("等級:B")
46:         } else {
47:             return Text("等級:QQ")
48:         }
49:     }
50: }

observeState.jpg

Figure 28: @ObservableObject v.s. @StateObject

上例中,按加分後,及格與等級的判斷都能正常運作,然而一旦切換學生姓名與學號,scoreModel 的分數就會被重置為 60 分。原因在於按下「加分」button 導致 ContentView 的狀態發生變化,ContentView.body 被重新求值,連帶 scoreBoard 這個 View 也被重建,而其中的 scoreModel 也一起重新生成,於是之前所做的改變(狀態)就消失了。

13.3.2.2 Create once

只要理解了@ObservedObject 存在的問題,@StateObject 的意義也就很明顯了。@StateObject 就是@State 的升級版:@State 是針對 struct 狀態所創建的存儲,@StateObject 則是針對 ObservableObjectclass 的存儲。它保證這個 class 實例不會隨著 View 被重新創建。從而解決問題17

解決方案:把 scoreBoard 中的@ObservedObject 改為@StateObject

1: ...
2: struct ScoreBoad: View {
3:     @ObservedObject var scoreModel = ScoreModel()
4:     @State private var pass = false
5: 
6:     var body: some View {
7:         ....
8:     }
9: }

13.3.3 使用@EnvironmentObject 保持狀態

除了@StateObject 外,另一種讓狀態 object 保持住的方式,是在更外層使用.environmentObject:

1: struct SwiftUINewApp: App {
2:     var body: some Scene {
3:         WindowGroup {
4:             ContentView().environmentObject(scoreModel())
5:         }
6:     }
7: }

這樣,scoreModel 對象將被注入到環境中,不再隨著 ContentView 的刷新而變更。在使用時,只需要遵循普通的 environment 方式,把 Model 聲明為@EnvironmentObject 就行了:

 1: struct ScoreBoard: View {
 2:     @EnvironmentObject var scoreModel: ScoreModel
 3:     // ...
 4: 
 5:     // ScoreText(model: model).padding(.top, 20)
 6:     ScoreText().padding(.top, 20)
 7: }
 8: 
 9: struct GradeText: View {
10:     @EnvironmentObject var scoreModel: ScofeModel
11:     // ...
12: }

13.3.4 Property wrapper syntactic Sugar

1: @Published var dice: Dice = Dice()

上述宣告實際同以下 struct

1: struct Published {
2:     var wrappedValue: Dice
3:     var projectedValue: Publisher<Dice, Never>
4: }

接下來 Swift 產生以下變數

1: var _dice: Published = Published(wrappedValue: Dice())
2: var dice: Dice {
3:     get { _dice.wrappedValue  }
4:     set { _dice.wrappedValue = newValue }
5: }

13.3.5 各種 property wrapper 比較

Three ways for sharing data in SwiftUI18

@State                         @ObservedObject                @EnvironmentObject            
Simple properties like String 
or Int                        
Can be shared across views    
                              
Similar to @ObservedObject    
                              
Belongs to a specific view    
                              
                              
More complex properties (e.g. 
custom type)                  
                              
Possiblity to make it         
available to all views through
the application itself        
Never used outside that view  
                              
                              
                              
External reference type that  
has to be managed (Create an  
instance of the class, create 
its own properties, ...)      
If one view changes the model 
all views update              
                              
                              
                              
                              
Class should conform to       
ObservableObject              
                              
                              
                              
                              
                              
@Published property wrapper   
used to mark properties that  
should force a view to refresh
                              
                              
                              

14 UserDefaults

14.1 幾種可以在 iOS app 永久儲存資料的方式:

  • Filesystem: FileManager
  • SQL database: CoreData(自學)
  • Cloud: ClodKit, Firebase(下學期進度)
  • UserDefualts

14.2 UserDefautls

我們可以將 UserDefaults 視為 persistent dictionary。UserDefaults 可以儲存 Property List 類型的資料。Property List is not a protocol or a struct or anything tangible or Swift-like. It is any combination of String, Int, Bool, Floating point, Date, Array or Dictionary.

A powerful way to do this is using the Codable protocol inf Swift. Codable converts structs into Data objects.

14.3 Using userDefaults

1: let defaults = UserDefaults.standard

14.3.1 Storing Data

1: defaults.set(object, forKey: "SomeKey")
2: defautls.setDouble(37.5, forKey: "MyDouble")

14.3.2 Retrieving Data

1: let i: Int = defaults.integer(forKey: "MyInt")
2: let u: URL? = defaults.url(forKey: "MyURL")
3: let strings: [String]? = defaults.stringArray(forKey: "MyString")

14.4 Demo

14.4.1 Create UserDefault.swift

 1: import Foundation
 2: import Combine
 3: 
 4: class UserSettings: ObservableObject {
 5:     @Published var username: String {
 6:         didSet {
 7:             UserDefaults.standard.set(username, forKey: "username")
 8:         }
 9:     }
10:     @Published var isVIP: Bool {
11:         didSet {
12:             UserDefaults.standard.set(isVIP, forKey: "isAccountVIP")
13:         }
14:     }
15:     init() {
16:         self.username = UserDefaults.standard.object(forKey: "username") as? String ?? ""
17:         self.isVIP = UserDefaults.standard.object(forKey: "isAccountVIP") as? Bool ?? true
18:     }
19: }

14.4.2 use userSetting in ContentView.swift

 1: import SwiftUI
 2: 
 3: struct settingsView: View {
 4:     @ObservedObject var userSettings = UserSettings()
 5:     var body: some View {
 6:         List {
 7:             HStack {
 8:                 Text("Username")
 9:                 TextField("Username", text:$userSettings.username)
10:                     .textFieldStyle(RoundedBorderTextFieldStyle())
11:             }
12:             Toggle(isOn: $userSettings.isVIP) {
13:                 Text("VIP Account")
14:             }.padding()
15:         }
16:     }
17: }
18: 
19: struct ContentView: View {
20:     var body: some View {
21:         NavigationView {
22:             VStack {
23:                 Text("BALABALA")
24:                 NavigationLink("Settings", destination: settingsView())
25:                 Spacer()
26:             }.navigationTitle("主畫面")
27:             .padding()
28:         }
29:     }
30: }

UserDefaults.jpg

Figure 29: UserDefault for Settings

14.4.3 BookList: UserDefault v.s. @EnvironmentObject

14.4.3.1 UserSettings.swift
 1: //
 2: //  UserSettings.swift
 3: //  navigation
 4: //
 5: //  Created by yen yung chin on 2020/12/14.
 6: //
 7: 
 8: import Foundation
 9: import Combine
10: 
11: class UserSettings: ObservableObject {
12:     @Published var username: String {
13:         didSet {
14:             UserDefaults.standard.set(username, forKey: "username")
15:         }
16:     }
17:     @Published var isVIP: Bool {
18:         didSet {
19:             UserDefaults.standard.set(isVIP, forKey: "isAccountVIP")
20:         }
21:     }
22:     init() {
23:         self.username = UserDefaults.standard.object(forKey: "username") as? String ?? ""
24:         self.isVIP = UserDefaults.standard.object(forKey: "isAccountVIP") as? Bool ?? true
25:     }
26: }
27: 
14.4.3.2 ContentView.swift

The way to arrange ore than one navigationBarItems.

 1: //
 2: //  ContentView.swift
 3: //  navigation
 4: //
 5: //  Created by yen yung chin on 2020/9/27.
 6: //
 7: 
 8: import SwiftUI
 9: 
10: struct ContentView: View {
11:     @ObservedObject var book = Book()
12:     @State var showNewBook = false
13:     @State var showSetting = false
14:     @EnvironmentObject var userSettings: UserSettings
15:     var body: some View {
16:         NavigationView {
17:             List(book.list) { book in
18:                 NavigationLink(destination: DetailView(book: book) ){
19:                     bookRow(book: book)
20:                 }
21:             }
22:             .navigationTitle("\(userSettings.username)書單")
23:             .navigationBarItems(trailing:
24:                                     HStack {
25:                                         Button(action: { self.showNewBook = true }, label: {
26:                                             Image(systemName: "plus")
27:                                         }).sheet(isPresented: self.$showNewBook, content: {
28:                                             AddNewBook(book: book, showThisView: $showNewBook, title: "", author: "")
29:                                         })
30:                                         Button(action: {
31:                                                 self.showSetting = true }, label: {
32:                                                     Image(systemName: "gearshape")
33:                                                 }).sheet(isPresented: self.$showSetting, content: {
34:                                                     settingView(showThisView: self.$showSetting)
35:                                                 })
36:                                     })
37:         }
38:     }
39: }
40: 
41: 
42: struct bookRow: View {
43:     var book: BookModel
44:     var body: some View {
45:         VStack(alignment: .leading) {
46:             Text(book.title)
47:             Text(book.author)
48:         }
49:     }
50: }
51: 
52: struct ContentView_Previews: PreviewProvider {
53:     static var previews: some View {
54:         ContentView()
55:     }
56: }
57: 

UserDefault-1.png

Figure 30: UserDefault v.s. @Environment

14.4.3.3 settingView.swift
 1: //
 2: //  Setting.swift
 3: //  navigation
 4: //
 5: //  Created by yen yung chin on 2020/12/14.
 6: //
 7: 
 8: import SwiftUI
 9: 
10: struct settingView: View {
11:     @EnvironmentObject var userSettings: UserSettings
12:     @Binding var showThisView: Bool
13:     var body: some View {
14:         VStack {
15:             Text("Setting").font(.largeTitle)
16:             Spacer()
17:             List{
18:                 TextField("Username", text: $userSettings.username)
19:                 Toggle(isOn: $userSettings.isVIP, label: {
20:                     Text("VIP")
21:                 })
22:             }
23:             Button("DONE", action: {
24:                 self.showThisView = false
25:             })
26:         }
27:     }
28: }
29: 
30: struct Setting_Previews: PreviewProvider {
31:     static var previews: some View {
32:         settingView(showThisView: .constant(true))
33:     }
34: }
35: 

UserDefault-2.png

Figure 31: UserDefault v.s. @Environment

14.4.3.4 SceneDelegate.swift
 1: 
 2: import UIKit
 3: import SwiftUI
 4: 
 5: class SceneDelegate: UIResponder, UIWindowSceneDelegate {
 6: 
 7:     var window: UIWindow?
 8:     var userSetting = UserSettings()
 9: 
10:     func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
11: 
12:         let contentView = ContentView().environmentObject(userSetting)
13: 
14:         // Use a UIHostingController as window root view controller.
15:         if let windowScene = scene as? UIWindowScene {
16:             let window = UIWindow(windowScene: windowScene)
17:             window.rootViewController = UIHostingController(rootView: contentView)
18:             self.window = window
19:             window.makeKeyAndVisible()
20:         }
21:     }
22:       ......
23: }
24: 

14.5 Reading Resources

15 Filtered List

為 Dynamic List 加入顯示過濾功能

15.1 Model

BookModel.swift

1: import Foundation
2: 
3: struct BookModel: Identifiable {
4:     var id = UUID()
5:     var title: String
6:     var author: String
7:     var liked: Bool
8: }
9: 

15.2 ViewModel

Book.swift

 1: import Foundation
 2: 
 3: class Book: ObservableObject{
 4:     @Published var list: [BookModel]
 5:     init(){
 6:         self.list = [
 7:             BookModel(title:"X的悲劇", author: "艾勒里.昆恩", liked: false),
 8:             BookModel(title:"地獄藍調", author: "李查德", liked: false),
 9:             BookModel(title:"東方列車謀殺案", author: "阿嘉莎‧克莉絲蒂", liked: true),
10:             BookModel(title:"八百萬種死法", author: "勞倫斯.卜洛克", liked: false),
11:             BookModel(title:"血字研究", author: "柯南道爾", liked: true)
12:         ]
13:     }
14: }

15.3 主畫面

ContentView.swift
於 List 左上角加入一個 Hide/Show 的 Button,以控制是否於書籍列表中加入 Like

 1: import SwiftUI
 2: 
 3: struct ContentView: View {
 4:     @ObservedObject var book = Book()
 5:     @State var showNewBook = false
 6:     @State var showLiked = true
 7:     var body: some View {
 8:         NavigationView {
 9:             List(book.list) { book in
10:                 NavigationLink(destination: Text("Show Detail here...") ){
11:                     bookRow(book: book, showLiked: self.showLiked)
12:                 }
13:             }
14:             .navigationBarItems(
15:                 leading: Button(
16:                     action: { self.showLiked.toggle() },
17:                     label: { Text(self.showLiked ? "Hide" : "Show") }
18:                 ),
19:                 trailing: Button("New"){
20:                     self.showNewBook = true
21:                 }.sheet(isPresented: self.$showNewBook, content: {
22:                     AddNewBook(book: book, showThisView: $showNewBook, title: "", author: "", liked: false)
23:                 }))
24:         }
25:     }
26: }
27: 
28: struct bookRow: View {
29:     var book: BookModel
30:     var showLiked: Bool
31:     var body: some View {
32:         VStack(alignment: .leading) {
33: 
34:             HStack {
35:                 Text(book.title)
36:                 if self.showLiked && book.liked{
37:                     Image(systemName: "heart").foregroundColor(.red)
38:                 }
39:             }
40:             Text(book.author)
41:         }
42:     }
43: }
44: 
45: struct ContentView_Previews: PreviewProvider {
46:     static var previews: some View {
47:         ContentView()
48:     }
49: }

filterList.gif

Figure 32: Filtered List

Footnotes:

Author: Yung Chin, Yen

Created: 2021-02-15 Mon 21:34