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.
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)