Tab Bar Load Animation
Nucleic-Session: EEC60FAE-8368-49CA-9202-7026C12E7F8C Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
@@ -23,28 +23,46 @@ struct AdaptiveRootView: View {
|
|||||||
|
|
||||||
// MARK: - Compact (iPhone / narrow multitasking)
|
// MARK: - Compact (iPhone / narrow multitasking)
|
||||||
|
|
||||||
/// The original iPhone shell, relocated verbatim from `RootView`: a five-tab `TabView`. A
|
/// The iPhone shell: a five-tab `TabView` whose *system* tab bar is suppressed in favour of our
|
||||||
/// notification tap routes to the Sessions tab, where `SessionsView` consumes `pendingRoute`
|
/// own `NucleicTabBar` (below). We draw the bar ourselves because SwiftUI's per-navigation
|
||||||
|
/// `.toolbar(.hidden, for: .tabBar)` restores the system bar late (~1s) and un-animated when you
|
||||||
|
/// pop back from a pushed `SessionDetailView` — long enough that the floating "+" renders low
|
||||||
|
/// (anchored to a bottom safe-area inset that hasn't grown yet) and is briefly untappable before
|
||||||
|
/// snapping up. Owning the bar lets us slide it out/in with a spring the instant a session opens
|
||||||
|
/// or closes, so the return is smooth and the "+" reflows in one coordinated animation.
|
||||||
|
///
|
||||||
|
/// A notification tap routes to the Sessions tab, where `SessionsView` consumes `pendingRoute`
|
||||||
/// and pushes the detail.
|
/// and pushes the detail.
|
||||||
struct CompactRootView: View {
|
struct CompactRootView: View {
|
||||||
@EnvironmentObject var store: RemoteStore
|
@EnvironmentObject var store: RemoteStore
|
||||||
|
@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 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
|
||||||
|
/// (`openSessionID` is set by `SessionDetailView`'s appear/disappear, our animation trigger).
|
||||||
|
private var showsTabBar: Bool { store.openSessionID == nil }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TabView(selection: $tab) {
|
TabView(selection: $tab) {
|
||||||
HomeView()
|
HomeView().tag(0).toolbar(.hidden, for: .tabBar)
|
||||||
.tabItem { Label("Home", systemImage: "house") }.tag(0)
|
SessionsView().tag(1).toolbar(.hidden, for: .tabBar)
|
||||||
SessionsView()
|
ProjectsView().tag(2).toolbar(.hidden, for: .tabBar)
|
||||||
.tabItem { Label("Sessions", systemImage: "square.stack.3d.up") }
|
TodosView().tag(3).toolbar(.hidden, for: .tabBar)
|
||||||
.badge(store.needsYouCount).tag(1)
|
SettingsView().tag(4).toolbar(.hidden, for: .tabBar)
|
||||||
ProjectsView()
|
|
||||||
.tabItem { Label("Projects", systemImage: "folder") }.tag(2)
|
|
||||||
TodosView()
|
|
||||||
.tabItem { Label("To-dos", systemImage: "checklist") }.tag(3)
|
|
||||||
SettingsView()
|
|
||||||
.tabItem { Label("Settings", systemImage: "gearshape") }.tag(4)
|
|
||||||
}
|
}
|
||||||
|
// Our bar sits in a bottom inset so top-level content (and each tab's floating "+") lays
|
||||||
|
// out above it; a pushed session sees the inset collapse and runs its transcript full-bleed
|
||||||
|
// to the bottom. Both the slide and that reflow ride the one spring below.
|
||||||
|
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||||
|
if showsTabBar {
|
||||||
|
NucleicTabBar(selection: $tab, needsYou: store.needsYouCount)
|
||||||
|
.transition(.move(edge: .bottom))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(reduceMotion ? nil : .spring(response: 0.34, dampingFraction: 0.92),
|
||||||
|
value: showsTabBar)
|
||||||
// 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 }
|
||||||
@@ -56,6 +74,73 @@ struct CompactRootView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Our own bottom tab bar for the compact shell — a plain `.bar`-material strip of five items,
|
||||||
|
/// standing in for the system tab bar so `CompactRootView` can animate its visibility itself
|
||||||
|
/// (see that view for why). The material bleeds into the home-indicator area; the items stay in
|
||||||
|
/// the safe region. The Sessions item carries the "needs you" badge the system tab item used to.
|
||||||
|
struct NucleicTabBar: View {
|
||||||
|
@Binding var selection: Int
|
||||||
|
/// Sessions blocking on a human — the red badge on the Sessions item (was the tab item badge).
|
||||||
|
var needsYou: Int
|
||||||
|
|
||||||
|
private struct Item { let index: Int; let title: String; let icon: String }
|
||||||
|
private let items = [
|
||||||
|
Item(index: 0, title: "Home", icon: "house"),
|
||||||
|
Item(index: 1, title: "Sessions", icon: "square.stack.3d.up"),
|
||||||
|
Item(index: 2, title: "Projects", icon: "folder"),
|
||||||
|
Item(index: 3, title: "To-dos", icon: "checklist"),
|
||||||
|
Item(index: 4, title: "Settings", icon: "gearshape"),
|
||||||
|
]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
ForEach(items, id: \.index) { item in
|
||||||
|
itemButton(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
.padding(.bottom, 2)
|
||||||
|
// A hairline top separator, like the system bar's edge over scrolling content.
|
||||||
|
.overlay(alignment: .top) {
|
||||||
|
Rectangle().fill(Color.primary.opacity(0.10)).frame(height: 0.5)
|
||||||
|
}
|
||||||
|
// `.bar` is the material the system uses for tab/tool bars; bleed it into the bottom
|
||||||
|
// unsafe area so it reaches the screen edge under the home indicator.
|
||||||
|
.background(.bar, ignoresSafeAreaEdges: .bottom)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func itemButton(_ item: Item) -> some View {
|
||||||
|
let selected = selection == item.index
|
||||||
|
return Button {
|
||||||
|
selection = item.index
|
||||||
|
} label: {
|
||||||
|
VStack(spacing: 3) {
|
||||||
|
Image(systemName: item.icon)
|
||||||
|
.font(.system(size: 22, weight: selected ? .semibold : .regular))
|
||||||
|
.frame(height: 26)
|
||||||
|
.overlay(alignment: .topTrailing) {
|
||||||
|
if item.index == 1, needsYou > 0 {
|
||||||
|
Text(needsYou > 99 ? "99+" : "\(needsYou)")
|
||||||
|
.font(.system(size: 11, weight: .bold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 5).padding(.vertical, 1)
|
||||||
|
.background(Palette.attention, in: Capsule())
|
||||||
|
.offset(x: 14, y: -6)
|
||||||
|
.fixedSize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(item.title).font(.system(size: 10, weight: .medium))
|
||||||
|
}
|
||||||
|
.foregroundStyle(selected ? Palette.accent : Color.secondary)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel(item.title)
|
||||||
|
.accessibilityAddTraits(selected ? [.isButton, .isSelected] : .isButton)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Regular (iPad sidebar + detail)
|
// MARK: - Regular (iPad sidebar + detail)
|
||||||
|
|
||||||
/// What the sidebar can select. Mirrors the Mac sidebar: a few global destinations plus the
|
/// What the sidebar can select. Mirrors the Mac sidebar: a few global destinations plus the
|
||||||
|
|||||||
Reference in New Issue
Block a user