Appearance
Navigation & Tab Bar
Custom opacity-based tab switcher — why TabView was replaced and what that means for adding new tabs.
Why Not TabView
The standard SwiftUI TabView wraps UITabBarController. That brings two hard constraints:
- "More" overflow — iOS automatically moves tabs 5+ into a "More" list. LucidPal has 6 tabs; the 6th (Settings) would disappear into "More" with no way to suppress it without private API.
- View state destruction —
TabViewunloads off-screen tab views depending on iOS version and memory pressure. This causedAgentViewModeltask state, active chat sessions, and live note recording state to reset when switching tabs.
The custom tab bar solves both problems without any private API.
Architecture
Opacity switcher (ContentView)
All tab views are mounted simultaneously in a ZStack. Visibility is controlled exclusively by .opacity() and .allowsHitTesting():
swift
ZStack {
AgentView(...)
.opacity(selectedTab == .agent ? 1 : 0)
.allowsHitTesting(selectedTab == .agent)
SessionListView(...)
.opacity(selectedTab == .chat ? 1 : 0)
.allowsHitTesting(selectedTab == .chat)
// ... remaining tabs
}Consequence: Every tab is alive at all times. @State, @StateObject, and ObservableObject instances are never destroyed between tab switches. AgentViewModel.taskState, live recording, and chat scroll positions persist exactly as the user left them.
Bottom inset: Each tab view receives .safeAreaInset(edge: .bottom, spacing: 0) { Color.clear.frame(height: 80) } to prevent content from sliding under the tab bar.
AppTabBar
Sources/App/AppTabBar.swift
A standalone View pinned to the bottom of ContentView via ZStack(alignment: .bottom). It takes @Binding var selectedTab: Tab and renders 6 tappable items.
swift
AppTabBar(selectedTab: $selectedTab)
.ignoresSafeArea(edges: .bottom)Tab items use SF Symbols. Each tab has a default icon and a selected icon (filled variant). Tapping triggers UISelectionFeedbackGenerator.selectionChanged() via ContentView.onChange(of: selectedTab).
Tab enum
Defined at the bottom of ContentView.swift:
swift
enum Tab: Hashable {
case agent, chat, notes, record, habits, settings
}Each case declares title, icon (unselected SF Symbol), and selectedIcon (filled SF Symbol).
Tab Inventory
| Case | View | Entry gate |
|---|---|---|
.agent | AgentView | None |
.chat | SessionListView | None |
.notes | NotesListView | None |
.record | SessionRecordView | None (free trial — see Freemium Psychology) |
.habits | HabitDashboardView | None |
.settings | SettingsView | None |
No tab has an entry-level paywall. Premium gates are applied at the point of value delivery within each tab, not at the tab itself.
Adding a New Tab
- Add a new case to
TabinContentView.swift— providetitle,icon,selectedIcon. - Add the new view to the
ZStackinContentView.bodywith the opacity/hitTesting pattern:swiftMyNewView(...) .safeAreaInset(edge: .bottom, spacing: 0) { Color.clear.frame(height: inset) } .opacity(selectedTab == .myTab ? 1 : 0) .allowsHitTesting(selectedTab == .myTab) - Add a tab item in
AppTabBar. - If the view has dependencies injected at the app root (
LucidPalApp), add them toContentView's@ObservedObjectproperties and thread them throughRootView. - Update
MarketingEnvironmentscene routing if the tab should be reachable in marketing screenshots.
Note: All tab views are alive from launch. Avoid heavy initialisation in View.init() or onAppear for tabs — use .task with a guard against repeated runs, or push initialisation into the ViewModel.
Settings Search
SettingsView exposes a search button in its header that pushes SettingsSearchView via .navigationDestination(isPresented: $showSearch).
Implementation files:
Sources/Views/SettingsView.swift—@State private var showSearch, header button,.navigationDestinationSources/Views/SettingsView+Search.swift—SettingsSearchItemmodel,searchCatalogextension,SettingsSearchView,SettingsSearchResultsView,SearchResultRow
Why .navigationDestination and not inline
SettingsView.body is already close to the iOS device thread stack limit (512 KB vs macOS 8 MB) due to the Form { switch viewModel.settingsMode { ... } } tree, which Swift compiler expands into deeply nested _ConditionalContent<TupleView<(8 sections)>, TupleView<(9 sections)>> generic types. Adding any @ViewBuilder conditional inline (e.g. if isSearching { SearchView } else { settingsForm }) adds another _ConditionalContent wrapper and causes EXC_BAD_ACCESS SIGSEGV on device (not simulator, which has an 8 MB stack).
.navigationDestination isolates SettingsSearchView.body into its own stack frame at navigation time — zero type complexity added to SettingsView.body.
Rule: Never add @ViewBuilder conditionals or extracted computed @ViewBuilder properties to SettingsView.body or to the Form content. If new UI is needed in the Settings screen, push it via .navigationDestination or .sheet.
searchCatalog
Defined in SettingsView+Search.swift as an extension on SettingsView. Each SettingsSearchItem holds a closure that captures SettingsView @State vars (e.g. showIntegrations, showDataExport). This is safe because @State backing storage is reference-typed — the closures always write to the live storage even after navigation.
Search flow
User taps 🔍 → showSearch = true
→ .navigationDestination pushes SettingsSearchView (new stack frame)
→ SettingsSearchView.onAppear: focused = true (keyboard up)
→ user types → filteredItems(query:) filters searchCatalog
→ SettingsSearchResultsView renders results
→ user taps result → item.action() executes → sets @State on SettingsView
(e.g. showDataExport = true → DataExportSettingsView pushed when user navigates back)Deep-Link / Programmatic Navigation
ContentView.selectedTab is @State. To switch tabs programmatically from within a child view, pass selectedTab down as a @Binding, or use a shared @EnvironmentObject.
Current internal uses:
DownloadProgressPilltap →selectedTab = .settings(routes to download progress in Settings)- Siri query consumption →
sessionListViewModel.scheduleSiriQuery()(does not switch tabs, navigates within Chat tab viaSessionListViewModel)