實戰程式設計·使用SwiftUI從0到1完成一款iOS筆記App(四)

語言: CN / TW / HK

theme: smartblue

前提回顧

在上幾個章節,我們完成了念頭筆記的基本頁面的程式設計,並在上一章節中完成新建筆記的互動邏輯。

這幾天和讀者溝通時收到反饋,念頭筆記專案哪怕只有2個頁面互動,但是變數的雙向繫結很是麻煩,只要使用到@Binding宣告變數的檢視,在所有用到該檢視的頁面都需要做雙向繫結,這不優雅。

在本章中,基於上一章完成的新建筆記互動邏輯的基礎上,我們嘗試使用MVVM模式修改程式碼和完善其他功能。本章內容較多,引數及方法調整可能會導致一堆臨時性的BUG,請耐心學習和修改。

專案結構

首先是專案結構部分,目前我們完成了Model模型類ToastView吐司檢視ContentView首頁NewNoteView新建筆記檢視。如下圖所示:

按照專案程式設計的習慣,我們其實在正式程式設計前需要建立基礎的頁面結構,將模型類、檢視、實現方法按照資料夾分開。

在Xcode檢視視窗右鍵,選擇New Group,建立一個新的資料夾,如下圖所示:

我們將新的資料夾命名為Model,並把Model.swift檔案拖到裡面,如下圖所示:

同理,我們再建立一個View資料夾、Extension資料夾、ViewModel資料夾,並將ContentView.swiftNewNoteView.swift檔案放入View資料夾中,將ToastView.swift放入Extension資料夾中,如下圖所示:

以上便是一個基礎的專案的檔案結構,Model資料夾中放入需要使用的模型類,View資料夾中放入相應的頁面,而ViewModel資料夾放入功能的實現邏輯和方法,這便是之後要使用MVVM開發模式的做法。

至於其他的Swift檔案,拓展的功能類可以建立名為Extension的資料夾,封裝好的功能類可以放在Utils資料夾,公共類可以放在Constants資料夾......這些都是看專案和個人需要建立和使用。

Model

Model是我們的模型類,用於定義資料及其型別,由於我們需要用到MVVM開發模式,因此Model檔案中只需要定義簡單的引數就行了。如下程式碼所示:

``` import Foundation import SwiftUI

struct NoteModel: Identifiable,Codable{ var id = UUID() var writeTime: String var title: String var content: String } ```

上述程式碼中,為了更好說明MVVM開發模式中的Model,我們更改NoteItem模型類名稱為NoteModel便於理解。引數重新命名的方式為選擇引數點選右鍵,選擇Refactor,選擇Rename,修改為NoteModel

為了使用NoteModel模型類的序列化資料,NoteModel需要遵循Codable協議。

ViewModel

我們在ViewModel資料夾下建立一個新的Swift檔案,命名為ViewModel.swift。如下圖所示:

基礎概念

ViewModel是用來幹什麼的?

簡單來說,Model是宣告資料模型引數的,View是用來構建頁面和基礎互動的,ViewModel是用來實現基礎功能的,包含念頭筆記的增刪改查,都是在ViewModel中實現,然後在View檢視中呼叫,做到頁面和資料分開。

而我們可以看到在ContentView首頁檢視和NewNoteView新建筆記檢視中有很多引數是需要進行雙向繫結的,如果不使用ViewModel的方式,那麼頁面之間都需要宣告相同的引數,並做雙向繫結。頁面一多,那就和套娃一樣,一直“回綁”。

我們建立一個ViewModel類,並遵循ObservableObject協議,如下程式碼所示:

``` import Combine import Foundation import SwiftUI

class ViewModel: ObservableObject {

} ```

上述程式碼中,我們引用Combine框架,Combine為應用處理事件(增刪改查)提供了一種宣告性的方法。然後我們建立了一個ViewModel類,遵循ObservableObject協議,ObservableObject協議可以在檢視外繫結自定義的物件,便於開發者使用。

引數宣告

在ViewModel類中,我們宣告需要用到的引數,如下程式碼所示:

``` class ViewModel: ObservableObject {

//資料模型
@Published var noteModels = [NoteModel]()

//筆記引數
@Published var writeTime: String = ""
@Published var title: String = ""
@Published var content: String = ""
@Published var searchText = ""

//判斷是否正在搜尋
@Published var isSearching:Bool = false

//判斷是否是新增
@Published var isAdd:Bool = true

//開啟新建筆記彈窗
@Published var showNewNoteView:Bool = false

//開啟編輯筆記彈窗
@Published var showEditNoteView:Bool = false

//開啟刪除確認彈窗
@Published var showActionSheet:Bool = false

//提示資訊
@Published var showToast = false
@Published var showToastMessage: String = "提示資訊"

} ```

上述程式碼中,noteModels為引用NoteModel模型類資料,構建陣列。

然後是念頭筆記需要用到的引數writeTimetitlecontent,搜尋欄用到的引數searchText。當搜尋時,可能會由於關鍵字搜尋為空,導致搜尋列表變成“預設圖”模式,因此還需要宣告一個引數isSearching,判斷當前是否處於搜尋狀態。

很多筆記App開發都會把新建頁面和編輯頁面分開寫,包括網上下載下來的程式碼基本都是新增、編輯兩個頁面,而兩個頁面使用相同的程式碼。亦或者是乾脆就沒有編輯頁面,只能新增、刪除,不能編輯,這都不夠優雅。

因此,為了共用頁面,我們聲明瞭3個Bool型別的引數isAddshowNewNoteViewshowEditNoteView

isAdd用來 判斷當前是新增操作還是編輯操作,showNewNoteView用來繫結開啟新增筆記彈窗的觸發條件,showEditNoteView用來繫結編輯彈窗的觸發條件。

然後是刪除操作,刪除操作也需要宣告引數觸發,這裡宣告的引數名為showActionSheet

最後是Toast提示部分,使用到的兩個引數showToast是否展示Toast,以及showToastMessage提示資訊內容,我們也在ViewModel裡宣告。

如此,我們便把所有頁面用到的引數都抽離出來,後面就不需要在所有頁面都宣告一樣的變數,且保持程式碼清晰。

功能方法

下面我們來建立一些念頭筆記用到的方法,在之前的章節中我們實現了新建筆記的功能,但當我們每次重新開啟APP時,它又會“恢復”到初始模式,在上一次操作的資料全部清空了。

這是因為我們只是完成了簡單的操作而已,而沒有實現其核心功能,即把資料存起來。但是我們沒有資料庫也沒有云端,資料存在哪裡呢?是的,放在本地,放到本地快取起來。

在我們建立iOS專案時,系統會建立一個plist檔案,作為快取區,我們可以將資料暫時儲存在這裡。

載入資料

ViewModel類中,我們需要使用到的基本方法如下程式碼所示:

``` //初始化 init() { loadItems() saveItems() }

// 獲取裝置上的文件目錄路徑
func documentsDirectory() -> URL {
    FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
}

// 獲取plist資料檔案的路徑
func dataFilePath() -> URL {
    documentsDirectory().appendingPathComponent("IdeaNote.plist")
}

// 將資料寫入本地儲存
func saveItems() {
    let encoder = PropertyListEncoder()
    do {
        let data = try encoder.encode(noteModels)
        try data.write(to: dataFilePath(), options: Data.WritingOptions.atomic)
    } catch {
        print("Error writing items to file: (error.localizedDescription)")
    }
}

// 從本地儲存載入資料
func loadItems() {
    let path = dataFilePath()

    if let data = try? Data(contentsOf: path) {
        let decoder = PropertyListDecoder()
        do {
            noteModels = try decoder.decode([NoteModel].self, from: data)
        } catch {
            print("Error reading items: (error.localizedDescription)")
        }
    }
}

```

上述程式碼中,首先建立了一個方法documentsDirectory獲取裝置上的文件目錄路徑,再指定要獲取的目錄IdeaNote.plist的方法dataFilePath

獲得本地裝置目錄後,使用saveItems方法將noteModels陣列中的資料寫入到到本地儲存中,當App開啟的時候,使用loadItems方法讓List列表從本地儲存中載入資料遍歷列表。

最後在我們App初始化載入的時候,呼叫loadItems載入資料方法,並呼叫saveItems方法將資料寫入快取中。

以上是載入本地資料的基本方法。

增刪改查

緊接著,我們來實現念頭筆記的增刪改查的基本方法,如下程式碼所示:

``` // 建立筆記 func addItem(writeTime: String, title: String, content: String) { let newItem = NoteModel(writeTime: writeTime, title: title, content: content) noteModels.append(newItem) saveItems() }

// 獲得資料項ID
func getItemById(itemId: UUID) -> NoteModel? {
    return noteModels.first(where: { $0.id == itemId }) ?? nil
}

// 刪除筆記
func deleteItem(itemId: UUID) {
    noteModels.removeAll(where: { $0.id == itemId })
    saveItems()
}

// 編輯筆記
func editItem(item: NoteModel) {
    if let id = noteModels.firstIndex(where: { $0.id == item.id }) {
        noteModels[id] = item
        saveItems()
    }
}

// 搜尋筆記
func searchContet() {
    let query = searchText.lowercased()
    DispatchQueue.global(qos: .background).async {
        let filter = self.noteModels.filter { $0.content.lowercased().contains(query) }
        DispatchQueue.main.async {
            self.noteModels = filter
        }
    }
}

```

由於列表中每一行的資料都有對應的ID,因此除了新增資料以外,刪改查的基本功能都是通過資料的ID操作的,計算機都是先找到資料,再對資料進行操作。

新建筆記的方法addItem,通過傳入對應引數的值,然後將引數的值賦值給NoteModel模型類,再通過append新增的方法新增到noteModels陣列中,最後呼叫saveItems方法儲存到本地。

刪除筆記的方法deleteItem,需要先獲取到指定行資料的ID,這裡抽離出了獲得資料ID的方法getItemById,通過傳入資料ID與NoteModel模型類的ID進行匹配就可以知道是哪一條資料,再呼叫removeAll刪除noteModels陣列中指定ID的資料,最後呼叫saveItems方法儲存操作。

編輯筆記的方法editItem,也是傳入符合NoteModel模型類的資料,找到它的ID,最後呼叫saveItems方法儲存到本地。

搜尋筆記的方法searchContet,先定義使用者搜尋內容為searchText,再拿使用者輸入的內容和noteModels陣列中的content內容資料做對比,如果符合,就返回符合的資料到noteModels陣列中。

其他方法

除此之外,我們將原來在新建筆記頁面使用到的獲得當前時間的方法,包括判斷輸入內容是否為空的方法也納入到ViewModel裡面,如下程式碼所示 :

``` // 獲取當前系統時間 func getCurrentTime() -> String { let dateformatter = DateFormatter() dateformatter.dateFormat = "YYYY.MM.dd" return dateformatter.string(from: Date()) }

// 判斷文字是否為空
func isTextEmpty(text:String) -> Bool{
    if text == "" {
        return true
    } else {
        return false
    }
}

```

判斷內容是否為空的方法是傳入一個引數值,通過判斷是否為空,從而返回一個Bool型別的值,後面我們用來判斷輸入的標題和內容是否為空。

App使用

建立好ViewModel後,當我們要使用它,需要在IdeaNoteApp專案頁面宣告好使用的ViewModel,如下程式碼所示:

``` import SwiftUI

@main struct IdeaNoteApp: App {

@StateObject  var viewModel: ViewModel = ViewModel()

var body: some Scene {
    WindowGroup {
        ContentView()
            .environmentObject(viewModel)
    }
}

} ```

IdeaNoteApp頁面是整個App啟動時的頁面,它相當於我們的主函式,當前專案指向的首頁是ContentView首頁檢視,因此在使用MVVM專案開發模式時,當我們用使用到ViewModel時,就需要引用ViewModel到主函式中方可使用。

本章小結

恭喜你,準備工作已經就緒,隨時可以開始下一步的內容。

當然,為了讓大家更好地吸收學習內容,本章我們就分享瞭如何完成Model、View Model部分,下一章節中,我們將繼續完成View檢視的相關內容,我們將在原來View檢視的基礎上進行內容調整,方便大家更好地理解SwiftUI的運作模式。

快來動手試試吧~

版權宣告

本文為稀土掘金技術社群首發簽約文章,14天內禁止轉載,14天后未獲授權禁止轉載,侵權必究!

「其他文章」