longform: add reading progress bar

Display a thin purple progress bar at top of longform articles (kind 30023)
that tracks scroll position through the content. Uses top/bottom GeometryReader
trackers to measure content bounds and calculates progress linearly.

Closes: https://github.com/damus-io/damus/issues/3494
Changelog-Added: Added reading progress bar for longform articles
Signed-off-by: alltheseas <alltheseas@users.noreply.github.com>
This commit is contained in:
alltheseas
2026-01-04 18:27:37 -06:00
committed by Daniel D’Aquino
parent 767b318763
commit 0233f2ae48
2 changed files with 123 additions and 0 deletions

View File

@@ -23,9 +23,45 @@ struct ChatroomThreadView: View {
@State var showStickyHeader: Bool = false @State var showStickyHeader: Bool = false
@State var untrustedSectionOffset: CGFloat = 0 @State var untrustedSectionOffset: CGFloat = 0
// Add state for reading progress (longform articles)
@State private var readingProgress: CGFloat = 0
@State private var viewportHeight: CGFloat = 0
@State private var contentTopY: CGFloat = 0
@State private var contentBottomY: CGFloat = 0
@State private var initialTopY: CGFloat? = nil
private static let untrusted_network_section_id = "untrusted-network-section" private static let untrusted_network_section_id = "untrusted-network-section"
private static let sticky_header_adjusted_anchor = UnitPoint(x: UnitPoint.top.x, y: 0.2) private static let sticky_header_adjusted_anchor = UnitPoint(x: UnitPoint.top.x, y: 0.2)
/// Returns true if the selected event is a longform article (kind 30023).
var isLongformEvent: Bool {
thread.selected_event.kind == 30023
}
/// Updates reading progress based on scroll position.
private func updateReadingProgress() {
guard thread.selected_event.kind == 30023 else { return }
guard viewportHeight > 0 else { return }
// Capture initial position on first update
if initialTopY == nil {
initialTopY = contentTopY
}
guard let startY = initialTopY else { return }
// Content height is constant (bottom - top in global coords)
let contentHeight = contentBottomY - contentTopY
guard contentHeight > 0 else { return }
// How much we've scrolled from initial position
// As we scroll down, contentTopY decreases, so scrolled = startY - currentTopY
let scrolled = startY - contentTopY
let maxScroll = max(contentHeight - viewportHeight, 1)
let progress = scrolled / maxScroll
readingProgress = min(max(progress, 0), 1)
}
func go_to_event(scroller: ScrollViewProxy, note_id: NoteId) { func go_to_event(scroller: ScrollViewProxy, note_id: NoteId) {
let adjustedAnchor: UnitPoint = showStickyHeader ? ChatroomThreadView.sticky_header_adjusted_anchor : .top let adjustedAnchor: UnitPoint = showStickyHeader ? ChatroomThreadView.sticky_header_adjusted_anchor : .top
@@ -115,6 +151,20 @@ struct ChatroomThreadView: View {
ZStack(alignment: .top) { ZStack(alignment: .top) {
ScrollView(.vertical) { ScrollView(.vertical) {
VStack(spacing: 0) {
// Top scroll position tracker
GeometryReader { geo in
Color.clear
.onChange(of: geo.frame(in: .global).minY) { newY in
contentTopY = newY
updateReadingProgress()
}
.onAppear {
contentTopY = geo.frame(in: .global).minY
}
}
.frame(height: 1)
LazyVStack(alignment: .leading, spacing: 8) { LazyVStack(alignment: .leading, spacing: 8) {
// MARK: - Parents events view // MARK: - Parents events view
ForEach(thread.parent_events, id: \.id) { parent_event in ForEach(thread.parent_events, id: \.id) { parent_event in
@@ -221,11 +271,37 @@ struct ChatroomThreadView: View {
} }
} }
// Bottom scroll position tracker - placed before EndBlock so we measure article content, not padding
GeometryReader { geo in
Color.clear
.onChange(of: geo.frame(in: .global).minY) { newY in
contentBottomY = newY
updateReadingProgress()
}
.onAppear {
contentBottomY = geo.frame(in: .global).minY
}
}
.frame(height: 1)
EndBlock() EndBlock()
HStack {} HStack {}
.frame(height: tabHeight + getSafeAreaBottom()) .frame(height: tabHeight + getSafeAreaBottom())
} // End VStack wrapper
} }
.background(
GeometryReader { geo in
Color.clear
.onAppear {
viewportHeight = geo.size.height
}
.onChange(of: geo.size.height) { newHeight in
viewportHeight = newHeight
updateReadingProgress()
}
}
)
if showStickyHeader && !untrusted_events.isEmpty { if showStickyHeader && !untrusted_events.isEmpty {
VStack { VStack {
@@ -240,6 +316,15 @@ struct ChatroomThreadView: View {
.transition(.move(edge: .top).combined(with: .opacity)) .transition(.move(edge: .top).combined(with: .opacity))
.zIndex(1) .zIndex(1)
} }
// Reading progress bar - show for longform articles
if thread.selected_event.kind == 30023 {
VStack(spacing: 0) {
ReadingProgressBar(progress: readingProgress)
Spacer()
}
.zIndex(100)
}
} }
.onReceive(handle_notify(.post), perform: { notify in .onReceive(handle_notify(.post), perform: { notify in
switch notify { switch notify {
@@ -285,3 +370,4 @@ struct ChatroomView_Previews: PreviewProvider {
} }
} }
} }

View File

@@ -0,0 +1,37 @@
//
// ReadingProgressBar.swift
// damus
//
// Created by Claude on 2026-01-03.
//
import SwiftUI
/// A thin progress bar that indicates reading progress through longform content.
struct ReadingProgressBar: View {
/// Reading progress from 0.0 to 1.0.
let progress: CGFloat
var body: some View {
GeometryReader { geometry in
Rectangle()
.fill(DamusColors.purple)
.frame(width: geometry.size.width * min(max(progress, 0), 1))
}
.frame(height: 4)
.background(Color.gray.opacity(0.3))
}
}
struct ReadingProgressBar_Previews: PreviewProvider {
static var previews: some View {
VStack(spacing: 20) {
ReadingProgressBar(progress: 0)
ReadingProgressBar(progress: 0.25)
ReadingProgressBar(progress: 0.5)
ReadingProgressBar(progress: 0.75)
ReadingProgressBar(progress: 1.0)
}
.padding()
}
}