在 SwiftUI 中构建搜索体验
Craft search experiences in SwiftUI
2021年6月9日
一句话判断
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/await 和 Task)的实现、以及 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 上使用
.searchable的placement: .sidebar参数,把搜索框放在侧边栏顶部,比导航栏搜索框更符合这些平台的 UI 规范。
还有什么值得关注
searchable在 macOS 上的默认位置是工具栏(toolbar),可以通过placement: .automatic让系统自动选择最佳位置。NSUserActivity可以和searchable配合,让你的搜索结果出现在 Spotlight 搜索中。- SwiftUI 的
@FocusState可以配合搜索框使用,在特定条件下自动激活搜索。