Tab Bar Load Animation
Nucleic-Session: EEC60FAE-8368-49CA-9202-7026C12E7F8C Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user