为你的 iOS 应用实现搜索功能

了解如何使用 SwiftUI 提供的 searchable 修饰器,为你的 App 添加方便好用的搜索功能。

为你的 iOS 应用实现搜索功能

SwiftUI 有一个.searchable 修饰符,可以非常方便的为你的 App 添加高效且易用的搜索功能。

什么是 .searchable

在传统的 UIKit 中,要添加搜索功能,通常需要使用UISearchController 及相关的代理方法,过程较为繁琐。.searchable 是 SwiftUI 在 iOS 15  中引入的一个修饰符,它的目的是让在应用界面中添加搜索功能尽可能的简单:

searchable(text:placement:prompt:) | Apple Developer Documentation
Marks this view as searchable, which configures the display of a search field.

.searchable 的不同版本

.searchable 有不同的初始化器,适用于不同的 iOS 系统版本。例如,

在 iOS16 上,你可以使用支持 suggestedTokens 参数的 searchable,为搜索功能添加搜索建议:

searchable(text:tokens:suggestedTokens:placement:prompt:token:) | Apple Developer Documentation
Marks this view as searchable with text, tokens, and suggestions.

在 iOS17 上,你可以使用支持 isPresented 参数的 searchable,以便通过编程的方式控制搜索状态:

searchable(text:isPresented:placement:prompt:) | Apple Developer Documentation
Marks this view as searchable with programmatic presentation of the search field.

为 List 组件添加 .searchable

在 List 中使用搜索功能,可能是最常见的使用场景。通过在列表顶部添加搜索栏,用户可以快速过滤列表中的项。

如下示例代码,使用 List 创建了一个列表视图(我省略了一些细节):

struct ContentView: View {
    
    var body: some View {
        NavigationStack {
            List(filteredRestaurants) { restaurant in
                HStack {
                    Text(restaurant.name)
                        .font(.headline)
                    Spacer()
                    Text(restaurant.priceLevel)
                        .font(.subheadline)
                        .foregroundColor(.gray)
                }
            }
            .navigationTitle("上海美食")
        }
    }
}

要为这个列表添加搜索功能,我们只需要几个简单的步骤:

  • 在 List 组件上,添加.searchable修饰器
  • 创建一个 @State 变量,用于存储用户输入的搜索字符
  • 创建一个 filteredRestaurants 变量,它将基于用户的输入来过滤 restaurants。
struct ContentView: View {
    @State private var searchText = ""
    
    var filteredRestaurants: [Restaurant] {
        if searchText.isEmpty {
            return restaurants
        } else {
            return restaurants.filter { restaurant in
                restaurant.name.localizedCaseInsensitiveContains(searchText) ||
                restaurant.cuisine.localizedCaseInsensitiveContains(searchText) ||
                restaurant.address.localizedCaseInsensitiveContains(searchText)
            }
        }
    }
    
    var body: some View {
        NavigationStack {
            List(filteredRestaurants) { restaurant in
                HStack {
                    Text(restaurant.name)
                        .font(.headline)
                    Spacer()
                    Text(restaurant.priceLevel)
                        .font(.subheadline)
                        .foregroundColor(.gray)
                }
            }
            .navigationTitle("上海美食")
            .searchable(
                text: $searchText,
                prompt: "搜索餐厅名称、菜系或地址"
            )
        }
    }
}

就这么简单。SwiftUI 会自动添加搜索框,为我们处理动画效果等等:

0:00
/0:11

在 iOS 设备上默认隐藏搜索框

在 iOS 设备上,搜索框会默认一直显示:

在一些情况下,比如搜索框的使用频率并不高的时候,你可能并不希望搜索框一直显示,因为它会占据你的屏幕空间。遗憾的是,.searchable 修饰器目前并没有一个原生的 API,用于在 iOS 设备上设置搜索框的可见性。

注意:isPresented 属性用于激活搜索状态,但即使 isPresentedfalse,搜索框仍然会显示。

为了实现这个需求,我们需要使用一点小技巧,在 View 中条件性地应用 .searchable。也就是说,仅当 showingSearchBar 为 true 时才给列表添加 .searchable 修饰符。

import SwiftUI

struct ContentView: View {
    @State private var searchText = ""
    @State private var showingSearchBar = false
    @State private var restaurants = Restaurant.sampleData

    var filteredRestaurants: [Restaurant] {
        if searchText.isEmpty {
            return restaurants
        } else {
            return restaurants.filter { restaurant in
                restaurant.name.localizedCaseInsensitiveContains(searchText)
                    || restaurant.cuisine.localizedCaseInsensitiveContains(
                        searchText)
                    || restaurant.address.localizedCaseInsensitiveContains(
                        searchText)
            }
        }
    }

    var body: some View {
        NavigationStack {
            List(filteredRestaurants) { restaurant in
                VStack(alignment: .leading, spacing: 8) {
                    HStack {
                        Text(restaurant.name)
                            .font(.headline)
                        Spacer()
                        Text(restaurant.priceLevel)
                            .font(.subheadline)
                            .foregroundColor(.gray)
                    }

                    Text(restaurant.cuisine)
                        .font(.subheadline)
                        .foregroundColor(.secondary)

                    Text(restaurant.address)
                        .font(.caption)
                        .foregroundColor(.gray)

                    HStack {
                        Image(systemName: "star.fill")
                            .foregroundColor(.yellow)
                        Text(String(format: "%.1f", restaurant.rating))
                            .font(.subheadline)
                    }
                }
            }
            .navigationTitle("上海美食")
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Button {
                        withAnimation(Animation.spring) {
                            showingSearchBar.toggle()
                        }
                    } label: {
                        Image(systemName: "magnifyingglass")
                    }
                }
            }
            .onChange(of: showingSearchBar) { _, newValue in
                if !newValue {
                    searchText = ""
                }
            }
            // 当 isSearching 为 true 时才附加 searchable
            .if(showingSearchBar) { view in
                view.searchable(
                    text: $searchText,
                    isPresented: $showingSearchBar,
                    placement: .navigationBarDrawer(displayMode: .automatic),
                    prompt: "搜索餐厅名称、菜系或地址"
                )
            }
        }
    }
}

extension View {
    @ViewBuilder
    func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content)
        -> some View
    {
        if condition {
            transform(self)
        } else {
            self
        }
    }
}

#Preview {
    ContentView()
}
0:00
/0:11