Craft search experiences in SwiftUI
Swift & UI 进阶 22m

在 SwiftUI 中构建搜索体验

Craft search experiences in SwiftUI

2021年6月9日

在 Apple 官方观看视频

一句话判断

SwiftUI 的 searchable modifier 终于把搜索从一个”自己拼 UI + 自己管状态”的痛苦过程变成了一个声明式的修饰符——但它的异步搜索和 suggestion 机制需要你重新思考搜索的架构。

这场 Session 讲了什么

Session 介绍了 SwiftUI 在 iOS 15 / macOS Monterey 中新增的搜索相关 API。核心是 searchable modifier——一个可以附加到任何 SwiftUI 视图上的搜索功能修饰符,自动在 navigation bar 中渲染搜索框(iOS)或工具栏搜索框(macOS)。

searchable 接受一个 text binding 作为搜索文本,还支持 prompt(占位文字)、suggestions(搜索建议)和 placement(搜索框位置)等参数。Session 重点讲解了三个进阶场景:搜索建议(searchSuggestions)的自定义渲染、异步搜索(配合 async/awaitTask)的实现、以及 onSubmit(of: .search)onChange(of:) 的正确使用方式。还有一个容易被忽略的改进:dismissSearch() 环境方法让你可以在代码中关闭搜索界面。

值得深挖的点

搜索建议的数据驱动架构

searchSuggestions 接受一个 @ViewBuilder 闭包,这意味着搜索建议的 UI 完全由你控制。但更合理的做法是把建议逻辑抽离成一个 Observable Object——用户输入变化时异步查询建议数据,视图通过 @StateObject 自动刷新。Session 建议使用 Task 来管理异步查询,并在 onChange 中取消上一次的查询任务(通过 Task.cancel()),避免旧的查询结果覆盖新的。

可搜索组件的层级传播

searchable 会自动传播到 navigation stack 的所有子视图。如果你在根视图上添加了 searchable,push 到下一个视图时搜索框仍然存在。这在某些场景下是期望的行为(比如在搜索结果页面继续搜索),但在另一些场景下需要隐藏。使用 .searchablecompletion() modifier 可以在子视图中控制搜索框的可见性。

代码片段

// 基础搜索:列表过滤
struct ProductListView: View {
    @State private var searchText = ""
    @State private var products: [Product] = Product.sampleData
    
    var filteredProducts: [Product] {
        if searchText.isEmpty {
            return products
        }
        return products.filter { 
            $0.name.localizedCaseInsensitiveContains(searchText) 
        }
    }
    
    var body: some View {
        NavigationView {
            List(filteredProducts) { product in
                ProductRow(product: product)
            }
            .navigationTitle("商品")
            .searchable(text: $searchText, 
                        prompt: "搜索商品名称")
        }
    }
}
// 带搜索建议的异步搜索
struct SearchView: View {
    @State private var searchText = ""
    @State private var suggestions: [SearchSuggestion] = []
    @State private var searchTask: Task<Void, Never>?
    
    var body: some View {
        NavigationView {
            List {
                ForEach(searchResults) { result in
                    SearchResultRow(result: result)
                }
            }
            .searchable(text: $searchText)
            .searchSuggestions {
                // 自定义搜索建议的渲染
                ForEach(suggestions) { suggestion in
                    Button {
                        searchText = suggestion.text
                    } label: {
                        HStack {
                            Image(systemName: suggestion.icon)
                            Text(suggestion.text)
                                .foregroundStyle(.secondary)
                        }
                    }
                }
            }
            .onChange(of: searchText) { newValue in
                // 取消上一次的搜索建议请求
                searchTask?.cancel()
                
                // 发起新的异步查询
                searchTask = Task {
                    try? await Task.sleep(nanoseconds: 300_000_000) // 300ms 防抖
                    guard !Task.isCancelled else { return }
                    suggestions = await fetchSuggestions(for: newValue)
                }
            }
            .onSubmit(of: .search) {
                // 用户按下搜索键后执行正式搜索
                performFullSearch(searchText)
            }
        }
    }
}
// 在代码中控制搜索行为
struct DetailView: View {
    @Environment(\.dismissSearch) var dismissSearch
    @Environment(\.isSearching) var isSearching
    
    var body: some View {
        VStack {
            Text("详情内容")
            
            if isSearching {
                Button("关闭搜索") {
                    dismissSearch()
                }
            }
        }
        // 在这个视图中隐藏搜索框
        .searchableCompletion()
    }
}

最佳实践

  • 搜索建议请求加入 300ms 的防抖(debounce),避免用户每输入一个字符就发一次网络请求。用 Task.sleep 实现,比 Combine 的 debounce 更直观。
  • 搜索结果为空时展示”热门搜索”或”推荐内容”作为默认建议,而不是空白页面——这能引导用户开始搜索。
  • 在 iPad 和 macOS 上使用 .searchableplacement: .sidebar 参数,把搜索框放在侧边栏顶部,比导航栏搜索框更符合这些平台的 UI 规范。

还有什么值得关注

  • searchable 在 macOS 上的默认位置是工具栏(toolbar),可以通过 placement: .automatic 让系统自动选择最佳位置。
  • NSUserActivity 可以和 searchable 配合,让你的搜索结果出现在 Spotlight 搜索中。
  • SwiftUI 的 @FocusState 可以配合搜索框使用,在特定条件下自动激活搜索。
WWDC 2021