Skip to content

Commit a3a8447

Browse files
illixionclaude
andcommitted
Restyle tab bar ornament with Liquid Glass and crossfade tabs
Briefly tried switching the main tab bar to a native visionOS TabView, but it had two unfixable issues for our use case: tab changes triggered a window-wide scale animation, and the leading tab bar ornament was occluded by protruding diorama thumbnail foreground layers (the diorama parallax requires +z, and a `.cornerRadius().clipped()` thumbnail can't be shifted back without the flat layer getting culled). Sticking with a custom ornament instead, but refreshed to look closer to native: - Liquid Glass bar (`.glassBackgroundEffect()`) anchored at `.bottomFront` with extra top padding so it sits clear of any forward-popped diorama - Selected tab renders as a `.thinMaterial` capsule pill with a hairline border and inline label, replacing the flat blue square - `.hoverEffect(.highlight)` on every tab so gaze targeting is legible for inactive controls, not just the selected one - `.help(tab.rawValue)` adds a hover tooltip per tab - Tab content crossfades via `.id(selectedTab)` + opacity transition instead of hard-cutting Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 56535b6 commit a3a8447

2 files changed

Lines changed: 90 additions & 47 deletions

File tree

SpatialStash/SpatialStash/Views/ContentView.swift

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,24 +13,29 @@ struct ContentView: View {
1313
@State private var windowModel = MainWindowModel()
1414

1515
var body: some View {
16-
Group {
17-
switch windowModel.selectedTab {
18-
case .pictures:
19-
PicturesTabView()
20-
case .videos:
21-
VideosTabView()
22-
case .local:
23-
LocalTabView()
24-
case .filters:
25-
FiltersTabView()
26-
case .settings:
27-
SettingsTabView()
28-
case .remote:
29-
RemoteTabView()
30-
case .console:
31-
DebugConsoleView()
16+
ZStack {
17+
Group {
18+
switch windowModel.selectedTab {
19+
case .pictures:
20+
PicturesTabView()
21+
case .videos:
22+
VideosTabView()
23+
case .local:
24+
LocalTabView()
25+
case .filters:
26+
FiltersTabView()
27+
case .settings:
28+
SettingsTabView()
29+
case .remote:
30+
RemoteTabView()
31+
case .console:
32+
DebugConsoleView()
33+
}
3234
}
35+
.id(windowModel.selectedTab)
36+
.transition(.opacity)
3337
}
38+
.animation(.smooth(duration: 0.25), value: windowModel.selectedTab)
3439
.environment(appModel)
3540
.environment(windowModel)
3641
.ornament(

SpatialStash/SpatialStash/Views/TabBarOrnament.swift

Lines changed: 69 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -25,41 +25,79 @@ struct TabBarOrnament: View {
2525
}
2626

2727
var body: some View {
28-
HStack(spacing: 24) {
28+
HStack(spacing: 8) {
2929
ForEach(visibleTabs) { tab in
30-
Button {
31-
// Track last content tab for filter context
32-
if tab == .pictures || tab == .videos {
33-
windowModel.lastContentTab = tab
34-
}
30+
TabBarButton(
31+
tab: tab,
32+
isSelected: windowModel.selectedTab == tab,
33+
action: { select(tab) }
34+
)
35+
}
36+
}
37+
.padding(.horizontal, 12)
38+
.padding(.vertical, 8)
39+
.glassBackgroundEffect()
40+
// Sit below the window's bottom edge so a protruding diorama
41+
// foreground layer doesn't visually clip the tab bar.
42+
.padding(.top, 20)
43+
}
44+
45+
private func select(_ tab: Tab) {
46+
if tab == .pictures || tab == .videos {
47+
windowModel.lastContentTab = tab
48+
}
49+
if tab == .local && windowModel.selectedTab == .local {
50+
windowModel.localTabReselected += 1
51+
} else {
52+
windowModel.selectedTab = tab
53+
}
54+
}
55+
}
3556

36-
// Special handling for Local tab re-selection
37-
if tab == .local && windowModel.selectedTab == .local {
38-
windowModel.localTabReselected += 1
39-
} else {
40-
windowModel.selectedTab = tab
41-
}
42-
} label: {
43-
VStack(spacing: 4) {
44-
Image(systemName: tab.systemImage)
45-
.font(.title2)
46-
Text(tab.rawValue)
47-
.font(.caption)
48-
}
49-
.foregroundColor(windowModel.selectedTab == tab ? .white : .secondary)
50-
.padding(.horizontal, 16)
51-
.padding(.vertical, 8)
52-
.background(
53-
windowModel.selectedTab == tab
54-
? Color.accentColor.opacity(0.3)
55-
: Color.clear
56-
)
57-
.cornerRadius(8)
57+
private struct TabBarButton: View {
58+
let tab: Tab
59+
let isSelected: Bool
60+
let action: () -> Void
61+
62+
var body: some View {
63+
Button(action: action) {
64+
HStack(spacing: 8) {
65+
Image(systemName: tab.systemImage)
66+
.font(.title3)
67+
if isSelected {
68+
Text(tab.rawValue)
69+
.font(.callout)
70+
.fontWeight(.medium)
71+
.transition(.opacity)
5872
}
59-
.buttonStyle(.plain)
6073
}
74+
.frame(minWidth: 44, minHeight: 32)
75+
.padding(.horizontal, isSelected ? 14 : 10)
76+
.padding(.vertical, 8)
77+
.contentShape(Capsule())
6178
}
62-
.padding()
63-
.glassBackgroundEffect()
79+
.buttonStyle(TabBarButtonStyle(isSelected: isSelected))
80+
.hoverEffect(.highlight)
81+
.help(tab.rawValue)
82+
.animation(.smooth(duration: 0.22), value: isSelected)
83+
}
84+
}
85+
86+
private struct TabBarButtonStyle: ButtonStyle {
87+
let isSelected: Bool
88+
89+
func makeBody(configuration: Configuration) -> some View {
90+
configuration.label
91+
.foregroundStyle(isSelected ? .primary : .secondary)
92+
.background {
93+
if isSelected {
94+
Capsule()
95+
.fill(.thinMaterial)
96+
.overlay(
97+
Capsule()
98+
.strokeBorder(.white.opacity(0.18), lineWidth: 0.5)
99+
)
100+
}
101+
}
64102
}
65103
}

0 commit comments

Comments
 (0)