iT邦幫忙

2024 iThome 鐵人賽

DAY 18
0

為了讓使用者輕鬆管理家中的物品並清楚掌握存放位置,我們加入地點管理功能。今天我們將實作地點管理功能,讓使用者能夠方便地查看、刪除和新增地點。準備好了嗎?讓我們開始吧!

目標

以下為我們今天要實作的目標:

  • 設計並實作地點列表,讓使用者可以查看已經儲存的地點資料。
  • 新增一個彈跳視窗,讓使用者可以新增地點並選擇顏色。
  • 與資料庫整合,儲存與刪除地點資料。

https://ooorito.com/wp-content/uploads/2024/09/%E5%88%97%E8%A1%A8%E9%A0%81%E7%A4%BA%E6%84%8F%E5%9C%96.webp

https://ooorito.com/wp-content/uploads/2024/09/%E6%96%B0%E5%A2%9E%E9%A0%81%E7%A4%BA%E6%84%8F%E5%9C%96.webp

主要實作

實作 LocationListViewModel

和昨天一樣,我們先建立 ViewModel。這個 ViewModel 負責新增、查詢、刪除地點資料,我們需要在 ViewModel 中實作以下功能:

  • 顯示地點資料的陣列locations,用來儲存從資料庫中抓取的所有地點。
  • 顯示成功訊息showSuccessToast,當新增地點成功時,觸發這個變數來顯示通知。
  • 顯示失敗訊息failHandle,用來處理錯誤訊息,當新增或刪除失敗時,顯示錯誤提示。
  • 負責控制 Core Data 資料的物件:DataManager,這是我們與 Core Data 互動的主要接口。
  • 抓取地點的函數fetchLocations(),用來從資料庫中載入所有已存在的地點。
  • 新增地點的函數addLocation(name: String, colorHex: String),負責將使用者輸入的名稱和顏色存入 Core Data。
  • 刪除地點的函數deleteLocation( location: Location),負責刪除指定的地點,並在刪除成功後重新抓取資料。如果刪除失敗,則會顯示錯誤訊息。

下面是 LocationListViewModel 完整的程式碼

import SwiftUI

class LocationListViewModel: ObservableObject {
    @Published var locations: [Location] = []
    @Published var showSuccessToast: Bool = false
    @Published var failHandle: (isFail: Bool, title: String) = (isFail: false, title: "")
    
    private let dataManager: DataManager
    
    init(dataManager: DataManager = DataManager()) {
        self.dataManager = dataManager
        fetchLocations()
    }
    
    func fetchLocations() {
        locations = dataManager.fetchLocations()
    }
    
    func addLocation(name: String, colorHex: String) {
        if name.isEmpty || colorHex.isEmpty {
            failHandle = (isFail: true, title: "您有欄位尚未輸入")
        } else {
            let result = dataManager.addLocation(name: name, colorHex: colorHex)
            if result {
                fetchLocations()
            } else {
                failHandle = (isFail: true, title: "發生錯誤")
            }
        }
    }
    
    func deleteLocation(_ location: Location) {
        let result = dataManager.deleteLocation(location)
        if !result {
            failHandle = (isFail: true, title: "發生錯誤")
        } else {
            fetchLocations()
        }
    }
}

實作 AddLocationModalView

因為我們等等會需要在 LocationListView 呼叫 AddLocationModalView 頁面,因此我們先來實作 AddLocationModalView 吧!
AddLocationModalView 是用來新增地點的彈跳視窗,使用者可以在這裡輸入地點名稱並選擇顏色。

建立半透明黑色背景

在 AddLocationModalView 中,作法和 Day14 在製作側邊欄時有點相像。需要先製作出一個半透明的黑色背景,用來當作遮罩。這樣的效果像是在模擬跳出 alert 的感覺,讓使用者的注意力放在跳出的視窗當中。

我們需要新增變數 isPresented 控制彈跳視窗的顯示,並且利用 ZStack 來設定背景顏色。透過 onTapGesture 這個 Modifier 來增加背景的點擊事件,讓使用者可以點選背景就關閉彈跳視窗。

struct AddLocationModalView: View {
    @Binding var isPresented: Bool // 控制彈跳視窗的顯示
    var body: some View {
        ZStack {
            // 背景遮罩
            Color.black.opacity(0.4)
                .edgesIgnoringSafeArea(.all)
                .onTapGesture {
                    isPresented = false // 點擊背景關閉彈跳視窗
                }
        }
    }
}

建立基本元件

在建立元件之前,我們必須要將這些元件會使用到的變數先建立好:

@ObservedObject var viewModel: LocationListViewModel
@State private var locationName: String = ""
@State private var selectedColorHex: String = "#FF5733"
let colors: [String] = [
        "#FF5733", "#FF8D1A", "#FFC300", "#DAF7A6",
        "#33FF57", "#33FFF9", "#3380FF", "#9D33FF",
        "#FF33B5", "#F39C12", "#2ECC71", "#3498DB"
    ]
let columns = [
        GridItem(.flexible()),
        GridItem(.flexible()),
        GridItem(.flexible()),
        GridItem(.flexible()),
        GridItem(.flexible()),
        GridItem(.flexible())
    ]

接著照著目標的示意圖,建立需要的元件:TextField、Text、Button 等。

var body: some View {
    ZStack {
        // 背景遮罩
        Color.black.opacity(0.4)
        .edgesIgnoringSafeArea(.all)
        .onTapGesture {
            isPresented = false // 點擊背景關閉彈跳視窗
        }
        
        // 彈跳視窗的內容
        VStack {
            Text("新增地點")
                .font(.headline)
                .frame(maxWidth: .infinity)
            
            // 地點名稱輸入框
            TextField("輸入地點名稱", text: $locationName)
                .padding()
                .background(Color.gray.opacity(0.2))
                .cornerRadius(10)
                .padding(.horizontal)
            
            // 顏色選擇區
            LazyVGrid(columns: columns, spacing: 12) {
                ForEach(colors, id: \.self) { colorHex in
                    Button(action: {
                        selectedColorHex = colorHex
                    }) {
                        Circle()
                            .fill(Color(hexString: colorHex) ?? .black)
                            .frame(width: 40, height: 40)
                            .overlay(
                                Circle()
                                    .stroke(Color.black, lineWidth: selectedColorHex == colorHex ? 2 : 0)
                            )
                    }
                }
            }
            .padding()
            
            
            Button(action: {
                viewModel.addLocation(name: locationName, colorHex: selectedColorHex)
            }) {
                Text("新增")
                    .frame(maxWidth: .infinity)
                    .padding()
                    .background(Color.blue)
                    .foregroundColor(.white)
                    .cornerRadius(10)
            }
            .buttonStyle(PlainButtonStyle())
        }
        .padding()
        .frame(width: 360)
        .background(Color.white)
        .cornerRadius(20)
        .shadow(radius: 20)
    }
}

https://ooorito.com/wp-content/uploads/2024/09/%E6%96%B0%E5%A2%9E%E5%9C%B0%E9%BB%9E%E7%95%AB%E9%9D%A2.webp

目前的畫面會像上面的圖片一樣,這時候會發現,還少了一個「X」的按鈕。我們試著將它新增上去:

這邊我們必須要使用 ZStack 將「新增地點」這個 Text 和「X」按鈕包起來,因為我們想要讓「X」按鈕和「新增地點」水平垂直,並且「新增地點」文字必須和整個畫面置中。

ZStack {
    Text("新增地點")
        .font(.headline)
        .frame(maxWidth: .infinity)
    
    HStack() {
        Spacer() 
        Button(action: {
            isPresented = false
        }) {
            Image(systemName: "xmark.circle.fill")
                .foregroundColor(.gray)
                .font(.title2)
        }
    }
}

這樣做就會像目標一樣,在右上角建立一個「X」的按鈕。

建立成功與錯誤提示

這裡使用 AlertToast 來提示使用者新增地點成功或失敗,如果忘記 AlertToast 怎麼使用,可以回到 Day12 複習一下唷!

.toast(isPresenting: $viewModel.showSuccessToast, alert: {
    AlertToast(type: .complete(Color.green), title: "完成")
}, completion: {
    isPresented = false
})

.toast(isPresenting: $viewModel.failHandle.isFail, alert: {
    AlertToast(type: .error(Color.red), title: viewModel.failHandle.title)
})

到這邊為止,我們的 AddLocationModalView 就完成了,下面附上完整程式碼:

import SwiftUI
import AlertToast

struct AddLocationModalView: View {
    @Binding var isPresented: Bool
    @ObservedObject var viewModel: LocationListViewModel
    @State private var locationName: String = ""
    @State private var selectedColorHex: String = "#FF5733"
    let colors: [String] = [
            "#FF5733", "#FF8D1A", "#FFC300", "#DAF7A6",
            "#33FF57", "#33FFF9", "#3380FF", "#9D33FF",
            "#FF33B5", "#F39C12", "#2ECC71", "#3498DB"
        ]
    let columns = [
            GridItem(.flexible()),
            GridItem(.flexible()),
            GridItem(.flexible()),
            GridItem(.flexible()),
            GridItem(.flexible()),
            GridItem(.flexible())
        ]
    var body: some View {
        ZStack {
            Color.black.opacity(0.4)
            .edgesIgnoringSafeArea(.all)
            .onTapGesture {
                isPresented = false
            }
            
            VStack {
                ZStack {
                    Text("新增地點")
                        .font(.headline)
                        .frame(maxWidth: .infinity)
                    
                    HStack() {
                        Spacer()
                        Button(action: {
                            isPresented = false 
                        }) {
                            Image(systemName: "xmark.circle.fill")
                                .foregroundColor(.gray)
                                .font(.title2)
                        }
                    }
                }
                
                TextField("輸入地點名稱", text: $locationName)
                    .padding()
                    .background(Color.gray.opacity(0.2))
                    .cornerRadius(10)
                    .padding(.horizontal)
                
                LazyVGrid(columns: columns, spacing: 12) {
                    ForEach(colors, id: \.self) { colorHex in
                        Button(action: {
                            selectedColorHex = colorHex
                        }) {
                            Circle()
                                .fill(Color(hexString: colorHex) ?? .black)
                                .frame(width: 40, height: 40)
                                .overlay(
                                    Circle()
                                        .stroke(Color.black, lineWidth: selectedColorHex == colorHex ? 2 : 0)
                                )
                        }
                    }
                }
                .padding()
                
                
                Button(action: {
                    viewModel.addLocation(name: locationName, colorHex: selectedColorHex)
                }) {
                    Text("新增")
                        .frame(maxWidth: .infinity)
                        .padding()
                        .background(Color.blue)
                        .foregroundColor(.white)
                        .cornerRadius(10)
                }
                .buttonStyle(PlainButtonStyle())
            }
            .padding()
            .frame(width: 360)
            .background(Color.white)
            .cornerRadius(20)
            .shadow(radius: 20)
        }
        .toast(isPresenting: $viewModel.showSuccessToast, alert: {
            AlertToast(type: .complete(Color.green), title: "完成")
        }, completion: {
            isPresented = false
        })
        
        .toast(isPresenting: $viewModel.failHandle.isFail, alert: {
            AlertToast(type: .error(Color.red), title: viewModel.failHandle.title)
        })
    }
}

#Preview {
    AddLocationModalView(isPresented: .constant(true), viewModel: LocationListViewModel())
}

實作 LocationListView

在 LocationListView 中,我們使用 List 顯示每個列表資料,其中包含顏色和地點名稱。

建立基本元件

在 LocationListView 中,我們透過 State 變數來控制新增地點視窗是否顯示 (isShowingAddLocationView),並且使用 @ObservedObject 監聽 LocationListViewModel 以進行管理地點資料。

並且新建 List 來顯示地點資料,利用 onDelete 這個 Modifier 實現刪除的行為。List 的相關用法在 Day9Day10 有解釋過,需要的讀者可以先回去看一下唷!

struct LocationListView: View {
    @State private var isShowingAddLocationView = false
    @ObservedObject private var viewModel: LocationListViewModel
    
    init(viewModel: LocationListViewModel = LocationListViewModel()) {
        self.viewModel = viewModel
    }
    
    var body: some View {
        NavigationView {
            List {
                ForEach(viewModel.locations, id: \.id) { location in
                    HStack {
                        // 顯示地點顏色
                        Circle()
                            .fill(Color(hexString: location.colorHex) ?? .black)
                            .frame(width: 30, height: 30)
                        
                        // 顯示地點名稱
                        Text(location.name)
                            .font(.body)
                            .padding(.leading, 10)
                        
                        Spacer()
                        
                    }
                    .padding(.vertical, 8)
                }
                .onDelete { indexSet in
                    indexSet.forEach { index in
                        let location = viewModel.locations[index]
                        viewModel.deleteLocation(location)
                    }
                }
            }
        }
    }
}

新增地點按鈕

透過 toolbar ,就可以在右上角新增一個 "加號" 按鈕,當點擊時,會顯示新增地點的彈出視窗。

.toolbar {
    ToolbarItem(placement: .navigationBarTrailing) {
        Button(action: {
            isShowingAddLocationView = true
        }) {
            Image(systemName: "plus")
                .font(.title2)
        }
    }
}

呼叫新增地點視窗

使用 overlay Modifier 將 AddLocationModalView 顯示出來。

.overlay(
    Group {
        if isShowingAddLocationView {
            AddLocationModalView(isPresented: $isShowingAddLocationView, viewModel: viewModel)
        }
    }
)

這樣就大功告成了,以下是 LocationListView 完整的程式碼:

import SwiftUI

struct LocationListView: View {
    @State private var isShowingAddLocationView = false
    @ObservedObject private var viewModel: LocationListViewModel
    
    init(viewModel: LocationListViewModel = LocationListViewModel()) {
        self.viewModel = viewModel
    }
    
    var body: some View {
        NavigationView {
            List {
                ForEach(viewModel.locations, id: \.id) { location in
                    HStack {
                        // 顯示地點顏色
                        Circle()
                            .fill(Color(hexString: location.colorHex) ?? .black)
                            .frame(width: 30, height: 30)
                        
                        // 顯示地點名稱
                        Text(location.name)
                            .font(.body)
                            .padding(.leading, 10)
                        
                        Spacer()
                        
                    }
                    .padding(.vertical, 8)
                }
                .onDelete { indexSet in
                    indexSet.forEach { index in
                        let location = viewModel.locations[index]
                        viewModel.deleteLocation(location)
                    }
                }
            }
            .listStyle(InsetGroupedListStyle())
            .navigationTitle("地點列表")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    Button(action: {
                        isShowingAddLocationView = true
                    }) {
                        Image(systemName: "plus")
                            .font(.title2)
                    }
                }
            }
            .overlay(
                Group {
                    if isShowingAddLocationView {
                        AddLocationModalView(isPresented: $isShowingAddLocationView, viewModel: viewModel)
                    }
                }
            )
        }
    }
}

#Preview {
    LocationListView()
}

總結

今天完成地點列表功能的實作,讓使用者可以查看、刪除和新增家中的地點。並且透過彈跳視窗,方便使用者輸入地點名稱並選擇顏色。明天,我們將把分類管理與地點管理功能整合進側邊欄與家用品資料中。


上一篇
Day 17: SwiftUI 新增分類功能實作
下一篇
Day19: SwiftUI 分類管理、地點管理與側邊欄結合,提升物品管理功能
系列文
用 SwiftUI 掌控家庭日用品庫存30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言