feat: Implement performant scroll progress tracking with PreferenceKey

Replace onScrollGeometryChange/onScrollPhaseChange with ContentHeightPreferenceKey
approach for improved scroll performance and accurate read progress tracking.

Changes:
- Add ScrollOffsetPreferenceKey and ContentHeightPreferenceKey for scroll tracking
- Track content end position dynamically as WebView loads
- Calculate progress from scroll offset relative to total scrollable content
- Implement 3% threshold for progress updates to reduce API calls
- Add progress locking at 100% to prevent pixel-variation regressions
- Guarantee 100% update when reaching end of content
- Apply to both BookmarkDetailLegacyView and BookmarkDetailView2

Technical approach:
- Place invisible marker at end of content to measure position in scrollView
- Update initialContentEndPosition as content grows during WebView loading
- Progress = (initialPosition - currentPosition) / (initialPosition - containerHeight)
- Lock progress once 100% reached to avoid 100% -> 99% fluctuations
This commit is contained in:
Ilyas Hallak 2025-10-12 20:56:59 +02:00
parent bef6a9dc2f
commit a5d94d1aee
2 changed files with 102 additions and 42 deletions

View File

@ -25,7 +25,8 @@ struct BookmarkDetailLegacyView: View {
@State private var viewModel: BookmarkDetailViewModel @State private var viewModel: BookmarkDetailViewModel
@State private var webViewHeight: CGFloat = 300 @State private var webViewHeight: CGFloat = 300
@State private var contentHeight: CGFloat = 0 @State private var contentEndPosition: CGFloat = 0
@State private var initialContentEndPosition: CGFloat = 0
@State private var showingFontSettings = false @State private var showingFontSettings = false
@State private var showingLabelsSheet = false @State private var showingLabelsSheet = false
@State private var readingProgress: Double = 0.0 @State private var readingProgress: Double = 0.0
@ -121,42 +122,76 @@ struct BookmarkDetailLegacyView: View {
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
}
// Invisible marker to measure total content height - placed AFTER all content
Color.clear
.frame(height: 1)
.background( .background(
GeometryReader { contentGeo in GeometryReader { endGeo in
Color.clear.preference( Color.clear.preference(
key: ContentHeightPreferenceKey.self, key: ContentHeightPreferenceKey.self,
value: contentGeo.size.height value: endGeo.frame(in: .named("scrollView")).maxY
) )
} }
) )
} }
}
.coordinateSpace(name: "scrollView") .coordinateSpace(name: "scrollView")
.clipped() .clipped()
.ignoresSafeArea(edges: .top) .ignoresSafeArea(edges: .top)
.scrollPosition($scrollPosition) .scrollPosition($scrollPosition)
.onPreferenceChange(ContentHeightPreferenceKey.self) { height in .onPreferenceChange(ContentHeightPreferenceKey.self) { endPosition in
contentHeight = height contentEndPosition = endPosition
}
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { offset in
// Calculate progress from scroll offset
let scrollOffset = -offset.y // Negative because scroll goes down
let containerHeight = geometry.size.height let containerHeight = geometry.size.height
let maxOffset = contentHeight - containerHeight
guard maxOffset > 0 else { return } // Update initial position if content grows (WebView still loading) or first time
// We always take the maximum position seen (when scrolled to top, this is total content height)
if endPosition > initialContentEndPosition && endPosition > containerHeight * 1.2 {
initialContentEndPosition = endPosition
print("📏 Content end position updated: \(Int(endPosition)) (container: \(Int(containerHeight)))")
}
let rawProgress = scrollOffset / maxOffset // Calculate progress from how much the end marker has moved up
let progress = min(max(rawProgress, 0), 1) guard initialContentEndPosition > 0 else {
print("⏳ Waiting for content to load... current: \(Int(endPosition)), container: \(Int(containerHeight))")
return
}
// Only update if change >= 3% let totalScrollableDistance = initialContentEndPosition - containerHeight
guard totalScrollableDistance > 0 else {
print("⚠️ Content not scrollable: initial=\(initialContentEndPosition), container=\(containerHeight)")
return
}
// How far has the marker moved from its initial position?
let scrolled = initialContentEndPosition - endPosition
let rawProgress = scrolled / totalScrollableDistance
var progress = min(max(rawProgress, 0), 1)
// Lock progress at 100% once reached (don't go back to 99% due to pixel variations)
if lastSentProgress >= 0.995 {
progress = max(progress, 1.0)
}
print("📊 Progress: \(Int(progress * 100))% | scrolled: \(Int(scrolled)) / \(Int(totalScrollableDistance)) | endPos: \(Int(endPosition))")
// Check if we should update: threshold OR reaching 100% for first time
let threshold: Double = 0.03 let threshold: Double = 0.03
if abs(progress - lastSentProgress) >= threshold { let reachedEnd = progress >= 1.0 && lastSentProgress < 1.0
let shouldUpdate = abs(progress - lastSentProgress) >= threshold || reachedEnd
if shouldUpdate {
print("✅ Updating progress: \(Int(lastSentProgress * 100))% → \(Int(progress * 100))%\(reachedEnd ? " [END]" : "")")
lastSentProgress = progress lastSentProgress = progress
readingProgress = progress readingProgress = progress
viewModel.debouncedUpdateReadProgress(id: bookmarkId, progress: progress, anchor: nil) viewModel.debouncedUpdateReadProgress(id: bookmarkId, progress: progress, anchor: nil)
} }
} }
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { _ in
// Not needed anymore, we track via ContentHeightPreferenceKey
}
} }
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)

View File

@ -10,7 +10,8 @@ struct BookmarkDetailView2: View {
@State private var viewModel: BookmarkDetailViewModel @State private var viewModel: BookmarkDetailViewModel
@State private var webViewHeight: CGFloat = 300 @State private var webViewHeight: CGFloat = 300
@State private var contentHeight: CGFloat = 0 @State private var contentEndPosition: CGFloat = 0
@State private var initialContentEndPosition: CGFloat = 0
@State private var showingFontSettings = false @State private var showingFontSettings = false
@State private var showingLabelsSheet = false @State private var showingLabelsSheet = false
@State private var readingProgress: Double = 0.0 @State private var readingProgress: Double = 0.0
@ -120,42 +121,66 @@ struct BookmarkDetailView2: View {
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
}
// Invisible marker to measure total content height - placed AFTER all content
Color.clear
.frame(height: 1)
.background( .background(
GeometryReader { contentGeo in GeometryReader { endGeo in
Color.clear.preference( Color.clear.preference(
key: ContentHeightPreferenceKey.self, key: ContentHeightPreferenceKey.self,
value: contentGeo.size.height value: endGeo.frame(in: .named("scrollView")).maxY
) )
} }
) )
} }
}
.coordinateSpace(name: "scrollView") .coordinateSpace(name: "scrollView")
.clipped() .clipped()
.ignoresSafeArea(edges: .top) .ignoresSafeArea(edges: .top)
.scrollPosition($scrollPosition) .scrollPosition($scrollPosition)
.onPreferenceChange(ContentHeightPreferenceKey.self) { height in .onPreferenceChange(ContentHeightPreferenceKey.self) { endPosition in
contentHeight = height contentEndPosition = endPosition
}
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { offset in
// Calculate progress from scroll offset
let scrollOffset = -offset.y
let containerHeight = geometry.size.height let containerHeight = geometry.size.height
let maxOffset = contentHeight - containerHeight
guard maxOffset > 0 else { return } // Update initial position if content grows (WebView still loading) or first time
// We always take the maximum position seen (when scrolled to top, this is total content height)
if endPosition > initialContentEndPosition && endPosition > containerHeight * 1.2 {
initialContentEndPosition = endPosition
}
let rawProgress = scrollOffset / maxOffset // Calculate progress from how much the end marker has moved up
let progress = min(max(rawProgress, 0), 1) guard initialContentEndPosition > 0 else { return }
// Only update if change >= 3% let totalScrollableDistance = initialContentEndPosition - containerHeight
guard totalScrollableDistance > 0 else { return }
// How far has the marker moved from its initial position?
let scrolled = initialContentEndPosition - endPosition
let rawProgress = scrolled / totalScrollableDistance
var progress = min(max(rawProgress, 0), 1)
// Lock progress at 100% once reached (don't go back to 99% due to pixel variations)
if lastSentProgress >= 0.995 {
progress = max(progress, 1.0)
}
// Check if we should update: threshold OR reaching 100% for first time
let threshold: Double = 0.03 let threshold: Double = 0.03
if abs(progress - lastSentProgress) >= threshold { let reachedEnd = progress >= 1.0 && lastSentProgress < 1.0
let shouldUpdate = abs(progress - lastSentProgress) >= threshold || reachedEnd
if shouldUpdate {
lastSentProgress = progress lastSentProgress = progress
readingProgress = progress readingProgress = progress
viewModel.debouncedUpdateReadProgress(id: bookmarkId, progress: progress, anchor: nil) viewModel.debouncedUpdateReadProgress(id: bookmarkId, progress: progress, anchor: nil)
} }
} }
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { _ in
// Not needed anymore, we track via ContentHeightPreferenceKey
}
} }
} }