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 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 {
NavigationStack {
VStack(spacing: 0) {
// Sub-tab selector
HStack(spacing: 0) {
ForEach(Array(subTabs.enumerated()), id: \.offset) { index, tab in
Button {
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
}
// Entry list as the main content scrolls under the glass nav bar
EntryListView(vm: vm, isCardView: isCardView, isAutoScrolling: $isAutoScrolling, scrollSpeed: scrollSpeed)
.navigationTitle("Reader")
.navigationSubtitle(subtitleText)
.toolbar {
if isAutoScrolling {
// Auto-scroll mode: speed controls in toolbar
ToolbarItemGroup(placement: .topBarLeading) {
Button {
scrollSpeed = max(0.25, scrollSpeed - 0.25)
} label: {
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("\(counters.totalUnread)")
.font(.caption2.weight(.bold))
.foregroundStyle(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.accentWarm)
.clipShape(Capsule())
}
Text(String(format: "%.2fx", scrollSpeed))
.font(.body.weight(.bold).monospacedDigit())
.foregroundStyle(Color.textPrimary)
Button {
scrollSpeed = min(3.0, scrollSpeed + 0.25)
} label: {
Image(systemName: "plus")
}
.foregroundStyle(selectedSubTab == index ? Color.accentWarm : Color.textSecondary)
.padding(.vertical, 10)
.padding(.horizontal, 16)
.background {
if selectedSubTab == index {
Capsule()
.fill(Color.accentWarm.opacity(0.12))
}
} else {
// Normal mode: sub-tabs + controls
ToolbarItemGroup(placement: .topBarLeading) {
ForEach(Array(subTabs.enumerated()), id: \.offset) { index, tab in
Button {
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)
.padding(.top, 8)
// 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)
.sheet(isPresented: $showFeedSheet) {
AddFeedSheet(vm: vm)
}
.frame(height: 44)
// 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: $showFeedManagement) {
FeedManagementSheet(vm: vm)
}
}
.sheet(isPresented: $showFeedSheet) {
AddFeedSheet(vm: vm)
}
.sheet(isPresented: $showFeedManagement) {
FeedManagementSheet(vm: vm)
}
}
.onAppear {
ArticleRenderer.shared.reWarmIfNeeded()