feat: Liquid Glass navigation bar for Reader (iOS 26 standard APIs)
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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user