iOS 開發 #12 | SwiftUI ( 2 )

學習 SwiftUI 從 Todo List 開始 ( 1 ) UI 設計

Han
彼得潘的 Swift iOS / Flutter App 開發教室
18 min readSep 26, 2024

--

圖源:What’s new in SwiftUI — WWDC21 — Videos — Apple Developer

前言

之前讀到這篇文章發現原來一個 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 這個元件 ,但我們可以用 ButtonImageToggle 來做出 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 裡

CheckBox Demo

你問我說什麼是 @State ?

在上一篇有提到 @State 這個東西對吧……還有印象吧……

如果沒印象,這邊我簡單講解一下,@State 是一個屬性包裝器,用來管理 View 裡面的資料,有了 @State 就可以在 View 裡面對變數做資料的修改

舉個例子,這邊有一個沒加 @State 的變數 count ,而我在 Button 的點擊事件中新增 count += 1 後發現了一個錯誤

Left side of mutating operator isn’t mutable: ‘self’ is immutable

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 : 用於填充 TextHStack 最尾端的空間,使 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)
}
}

這時會發現,ㄟ怎麼會有 btnTitleimg 兩個不帶 @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()
}
}
}
}

這邊多了 TextFieldButton 的新用法,先介紹 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(_:): 當用戶完成輸入並按下 returnenter 時觸發的回調
  • .text:綁定的文字
  • .keyboardType:設置鍵盤類型
  • .foregroundStyle(_:):設定文字顏色
  • .font(_:):設定字體大小和風格
  • .multilineTextAlignment:設置多行文字的對齊方式
  • .lineLimit:設置行數限制
  • .disableAutocorrection:是否禁用自動更正
  • .textContentType:設置文本內容類型(例如,電子郵件地址、密碼)
  • .secureField:用於輸入密碼

你說看完這幾個常用的 Modifier 後沒看到 .overlay ?我最討厭像你這種直覺敏銳的小鬼

圖源:Chen Chen on X: “@snowballmonsta 我最討厭像你這種直覺敏銳的小鬼https://t.co/5PXciIwvHR" / X

.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 a ContentView 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 設計出來,下一篇來說說資料處理的部分

--

--