Skip to content

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:

  1. "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.
  2. View state destructionTabView unloads off-screen tab views depending on iOS version and memory pressure. This caused AgentViewModel task 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

CaseViewEntry gate
.agentAgentViewNone
.chatSessionListViewNone
.notesNotesListViewNone
.recordSessionRecordViewNone (free trial — see Freemium Psychology)
.habitsHabitDashboardViewNone
.settingsSettingsViewNone

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

  1. Add a new case to Tab in ContentView.swift — provide title, icon, selectedIcon.
  2. Add the new view to the ZStack in ContentView.body with the opacity/hitTesting pattern:
    swift
    MyNewView(...)
        .safeAreaInset(edge: .bottom, spacing: 0) { Color.clear.frame(height: inset) }
        .opacity(selectedTab == .myTab ? 1 : 0)
        .allowsHitTesting(selectedTab == .myTab)
  3. Add a tab item in AppTabBar.
  4. If the view has dependencies injected at the app root (LucidPalApp), add them to ContentView's @ObservedObject properties and thread them through RootView.
  5. Update MarketingEnvironment scene 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.


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, .navigationDestination
  • Sources/Views/SettingsView+Search.swiftSettingsSearchItem model, searchCatalog extension, 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)

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:

  • DownloadProgressPill tap → selectedTab = .settings (routes to download progress in Settings)
  • Siri query consumption → sessionListViewModel.scheduleSiriQuery() (does not switch tabs, navigates within Chat tab via SessionListViewModel)

Internal — not for distribution