perf: Optimize scroll tracking with PreferenceKey instead of onScrollGeometryChange

- Implemented ScrollOffsetPreferenceKey for performance-optimized scroll tracking
- Added invisible GeometryReader at top of ScrollView to track offset
- Replaced onScrollGeometryChange + onScrollPhaseChange with onPreferenceChange
- Removed currentScrollOffset and scrollViewHeight state variables
- Continuous tracking with 3% threshold check in onPreferenceChange
- Updated JumpButton to receive containerHeight as parameter

PreferenceKey approach is more performant than onScrollGeometryChange:
- Single preference update instead of multiple geometry changes
- Direct access to scroll coordinate space
- Simpler state management with lastSentProgress threshold
This commit is contained in:
Ilyas Hallak 2025-10-10 15:49:48 +02:00
parent a782a27eea
commit e9195351aa

View File

@ -1,6 +1,14 @@
import SwiftUI import SwiftUI
import SafariServices import SafariServices
// PreferenceKey for scroll offset tracking
struct ScrollOffsetPreferenceKey: PreferenceKey {
static var defaultValue: CGPoint = .zero
static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {
value = nextValue()
}
}
struct BookmarkDetailLegacyView: View { struct BookmarkDetailLegacyView: View {
let bookmarkId: String let bookmarkId: String
@Binding var useNativeWebView: Bool @Binding var useNativeWebView: Bool
@ -12,8 +20,7 @@ struct BookmarkDetailLegacyView: View {
@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
@State private var scrollViewHeight: CGFloat = 1 @State private var lastSentProgress: Double = 0.0
@State private var currentScrollOffset: CGFloat = 0
@State private var showJumpToProgressButton: Bool = false @State private var showJumpToProgressButton: Bool = false
@State private var scrollPosition = ScrollPosition(edge: .top) @State private var scrollPosition = ScrollPosition(edge: .top)
@State private var showingImageViewer = false @State private var showingImageViewer = false
@ -39,6 +46,18 @@ struct BookmarkDetailLegacyView: View {
.frame(height: 3) .frame(height: 3)
GeometryReader { geometry in GeometryReader { geometry in
ScrollView { ScrollView {
// Invisible GeometryReader to track scroll offset
GeometryReader { scrollGeo in
Color.clear.preference(
key: ScrollOffsetPreferenceKey.self,
value: CGPoint(
x: scrollGeo.frame(in: .named("scrollView")).minX,
y: scrollGeo.frame(in: .named("scrollView")).minY
)
)
}
.frame(height: 0)
VStack(spacing: 0) { VStack(spacing: 0) {
ZStack(alignment: .top) { ZStack(alignment: .top) {
headerView(width: geometry.size.width) headerView(width: geometry.size.width)
@ -47,7 +66,7 @@ struct BookmarkDetailLegacyView: View {
titleSection titleSection
Divider().padding(.horizontal) Divider().padding(.horizontal)
if showJumpToProgressButton { if showJumpToProgressButton {
JumpButton() JumpButton(containerHeight: geometry.size.height)
} }
if let settings = viewModel.settings, !viewModel.articleContent.isEmpty { if let settings = viewModel.settings, !viewModel.articleContent.isEmpty {
WebView( WebView(
@ -95,36 +114,30 @@ struct BookmarkDetailLegacyView: View {
} }
} }
} }
.coordinateSpace(name: "scrollView")
.ignoresSafeArea(edges: .top) .ignoresSafeArea(edges: .top)
.scrollPosition($scrollPosition) .scrollPosition($scrollPosition)
.onScrollGeometryChange(for: CGFloat.self) { geo in .onPreferenceChange(ScrollOffsetPreferenceKey.self) { offset in
geo.contentOffset.y // Calculate progress from scroll offset
} action: { oldValue, newValue in let scrollOffset = -offset.y // Negative because scroll goes down
currentScrollOffset = newValue let containerHeight = geometry.size.height
} let maxOffset = webViewHeight - containerHeight
.onScrollGeometryChange(for: CGFloat.self) { geo in
geo.containerSize.height guard maxOffset > 0 else { return }
} action: { oldValue, newValue in
scrollViewHeight = newValue let rawProgress = scrollOffset / maxOffset
}
.onScrollPhaseChange { oldPhase, newPhase in
// Only calculate when scrolling ends (interacting -> idle)
if oldPhase == .interacting && newPhase == .idle {
let offset = currentScrollOffset
let maxOffset = webViewHeight - geometry.size.height
let rawProgress = offset / (maxOffset > 0 ? maxOffset : 1)
let progress = min(max(rawProgress, 0), 1) let progress = min(max(rawProgress, 0), 1)
// Only update if change >= 3% // Only update if change >= 3%
let threshold: Double = 0.03 let threshold: Double = 0.03
if abs(progress - readingProgress) >= threshold { if abs(progress - lastSentProgress) >= threshold {
lastSentProgress = progress
readingProgress = progress readingProgress = progress
viewModel.debouncedUpdateReadProgress(id: bookmarkId, progress: progress, anchor: nil) viewModel.debouncedUpdateReadProgress(id: bookmarkId, progress: progress, anchor: nil)
} }
} }
} }
} }
}
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
@ -449,9 +462,9 @@ struct BookmarkDetailLegacyView: View {
} }
@ViewBuilder @ViewBuilder
func JumpButton() -> some View { func JumpButton(containerHeight: CGFloat) -> some View {
Button(action: { Button(action: {
let maxOffset = webViewHeight - scrollViewHeight let maxOffset = webViewHeight - containerHeight
let offset = maxOffset * (Double(viewModel.readProgress) / 100.0) let offset = maxOffset * (Double(viewModel.readProgress) / 100.0)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
scrollPosition = ScrollPosition(y: offset) scrollPosition = ScrollPosition(y: offset)