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.


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