feat: Liquid Glass navigation bar for Reader (iOS 26 standard APIs)
All checks were successful
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 4s

Removed .navigationBarHidden(true) and all custom header layout.
Now uses standard iOS 26 navigation APIs that get Liquid Glass free:

- .navigationTitle("Reader") + .navigationSubtitle("74 unread")
- ToolbarItemGroup for sub-tabs (Unread/Starred/All) on leading
- ToolbarSpacer between groups
- ToolbarItemGroup for grid/list + menu on trailing
- Feed filter chips in .bottomBar toolbar
- When auto-scrolling: toolbar swaps to speed controls

The glass nav bar is translucent — content scrolls underneath.
Collapses to inline glass pill on scroll (system behavior).
No custom backgrounds, no custom layout — all system-managed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-04 10:03:58 -05:00
parent 61cd78e080
commit d75fb870d7

View File

@@ -9,169 +9,138 @@ struct ReaderTabView: View {
@State private var showFeedManagement = false @State private var showFeedManagement = false
@State private var isCardView = true @State private var isCardView = true
private var subtitleText: String {
if let counters = vm.counters, counters.totalUnread > 0 {
return "\(counters.totalUnread) unread"
}
return "All caught up"
}
var body: some View { var body: some View {
NavigationStack { NavigationStack {
VStack(spacing: 0) { // Entry list as the main content scrolls under the glass nav bar
// Sub-tab selector EntryListView(vm: vm, isCardView: isCardView, isAutoScrolling: $isAutoScrolling, scrollSpeed: scrollSpeed)
HStack(spacing: 0) { .navigationTitle("Reader")
ForEach(Array(subTabs.enumerated()), id: \.offset) { index, tab in .navigationSubtitle(subtitleText)
Button { .toolbar {
withAnimation(.easeInOut(duration: 0.2)) { if isAutoScrolling {
selectedSubTab = index // Auto-scroll mode: speed controls in toolbar
switch index { ToolbarItemGroup(placement: .topBarLeading) {
case 0: vm.applyFilter(.unread) Button {
case 1: vm.applyFilter(.starred) scrollSpeed = max(0.25, scrollSpeed - 0.25)
case 2: vm.applyFilter(.all) } label: {
default: break Image(systemName: "minus")
}
} }
} label: {
HStack(spacing: 4) {
Text(tab)
.font(.subheadline.weight(selectedSubTab == index ? .semibold : .regular))
if index == 0, let counters = vm.counters, counters.totalUnread > 0 { Text(String(format: "%.2fx", scrollSpeed))
Text("\(counters.totalUnread)") .font(.body.weight(.bold).monospacedDigit())
.font(.caption2.weight(.bold)) .foregroundStyle(Color.textPrimary)
.foregroundStyle(.white)
.padding(.horizontal, 6) Button {
.padding(.vertical, 2) scrollSpeed = min(3.0, scrollSpeed + 0.25)
.background(Color.accentWarm) } label: {
.clipShape(Capsule()) Image(systemName: "plus")
}
} }
.foregroundStyle(selectedSubTab == index ? Color.accentWarm : Color.textSecondary) }
.padding(.vertical, 10) } else {
.padding(.horizontal, 16) // Normal mode: sub-tabs + controls
.background { ToolbarItemGroup(placement: .topBarLeading) {
if selectedSubTab == index { ForEach(Array(subTabs.enumerated()), id: \.offset) { index, tab in
Capsule() Button {
.fill(Color.accentWarm.opacity(0.12)) withAnimation(.easeInOut(duration: 0.2)) {
selectedSubTab = index
switch index {
case 0: vm.applyFilter(.unread)
case 1: vm.applyFilter(.starred)
case 2: vm.applyFilter(.all)
default: break
}
}
} label: {
Text(tab)
.fontWeight(selectedSubTab == index ? .semibold : .regular)
}
.tint(selectedSubTab == index ? Color.accentWarm : Color.textSecondary)
}
}
ToolbarSpacer(.fixed, placement: .topBarTrailing)
ToolbarItemGroup(placement: .topBarTrailing) {
Button {
withAnimation(.easeInOut(duration: 0.2)) {
isCardView.toggle()
}
} label: {
Image(systemName: isCardView ? "list.bullet" : "square.grid.2x2")
}
Menu {
Button {
Task { await vm.markAllRead() }
} label: {
Label("Mark All Read", systemImage: "checkmark.circle")
}
Button {
Task { await vm.refresh() }
} label: {
Label("Refresh Feeds", systemImage: "arrow.clockwise")
}
Divider()
Button {
showFeedManagement = true
} label: {
Label("Manage Feeds", systemImage: "list.bullet")
}
Button {
showFeedSheet = true
} label: {
Label("Add Feed", systemImage: "plus")
}
} label: {
Image(systemName: "ellipsis")
}
}
}
// Feed filter chips bottom toolbar (scrolls with content)
ToolbarItem(placement: .bottomBar) {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
feedFilterChip("All", isSelected: isAllSelected) {
let tab = selectedSubTab
switch tab {
case 0: vm.applyFilter(.unread)
case 1: vm.applyFilter(.starred)
case 2: vm.applyFilter(.all)
default: vm.applyFilter(.unread)
}
}
ForEach(vm.feeds) { feed in
let count = vm.counters?.count(forFeed: feed.id) ?? 0
feedFilterChip(
feed.title,
count: selectedSubTab == 0 ? count : nil,
isSelected: vm.currentFilter == .feed(feed.id)
) {
vm.applyFilter(.feed(feed.id))
}
} }
} }
} }
} }
Spacer()
// Inline controls: grid/list + more menu
HStack(spacing: 4) {
Button {
withAnimation(.easeInOut(duration: 0.2)) {
isCardView.toggle()
}
} label: {
Image(systemName: isCardView ? "list.bullet" : "square.grid.2x2")
.font(.subheadline)
.foregroundStyle(Color.accentWarm)
.frame(width: 32, height: 32)
}
Menu {
Button {
Task { await vm.markAllRead() }
} label: {
Label("Mark All Read", systemImage: "checkmark.circle")
}
Button {
Task { await vm.refresh() }
} label: {
Label("Refresh Feeds", systemImage: "arrow.clockwise")
}
Divider()
Button {
showFeedManagement = true
} label: {
Label("Manage Feeds", systemImage: "list.bullet")
}
Button {
showFeedSheet = true
} label: {
Label("Add Feed", systemImage: "plus")
}
} label: {
Image(systemName: "ellipsis")
.font(.subheadline)
.foregroundStyle(Color.accentWarm)
.frame(width: 32, height: 32)
}
}
.padding(.trailing, 8)
} }
.padding(.leading) .sheet(isPresented: $showFeedSheet) {
.padding(.top, 8) AddFeedSheet(vm: vm)
// Feed filter bar
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
feedFilterChip("All", isSelected: isAllSelected) {
let tab = selectedSubTab
switch tab {
case 0: vm.applyFilter(.unread)
case 1: vm.applyFilter(.starred)
case 2: vm.applyFilter(.all)
default: vm.applyFilter(.unread)
}
}
ForEach(vm.feeds) { feed in
let count = vm.counters?.count(forFeed: feed.id) ?? 0
feedFilterChip(
feed.title,
count: selectedSubTab == 0 ? count : nil,
isSelected: vm.currentFilter == .feed(feed.id)
) {
vm.applyFilter(.feed(feed.id))
}
}
}
.padding(.horizontal)
.padding(.vertical, 8)
} }
.frame(height: 44) .sheet(isPresented: $showFeedManagement) {
FeedManagementSheet(vm: vm)
// Entry list
EntryListView(vm: vm, isCardView: isCardView, isAutoScrolling: $isAutoScrolling, scrollSpeed: scrollSpeed)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.canvas)
.navigationBarHidden(true)
.safeAreaBar(edge: .bottom) {
if isAutoScrolling {
HStack(spacing: 16) {
Button {
scrollSpeed = max(0.25, scrollSpeed - 0.25)
} label: {
Image(systemName: "minus")
.font(.caption.weight(.bold))
.foregroundStyle(Color.accentWarm)
}
Text(String(format: "%.2fx", scrollSpeed))
.font(.system(size: 13, weight: .bold, design: .monospaced))
.foregroundStyle(Color.textPrimary)
Button {
scrollSpeed = min(3.0, scrollSpeed + 0.25)
} label: {
Image(systemName: "plus")
.font(.caption.weight(.bold))
.foregroundStyle(Color.accentWarm)
}
}
.padding(.horizontal, 20)
} }
}
.sheet(isPresented: $showFeedSheet) {
AddFeedSheet(vm: vm)
}
.sheet(isPresented: $showFeedManagement) {
FeedManagementSheet(vm: vm)
}
} }
.onAppear { .onAppear {
ArticleRenderer.shared.reWarmIfNeeded() ArticleRenderer.shared.reWarmIfNeeded()