feat: Liquid Glass control bar for Reader (replaces hidden toolbar)
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:
@@ -11,8 +11,9 @@ struct ReaderTabView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
|
ZStack(alignment: .bottom) {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Sub-tab selector — ALWAYS rendered (prevents layout jitter)
|
// Sub-tab selector
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
ForEach(Array(subTabs.enumerated()), id: \.offset) { index, tab in
|
ForEach(Array(subTabs.enumerated()), id: \.offset) { index, tab in
|
||||||
Button {
|
Button {
|
||||||
@@ -55,7 +56,7 @@ struct ReaderTabView: View {
|
|||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
|
|
||||||
// Feed filter bar — ALWAYS rendered, empty during load (stable height)
|
// Feed filter bar
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
feedFilterChip("All", isSelected: isAllSelected) {
|
feedFilterChip("All", isSelected: isAllSelected) {
|
||||||
@@ -82,60 +83,113 @@ struct ReaderTabView: View {
|
|||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
}
|
}
|
||||||
.frame(height: 44) // Fixed height prevents layout shift
|
.frame(height: 44)
|
||||||
|
|
||||||
// Entry list
|
// Entry list
|
||||||
EntryListView(vm: vm, isCardView: isCardView, isAutoScrolling: $isAutoScrolling, scrollSpeed: scrollSpeed)
|
EntryListView(vm: vm, isCardView: isCardView, isAutoScrolling: $isAutoScrolling, scrollSpeed: scrollSpeed)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background(Color.canvas)
|
.background(Color.canvas)
|
||||||
|
|
||||||
|
// MARK: - Liquid Glass Control Bar
|
||||||
|
glassControlBar
|
||||||
|
.padding(.bottom, 2)
|
||||||
|
}
|
||||||
.navigationBarHidden(true)
|
.navigationBarHidden(true)
|
||||||
.toolbar {
|
.sheet(isPresented: $showFeedSheet) {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
AddFeedSheet(vm: vm)
|
||||||
// Auto-scroll controls
|
}
|
||||||
HStack(spacing: 8) {
|
.sheet(isPresented: $showFeedManagement) {
|
||||||
|
FeedManagementSheet(vm: vm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
ArticleRenderer.shared.reWarmIfNeeded()
|
||||||
|
}
|
||||||
|
.onChange(of: selectedSubTab) { _, _ in
|
||||||
|
isAutoScrolling = false
|
||||||
|
}
|
||||||
|
.onChange(of: vm.currentFilter) { _, _ in
|
||||||
|
isAutoScrolling = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Glass Control Bar
|
||||||
|
|
||||||
|
private var glassControlBar: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
if isAutoScrolling {
|
if isAutoScrolling {
|
||||||
|
// Expanded: speed controls
|
||||||
|
HStack(spacing: 12) {
|
||||||
Button {
|
Button {
|
||||||
scrollSpeed = max(0.25, scrollSpeed - 0.25)
|
scrollSpeed = max(0.25, scrollSpeed - 0.25)
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "minus")
|
Image(systemName: "minus")
|
||||||
.font(.caption2.weight(.bold))
|
.font(.caption.weight(.bold))
|
||||||
.foregroundStyle(Color.accentWarm)
|
.foregroundStyle(Color.accentWarm)
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(String(format: "%.2fx", scrollSpeed))
|
Text(String(format: "%.2fx", scrollSpeed))
|
||||||
.font(.caption.weight(.semibold).monospacedDigit())
|
.font(.caption.weight(.bold).monospacedDigit())
|
||||||
.foregroundStyle(Color.textPrimary)
|
.foregroundStyle(Color.textPrimary)
|
||||||
.frame(width: 42)
|
.frame(width: 44)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
scrollSpeed = min(3.0, scrollSpeed + 0.25)
|
scrollSpeed = min(3.0, scrollSpeed + 0.25)
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "plus")
|
Image(systemName: "plus")
|
||||||
.font(.caption2.weight(.bold))
|
.font(.caption.weight(.bold))
|
||||||
.foregroundStyle(Color.accentWarm)
|
.foregroundStyle(Color.accentWarm)
|
||||||
}
|
.frame(width: 32, height: 32)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.frame(height: 20)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
isAutoScrolling.toggle()
|
withAnimation(.spring(duration: 0.3)) {
|
||||||
|
isAutoScrolling = false
|
||||||
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: isAutoScrolling ? "stop.fill" : "play.fill")
|
Image(systemName: "stop.fill")
|
||||||
.foregroundStyle(isAutoScrolling ? .red : Color.accentWarm)
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.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
|
||||||
}
|
}
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
} 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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user