iT邦幫忙

2024 iThome 鐵人賽

DAY 13
0

昨天我們實作新增項目的功能。今天,我們將繼續為家用品管理 App 增加一個功能,那就是編輯現有的項目。這樣,使用者可以在物品資訊發生變動時輕鬆地進行修改,保持清單的準確性。

目標

我們將設計一個編輯項目的頁面(EditItemView),該頁面會包含目前物品的所有資訊,讓使用者可以對名稱、數量、價格、日期等欄位進行修改。此外,我們還會加入「已使用數量」的區塊,讓使用者可以記錄物品的使用情況。

https://ooorito.com/wp-content/uploads/2024/09/%E7%9B%AE%E6%A8%99.webp

主要實作

更新 DataManager

昨天我們更新了 DataManager 的 addItem 方法,今天要來更新 updateItem 方法,讓它能夠接收更多的資訊。

func updateItem(item: Item, name: String, quantity: Int, price: Double, dateAdded: Date, expiryDate: Date?, isUsedUp: Bool, usedQuantity: Int) -> Bool {
    item.name = name
    item.quantity = Int16(quantity)
    item.isUsedUp = isUsedUp
    item.dateAdded = dateAdded
    item.expiryDate = expiryDate
    item.price = price
    item.usedQuantity = Int16(usedQuantity)
    return saveContext()
}

實作 EditItemViewModel

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

在 EditItemViewModel 中,我們將檢查使用者輸入的每個欄位,確認資料格式正確。如果有任何錯誤,將顯示錯誤訊息給使用者。如果檢查通過,並且成功將資料更新到資料庫,我們會顯示成功訊息,通知使用者,同時返回到首頁,方便使用者進行其他操作。

請新增一個 Swift 檔,並命名為 EditItemViewModel,後加入以下程式碼:

class EditItemViewModel: ObservableObject {
    @Published var name: String
    @Published var quantity: Int
    @Published var price: String
    @Published var dateAdded: Date
    @Published var expiryDate: Date
    @Published var shouldRemindExpiryDate: Bool
    @Published var usedQuantity: Int
    @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
}

和 AddItemViewModel 一樣,先宣告使用到的欄位。因為編輯時我們希望可以讓使用者標記物品的使用狀況,所以會多了 usedQuantity 這個欄位。

和 AddItemViewModel 不一樣的是,我們要在 ViewModel 中建立一個變數 item,為了從首頁將要編輯的項目傳遞到編輯頁面進行編輯,我們需要使用 item 來將項目的資料顯示在畫面上。並且要實作 init() 進行 ViewModel 的初始化。

private let item: Item
init(dataManager: DataManager, item: Item) {
    self.dataManager = dataManager
    self.item = item
    // 初始化各個欄位
    self.name = item.name ?? ""
    self.quantity = Int(item.quantity)
    self.price = String(item.price)
    self.dateAdded = item.dateAdded ?? Date()
    self.expiryDate = item.expiryDate ?? Date()
    self.shouldRemindExpiryDate = item.expiryDate != nil
    self.usedQuantity = Int(item.usedQuantity)
}

接下來,我們要實作資料欄位的驗證:

func save() {
    if validateAndSave(), let priceValue = Double(price) {
        
        let result = dataManager.updateItem(item: item, name: name, quantity: Int(Int16(quantity)), price: priceValue, dateAdded: dateAdded, expiryDate: shouldRemindExpiryDate ? expiryDate : nil, isUsedUp: quantity == usedQuantity ? true : false, usedQuantity: usedQuantity)
        
        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
    }
    
    // 驗證已使用數量
    guard usedQuantity >= 0 else {
        failHandle = (isFail: true, title: "已使用數量不能小於 0")
        return false
    }
    guard usedQuantity <= quantity else {
        failHandle = (isFail: true, title: "已使用數量不能超過 \(quantity)")
        return false
    }
    
    return true
}

這樣 EditItemViewModel 的部分就完成啦,我把完整的 code 放在這裡,給需要的讀者參考:

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

    private let dataManager: DataManager
    private let item: Item

    // 資料庫限制的最大值
    private let maxNameLength = 255
    private let maxQuantity: Int = 1000
    private let maxPrice: Double = 1000000.0

    init(dataManager: DataManager, item: Item) {
        self.dataManager = dataManager
        self.item = item
        // 初始化各個欄位
        self.name = item.name ?? ""
        self.quantity = Int(item.quantity)
        self.price = String(item.price)
        self.dateAdded = item.dateAdded ?? Date()
        self.expiryDate = item.expiryDate ?? Date()
        self.shouldRemindExpiryDate = item.expiryDate != nil
        self.usedQuantity = Int(item.usedQuantity)
    }

    func save() {
        if validateAndSave(), let priceValue = Double(price) {
            
            let result = dataManager.updateItem(item: item, name: name, quantity: Int(Int16(quantity)), price: priceValue, dateAdded: dateAdded, expiryDate: shouldRemindExpiryDate ? expiryDate : nil, isUsedUp: quantity == usedQuantity ? true : false, usedQuantity: usedQuantity)
            
            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
    }
}

實作 EditItemView

接下來,我們來建立編輯頁的 UI - EditItemView。這個頁面會讓使用者檢視和編輯已經存在的項目資料。我們將加入一個 Form,裡面包含各種輸入元件,如 TextFieldStepperDatePickerToggle,來讓使用者編輯物品的詳細資訊。

我們先新增一個名為 EditItemView 的 Swift 檔,並加入以下程式碼:

import SwiftUI
import AlertToast

struct EditItemView: View {
    @ObservedObject var viewModel: EditItemViewModel
    @Environment(\.dismiss) private var dismiss
    
    var body: some View {
        Form {
            Section(header: Text("基本資料")) {
                HStack {
                    Text("名稱")
                    Spacer()
                    TextField("名稱", text: $viewModel.name)
                        .multilineTextAlignment(.trailing)
                        .textFieldStyle(RoundedBorderTextFieldStyle())
                }
                HStack {
                    Text("數量")
                    Spacer()
                    Stepper(value: $viewModel.quantity, in: 1...100) {
                        Text("\(viewModel.quantity)")
                            .bold()
                            .frame(width: 50, alignment: .trailing)
                    }
                }
                HStack {
                    Text("價格")
                    Spacer()
                    TextField("價格", text: $viewModel.price)
                        .keyboardType(.decimalPad)
                        .multilineTextAlignment(.trailing)
                        .textFieldStyle(RoundedBorderTextFieldStyle())
                }
            }

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

            Section(header: Text("已使用數量")) {
                HStack {
                    Text("已使用")
                    Spacer()
                    Stepper(value: $viewModel.usedQuantity, in: 0...viewModel.quantity) {
                        Text("\(viewModel.usedQuantity)")
                            .bold()
                            .frame(width: 50, alignment: .trailing)
                    }
                }
            }

            Button(action: {
                viewModel.save()
            }) {
                Text("儲存")
                    .frame(maxWidth: .infinity)
                    .padding()
                    .background(Color.blue)
                    .foregroundColor(.white)
                    .cornerRadius(8)
            }
        }
        .navigationBarTitle("編輯物品", displayMode: .inline)
        .toast(isPresenting: $viewModel.failHandle.isFail, alert: {
            AlertToast(type: .error(Color.red), title: viewModel.failHandle.title)
        })
        .toast(isPresenting: $viewModel.showSuccessToast, alert: {
            AlertToast(type: .complete(Color.green), title: "儲存成功")
        }, completion:  {
            dismiss()
        })
    }
}



#Preview {
    let dataManager = DataManager()
    let item = Item(context: dataManager.container.viewContext)
    item.name = "測試物品"
    item.quantity = 5
    item.price = 100.0
    item.dateAdded = Date()
    item.expiryDate = Calendar.current.date(byAdding: .day, value: 30, to: Date())
    item.usedQuantity = 2
    
    return EditItemView(viewModel: EditItemViewModel(dataManager: dataManager, item: item))
}

這邊除了 UI 排版以外,基本上就是把 Item 的值帶入到相對應的欄位顯示。

比較快速的方法就是拿 AddItemView 來修改,不過不能 UI 長得一模一樣,因為 AddItemView 得畫面直接帶入資訊,使用者會不知道這個欄位的資料代表什麼,所以還是要做一些變化。

dismiss

在 SwiftUI 中,@Environment(\.dismiss) 是一個用來關閉當前畫面的環境變數。這個屬性在 iOS 15 及之後的版本中被引入,並用來替代過去常用的 @Environment(\.presentationMode)

dismiss 的作用
dismiss 是一個關閉當前呈現畫面的簡單方法,無論是透過 NavigationLink 推出的畫面,還是透過 sheetpopover 等方式呈現的畫面,都可以用 dismiss() 來關閉。相比 presentationModedismiss 提供了一個更加簡單、直覺的方式來管理畫面的消失操作。

如何使用
在 EditItemView 中,我們可以透過 @Environment(\.dismiss) 取得 dismiss 方法,並在需要時直接調用 dismiss() 來關閉當前畫面。例如,當使用者成功編輯或新增項目後,我們可以在顯示成功提示後自動關閉畫面,返回到上一層。

@Environment(\.dismiss) private var dismiss

...

.toast(isPresenting: $viewModel.showSuccessToast, alert: {
    AlertToast(type: .complete(Color.green), title: "儲存成功")
}, completion:  {
    dismiss()
})

結合 AlertToast 使用
在我們的範例中,當使用者成功完成某項操作(如儲存資料)後,showSuccessToast 會觸發顯示一個成功提示。當這個提示消失後,我們會自動使用 dismiss(),這樣可以讓使用者無縫地返回到上一個頁面。

為什麼選擇 dismiss
相比 presentationMode.wrappedValue.dismiss()dismiss 更加簡潔且易於理解,特別是在簡單的畫面層級控制中。使用 dismiss 可以讓程式更清晰,並避免不必要的錯誤。

透過這種設計,我們能夠提供更好的使用者體驗,讓 App 的操作更流暢自然。

參考資料:dismiss | Apple Developer Documentation

更新 ContentView

建立好編輯頁之後,我們要在首頁編寫跳轉到編輯頁的程式,我們來修改一下 List 的地方:

List {
    ForEach(viewModel.items) { item in
        NavigationLink(destination: EditItemView(viewModel: EditItemViewModel(dataManager: viewModel.dataManager, item: item))) {
            
            HStack {
                VStack(alignment: .leading) {
                    Text(item.name)
                    if let expiryDate = item.expiryDate {
                        Text("到期日\(expiryDate)")
                            .font(.subheadline).foregroundColor(.gray)
                    }
                }
                Spacer()
                Text("數量: \(item.quantity)").font(.subheadline)
            }
        }
    }
    .onDelete(perform: viewModel.deleteItems)
}

如果 viewModel.dataManager 這裡出錯的話,可以去 ItemViewModel 把宣告 dataManager 時設定的 private 移除就可以囉!

https://ooorito.com/wp-content/uploads/2024/09/%E5%AE%8C%E6%88%90.gif

總結

我們今天順利完成了家用品管理 App 的編輯功能。雖然這些操作和新增項目的步驟類似,但透過再一次的實作,加深了我們對這些概念的理解。明天我們將繼續為家用品管理 App 增加更多功能。明天見!


上一篇
Day 12: SwiftUI 新增項目頁面與懸浮按鈕設計
下一篇
Day 14: SwiftUI 建立側邊欄
系列文
用 SwiftUI 掌控家庭日用品庫存30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言