iOS 17 App 程式設計實戰心法(SwiftUI)

第 19 章
使用 Searchable 加入搜尋列

I knew that if I failed I wouldn't regret that, but I knew the one thing I might regret is not trying.

– Jeff Bezos

對於大部分的表格式 App,螢幕頂部通常有一個搜尋列( Search Bar ),而你要如何實作用來資料搜尋的搜尋列呢?在本章中,我們會為 FoodPin App 加上搜尋列。有了搜尋列,我們將強化這個 App,以讓使用者能夠搜尋到可用的餐廳。

在 iOS 15 之前,SwiftUI 沒有內建的修飾器來處理清單視圖中的搜尋,開發者必須建立自己的解決方案。在我們的 [《精通 SwiftUI》》一書中,我們有一個章節說明如何在SwiftUI 中使用TextField 來建立一個自訂搜尋列,並顯示搜尋結果。

自 iOS 15 起,SwiftUI 框架為清單視圖導入一個名為「searchable」的修飾器,你只需將修飾器加到清單視圖中,並建立一個搜尋欄位即可。

使用 Searchable

一般來說,要在導覽列中加入一個搜尋列,基本上可歸結為以下這行程式碼:

.searchable(text: $searchText)

假設我們在導覽視圖(或堆疊)中嵌入一個 List 視圖,你可以透過將 searable 修飾器加到導覽視圖來新增一個搜尋列。以下是一個例子:

struct SearchListView: View {

    @State private var searchText = ""

    var body: some View {
        NavigationStack {
            .
            .
            .
        }
        .searchable(text: $searchText)
    }
}

狀態變數是用來存放使用者在搜尋欄位中輸入的搜尋文字。只需幾行程式碼,SwiftUI 就會自動為你渲染搜尋列,並將其放在導覽列標題的下方。

將搜尋列加入餐廳清單視圖

現在,我們來嘗試在 FoodPin App 中加入一個搜尋列。開啟 RestaurantListView.swift, 並宣告下列的狀態變數:

@State private var searchText = ""

接下來,將 searchable 修飾器加到 NavigationStack:

NavigationStack {
  .
  .
  .
}
.searchable(text: $searchText)

由於我在前面已經解釋過該程式碼,因此這裡不再重複說明了。當你加入修飾器後,搜尋列應該會出現在清單視圖的正上方,如圖 19.1 所示。

圖 19.1. 導覽列中加入搜尋列
圖 19.1. 導覽列中加入搜尋列

預設上,它顯示「Search」文字作為占位符號,如果你想要更改它,可編寫 .searchable 修飾器如下,並在prompt 參數中指定你自己的占位符號的值。

.searchable(text: $searchText, prompt: "Search restaurants...")

搜尋列的位置

.searchable 修飾器有一個 placement 參數,可讓你指定搜尋列的放置位置。預設上,它設定為「.automatic」。在 iPhone 上,搜尋列位於導覽列標題的下方,不過當您向上滑動清單時,它將隱藏起來。

圖 19.2. 新增固定的搜尋列
圖 19.2. 新增固定的搜尋列

如果你想要固定顯示搜尋列,則可以變更 .searchable 修飾器,並指定 placement 參數, 如下所示:

.searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: "Search restaurants...")

So far, we attach the .searchable modifier to the navigation view. You can actually attach it to the List view and achieve the same result on iPhone.

T到目前為止,我們將.searchable 修飾器加到導覽視圖,實際上你也可以將它加到List 視圖,在iPhone 上也能實現相同的結果。

也就是說,在iPad OS 上使用分割視圖時,.searchable 修飾器的位置會影響搜尋欄位的位置。如果你想要了解更多有關placement 參數的用法,你可以進一步參考以下的教學說明: https://www.appcoda.com/swiftui-searchable/

執行搜尋並顯示搜尋結果

搜尋列不提供任何預設的功能來篩選資料,你有責任開發自己的實作來篩選內容。以 FoodPin App 為例,使用者可以根據餐廳名稱來搜尋餐廳。

篩選清單資料有不同方式,你可以建立一個執行即時資料篩選的計算屬性,或者你可以加上 .onChange 修飾器來追蹤搜尋欄位的變化。以下是可以加到 NavigationStack 的 .onChange 修飾器的範例程式碼:

.onChange(of: searchText) { oldValue, newValue in
    if !newValue.isEmpty {
        searchResult = restaurants.filter { $0.name.contains(newValue) }
    }
}

在上列的程式碼中,我們使用 onChange 修飾器來監看搜尋文字的變化。當有變化時, 我們使用 contains 方法來查看餐廳名稱是否包含搜尋文字,如果找到搜尋文字,則該方法回傳 true,表示餐廳名稱應包含在新陣列中;否則的話,回傳 false 來排除該項目。

另一種方法是使用 SwiftData 來執行搜尋查詢。SwiftData 的主要功能之一是能夠使用述詞(predicate )來執行資料篩選和搜尋,例如:要搜尋包含特定搜尋字詞的餐廳,您可以將下列程式碼加到 NavigationStack 中,如下所示:

.onChange(of: searchText) { oldValue, newValue in
    let predicate = #Predicate<Restaurant> { $0.name.localizedStandardContains(newValue) }

    let descriptor = FetchDescriptor<Restaurant>(predicate: predicate)

    if let result = try? modelContext.fetch(descriptor) {
        searchResult = result
    }
}

SwiftData 為開發者提供 #Predicate 巨集來定義搜尋條件。localizedStandardContains 方法用於執行區域感知(locale-aware )、大小寫和變音符號敏感度搜尋。

然後,我們使用述詞來建立 FetchDescriptor 的實例。此描述子(descriptor )描述了在執行提取時要使用的條件、排序順序和任何其他的設定。要從資料庫中檢索資料,我們使用描述子呼叫模型內容的fetch 方法。

我們尚未修改程式碼來配合 searchResult 的使用。我們先宣告一個狀態變數來存放 searchResult,和另一個狀態變數來追蹤搜尋列的狀態:

@State private var searchResult: [Restaurant] = []
@State private var isSearchActive = false

接下來,替換 if-else 程式碼區塊如下:

if restaurants.count == 0 {
    Image("emptydata")
        .resizable()
        .scaledToFit()
} else {
    let listItems = isSearchActive ? searchResult : restaurants

    ForEach(listItems.indices, id: \.self) { index in
        ZStack(alignment: .leading) {
            NavigationLink(destination: RestaurantDetailView(restaurant: listItems[index])) {
                EmptyView()
            }
            .opacity(0)

            BasicTextImageRow(restaurant: listItems[index])
        }
    }
    .onDelete(perform: deleteRecord)
    .listRowSeparator(.hidden)
}

簡而言之,我們在搜尋處於活動狀態時顯示搜尋結果;否則,我們將顯示從資料庫中檢索到的所有餐廳。

除了上述的更改,我們還需要修改 searchable 修飾器,如下所示:

.searchable(text: $searchText, isPresented: $isSearchActive, placement: .navigationBarDrawer(displayMode: .always), prompt: "Search restaurants...")

我們加入 isPresented 參數,並將綁定傳送給 isSearchActive。當使用者點擊搜尋列時, SwiftUI 會更新 isSearchActive 的值為「true」。

太棒了 !你已經準備好啟動你的 App,並測試搜尋功能。很棒的是,你可以透過點擊搜尋結果來導覽至餐廳細節,你無須編寫任何的程式碼,即可讓該功能運作,如圖 19.3 所示。

圖 19.3. 搜尋結果
圖 19.3. 搜尋結果

搜尋建議

SwiftUI 還提供另一個名為「searchSuggestions」的修飾器,可以讓你新增搜尋建議清單,以顯示一些常用的搜尋名稱或搜尋歷史。例如:你能建立可點擊的搜尋建議,如下所示:

.searchSuggestions{
    Text("Cafe")
    Text("Thai")
}

這會顯示帶有兩個可點擊搜尋字詞的搜尋建議,如圖 19.4 所示,使用者可以輸入搜尋關鍵字或點擊搜尋建議來執行搜尋。

圖 19.4. 顯示搜尋建議
圖 19.4. 顯示搜尋建議

在上述的情況下,搜尋建議視圖將始終顯示,這可能會擋住搜尋結果。為了隱藏搜尋建議,您需要一個其他變數來控制其外觀。

.searchSuggestions{
    if searchText.isEmpty {
        Text("Cafe").searchCompletion("Cafe")
        Text("Thai").searchCompletion("Thai")
    }
}

你的作業加強搜尋功能

目前 App 只讓使用者以餐廳名稱來搜尋餐廳,而本章給予你的作業是「加強搜尋功能」,讓它也能支援位置搜尋。例如:當你的使用者在搜尋欄位中輸入「Sydney」,則該 App 會搜尋餐廳清單,並顯示位於 Sydney 或名稱包含 Sydney 的餐廳。

本章小結

現在你應該知道要如何在 iOS App 實作搜尋列。我們透過加強搜尋功能來使 FoodPin App 變得更好,當你有大量的資訊要顯示時,這個搜尋功能特別重要。如果你仍然不完全了解搜尋列功能,我建議在繼續往下閱讀之前,請重新閱讀本章的所有內容。.

在本章所準備的範例檔中,有最後完整的Xcode 專案 (http://www.appcoda.com/resources/swift59/swiftui-foodpin-search.zip )及作業的解答可供你下載參考。