iOS 開發 #12 | SwiftUI ( 2 )
學習 SwiftUI 從 Todo List 開始 ( 1 ) UI 設計
前言
之前讀到這篇文章發現原來一個 Todo List App 裡面包含的知識那麼多,那這邊就透過 SwiftUI 來設計一個 Todo List App
Todo List App UI 設計
在做 App 前先來設計 App 的 UI
這是這次 App 的 UI 畫面,很陽春我知道
OK 有了 UI 設計稿了,接下來就是讓他在 SwiftUI 呈現出來
首先先是 TodoCard
TodoCard
這個 App 用的 Cell 長這樣,一個背景顏色為 #ABABAB
,裡面包著一個 CheckBox
和一個用於顯示標題的 Text
在 SwiftUI 中是沒有 CheckBox
這個元件 ,但我們可以用 Button
、Image
或 Toggle
來做出 CheckBox
,這次我是用 Button
搭配 Image
來做出 CheckBox
CheckBox
import SwiftUI
struct CheckBox: View {
@State var isChecked: Bool = false
var body: some View {
Button(
action: {
isChecked.toggle()
},
label: {
if (isChecked) {
Image(systemName: "checkmark.square.fill")
.font(.system(size: 30))
.foregroundStyle(.black)
} else {
Image(systemName: "square")
.font(.system(size: 30))
.foregroundStyle(.black)
}
}
)
}
}
在 CheckBox
中用 @State
來宣告 isChecked
為一個可變的 Boolean
變數,透過 isChecked
來控制這個 Button
顯示的是已勾選的圖片 ( Image(systemName: “checkmark.square.fill”)
) 還是未勾選的圖片 ( Image(systemName: “square”)
)
使用 .font(.system(size: 30))
來設定 Image 的大小,這邊用 .font(_:)
來設定大小是因為我是使用 SF Symbols
的圖片,所以可以直接使用 .font(_:)
來設定大小,如果是用非 SF Symbols
的圖片就需要用到 .resizable()
與 .frame(width: CGFloat, height: CGFloat)
來重設大小
使用 .foregroundStyle(.black)
把圖片設定成黑色,這邊用 .foregroundStyle(_:)
來設定大小是因為我是使用 SF Symbols
的圖片,所以可以直接使用 .foregroundStyle(_:)
來設定顏色,若是用非SF Symbols
的圖片……抱歉窩不知道要怎麼改,我自己是直接在外面先改完顏色後再丟進 project 裡
你問我說什麼是 @State
?
在上一篇有提到 @State
這個東西對吧……還有印象吧……
如果沒印象,這邊我簡單講解一下,@State
是一個屬性包裝器,用來管理 View 裡面的資料,有了 @State
就可以在 View 裡面對變數做資料的修改
舉個例子,這邊有一個沒加 @State
的變數 count
,而我在 Button 的點擊事件中新增 count += 1
後發現了一個錯誤
Left side of mutating operator isn’t mutable: ‘self’ is immutable
SwiftUI 中的 View
是不變的(immutable)。代表在 SwiftUI 中你不能直接在 View
內修改狀態,因為 View
是基於一個不變的結構
SwiftUI 提供了 @State
這樣的屬性包裝器來管理可變狀態,並讓 SwiftUI
自動處理狀態變化時的視圖重繪
所以當在 SwiftUI 中遇到需要去修改變數的時候,就需要使用 @State
來告訴 SwiftUI 說這個變數是可變的
所以我們再回頭來看一下這段錯誤的地方,他出錯的地方是 count += 1
,那在往上追蹤會發現 count
並沒有被宣告成 @State
,所以解決辦法就是把 count
宣告成 @State
,這個錯誤就能解決了
OK 現在問題解決了,也比較詳細的介紹 @State
,畢竟 @State
這個元件使用的機率非常高,所以這邊花一點點篇幅介紹
再來就是把元件組成 TodoCard
import SwiftUI
struct TodoCard: View {
var body: some View {
HStack {
CheckBoxTest()
.padding(10)
Text("title")
.font(.title)
.padding(5)
.foregroundStyle(.black)
Spacer()
}
.padding()
.frame(maxWidth: .infinity)
.background(Color(red: 171/255, green: 171/255, blue: 171/255))
.swipeActions(edge: .leading, content: {
Button(action: {}) {
Image(uiImage: UIImage(named: "svg_delete")!)
}
})
.tint(.red)
}
}
emm…….是不是看不懂,那我一層一層拆開來講講
HStack {
// 等等會講解
}
.padding()
.frame(maxWidth: .infinity)
.background(Color(red: 171/255, green: 171/255, blue: 171/255))
.swipeActions(edge: .leading, content: {
// 等等會講解
})
.tint(.red)
這邊是最外層,HStack
是水平佈局,上篇講過了就不提了,主要會談的是下面的 Modifier
.padding()
: 設定默認的內邊距.frame(maxWidth: .infinity)
: 把HStack
的寬度設為.infinity
,也就是跟螢幕一樣寬.background(Color(red: 171/255, green: 171/255, blue: 171/255))
:
把HStack
的背景顏色設定為Color(red: 171/255, green: 171/255, blue: 171/255)
.swipeActions(edge: .leading, content: {})
: 設定HStack
的側滑時顯示的元件,這個等等會再講解.tint(.red)
: 把側滑空間染成紅色 ( 抱歉我真不知道怎麼解釋 )
接著是 HStack
裡面的東西
HStack {
CheckBoxTest()
.padding(10)
Text("title")
.font(.title)
.padding(5)
.foregroundStyle(.black)
Spacer()
}
CheckBox
: 剛剛做的組件,不懂的自己往上翻Text
: 用於顯示Todo
的標題
1..font(.title)
: 設定Text
的字體風格為.title
2..padding(5)
: 設定內邊距為 5
3..foregroundStyle(.black)
: 設定字體顏色為黑色Spacer
: 用於填充Text
跟HStack
最尾端的空間,使Text
能置左顯示
最後是 .swipeActions()
func swipeActions<T>(
edge: HorizontalEdge = .trailing,
allowsFullSwipe: Bool = true,
@ViewBuilder content: () -> T
) -> some View where T : View
這段是從 Apple 官網上複製下來的,我們就來看看幾個參數的設定
edge: HorizontalEdge = .trailing
: 這是設定側滑的方向,預設是向左滑動,他只能選擇水平方向.leading (從頭 / 向右滑動)
&.trailing (從尾 / 向左滑動)
allowsFullSwipe: Bool = true
: 是否同意完全滑動@ViewBuilder content: () -> T
: 這邊可以放入你要顯示的物件,如Button
OK 這邊刻完 TodoCard
接下來來刻 FloatActionButton
FloatActionButton
如果是有接觸 Android 的對這個元件應該不陌生吧,這元件簡單來說他有點像漂浮在畫面中,若畫面上有可滑動的 View ,這元件不會隨著 View 的滾動而改變他的位置,這個是我開發時很喜歡用的元件之一
import SwiftUI
struct FloatActionButton: View {
var btnTitle: String
var img: String
var body: some View {
Button(action: {}, label: {
HStack {
Image(uiImage: UIImage(named: img)!)
Text(btnTitle)
.foregroundStyle(.black)
}
.padding()
.background(Color(red: 231/255, green: 224/255, blue: 236/255))
.clipShape(RoundedRectangle(cornerRadius: 14))
})
.shadow(radius: 10)
}
}
這時會發現,ㄟ怎麼會有 btnTitle
跟 img
兩個不帶 @State
的變數,其實如果在 body 的上面宣告變數的話,代表呼叫這個組件時需要攜帶該變數資料型態的參數,例如這段 Code 中我要呼叫 FloatActionButton
時就必須要打 FloatActionButton(btnTitle: String, img: String)
組件都做得差不多了,接著就是畫面的設計了
AddTaskPage
這個 Page 是用來輸入 Todo 的頁面,我希望他是用 Sheet 的方式顯示出來
OK,有設計稿後用 SwiftUI 來刻出來
import SwiftUI
struct AddTask: View {
@State private var task: String = ""
var body: some View {
VStack {
HStack {
Text("Add Task")
.font(.title)
.padding()
Spacer()
}
.padding()
TextField("Enter Task", text: $task)
.padding()
.overlay(RoundedRectangle(cornerRadius: 14).stroke(lineWidth: 1))
.padding()
HStack {
Spacer()
Button("Add") {}
.padding()
.background(Color(red: 171/255, green: 171/255, blue: 171/255))
.clipShape(RoundedRectangle(cornerRadius: 14))
.foregroundStyle(.black)
.padding()
}
}
}
}
這邊多了 TextField
跟 Button
的新用法,先介紹 Button
的新用法
如果你的 Button
裡面只想讓他顯示文字的話,就可以使用
Button("這邊放你要顯示的文字") {
// 這邊放點擊後要做的事
}
再來換 TextField
TextField
這個相當於 UITextField
,是用來接受用戶輸入的 View
,他需要綁定一個 @State
來儲存用戶輸入的值
@State private var text: String = ""
TextField("Placeholder", text: $text)
.padding() // 設定內邊距
.border(Color.gray, width: 1) // 設定邊框顏色和寬度
.textFieldStyle(RoundedBorderTextFieldStyle()) // 使用圓角邊框樣式
在 TextField 的第一個參數為 Hint 或是 Placeholder,這個用意是讓你知道他要輸入的東西,第二個就是綁定 @State
來儲存輸入的值
常用的幾個參數
.padding(_:)
.border(_:width:)
:設定邊框顏色和寬度.textFieldStyle(_:)
:設定文字框樣式(例如RoundedBorderTextFieldStyle
).onEditingChanged(_:)
:監聽編輯狀態變化.onCommit(_:)
: 當用戶完成輸入並按下return
或enter
時觸發的回調.text
:綁定的文字.keyboardType
:設置鍵盤類型.foregroundStyle(_:)
:設定文字顏色.font(_:)
:設定字體大小和風格.multilineTextAlignment
:設置多行文字的對齊方式.lineLimit
:設置行數限制.disableAutocorrection
:是否禁用自動更正.textContentType
:設置文本內容類型(例如,電子郵件地址、密碼).secureField
:用於輸入密碼
你說看完這幾個常用的 Modifier
後沒看到 .overlay
?我最討厭像你這種直覺敏銳的小鬼
.overlay
可以將一個 View
疊加在另一個 View
的上方
例如在這段 code 中
TextField("Enter Task", text: $task)
.padding()
.overlay(RoundedRectangle(cornerRadius: 14).stroke(lineWidth: 1))
.padding()
他是把一個圓角半徑 14、寬度為 1的邊框疊加在 TextField
上
最後他長這樣
有了輸入的頁面,再來就是顯示 Todo List 的頁面
HomePage
這個頁面用來顯示 Todo List 跟呼叫 AddTaskPage
來新增待辦事項
import SwiftUI
struct HomePage: View {
@State private var showSheet: Bool = false
var body: some View {
VStack {
List(0..<10) { index in
TodoCard()
.listRowInsets(EdgeInsets())
.listRowSpacing(.infinity)
.listRowBackground(Color.clear)
}
.background(.clear)
}
.frame(maxWidth: .infinity)
.overlay(alignment: .bottomTrailing, content: {
FloatActionButton(btnTitle: "Add", img: "svg_add")
.padding(20)
})
.sheet(isPresented: $showSheet, content: {
AddTask()
.presentationDetents([.height(300)])
.presentationDragIndicator(.visible)
})
}
}
這邊又多了新東西 List
List
這個相當於 UITableView
,是用來顯示列表,他具有自動處理重用跟滾動的功能
常用的參數有
.selection
:用於多選和單選的綁定.content
:根據數據產生物件.listRowInsets
:設定表格內容邊距.listRowSpacing
:設定表格內容的間距
像上面是都一個 0 到 9 的陣列進去,畫面就會顯示 10 個 TodoCard
你會發現這樣好像不是我在 Figma 上的樣子,其實可以透過 .listStyle
來修改 List
的樣式
如果我們把它設成 .grouped
那他就會變成這樣,若對這個 .listStyle
有興趣可以自己玩玩看
OK,現在主頁也完成了,接著是把它放入 ContentView
ContentView
import SwiftUI
struct ContentView: View {
var body: some View {
HomePage()
}
}
#Preview {
ContentView()
}
在上面文章的第一段中有提到
In SwiftUI,
ContentView
is often the initial view that's presented to the user when the app launches. When you create a new SwiftUI project in Xcode, the template automatically provides aContentView
struct as a starting point for your app's user interface.
ContentView
通常是應用程式啟動時呈現給使用者的初始 View
,在 TodoListApp
中可以看到在 WindowGroup
中引用了 ContentView
,而 WindowGroup
的功能主要是指定要顯示的主要 View
,如果不喜歡 ContentView
,你也可以在 WindowGroup
裡引用你自己建立的 View
我自己的習慣是額外建立一個 Page 的檔案,然後再到 ContentView
引用這個 Page,如果你要直接把主頁寫在 ContentView
裡也行,甚至你不想用 ContentView
,要改用你自己建立的 View
也行
在框起來的地方修改成你的 View
就可以完成不用 ContentView
來渲染畫面了
後記
這篇先把 UI 設計出來,下一篇來說說資料處理的部分