Move search to a dedicated tab in the tab bar

Replace the Notes list's searchable accessory with a Tab(role: .search)
— the separated magnifier at the trailing edge of the iOS 26 tab bar —
backed by a new SearchView over text, tags, and capture location. The
Notes list drops its search state and always shows the context shelf
plus all notes.

Claude-Session: https://claude.ai/code/session_01GKfAiKiQKLDTmbiGva4FGE
This commit is contained in:
2026-07-15 16:25:34 -04:00
parent 798abf3b90
commit 970eebaf3c
4 changed files with 91 additions and 26 deletions
+6
View File
@@ -19,6 +19,12 @@ struct ContentView: View {
Tab("Settings", systemImage: "gearshape") {
SettingsView()
}
// role: .search renders as the separated magnifier tab at the
// trailing edge of the tab bar on iOS 26.
Tab("Search", systemImage: "magnifyingglass", role: .search) {
SearchView()
}
}
}
}
+5 -25
View File
@@ -3,13 +3,13 @@ import SwiftData
import SwiftUI
/// The main list: a "Right here, right now" shelf of context-relevant notes
/// on top, then everything else newest-first. Search filters text and tags.
/// on top, then everything else newest-first. Search lives in its own tab
/// (`SearchView`, the tab bar's trailing magnifier).
struct NotesListView: View {
@Environment(SyncEngine.self) private var syncEngine
@Environment(ContextService.self) private var contextService
@Query(sort: \NoteEntity.createdAt, order: .reverse) private var notes: [NoteEntity]
@State private var searchText = ""
@State private var composing = false
@State private var recording = false
@State private var editingNote: NoteEntity?
@@ -21,15 +21,6 @@ struct NotesListView: View {
list
}
.navigationTitle("Notes")
// iOS 26: attached at the NavigationStack root inside a TabView, this
// renders as the Liquid-Glass toolbar search accessory (bottom, in the
// tab bar). `.searchToolbarBehavior(.minimize)` collapses it to the
// magnifier capsule until tapped a modifier that only exists on iOS,
// hence the guard; macOS keeps the standard toolbar search field.
.searchable(text: $searchText, prompt: "Search notes and tags")
#if os(iOS)
.searchToolbarBehavior(.minimize)
#endif
.sheet(isPresented: $composing) {
NoteEditorView(mode: .create)
}
@@ -79,7 +70,7 @@ struct NotesListView: View {
}
}
if searchText.isEmpty, !relevantNotes.isEmpty {
if !relevantNotes.isEmpty {
Section("Right here, right now") {
ForEach(relevantNotes) { note in
row(note)
@@ -87,8 +78,8 @@ struct NotesListView: View {
}
}
Section(searchText.isEmpty ? "All notes" : "Results") {
ForEach(filteredNotes) { note in
Section("All notes") {
ForEach(notes) { note in
row(note)
}
}
@@ -133,17 +124,6 @@ struct NotesListView: View {
.map(\.note)
}
private var filteredNotes: [NoteEntity] {
guard !searchText.isEmpty else { return notes }
let needle = searchText.lowercased()
return notes.filter { note in
note.text.lowercased().contains(needle)
|| note.tags.contains { $0.lowercased().contains(needle) }
|| (note.placeName?.lowercased().contains(needle) ?? false)
|| (note.locality?.lowercased().contains(needle) ?? false)
}
}
private func delete(_ note: NoteEntity) {
guard let id = ULID(string: note.id) else { return }
Task {
+79
View File
@@ -0,0 +1,79 @@
import IndieSync
import SwiftData
import SwiftUI
/// The Search tab: full-library search over note text, tags, and capture
/// location. Lives behind the tab bar's dedicated search tab (`role: .search`),
/// which iOS 26 renders as the separated magnifier at the trailing edge.
struct SearchView: View {
@Environment(SyncEngine.self) private var syncEngine
@Query(sort: \NoteEntity.createdAt, order: .reverse) private var notes: [NoteEntity]
@State private var searchText = ""
@State private var editingNote: NoteEntity?
var body: some View {
NavigationStack {
List {
ForEach(results) { note in
row(note)
}
}
.navigationTitle("Search")
.searchable(text: $searchText, prompt: "Search notes and tags")
.overlay {
if searchText.isEmpty {
ContentUnavailableView(
"Search your notes",
systemImage: "magnifyingglass",
description: Text("Find notes by text, tag, or the place you captured them.")
)
} else if results.isEmpty {
ContentUnavailableView.search(text: searchText)
}
}
.sheet(item: $editingNote) { note in
NoteEditorView(mode: .edit(note))
}
}
}
private func row(_ note: NoteEntity) -> some View {
Button {
editingNote = note
} label: {
NoteRowView(note: note)
}
.buttonStyle(.plain)
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
delete(note)
} label: {
Label("Delete", systemImage: "trash")
}
}
}
private var results: [NoteEntity] {
guard !searchText.isEmpty else { return [] }
return notes.filter { $0.matches(search: searchText) }
}
private func delete(_ note: NoteEntity) {
guard let id = ULID(string: note.id) else { return }
Task {
try? await syncEngine.deleteNote(id: id)
}
}
}
extension NoteEntity {
/// Case-insensitive match against text, tags, and capture location.
func matches(search: String) -> Bool {
let needle = search.lowercased()
return text.lowercased().contains(needle)
|| tags.contains { $0.lowercased().contains(needle) }
|| (placeName?.lowercased().contains(needle) ?? false)
|| (locality?.lowercased().contains(needle) ?? false)
}
}