feat: auto-scroll play button in tab bar using Tab(role: .search)
All checks were successful
Security Checks / dockerfile-lint (push) Successful in 4s
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s

Migrated to new Tab API (iOS 18+). The auto-scroll play button
uses Tab(role: .search) with systemImage: "play.fill" — this gives
the separated circular placement on the trailing side of the tab
bar, identical to the Photos app search icon.

Tapping the play icon:
- Switches to Reader tab
- Starts auto-scroll

When playing: a Liquid Glass speed control capsule appears above
the tab bar with [ - ] 1.00x [ + ] [ stop ].

Removed the old floating glass pill implementation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-04 07:34:35 -05:00
parent 5d2262e17a
commit 8fadb3f3e9

View File

@@ -32,46 +32,53 @@ struct MainTabView: View {
@State private var scrollSpeed: Double = 1.0 @State private var scrollSpeed: Double = 1.0
private var showReader: Bool { private var showReader: Bool {
auth.currentUser?.id != 4 // Madiha doesn't see Reader auth.currentUser?.id != 4
} }
var body: some View { var body: some View {
ZStack { ZStack {
TabView(selection: $selectedTab) { TabView(selection: $selectedTab) {
Tab("Home", systemImage: "house.fill", value: 0) {
HomeView(selectedTab: $selectedTab) HomeView(selectedTab: $selectedTab)
.tabItem { Label("Home", systemImage: "house.fill") } }
.tag(0)
Tab("Fitness", systemImage: "flame.fill", value: 1) {
FitnessTabView() FitnessTabView()
.tabItem { Label("Fitness", systemImage: "flame.fill") } }
.tag(1)
if showReader { if showReader {
Tab("Reader", systemImage: "newspaper.fill", value: 2) {
ReaderTabView(vm: readerVM, isAutoScrolling: $isAutoScrolling, scrollSpeed: $scrollSpeed) ReaderTabView(vm: readerVM, isAutoScrolling: $isAutoScrolling, scrollSpeed: $scrollSpeed)
.tabItem { Label("Reader", systemImage: "newspaper.fill") } }
.tag(2)
// Auto-scroll button uses .search role for the separated
// circular placement on the trailing side of the tab bar
Tab("Auto", systemImage: "play.fill", value: 3, role: .search) {
// When tapped, toggle auto-scroll instead of showing search
Color.clear
.onAppear {
// Switch to Reader tab and start auto-scroll
selectedTab = 2
withAnimation(.spring(duration: 0.3)) {
isAutoScrolling = true
}
}
}
} }
} }
.tint(Color.accentWarm) .tint(Color.accentWarm)
.modifier(TabBarMinimizeModifier()) .modifier(TabBarMinimizeModifier())
// Floating buttons // Floating buttons (hidden on Reader tab)
if selectedTab != 2 {
VStack { VStack {
Spacer() Spacer()
HStack(alignment: .bottom) { HStack(alignment: .bottom) {
if selectedTab != 2 {
FeedbackButton() FeedbackButton()
.padding(.leading, 20) .padding(.leading, 20)
}
Spacer() Spacer()
if selectedTab == 2 {
// Reader: auto-scroll control at tab bar level
readerAutoScrollPill
.padding(.trailing, 8)
.padding(.bottom, 0)
} else {
Button { showAssistant = true } label: { Button { showAssistant = true } label: {
Image(systemName: "plus") Image(systemName: "plus")
.font(.title2.weight(.semibold)) .font(.title2.weight(.semibold))
@@ -83,10 +90,63 @@ struct MainTabView: View {
} }
.padding(.trailing, 20) .padding(.trailing, 20)
} }
}
.padding(.bottom, 70) .padding(.bottom, 70)
} }
} }
// Auto-scroll speed controls (overlay when playing)
if isAutoScrolling && selectedTab == 2 {
VStack {
Spacer()
HStack(spacing: 12) {
Button {
scrollSpeed = max(0.25, scrollSpeed - 0.25)
} label: {
Image(systemName: "minus")
.font(.caption.weight(.bold))
.foregroundStyle(Color.accentWarm)
.frame(width: 32, height: 32)
}
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)
.frame(width: 32, height: 32)
}
Divider()
.frame(height: 20)
Button {
withAnimation(.spring(duration: 0.3)) {
isAutoScrolling = false
}
} label: {
Image(systemName: "stop.fill")
.font(.system(size: 12))
.foregroundStyle(.white)
.frame(width: 34, height: 34)
.background(Color.red.opacity(0.8))
.clipShape(Circle())
}
}
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(.ultraThinMaterial, in: Capsule())
.shadow(color: .black.opacity(0.1), radius: 8, y: 2)
.padding(.bottom, 70)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
.animation(.spring(duration: 0.3), value: isAutoScrolling)
}
}
.confettiCannon( .confettiCannon(
trigger: $confettiTrigger, trigger: $confettiTrigger,
num: 80, num: 80,
@@ -104,8 +164,6 @@ struct MainTabView: View {
.task { .task {
guard showReader else { return } guard showReader else { return }
let renderer = ArticleRenderer.shared let renderer = ArticleRenderer.shared
// Window is now available attach WKWebView to force GPU
// compositor launch during startup, not first article tap
renderer.attachToWindow() renderer.attachToWindow()
await readerVM.loadInitial() await readerVM.loadInitial()
} }
@@ -117,71 +175,6 @@ struct MainTabView: View {
confettiTrigger += 1 confettiTrigger += 1
UINotificationFeedbackGenerator().notificationOccurred(.success) UINotificationFeedbackGenerator().notificationOccurred(.success)
} }
// MARK: - Reader Auto-Scroll Pill (tab bar level)
private var readerAutoScrollPill: some View {
Group {
if isAutoScrolling {
// Expanded: speed + stop
HStack(spacing: 8) {
Button {
scrollSpeed = max(0.25, scrollSpeed - 0.25)
} label: {
Image(systemName: "minus")
.font(.caption2.weight(.bold))
.foregroundStyle(Color.accentWarm)
.frame(width: 28, height: 28)
}
Text(String(format: "%.2fx", scrollSpeed))
.font(.system(size: 11, weight: .bold, design: .monospaced))
.foregroundStyle(Color.textPrimary)
Button {
scrollSpeed = min(3.0, scrollSpeed + 0.25)
} label: {
Image(systemName: "plus")
.font(.caption2.weight(.bold))
.foregroundStyle(Color.accentWarm)
.frame(width: 28, height: 28)
}
Button {
withAnimation(.spring(duration: 0.3)) {
isAutoScrolling = false
}
} label: {
Image(systemName: "stop.fill")
.font(.system(size: 11))
.foregroundStyle(.white)
.frame(width: 32, height: 32)
.background(Color.red.opacity(0.8))
.clipShape(Circle())
}
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(.ultraThinMaterial, in: Capsule())
.transition(.scale(scale: 0.5).combined(with: .opacity))
} else {
// Collapsed: play button circle (like Photos search icon)
Button {
withAnimation(.spring(duration: 0.3)) {
isAutoScrolling = true
}
} label: {
Image(systemName: "play.fill")
.font(.system(size: 15))
.foregroundStyle(Color.accentWarm)
.frame(width: 48, height: 48)
.background(.ultraThinMaterial, in: Circle())
}
.transition(.scale(scale: 0.5).combined(with: .opacity))
}
}
.animation(.spring(duration: 0.3), value: isAutoScrolling)
}
} }
// MARK: - Assistant Sheet // MARK: - Assistant Sheet