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:
committed by
Daniel D’Aquino
parent
767b318763
commit
0233f2ae48
@@ -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 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
37
damus/Features/Longform/Views/ReadingProgressBar.swift
Normal file
37
damus/Features/Longform/Views/ReadingProgressBar.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user