iT邦幫忙

2024 iThome 鐵人賽

DAY 12
0

昨天提到新增項目時,使用者只能輸入兩個欄位,為了避免程式閃退,我們在 addItem 方法中使用了預設值。然而,這並不是最佳解決方案。因此,我們將讓使用者自行輸入所有必要的欄位,並設計一個新的頁面來完成這項功能。

目標

我們預計在首頁的下方新增一個圓形的懸浮按鈕,上面顯示「+」符號,提示使用者可以點擊來新增項目。點擊按鈕後,畫面將切換到新增頁面 AddItemView,讓使用者自行輸入所需的資料欄位。

使用者填寫完資料後,點擊頁面下方的「新增物品」按鈕時,系統會先進行資料驗證。如果發現輸入的資料格式或內容有誤,系統會顯示錯誤提示,提醒使用者進行修正。若資料驗證通過並成功新增項目,系統也會顯示成功提示,並清空已填入的資料,以便使用者繼續操作。

https://ooorito.com/wp-content/uploads/2024/08/%E5%AE%8C%E6%88%90%E7%A4%BA%E6%84%8F%E5%9C%96.gif

主要實作

實作 AddItemView

我們將從設計 AddItemView 開始。這個頁面會使用多個 TextFieldStepper,讓使用者輸入項目的名稱、數量、價格等資訊,這樣可以讓所有必要的資料在新增項目時都能被正確地填寫。

新增一個 Swift 檔,名稱為 AddItemView。建立完成後,加入以下程式碼:

import SwiftUI

struct AddItemView: View {
    @State private var name: String = ""
    @State private var quantity: Int = 1
    @State private var price: String = ""
    @State private var dateAdded: Date = Date()
    @State private var expiryDate: Date = Date()
    @State private var shouldRemindExpiryDate = false

    var body: some View {
        Form {
            Section(header: Text("基本資料")) {
                TextField("物品名稱", text: $name)

                Stepper(value: $quantity, in: 1...100) {
                    Text("數量: \(quantity)")
                }

                TextField("價格", text: $price)
                    .keyboardType(.decimalPad)
            }

            Section(header: Text("日期")) {
                DatePicker("加入日期", selection: $dateAdded, displayedComponents: .date)
                
                Toggle("提醒到期日", isOn: $shouldRemindExpiryDate)
                
                if shouldRemindExpiryDate {
                    DatePicker("到期日", selection: $expiryDate, displayedComponents: .date)
                }
            }

            Button(action: {
                // 新增物品的邏輯將在這裡加入
            }) {
                Text("新增物品")
                    .frame(maxWidth: .infinity)
            }
        }
    }
}

https://ooorito.com/wp-content/uploads/2024/08/AddItemView_UI.gif

這段程式碼出現一些之前沒介紹過的元件,讓我們一個一個來看吧!

Form

Form 是 SwiftUI 中用來構建表單畫面的容器元件。表單通常用來組織和排列多個輸入元件,例如 TextFieldTogglePicker 等。使用 Form 可以讓畫面變得更加整齊和易於操作,特別是在需要收集多個使用者輸入時非常實用。

參考資料:

Section

Section 是用來在 Form 中組織內容的元件。它允許我們將表單內容分組,每個分組可以有一個標題(header)或尾部(footer)。這樣的分組方式能夠讓表單結構更加清晰,讓使用者更容易理解和填寫表單內容。

參考資料:

DatePicker

DatePicker 是 SwiftUI 中用來選擇日期或時間的元件。它可以用來讓使用者選擇日期、時間或兩者的組合。你可以透過 displayedComponents 來指定顯示的類型(如日期或時間)。在這個範例中,我們用 DatePicker 來選擇物品的加入日期和到期日。

參考資料:

Toggle

Toggle 是一個開關元件,和 UIKit 中的 UISwitch 類似。Toggle 用來讓使用者開啟或關閉某個選項。在這個範例中,Toggle 用來決定是否顯示到期日提醒的 DatePicker

參考資料:

更新 DataManager

為了讓使用者輸入的資訊更全面,我們需要更新 DataManager 中的 addItem 方法,將預設值移除,改為使用傳遞進來的參數。除此之外,我們的目標之一是要在資料新增成功時提示使用者,因此也需要更新 saveContext 方法,使其在成功保存資料時回傳布林值,方便後續操作。

func addItem(name: String, quantity: Int, price: Double, dateAdded: Date, expiryDate: Date?) -> Bool {
    let newItem = Item(context: container.viewContext)
    newItem.id = UUID()
    newItem.name = name
    newItem.quantity = Int16(quantity)
    newItem.isUsedUp = false
    newItem.dateAdded = dateAdded
    newItem.price = price
    newItem.usedQuantity = 0
    newItem.expiryDate = expiryDate
    
    return saveContext()
}

private func saveContext() -> Bool {
    let context = container.viewContext
    if context.hasChanges {
        do {
            try context.save()
            return true
        } catch {
            print("Failed to save context: \(error)")
            return false
        }
    }
    return true
}

實作 AddItemViewModel

接下來,我們要為 AddItemView 設計一個 ViewModel。這個 ViewModel 將負責處理使用者輸入的資料並與 DataManager 進行互動。

在 AddItemViewModel 中,我們會檢查使用者輸入的每個欄位,確保資料格式正確。如果有任何錯誤,將顯示錯誤訊息提醒使用者。如果所有檢查都通過並且資料成功加入資料庫,則會顯示成功訊息給使用者,同時清空已填入的欄位,讓使用者能方便地輸入下一筆資料。

新增一個 Swift 檔,命名為 AddItemViewModel,先將我們會需要的欄位都建立出來:

class AddItemViewModel: ObservableObject {
    @Published var name: String = ""
    @Published var quantity: Int = 1
    @Published var price: String = ""
    @Published var dateAdded: Date = Date()
    @Published var expiryDate: Date = Date()
    @Published var shouldRemindExpiryDate = false
    @Published var showSuccessToast: Bool = false
    @Published var failHandle: (isFail: Bool, title: String) = (isFail: false, title: "")

    private let dataManager: DataManager
    
    // 設定資料庫限制的最大值(這裡假設了一些值,根據實際情況進行調整)
    private let maxNameLength = 255
    private let maxQuantity: Int = 1000
    private let maxPrice: Double = 1000000.0

    init(dataManager: DataManager = DataManager()) {
        self.dataManager = dataManager
    }
}

除了我們需要儲存的資料以外,多宣告了 maxNameLengthmaxQuantitymaxPrice,是為了避免使用者輸入的數字超過資料庫限制,不過這裡我先隨意抓一個數字。

接著,我們建立 func reset() ,用於新增成功後,清空畫面上的資料,方便使用者加入下一筆資料使用。

func reset() {
    name = ""
    price = ""
    quantity = 1
    dateAdded = Date()
    shouldRemindExpiryDate = false
    expiryDate = Date()
    failHandle = (isFail: false, title: "")
}

完成之後,重頭戲來了!我們要建立 func save(),來幫我們儲存使用者所輸入的資訊,並且在裡面做所有欄位的格式驗證。

func save() {
    if validateAndSave(), let priceValue = Double(price) {
        // 資料驗證通過,儲存資料
        let result = dataManager.addItem(
            name: name,
            quantity: quantity,
            price: priceValue,
            dateAdded: dateAdded,
            expiryDate: shouldRemindExpiryDate ? expiryDate : nil
        )
        
        if result {
            showSuccessToast = true
        } else {
            failHandle = (isFail: true, title: "資料新增失敗")
        }
    }
}

func validateAndSave() -> Bool {
    // 驗證名稱
    guard !name.isEmpty else {
        failHandle = (isFail: true, title: "名稱不能為空")
        return false
    }
    guard name.count <= maxNameLength else {
        failHandle = (isFail: true, title: "名稱字數不能超過 \(maxNameLength) 個字")
        return false
    }
    
    // 驗證數量
    guard quantity > 0 else {
        failHandle = (isFail: true, title: "數量不能小於 1")
        return false
    }
    guard quantity <= maxQuantity else {
        failHandle = (isFail: true, title: "數量不能超過 \(maxQuantity)")
        return false
    }
    
    // 驗證價格
    guard let priceValue = Double(price), priceValue >= 0 else {
        failHandle = (isFail: true, title: "價格格式錯誤或價格不能為負數")
        return false
    }
    guard priceValue <= maxPrice else {
        failHandle = (isFail: true, title: "價格不能超過 \(maxPrice)")
        return false
    }
    
    // 驗證新增日
    guard dateAdded <= Date() else {
        failHandle = (isFail: true, title: "新增日期不能大於今天")
        return false
    }
    
    // 驗證到期日(可為空,但若不為空則需驗證)
    if shouldRemindExpiryDate && expiryDate < Date() {
        failHandle = (isFail: true, title: "到期日不能小於今天")
        return false
    }
    
    return true
}

這段程式碼中的驗證邏輯如下:

  • 名稱:不得為空,字數不得超過資料庫限制。
  • 數量:不得為空,數字不得超過資料庫限制,也不可小於 1。
  • 價格:不得為空,數字不得超過資料庫限制,也不可小於 0。
  • 新增日:不得為空,日期不得大於今日。
  • 到期日:可為空,但如果不為空,日期不得小於今日。

這樣的設計能夠確保使用者輸入的資料格式正確,並且在所有資料正確無誤後,才會將資料存入資料庫中。
這樣 AddItemViewModel 就完成了,下面放上完整程式碼:

class AddItemViewModel: ObservableObject {
    @Published var name: String = ""
    @Published var quantity: Int = 1
    @Published var price: String = ""
    @Published var dateAdded: Date = Date()
    @Published var expiryDate: Date = Date()
    @Published var shouldRemindExpiryDate = false
    @Published var showSuccessToast: Bool = false
    @Published var failHandle: (isFail: Bool, title: String) = (isFail: false, title: "")

    private let dataManager: DataManager
    
    // 設定資料庫限制的最大值(這裡假設了一些值,根據實際情況進行調整)
    private let maxNameLength = 255
    private let maxQuantity: Int = 1000
    private let maxPrice: Double = 1000000.0

    init(dataManager: DataManager = DataManager()) {
        self.dataManager = dataManager
    }

    func reset() {
        name = ""
        price = ""
        quantity = 1
        dateAdded = Date()
        shouldRemindExpiryDate = false
        expiryDate = Date()
        failHandle = (isFail: false, title: "")
    }
    
    func save() {
        if validateAndSave(), let priceValue = Double(price) {
            // 資料驗證通過,儲存資料
            let result = dataManager.addItem(
                name: name,
                quantity: quantity,
                price: priceValue,
                dateAdded: dateAdded,
                expiryDate: shouldRemindExpiryDate ? expiryDate : nil
            )
            
            if result {
                showSuccessToast = true
            } else {
                failHandle = (isFail: true, title: "資料新增失敗")
            }
        }
    }
    
    func validateAndSave() -> Bool {
        // 驗證名稱
        guard !name.isEmpty else {
            failHandle = (isFail: true, title: "名稱不能為空")
            return false
        }
        guard name.count <= maxNameLength else {
            failHandle = (isFail: true, title: "名稱字數不能超過 \(maxNameLength) 個字")
            return false
        }
        
        // 驗證數量
        guard quantity > 0 else {
            failHandle = (isFail: true, title: "數量不能小於 1")
            return false
        }
        guard quantity <= maxQuantity else {
            failHandle = (isFail: true, title: "數量不能超過 \(maxQuantity)")
            return false
        }
        
        // 驗證價格
        guard let priceValue = Double(price), priceValue >= 0 else {
            failHandle = (isFail: true, title: "價格格式錯誤或價格不能為負數")
            return false
        }
        guard priceValue <= maxPrice else {
            failHandle = (isFail: true, title: "價格不能超過 \(maxPrice)")
            return false
        }
        
        // 驗證新增日
        guard dateAdded <= Date() else {
            failHandle = (isFail: true, title: "新增日期不能大於今天")
            return false
        }
        
        // 驗證到期日(可為空,但若不為空則需驗證)
        if shouldRemindExpiryDate && expiryDate < Date() {
            failHandle = (isFail: true, title: "到期日不能小於今天")
            return false
        }
        
        return true
    }
}

有了 AddItemViewModel 後,我們需要更新 AddItemView,讓它可以與 ViewModel 進行資料綁定。

在 AddItemViewModel 中,當我們驗證出使用者輸入的欄位有錯誤時,需要提示使用者進行修改。為了讓提示更加美觀,我們將使用 AlertToast 來顯示錯誤提醒的訊息。

AlertToast

AlertToast 是一個 SwiftUI 中非常方便的第三方元件,用來顯示彈出式提示(Toast)訊息。這個元件能夠在使用者操作後,快速且簡潔地給出回饋,例如成功提示、錯誤訊息等,無需額外的視窗或跳轉頁面。

在 AddItemView 中,我們會使用 AlertToast 來顯示驗證錯誤的訊息,讓使用者即時了解輸入的資料是否有誤。這樣的即時回饋有助於提升使用者體驗,避免因錯誤資料而導致後續問題。

如何使用 AlertToast
要在 SwiftUI 中使用 AlertToast,首先需要引入這個第三方工具。你可以通過 Swift Package Manager 或 CocoaPods 來安裝它。詳細的安裝方法可以參考下方的參考資料。

接著,我們可以在 View 中利用 AlertToast 的方法來彈出提示訊息。

例如,在新增項目時,如果檢查到使用者輸入的資料有誤,我們就會調用 AlertToast 來顯示對應的錯誤訊息。這樣使用者可以在畫面中看到即時的錯誤提示,然後修正資料。

參考資料:

安裝完之後,讓我們來修改 AddItemView :

import SwiftUI
import AlertToast

struct AddItemView: View {
    @ObservedObject var viewModel: AddItemViewModel

    init(viewModel: AddItemViewModel = AddItemViewModel()) {
        _viewModel = ObservedObject(wrappedValue: viewModel)
    }

    var body: some View {
        VStack {
            Form {
                Section(header: Text("基本資料")) {
                    TextField("物品名稱", text: $viewModel.name)
                    
                    Stepper(value: $viewModel.quantity, in: 1...100) {
                        Text("數量: \(viewModel.quantity)")
                    }
                    
                    TextField("價格", text: $viewModel.price)
                        .keyboardType(.decimalPad)
                }
                
                Section(header: Text("日期")) {
                    DatePicker("加入日期", selection: $viewModel.dateAdded, displayedComponents: .date)
                    
                    Toggle("提醒到期日", isOn: $viewModel.shouldRemindExpiryDate)
                    
                    if viewModel.shouldRemindExpiryDate {
                        DatePicker("到期日", selection: $viewModel.expiryDate, displayedComponents: .date)
                    }
                }
                
                Button(action: {
                    viewModel.save()
                }) {
                    Text("新增物品")
                        .frame(maxWidth: .infinity)
                }
            }
            .toast(isPresenting: $viewModel.showSuccessToast, alert: {
                AlertToast(type: .complete(Color.green), title: "完成")
            }, completion: {
                viewModel.reset()
            })
            
            .toast(isPresenting: $viewModel.failHandle.isFail, alert: {
                AlertToast(type: .error(Color.red), title: viewModel.failHandle.title)
            })
            .navigationBarTitle("新增物品", displayMode: .inline)
        }
    }
}

我們將原本宣告的多個 @State 變數移除,改用 @ObservedObject 宣告 ViewModel。接著,將 TextField、Stepper、DatePicker 以及 Toggle 等元件的綁定參數替換為 ViewModel 內的對應屬性。Button 的 action 則設定為呼叫 ViewModel 的 save 方法。最後,我們加入了用來提示使用者的 AlertToast,並將 isPresenting 綁定到 ViewModel 的資料,以判斷是否需要顯示提示訊息。

到這裡為止,我們已經完成了新增項目的新頁面設計。接下來,我們需要在首頁新增一個按鈕,讓使用者可以點擊後跳轉到這個新增項目的頁面。

懸浮按鈕

現在我們要在首頁(ContentView)下方新增一個懸浮的圓形按鈕,當使用者點擊這個按鈕時,會跳轉到剛剛設計的新增項目頁面(AddItemView)。

懸浮按鈕的設計將使用 SwiftUI 中的 ZStack 元件來實現,這樣可以讓按鈕懸浮在列表之上,即使資料量過多,列表可以向下滾動,按鈕仍然會固定在螢幕底部。關於 ZStack 的說明在 Day4,沒看過的讀者可以回去看一下唷!

首先,我們需要在 ContentView 中進行修改,來加入這個懸浮按鈕。

import SwiftUI

struct ContentView: View {
    @StateObject private var viewModel: ItemViewModel = ItemViewModel()
    @State private var navigateToAddItemView = false

    var body: some View {
        NavigationView {
            ZStack(alignment: .bottomTrailing) {
                List {
                    ForEach(viewModel.items) { item in
                        HStack {
                            Text(item.name)
                            Spacer()
                            Text("數量: \(item.quantity)")
                        }
                    }
                    .onDelete(perform: viewModel.deleteItems)
                }
                .onAppear {
                    viewModel.fetchItems()
                }
                .navigationTitle("家用品清單")
                
                NavigationLink(destination: AddItemView(viewModel: AddItemViewModel()), isActive: $navigateToAddItemView
                ) {
                    Button(action: {
                        navigateToAddItemView = true
                    }) {
                        Image(systemName: "plus")
                            .font(.largeTitle)
                            .foregroundColor(.white)
                            .padding()
                            .background(Color.blue)
                            .clipShape(Circle())
                            .shadow(radius: 10)
                    }
                    .padding()
                }
                
            }
        }
    }
}
  • NavigationLink:這裡我們新增了一個 NavigationLink,並綁定了一個 navigateToAddItemView 布林值。這個 NavigationLink 的目的地是我們之前設計的 AddItemView。isActive 綁定這個布林值來控制何時觸發導覽。
  • 按鈕觸發導覽:當按鈕被點擊時,我們將 navigateToAddItemView 設為 true,這將觸發 NavigationLink,進行頁面跳轉。
  • 導覽返回功能:這樣的設計讓使用者可以在新增項目後,自然地返回到清單頁面,符合多層次導覽的使用習慣。
  • 使用 onAppear()onAppear() 與 UIKit 的 viewWillAppear 類似,它會在 View 即將出現時執行。我們利用這個方法,在頁面顯示時重新取得資料,讓列表的內容是最新的。

https://ooorito.com/wp-content/uploads/2024/08/%E5%AE%8C%E6%88%90%E7%A4%BA%E6%84%8F%E5%9C%96.gif

這樣一來,使用者點擊懸浮按鈕後,就會跳轉到新增項目的頁面。完成操作後,使用者可以透過標題列的返回按鈕回到上一頁,並且列表會自動更新,顯示最新的資料。

總結

今天我們成功地為家用品管理 App 增加一個全新的新增項目功能,從建立新增項目的頁面、加入資料驗證機制、到實作懸浮按鈕以及頁面跳轉,一步步讓我們的 App 更加完整。明天我們要新增讓使用者修改列表項目以及標記物品以使用的功能,今天就先寫到這。我們明天見!


上一篇
Day 11: 將資料儲存到 Core Data
系列文
用 SwiftUI 掌控家庭日用品庫存12
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言