Add scan flow MVP and local Axiom skill workspace
This snapshot establishes the camera-to-result recognition flow and related tests while checking in the project skill/docs assets required for the configured local tooling.
This commit is contained in:
7
.claude/skills/axiom-swiftui-search-ref/.openskills.json
Normal file
7
.claude/skills/axiom-swiftui-search-ref/.openskills.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"source": "CharlesWiltgen/Axiom",
|
||||
"sourceType": "git",
|
||||
"repoUrl": "https://github.com/CharlesWiltgen/Axiom",
|
||||
"subpath": "axiom-codex/skills/axiom-swiftui-search-ref",
|
||||
"installedAt": "2026-04-12T08:06:50.929Z"
|
||||
}
|
||||
737
.claude/skills/axiom-swiftui-search-ref/SKILL.md
Normal file
737
.claude/skills/axiom-swiftui-search-ref/SKILL.md
Normal file
@@ -0,0 +1,737 @@
|
||||
---
|
||||
name: axiom-swiftui-search-ref
|
||||
description: Use when implementing SwiftUI search — .searchable, isSearching, search suggestions, scopes, tokens, programmatic search control (iOS 15-18). For iOS 26 search refinements (bottom-aligned, minimized toolbar, search tab role), see swiftui-26-ref.
|
||||
license: MIT
|
||||
metadata:
|
||||
version: "1.0.0"
|
||||
---
|
||||
|
||||
# SwiftUI Search API Reference
|
||||
|
||||
## Overview
|
||||
|
||||
SwiftUI search is **environment-based and navigation-consumed**. You attach `.searchable()` to a view, but a *navigation container* (NavigationStack, NavigationSplitView, or TabView) renders the actual search field. This indirection is the source of most search bugs.
|
||||
|
||||
#### API Evolution
|
||||
|
||||
| iOS | Key Additions |
|
||||
|-----|---------------|
|
||||
| 15 | `.searchable(text:)`, `isSearching`, `dismissSearch`, suggestions, `.searchCompletion()`, `onSubmit(of: .search)` |
|
||||
| 16 | Search scopes (`.searchScopes`), search tokens (`.searchable(text:tokens:)`), `SearchScopeActivation` |
|
||||
| 16.4 | Search scope `activation` parameter (`.onTextEntry`, `.onSearchPresentation`) |
|
||||
| 17 | `isPresented` parameter, `suggestedTokens` parameter |
|
||||
| 17.1 | `.searchPresentationToolbarBehavior(.avoidHidingContent)` |
|
||||
| 18 | `.searchFocused($isFocused)` for programmatic focus control |
|
||||
| 26 | Bottom-aligned search, `.searchToolbarBehavior(.minimize)`, `Tab(role: .search)`, `DefaultToolbarItem(kind: .search)` — see `axiom-swiftui-26-ref` |
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
- Adding search to a SwiftUI list or collection
|
||||
- Implementing filter-as-you-type or submit-based search
|
||||
- Adding search suggestions with auto-completion
|
||||
- Using search scopes to narrow results by category
|
||||
- Using search tokens for structured queries
|
||||
- Controlling search focus programmatically
|
||||
- Debugging "search field doesn't appear" issues
|
||||
|
||||
For iOS 26 search features (bottom-aligned, minimized toolbar, search tab role), see `axiom-swiftui-26-ref`.
|
||||
|
||||
---
|
||||
|
||||
## Part 1: The searchable Modifier
|
||||
|
||||
### Core API
|
||||
|
||||
```swift
|
||||
.searchable(
|
||||
text: Binding<String>,
|
||||
placement: SearchFieldPlacement = .automatic,
|
||||
prompt: LocalizedStringKey
|
||||
)
|
||||
```
|
||||
|
||||
**Availability**: iOS 15+, macOS 12+, tvOS 15+, watchOS 8+
|
||||
|
||||
### How It Works
|
||||
|
||||
1. You attach `.searchable(text: $query)` to a view
|
||||
2. The **nearest navigation container** (NavigationStack, NavigationSplitView) renders the search field
|
||||
3. The view receives `isSearching` and `dismissSearch` through the environment
|
||||
4. Your view filters or queries based on the bound text
|
||||
|
||||
```swift
|
||||
struct RecipeListView: View {
|
||||
@State private var searchText = ""
|
||||
let recipes: [Recipe]
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List(filteredRecipes) { recipe in
|
||||
NavigationLink(recipe.name, value: recipe)
|
||||
}
|
||||
.navigationTitle("Recipes")
|
||||
.searchable(text: $searchText, prompt: "Find a recipe")
|
||||
}
|
||||
}
|
||||
|
||||
var filteredRecipes: [Recipe] {
|
||||
if searchText.isEmpty { return recipes }
|
||||
return recipes.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Placement Options
|
||||
|
||||
| Placement | Behavior |
|
||||
|-----------|----------|
|
||||
| `.automatic` | System decides (recommended) |
|
||||
| `.navigationBarDrawer` | Below navigation bar title (iOS) |
|
||||
| `.navigationBarDrawer(displayMode: .always)` | Always visible, not hidden on scroll |
|
||||
| `.sidebar` | In the sidebar column (NavigationSplitView) |
|
||||
| `.toolbar` | In the toolbar area |
|
||||
| `.toolbarPrincipal` | In toolbar's principal section |
|
||||
|
||||
**Gotcha**: SwiftUI may ignore your placement preference if the view hierarchy doesn't support it. Always test on the target platform.
|
||||
|
||||
### Column Association in NavigationSplitView
|
||||
|
||||
Where you attach `.searchable` determines which column displays the search field:
|
||||
|
||||
```swift
|
||||
NavigationSplitView {
|
||||
SidebarView()
|
||||
.searchable(text: $query) // Search in sidebar
|
||||
} detail: {
|
||||
DetailView()
|
||||
}
|
||||
|
||||
// vs.
|
||||
|
||||
NavigationSplitView {
|
||||
SidebarView()
|
||||
} detail: {
|
||||
DetailView()
|
||||
.searchable(text: $query) // Search in detail
|
||||
}
|
||||
|
||||
// vs.
|
||||
|
||||
NavigationSplitView {
|
||||
SidebarView()
|
||||
} detail: {
|
||||
DetailView()
|
||||
}
|
||||
.searchable(text: $query) // System decides column
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 2: Displaying Search Results
|
||||
|
||||
### isSearching Environment
|
||||
|
||||
```swift
|
||||
@Environment(\.isSearching) private var isSearching
|
||||
```
|
||||
|
||||
**Availability**: iOS 15+
|
||||
|
||||
Becomes `true` when the user activates search (taps the field), `false` when they cancel or you call `dismissSearch`.
|
||||
|
||||
**Critical rule**: `isSearching` must be read from a **child** of the view that has `.searchable`. SwiftUI sets the value in the searchable view's environment and does not propagate it upward.
|
||||
|
||||
```swift
|
||||
// Pattern: Overlay search results when searching
|
||||
struct WeatherCityList: View {
|
||||
@State private var searchText = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
// SearchResultsOverlay reads isSearching
|
||||
SearchResultsOverlay(searchText: searchText) {
|
||||
List(favoriteCities) { city in
|
||||
CityRow(city: city)
|
||||
}
|
||||
}
|
||||
.searchable(text: $searchText)
|
||||
.navigationTitle("Weather")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SearchResultsOverlay<Content: View>: View {
|
||||
let searchText: String
|
||||
@ViewBuilder let content: Content
|
||||
@Environment(\.isSearching) private var isSearching
|
||||
|
||||
var body: some View {
|
||||
if isSearching {
|
||||
// Show search results
|
||||
SearchResults(query: searchText)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### dismissSearch Environment
|
||||
|
||||
```swift
|
||||
@Environment(\.dismissSearch) private var dismissSearch
|
||||
```
|
||||
|
||||
**Availability**: iOS 15+
|
||||
|
||||
Calling `dismissSearch()` clears the search text, removes focus, and sets `isSearching` to `false`. Must be called from inside the searchable view hierarchy.
|
||||
|
||||
```swift
|
||||
struct SearchResults: View {
|
||||
@Environment(\.dismissSearch) private var dismissSearch
|
||||
|
||||
var body: some View {
|
||||
List(results) { result in
|
||||
Button(result.name) {
|
||||
selectResult(result)
|
||||
dismissSearch() // Close search after selection
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 3: Search Suggestions
|
||||
|
||||
### Adding Suggestions
|
||||
|
||||
Pass a `suggestions` closure to `.searchable`:
|
||||
|
||||
```swift
|
||||
.searchable(text: $searchText) {
|
||||
ForEach(suggestedResults) { suggestion in
|
||||
Text(suggestion.name)
|
||||
.searchCompletion(suggestion.name)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Availability**: iOS 15+
|
||||
|
||||
Suggestions appear in a list below the search field when the user is typing.
|
||||
|
||||
### searchCompletion Modifier
|
||||
|
||||
`.searchCompletion(_:)` binds a suggestion to a completion value. When the user taps the suggestion, the search text is replaced with the completion value.
|
||||
|
||||
```swift
|
||||
.searchable(text: $searchText) {
|
||||
ForEach(matchingColors) { color in
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(color.value)
|
||||
.frame(width: 16, height: 16)
|
||||
Text(color.name)
|
||||
}
|
||||
.searchCompletion(color.name) // Tapping fills search with color name
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Without `.searchCompletion()`**: Suggestions display but tapping them does nothing to the search field. This is the most common suggestions bug.
|
||||
|
||||
### Complete Suggestion Pattern
|
||||
|
||||
```swift
|
||||
struct ColorSearchView: View {
|
||||
@State private var searchText = ""
|
||||
let allColors: [NamedColor]
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List(filteredColors) { color in
|
||||
ColorRow(color: color)
|
||||
}
|
||||
.navigationTitle("Colors")
|
||||
.searchable(text: $searchText, prompt: "Search colors") {
|
||||
ForEach(suggestedColors) { color in
|
||||
Label(color.name, systemImage: "paintpalette")
|
||||
.searchCompletion(color.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var suggestedColors: [NamedColor] {
|
||||
guard !searchText.isEmpty else { return [] }
|
||||
return allColors.filter {
|
||||
$0.name.localizedCaseInsensitiveContains(searchText)
|
||||
}
|
||||
.prefix(5)
|
||||
.map { $0 } // Convert ArraySlice to Array
|
||||
}
|
||||
|
||||
var filteredColors: [NamedColor] {
|
||||
if searchText.isEmpty { return allColors }
|
||||
return allColors.filter {
|
||||
$0.name.localizedCaseInsensitiveContains(searchText)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 4: Search Submission
|
||||
|
||||
### onSubmit(of: .search)
|
||||
|
||||
Triggers when the user presses Return/Enter in the search field:
|
||||
|
||||
```swift
|
||||
.searchable(text: $searchText)
|
||||
.onSubmit(of: .search) {
|
||||
performSearch(searchText)
|
||||
}
|
||||
```
|
||||
|
||||
**Availability**: iOS 15+
|
||||
|
||||
### Filter vs Submit Decision
|
||||
|
||||
| Pattern | Use When | Example |
|
||||
|---------|----------|---------|
|
||||
| Filter-as-you-type | Local data, fast filtering | Contacts, settings |
|
||||
| Submit-based search | Network requests, expensive queries | App Store, web search |
|
||||
| Combined | Suggestions filter locally, submit triggers server | Maps, shopping |
|
||||
|
||||
### Combined Suggestions + Submit Pattern
|
||||
|
||||
```swift
|
||||
struct StoreSearchView: View {
|
||||
@State private var searchText = ""
|
||||
@State private var searchResults: [Product] = []
|
||||
let recentSearches: [String]
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List(searchResults) { product in
|
||||
ProductRow(product: product)
|
||||
}
|
||||
.navigationTitle("Store")
|
||||
.searchable(text: $searchText, prompt: "Search products") {
|
||||
// Local suggestions from recent searches
|
||||
ForEach(matchingRecent, id: \.self) { term in
|
||||
Label(term, systemImage: "clock")
|
||||
.searchCompletion(term)
|
||||
}
|
||||
}
|
||||
.onSubmit(of: .search) {
|
||||
// Server search on submit
|
||||
Task {
|
||||
searchResults = await ProductAPI.search(searchText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var matchingRecent: [String] {
|
||||
guard !searchText.isEmpty else { return recentSearches }
|
||||
return recentSearches.filter {
|
||||
$0.localizedCaseInsensitiveContains(searchText)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 5: Search Scopes (iOS 16+)
|
||||
|
||||
### Adding Scopes
|
||||
|
||||
Scopes add a segmented picker below the search field for narrowing results by category:
|
||||
|
||||
```swift
|
||||
enum SearchScope: String, CaseIterable {
|
||||
case all = "All"
|
||||
case recipes = "Recipes"
|
||||
case ingredients = "Ingredients"
|
||||
}
|
||||
|
||||
struct ScopedSearchView: View {
|
||||
@State private var searchText = ""
|
||||
@State private var searchScope: SearchScope = .all
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List(filteredResults) { result in
|
||||
ResultRow(result: result)
|
||||
}
|
||||
.navigationTitle("Cookbook")
|
||||
.searchable(text: $searchText)
|
||||
.searchScopes($searchScope) {
|
||||
ForEach(SearchScope.allCases, id: \.self) { scope in
|
||||
Text(scope.rawValue).tag(scope)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Availability**: iOS 16+, macOS 13+
|
||||
|
||||
### Scope Activation (iOS 16.4+)
|
||||
|
||||
Control when scopes appear:
|
||||
|
||||
```swift
|
||||
.searchScopes($searchScope, activation: .onTextEntry) {
|
||||
// Scopes appear only when user starts typing
|
||||
ForEach(SearchScope.allCases, id: \.self) { scope in
|
||||
Text(scope.rawValue).tag(scope)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Activation | Behavior |
|
||||
|------------|----------|
|
||||
| `.automatic` | System default |
|
||||
| `.onTextEntry` | Scopes appear when user types text |
|
||||
| `.onSearchPresentation` | Scopes appear when search is activated |
|
||||
|
||||
**Platform differences**:
|
||||
- **iOS/iPadOS**: Scopes appear on text entry by default, dismiss on cancel
|
||||
- **macOS**: Scopes appear when search is presented, dismiss on cancel
|
||||
|
||||
---
|
||||
|
||||
## Part 6: Search Tokens (iOS 16+)
|
||||
|
||||
Tokens are structured search elements that appear as "pills" in the search field alongside free text.
|
||||
|
||||
### Basic Tokens
|
||||
|
||||
```swift
|
||||
enum RecipeToken: Identifiable, Hashable {
|
||||
case cuisine(String)
|
||||
case difficulty(String)
|
||||
|
||||
var id: Self { self }
|
||||
}
|
||||
|
||||
struct TokenSearchView: View {
|
||||
@State private var searchText = ""
|
||||
@State private var tokens: [RecipeToken] = []
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List(filteredRecipes) { recipe in
|
||||
RecipeRow(recipe: recipe)
|
||||
}
|
||||
.navigationTitle("Recipes")
|
||||
.searchable(text: $searchText, tokens: $tokens) { token in
|
||||
switch token {
|
||||
case .cuisine(let name):
|
||||
Label(name, systemImage: "globe")
|
||||
case .difficulty(let name):
|
||||
Label(name, systemImage: "star")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Availability**: iOS 16+
|
||||
|
||||
**Token model requirements**: Each token element must conform to `Identifiable`.
|
||||
|
||||
### Suggested Tokens (iOS 17+)
|
||||
|
||||
```swift
|
||||
.searchable(
|
||||
text: $searchText,
|
||||
tokens: $tokens,
|
||||
suggestedTokens: $suggestedTokens,
|
||||
prompt: "Search recipes"
|
||||
) { token in
|
||||
Label(token.displayName, systemImage: token.icon)
|
||||
}
|
||||
```
|
||||
|
||||
**Availability**: iOS 17+ adds `suggestedTokens` and `isPresented` parameters.
|
||||
|
||||
### Combined Tokens + Text Filtering
|
||||
|
||||
```swift
|
||||
var filteredRecipes: [Recipe] {
|
||||
var results = allRecipes
|
||||
|
||||
// Apply token filters
|
||||
for token in tokens {
|
||||
switch token {
|
||||
case .cuisine(let cuisine):
|
||||
results = results.filter { $0.cuisine == cuisine }
|
||||
case .difficulty(let difficulty):
|
||||
results = results.filter { $0.difficulty == difficulty }
|
||||
}
|
||||
}
|
||||
|
||||
// Apply text filter
|
||||
if !searchText.isEmpty {
|
||||
results = results.filter {
|
||||
$0.name.localizedCaseInsensitiveContains(searchText)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 7: Programmatic Search Control (iOS 18+)
|
||||
|
||||
### searchFocused
|
||||
|
||||
Bind a `FocusState<Bool>` to the search field to activate or dismiss search programmatically:
|
||||
|
||||
```swift
|
||||
struct ProgrammaticSearchView: View {
|
||||
@State private var searchText = ""
|
||||
@FocusState private var isSearchFocused: Bool
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack {
|
||||
Button("Start Search") {
|
||||
isSearchFocused = true // Activate search field
|
||||
}
|
||||
|
||||
List(filteredItems) { item in
|
||||
Text(item.name)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Items")
|
||||
.searchable(text: $searchText)
|
||||
.searchFocused($isSearchFocused)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Availability**: iOS 18+, macOS 15+, visionOS 2+
|
||||
|
||||
**Note**: For a non-boolean variant, use `.searchFocused(_:equals:)` to match specific focus values.
|
||||
|
||||
### Comparison with dismissSearch
|
||||
|
||||
| API | Direction | iOS |
|
||||
|-----|-----------|-----|
|
||||
| `dismissSearch` | Dismiss only | 15+ |
|
||||
| `.searchFocused($bool)` | Activate or dismiss | 18+ |
|
||||
|
||||
Use `dismissSearch` if you only need to close search. Use `searchFocused` when you need to programmatically *open* search (e.g., a floating action button that opens search).
|
||||
|
||||
---
|
||||
|
||||
## Part 8: Platform Behavior
|
||||
|
||||
SwiftUI search adapts automatically per platform:
|
||||
|
||||
| Platform | Default Behavior |
|
||||
|----------|-----------------|
|
||||
| **iOS** | Search bar in navigation bar. Scrolls out of view by default; pull down to reveal. |
|
||||
| **iPadOS** | Same as iOS in compact; may appear in toolbar in regular width. |
|
||||
| **macOS** | Trailing toolbar search field. Always visible. |
|
||||
| **watchOS** | Dictation-first input. Search bar at top of list. |
|
||||
| **tvOS** | Tab-based search with on-screen keyboard. |
|
||||
|
||||
### iOS-Specific Behavior
|
||||
|
||||
```swift
|
||||
// Always-visible search field (doesn't scroll away)
|
||||
.searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always))
|
||||
|
||||
// Default: search field scrolls out, pull down to reveal
|
||||
.searchable(text: $searchText)
|
||||
```
|
||||
|
||||
### macOS-Specific Behavior
|
||||
|
||||
```swift
|
||||
// Search in toolbar (default on macOS)
|
||||
.searchable(text: $searchText, placement: .toolbar)
|
||||
|
||||
// Search in sidebar
|
||||
.searchable(text: $searchText, placement: .sidebar)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 9: Common Gotchas
|
||||
|
||||
### 1. Search Field Doesn't Appear
|
||||
|
||||
**Cause**: `.searchable` is not inside a navigation container.
|
||||
|
||||
```swift
|
||||
// WRONG: No navigation container
|
||||
List { ... }
|
||||
.searchable(text: $query)
|
||||
|
||||
// CORRECT: Inside NavigationStack
|
||||
NavigationStack {
|
||||
List { ... }
|
||||
.searchable(text: $query)
|
||||
}
|
||||
```
|
||||
|
||||
### 2. isSearching Always Returns false
|
||||
|
||||
**Cause**: Reading `isSearching` from the wrong view level.
|
||||
|
||||
```swift
|
||||
// WRONG: Reading from parent of searchable view
|
||||
struct ParentView: View {
|
||||
@Environment(\.isSearching) var isSearching // Always false
|
||||
@State private var query = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ChildView(isSearching: isSearching)
|
||||
.searchable(text: $query)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CORRECT: Reading from child view
|
||||
struct ChildView: View {
|
||||
@Environment(\.isSearching) var isSearching // Works
|
||||
|
||||
var body: some View {
|
||||
if isSearching {
|
||||
SearchResults()
|
||||
} else {
|
||||
DefaultContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Suggestions Don't Fill Search Field
|
||||
|
||||
**Cause**: Missing `.searchCompletion()` on suggestion views.
|
||||
|
||||
```swift
|
||||
// WRONG: No searchCompletion
|
||||
.searchable(text: $query) {
|
||||
ForEach(suggestions) { s in
|
||||
Text(s.name) // Displays but tapping does nothing
|
||||
}
|
||||
}
|
||||
|
||||
// CORRECT: With searchCompletion
|
||||
.searchable(text: $query) {
|
||||
ForEach(suggestions) { s in
|
||||
Text(s.name)
|
||||
.searchCompletion(s.name) // Fills search field on tap
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Placement on Wrong Navigation Level
|
||||
|
||||
**Cause**: Attaching `.searchable` to the wrong column in NavigationSplitView.
|
||||
|
||||
```swift
|
||||
// Might not appear where expected
|
||||
NavigationSplitView {
|
||||
SidebarView()
|
||||
} detail: {
|
||||
DetailView()
|
||||
}
|
||||
.searchable(text: $query) // System chooses column
|
||||
|
||||
// Explicit placement
|
||||
NavigationSplitView {
|
||||
SidebarView()
|
||||
.searchable(text: $query, placement: .sidebar) // In sidebar
|
||||
} detail: {
|
||||
DetailView()
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Search Scopes Don't Appear
|
||||
|
||||
**Cause**: Scopes require `.searchable` on the same view. They also require a navigation container.
|
||||
|
||||
```swift
|
||||
// WRONG: Scopes without searchable
|
||||
List { ... }
|
||||
.searchScopes($scope) { ... }
|
||||
|
||||
// CORRECT: Scopes alongside searchable
|
||||
List { ... }
|
||||
.searchable(text: $query)
|
||||
.searchScopes($scope) {
|
||||
Text("All").tag(Scope.all)
|
||||
Text("Recent").tag(Scope.recent)
|
||||
}
|
||||
```
|
||||
|
||||
### 6. iOS 26 Refinements
|
||||
|
||||
For bottom-aligned search, `.searchToolbarBehavior(.minimize)`, `Tab(role: .search)`, and `DefaultToolbarItem(kind: .search)`, see `axiom-swiftui-26-ref`. These build on the foundational APIs documented here.
|
||||
|
||||
---
|
||||
|
||||
## Part 10: API Quick Reference
|
||||
|
||||
### Modifiers
|
||||
|
||||
| Modifier | iOS | Purpose |
|
||||
|----------|-----|---------|
|
||||
| `.searchable(text:placement:prompt:)` | 15+ | Add search field |
|
||||
| `.searchable(text:tokens:token:)` | 16+ | Search with tokens |
|
||||
| `.searchable(text:tokens:suggestedTokens:isPresented:token:)` | 17+ | Tokens + suggested tokens + presentation control |
|
||||
| `.searchCompletion(_:)` | 15+ | Auto-fill search on suggestion tap |
|
||||
| `.searchScopes(_:_:)` | 16+ | Category picker below search |
|
||||
| `.searchScopes(_:activation:_:)` | 16.4+ | Scopes with activation control |
|
||||
| `.searchFocused(_:)` | 18+ | Programmatic search focus |
|
||||
| `.searchPresentationToolbarBehavior(_:)` | 17.1+ | Keep title visible during search |
|
||||
| `.searchToolbarBehavior(_:)` | 26+ | Compact/minimize search field |
|
||||
| `onSubmit(of: .search)` | 15+ | Handle search submission |
|
||||
|
||||
### Environment Values
|
||||
|
||||
| Value | iOS | Purpose |
|
||||
|-------|-----|---------|
|
||||
| `isSearching` | 15+ | Is user actively searching |
|
||||
| `dismissSearch` | 15+ | Action to dismiss search |
|
||||
|
||||
### Types
|
||||
|
||||
| Type | iOS | Purpose |
|
||||
|------|-----|---------|
|
||||
| `SearchFieldPlacement` | 15+ | Where search field renders |
|
||||
| `SearchScopeActivation` | 16.4+ | When scopes appear |
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
**WWDC**: 2021-10176, 2022-10023
|
||||
|
||||
**Docs**: /swiftui/view/searchable(text:placement:prompt:), /swiftui/environmentvalues/issearching, /swiftui/view/searchscopes(_:activation:_:), /swiftui/view/searchfocused(_:), /swiftui/searchfieldplacement
|
||||
|
||||
**Skills**: axiom-swiftui-26-ref, axiom-swiftui-nav-ref, axiom-swiftui-nav
|
||||
|
||||
---
|
||||
|
||||
**Last Updated** Based on WWDC 2021-10176 "Searchable modifier", sosumi.ai API reference
|
||||
**Platforms** iOS 15+, iPadOS 15+, macOS 12+, watchOS 8+, tvOS 15+
|
||||
@@ -0,0 +1,3 @@
|
||||
interface:
|
||||
display_name: "SwiftUI Search Reference"
|
||||
short_description: "Implementing SwiftUI search"
|
||||
Reference in New Issue
Block a user