feat: Liquid Glass control bar for Reader (replaces hidden toolbar)
All checks were successful
Security Checks / dependency-audit (push) Successful in 14s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 3s

Floating glass pill above the tab bar with .ultraThinMaterial:

Idle state: [ ▶ ] [ grid/list ] [ ⋯ ]
- Play: starts auto-scroll
- Grid/list: toggles card/list view
- Ellipsis menu: mark all read, refresh, manage feeds, add feed

Playing state: [ - ] 1.00x [ + ] | [ ■ ]
- Speed adjustment in 0.25 increments (0.25x–3.0x)
- Stop button (red)
- Animated spring transition between states

Removed .navigationBarHidden(true) toolbar items — all controls
now in the glass bar. Nav bar stays hidden (no title needed).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-04 07:12:42 -05:00
parent 85c3bb7a42
commit 1b23525493

View File

@@ -11,131 +11,185 @@ struct ReaderTabView: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
VStack(spacing: 0) { ZStack(alignment: .bottom) {
// Sub-tab selector ALWAYS rendered (prevents layout jitter) VStack(spacing: 0) {
HStack(spacing: 0) { // Sub-tab selector
ForEach(Array(subTabs.enumerated()), id: \.offset) { index, tab in HStack(spacing: 0) {
Button { ForEach(Array(subTabs.enumerated()), id: \.offset) { index, tab in
withAnimation(.easeInOut(duration: 0.2)) { Button {
selectedSubTab = index withAnimation(.easeInOut(duration: 0.2)) {
switch index { selectedSubTab = index
case 0: vm.applyFilter(.unread) switch index {
case 1: vm.applyFilter(.starred) case 0: vm.applyFilter(.unread)
case 2: vm.applyFilter(.all) case 1: vm.applyFilter(.starred)
default: break case 2: vm.applyFilter(.all)
default: break
}
} }
} } label: {
} label: { HStack(spacing: 4) {
HStack(spacing: 4) { Text(tab)
Text(tab) .font(.subheadline.weight(selectedSubTab == index ? .semibold : .regular))
.font(.subheadline.weight(selectedSubTab == index ? .semibold : .regular))
if index == 0, let counters = vm.counters, counters.totalUnread > 0 { if index == 0, let counters = vm.counters, counters.totalUnread > 0 {
Text("\(counters.totalUnread)") Text("\(counters.totalUnread)")
.font(.caption2.weight(.bold)) .font(.caption2.weight(.bold))
.foregroundStyle(.white) .foregroundStyle(.white)
.padding(.horizontal, 6) .padding(.horizontal, 6)
.padding(.vertical, 2) .padding(.vertical, 2)
.background(Color.accentWarm) .background(Color.accentWarm)
.clipShape(Capsule()) .clipShape(Capsule())
}
} }
} .foregroundStyle(selectedSubTab == index ? Color.accentWarm : Color.textSecondary)
.foregroundStyle(selectedSubTab == index ? Color.accentWarm : Color.textSecondary) .padding(.vertical, 10)
.padding(.vertical, 10) .padding(.horizontal, 16)
.padding(.horizontal, 16) .background {
.background { if selectedSubTab == index {
if selectedSubTab == index { Capsule()
Capsule() .fill(Color.accentWarm.opacity(0.12))
.fill(Color.accentWarm.opacity(0.12)) }
} }
} }
} }
} }
}
.padding(.horizontal)
.padding(.top, 8)
// Feed filter bar ALWAYS rendered, empty during load (stable height)
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(.horizontal)
.padding(.vertical, 8) .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)
}
.frame(height: 44)
// Entry list
EntryListView(vm: vm, isCardView: isCardView, isAutoScrolling: $isAutoScrolling, scrollSpeed: scrollSpeed)
} }
.frame(height: 44) // Fixed height prevents layout shift .frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.canvas)
// Entry list // MARK: - Liquid Glass Control Bar
EntryListView(vm: vm, isCardView: isCardView, isAutoScrolling: $isAutoScrolling, scrollSpeed: scrollSpeed) glassControlBar
.padding(.bottom, 2)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.canvas)
.navigationBarHidden(true) .navigationBarHidden(true)
.toolbar { .sheet(isPresented: $showFeedSheet) {
ToolbarItem(placement: .topBarTrailing) { AddFeedSheet(vm: vm)
// Auto-scroll controls }
HStack(spacing: 8) { .sheet(isPresented: $showFeedManagement) {
if isAutoScrolling { FeedManagementSheet(vm: vm)
Button { }
scrollSpeed = max(0.25, scrollSpeed - 0.25) }
} label: { .onAppear {
Image(systemName: "minus") ArticleRenderer.shared.reWarmIfNeeded()
.font(.caption2.weight(.bold)) }
.foregroundStyle(Color.accentWarm) .onChange(of: selectedSubTab) { _, _ in
} isAutoScrolling = false
}
.onChange(of: vm.currentFilter) { _, _ in
isAutoScrolling = false
}
}
Text(String(format: "%.2fx", scrollSpeed)) // MARK: - Glass Control Bar
.font(.caption.weight(.semibold).monospacedDigit())
.foregroundStyle(Color.textPrimary)
.frame(width: 42)
Button { private var glassControlBar: some View {
scrollSpeed = min(3.0, scrollSpeed + 0.25) HStack(spacing: 0) {
} label: { if isAutoScrolling {
Image(systemName: "plus") // Expanded: speed controls
.font(.caption2.weight(.bold)) HStack(spacing: 12) {
.foregroundStyle(Color.accentWarm) Button {
} scrollSpeed = max(0.25, scrollSpeed - 0.25)
} } label: {
Image(systemName: "minus")
Button { .font(.caption.weight(.bold))
isAutoScrolling.toggle() .foregroundStyle(Color.accentWarm)
} label: { .frame(width: 32, height: 32)
Image(systemName: isAutoScrolling ? "stop.fill" : "play.fill") }
.foregroundStyle(isAutoScrolling ? .red : Color.accentWarm)
Text(String(format: "%.2fx", scrollSpeed))
.font(.caption.weight(.bold).monospacedDigit())
.foregroundStyle(Color.textPrimary)
.frame(width: 44)
Button {
scrollSpeed = min(3.0, scrollSpeed + 0.25)
} label: {
Image(systemName: "plus")
.font(.caption.weight(.bold))
.foregroundStyle(Color.accentWarm)
.frame(width: 32, height: 32)
}
Divider()
.frame(height: 20)
Button {
withAnimation(.spring(duration: 0.3)) {
isAutoScrolling = false
} }
} label: {
Image(systemName: "stop.fill")
.font(.caption)
.foregroundStyle(.red)
.frame(width: 32, height: 32)
} }
} }
ToolbarItem(placement: .topBarTrailing) { .transition(.asymmetric(
insertion: .scale(scale: 0.8).combined(with: .opacity),
removal: .scale(scale: 0.8).combined(with: .opacity)
))
} else {
// Collapsed: action buttons
HStack(spacing: 4) {
// Auto-scroll play
Button {
withAnimation(.spring(duration: 0.3)) {
isAutoScrolling = true
}
} label: {
Image(systemName: "play.fill")
.font(.caption)
.foregroundStyle(Color.accentWarm)
.frame(width: 36, height: 36)
}
// Card/list toggle
Button { Button {
withAnimation(.easeInOut(duration: 0.2)) { withAnimation(.easeInOut(duration: 0.2)) {
isCardView.toggle() isCardView.toggle()
} }
} label: { } label: {
Image(systemName: isCardView ? "list.bullet" : "square.grid.2x2") Image(systemName: isCardView ? "list.bullet" : "square.grid.2x2")
.font(.caption)
.foregroundStyle(Color.accentWarm) .foregroundStyle(Color.accentWarm)
.frame(width: 36, height: 36)
} }
}
ToolbarItem(placement: .topBarTrailing) { // More menu
Menu { Menu {
Button { Button {
Task { await vm.markAllRead() } Task { await vm.markAllRead() }
@@ -163,29 +217,27 @@ struct ReaderTabView: View {
Label("Add Feed", systemImage: "plus") Label("Add Feed", systemImage: "plus")
} }
} label: { } label: {
Image(systemName: "ellipsis.circle") Image(systemName: "ellipsis")
.font(.caption)
.foregroundStyle(Color.accentWarm) .foregroundStyle(Color.accentWarm)
.frame(width: 36, height: 36)
} }
} }
} .transition(.asymmetric(
.sheet(isPresented: $showFeedSheet) { insertion: .scale(scale: 0.8).combined(with: .opacity),
AddFeedSheet(vm: vm) removal: .scale(scale: 0.8).combined(with: .opacity)
} ))
.sheet(isPresented: $showFeedManagement) {
FeedManagementSheet(vm: vm)
} }
} }
.onAppear { .padding(.horizontal, 8)
ArticleRenderer.shared.reWarmIfNeeded() .padding(.vertical, 4)
} .background(.ultraThinMaterial, in: Capsule())
.onChange(of: selectedSubTab) { _, _ in .shadow(color: .black.opacity(0.1), radius: 8, y: 2)
isAutoScrolling = false .animation(.spring(duration: 0.3), value: isAutoScrolling)
}
.onChange(of: vm.currentFilter) { _, _ in
isAutoScrolling = false
}
} }
// MARK: - Helpers
private var subTabs: [String] { ["Unread", "Starred", "All"] } private var subTabs: [String] { ["Unread", "Starred", "All"] }
private var isAllSelected: Bool { private var isAllSelected: Bool {