Tab Bar Load Animation

Nucleic-Session: EEC60FAE-8368-49CA-9202-7026C12E7F8C
Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
2026-07-05 18:49:28 -07:00
co-authored by Nucleic
parent ecfbbfd898
commit 84a339a0e7
@@ -38,31 +38,51 @@ struct CompactRootView: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.accessibilityReduceMotion) private var reduceMotion
// Initial tab; overridable via NUCLEIC_TAB for offline UI previews. // Initial tab; overridable via NUCLEIC_TAB for offline UI previews.
@State private var tab = Int(ProcessInfo.processInfo.environment["NUCLEIC_TAB"] ?? "") ?? 0 @State private var tab = Int(ProcessInfo.processInfo.environment["NUCLEIC_TAB"] ?? "") ?? 0
/// The shared "+" beside the tab bar opens the project-picker composer (the per-page discs are
/// gone on compact — this one bar owns "new chat").
@State private var showNewChat = false
/// Measured height of the floating bar, fed back into the bottom inset so tab content lays out
/// exactly above it (no guessing, no overlap).
@State private var barHeight: CGFloat = 72
/// The bar owns the bottom edge on the top-level tabs, but yields it to a pushed session's /// The bar owns the bottom edge on the top-level tabs, but yields it to a pushed session's
/// floating glass chat bar — so it's hidden exactly while a session detail is on screen /// floating glass chat bar — so it's hidden exactly while a session detail is on screen
/// (`openSessionID` is set by `SessionDetailView`'s appear/disappear, our animation trigger). /// (`openSessionID` is set by `SessionDetailView`'s appear/disappear, our animation trigger).
private var showsTabBar: Bool { store.openSessionID == nil } private var showsTabBar: Bool { store.openSessionID == nil }
/// The "+" is inert without control scope or a project to start in, so it's shown only when a
/// chat could actually be kicked off (mirrors `NewChatAffordance.canStart`).
private var canStartChat: Bool { store.canControl && !store.dashboard.projects.isEmpty }
var body: some View { var body: some View {
TabView(selection: $tab) { TabView(selection: $tab) {
HomeView().tag(0).toolbar(.hidden, for: .tabBar) HomeView().tag(0).toolbar(.hidden, for: .tabBar)
SessionsView().tag(1).toolbar(.hidden, for: .tabBar) SessionsView().tag(1).toolbar(.hidden, for: .tabBar)
ProjectsView().tag(2).toolbar(.hidden, for: .tabBar) ProjectsView().tag(2).toolbar(.hidden, for: .tabBar)
TodosView().tag(3).toolbar(.hidden, for: .tabBar) SettingsView().tag(3).toolbar(.hidden, for: .tabBar)
SettingsView().tag(4).toolbar(.hidden, for: .tabBar)
} }
// Our bar sits in a bottom inset so top-level content (and each tab's floating "+") lays // Reserve exactly the bar's height so top-level content (and each tab's own bottom chrome)
// out above it; a pushed session sees the inset collapse and runs its transcript full-bleed // lays out above the floating bar rather than under it; collapses to nothing when a session
// to the bottom. Both the slide and that reflow ride the one spring below. // hides the bar so the transcript runs full-bleed.
.safeAreaInset(edge: .bottom, spacing: 0) { .safeAreaInset(edge: .bottom, spacing: 0) {
if showsTabBar { Color.clear.frame(height: showsTabBar ? barHeight : 0)
NucleicTabBar(selection: $tab, needsYou: store.needsYouCount)
.transition(.move(edge: .bottom))
} }
// The bar itself lives in an always-mounted overlay and slides on `offset`, so it travels
// continuously off the bottom of the screen (a fixed distance past the home-indicator curve)
// instead of a transition that removes it mid-descent and makes it pop.
.overlay(alignment: .bottom) {
bottomBar
.background(
GeometryReader { geo in
Color.clear.preference(key: BarHeightKey.self, value: geo.size.height)
})
.onPreferenceChange(BarHeightKey.self) { barHeight = $0 }
.offset(y: showsTabBar ? 0 : barHeight + 120)
} }
.animation(reduceMotion ? nil : .spring(response: 0.34, dampingFraction: 0.92), .animation(reduceMotion ? nil : .spring(response: 0.38, dampingFraction: 0.9),
value: showsTabBar) value: showsTabBar)
// The project-picker composer, presented for the shared "+" (no per-page disc on compact).
.newChatAffordance(isPresented: $showNewChat, showsButton: false)
// A notification tap routes to its session by jumping to the Sessions tab. // A notification tap routes to its session by jumping to the Sessions tab.
.onChange(of: store.pendingRoute) { _, route in .onChange(of: store.pendingRoute) { _, route in
if route != nil { tab = 1 } if route != nil { tab = 1 }
@@ -72,15 +92,43 @@ struct CompactRootView: View {
if show { tab = 1; store.pendingSessionsList = false } if show { tab = 1; store.pendingSessionsList = false }
} }
} }
/// The floating bottom bar: the tab capsule, with the "+" disc riding to its right in the width
/// freed by dropping the To-dos tab (the iOS 26 tab-bar-plus-accessory shape).
private var bottomBar: some View {
HStack(spacing: 10) {
NucleicTabBar(selection: $tab, needsYou: store.needsYouCount)
if canStartChat {
Button { showNewChat = true } label: {
Image(systemName: "plus")
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(Palette.accent)
.frame(width: 56, height: 56)
.contentShape(Circle())
.glassCircle()
}
.buttonStyle(.plain)
.accessibilityLabel("New chat")
}
}
.padding(.horizontal, 16)
.padding(.bottom, 4)
}
} }
/// Our own bottom tab bar for the compact shell — a floating Liquid Glass capsule of five items, /// A preference carrying the floating bar's measured height up to `CompactRootView`, which feeds it
/// standing in for the system tab bar so `CompactRootView` can animate its visibility itself (see /// back into the bottom safe-area inset so content sits exactly above the bar.
/// that view for why) while keeping the iOS 26 floating-glass look. Real `glassEffect` on iOS 26; private struct BarHeightKey: PreferenceKey {
/// an ultra-thin material capsule with a hairline edge + soft shadow on earlier systems, matching static var defaultValue: CGFloat = 72
/// the app's `glassSurface`/`glassCircle`. It floats inset from the edges, above the home static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() }
/// indicator — it does not bleed to the screen edge. The Sessions item carries the "needs you" }
/// badge the system tab item used to.
/// Our own bottom tab bar for the compact shell — a floating Liquid Glass capsule of four items
/// (Home, Sessions, Projects, Settings), standing in for the system tab bar so `CompactRootView`
/// can animate its visibility itself (see that view for why) while keeping the iOS 26 floating-glass
/// look. Real `glassEffect` on iOS 26; an ultra-thin material capsule with a hairline edge + soft
/// shadow on earlier systems, matching the app's `glassSurface`/`glassCircle`. The Sessions item
/// carries the "needs you" badge the system tab item used to.
struct NucleicTabBar: View { struct NucleicTabBar: View {
@Binding var selection: Int @Binding var selection: Int
/// Sessions blocking on a human — the red badge on the Sessions item (was the tab item badge). /// Sessions blocking on a human — the red badge on the Sessions item (was the tab item badge).
@@ -91,8 +139,7 @@ struct NucleicTabBar: View {
Item(index: 0, title: "Home", icon: "house"), Item(index: 0, title: "Home", icon: "house"),
Item(index: 1, title: "Sessions", icon: "square.stack.3d.up"), Item(index: 1, title: "Sessions", icon: "square.stack.3d.up"),
Item(index: 2, title: "Projects", icon: "folder"), Item(index: 2, title: "Projects", icon: "folder"),
Item(index: 3, title: "To-dos", icon: "checklist"), Item(index: 3, title: "Settings", icon: "gearshape"),
Item(index: 4, title: "Settings", icon: "gearshape"),
] ]
var body: some View { var body: some View {
@@ -104,10 +151,6 @@ struct NucleicTabBar: View {
.padding(.horizontal, 6) .padding(.horizontal, 6)
.padding(.vertical, 8) .padding(.vertical, 8)
.glassBar() .glassBar()
// Float the capsule inset from the edges and just above the home indicator, rather than
// filling the bottom edge like the old opaque bar.
.padding(.horizontal, 16)
.padding(.bottom, 4)
} }
private func itemButton(_ item: Item) -> some View { private func itemButton(_ item: Item) -> some View {