perf: Optimize ScrollView performance with onScrollGeometryChange

- Replace PreferenceKey-based scroll tracking with onScrollGeometryChange API
- Remove ScrollOffsetPreferenceKey struct (no longer needed)
- Add 50px threshold for scroll offset updates to reduce processing frequency
- Add 5% threshold for readingProgress state updates to minimize view refreshes
- Simplify header view by removing parallax effect and nested GeometryReader
- Keep single GeometryReader only for container dimensions (width/height)
- Fix WebView width constraints with explicit frame settings

This significantly improves scroll performance by reducing unnecessary
calculations and state updates during scrolling.
This commit is contained in:
Ilyas Hallak 2025-10-09 19:22:49 +02:00
parent 2834102d45
commit 6addacb1d9

View File

@ -38,19 +38,13 @@ struct BookmarkDetailView: View {
ProgressView(value: readingProgress)
.progressViewStyle(LinearProgressViewStyle())
.frame(height: 3)
GeometryReader { outerGeo in
GeometryReader { geometry in
ScrollView {
VStack(spacing: 0) {
GeometryReader { geo in
Color.clear
.preference(key: ScrollOffsetPreferenceKey.self,
value: geo.frame(in: .named("scroll")).minY)
}
.frame(height: 0)
ZStack(alignment: .top) {
headerView(geometry: outerGeo)
headerView(width: geometry.size.width)
VStack(alignment: .leading, spacing: 16) {
Color.clear.frame(height: viewModel.bookmarkDetail.imageUrl.isEmpty ? 84 : headerHeight)
Color.clear.frame(width: geometry.size.width, height: viewModel.bookmarkDetail.imageUrl.isEmpty ? 84 : headerHeight)
titleSection
Divider().padding(.horizontal)
if showJumpToProgressButton {
@ -94,22 +88,40 @@ struct BookmarkDetailView: View {
.frame(maxWidth: .infinity)
}
}
.frame(maxWidth: .infinity)
}
}
}
.coordinateSpace(name: "scroll")
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { offset in
scrollViewHeight = outerGeo.size.height
let maxOffset = webViewHeight - scrollViewHeight
let rawProgress = -offset / (maxOffset != 0 ? maxOffset : 1)
let progress = min(max(rawProgress, 0), 1)
readingProgress = progress
viewModel.debouncedUpdateReadProgress(id: bookmarkId, progress: progress, anchor: nil)
}
.ignoresSafeArea(edges: .top)
.scrollPosition($scrollPosition)
.onScrollGeometryChange(for: CGFloat.self) { geo in
geo.contentOffset.y
} action: { oldValue, newValue in
// Early exit: only process if scroll changed significantly (> 50px)
guard abs(newValue - oldValue) > 50 else { return }
let offset = newValue
let maxOffset = webViewHeight - geometry.size.height
let rawProgress = offset / (maxOffset > 0 ? maxOffset : 1)
let progress = min(max(rawProgress, 0), 1)
// Only update if change is significant (> 5%) to avoid lag
let threshold: Double = 0.05
if abs(progress - readingProgress) > threshold {
readingProgress = progress
// Always update backend (debounced internally)
viewModel.debouncedUpdateReadProgress(id: bookmarkId, progress: progress, anchor: nil)
}
}
.onScrollGeometryChange(for: CGFloat.self) { geo in
geo.containerSize.height
} action: { oldValue, newValue in
scrollViewHeight = newValue
}
}
}
.frame(maxWidth: .infinity)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
@ -183,22 +195,15 @@ struct BookmarkDetailView: View {
// MARK: - ViewBuilder
@ViewBuilder
private func headerView(geometry: GeometryProxy) -> some View {
private func headerView(width: CGFloat) -> some View {
if !viewModel.bookmarkDetail.imageUrl.isEmpty {
GeometryReader { geo in
let offset = geo.frame(in: .global).minY
ZStack(alignment: .top) {
ZStack(alignment: .bottomTrailing) {
CachedAsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl))
.aspectRatio(contentMode: .fill)
.frame(width: geometry.size.width, height: headerHeight + (offset > 0 ? offset : 0))
.frame(width: width, height: headerHeight)
.clipped()
.offset(y: (offset > 0 ? -offset : 0))
// Tap area and zoom icon
VStack {
Spacer()
HStack {
Spacer()
// Zoom icon
Button(action: {
showingImageViewer = true
}) {
@ -218,11 +223,6 @@ struct BookmarkDetailView: View {
.padding(.trailing, 16)
.padding(.bottom, 16)
}
}
.frame(height: headerHeight + (offset > 0 ? offset : 0))
.offset(y: (offset > 0 ? -offset : 0))
}
}
.frame(height: headerHeight)
.ignoresSafeArea(edges: .top)
.onTapGesture {
@ -252,9 +252,10 @@ struct BookmarkDetailView: View {
webViewHeight = height
}
}
.frame(maxWidth: .infinity)
.frame(height: webViewHeight)
.cornerRadius(14)
.padding(.horizontal)
.padding(.horizontal, 4)
.animation(.easeInOut, value: webViewHeight)
} else if viewModel.isLoadingArticle {
ProgressView("Loading article...")
@ -451,14 +452,6 @@ struct BookmarkDetailView: View {
}
}
struct ScrollOffsetPreferenceKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}
#Preview {
NavigationView {
BookmarkDetailView(bookmarkId: "123",