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:
parent
2834102d45
commit
6addacb1d9
@ -38,19 +38,13 @@ struct BookmarkDetailView: View {
|
|||||||
ProgressView(value: readingProgress)
|
ProgressView(value: readingProgress)
|
||||||
.progressViewStyle(LinearProgressViewStyle())
|
.progressViewStyle(LinearProgressViewStyle())
|
||||||
.frame(height: 3)
|
.frame(height: 3)
|
||||||
GeometryReader { outerGeo in
|
GeometryReader { geometry in
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 0) {
|
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) {
|
ZStack(alignment: .top) {
|
||||||
headerView(geometry: outerGeo)
|
headerView(width: geometry.size.width)
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
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
|
titleSection
|
||||||
Divider().padding(.horizontal)
|
Divider().padding(.horizontal)
|
||||||
if showJumpToProgressButton {
|
if showJumpToProgressButton {
|
||||||
@ -94,22 +88,40 @@ struct BookmarkDetailView: View {
|
|||||||
.frame(maxWidth: .infinity)
|
.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)
|
.ignoresSafeArea(edges: .top)
|
||||||
.scrollPosition($scrollPosition)
|
.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)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
@ -183,22 +195,15 @@ struct BookmarkDetailView: View {
|
|||||||
// MARK: - ViewBuilder
|
// MARK: - ViewBuilder
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func headerView(geometry: GeometryProxy) -> some View {
|
private func headerView(width: CGFloat) -> some View {
|
||||||
if !viewModel.bookmarkDetail.imageUrl.isEmpty {
|
if !viewModel.bookmarkDetail.imageUrl.isEmpty {
|
||||||
GeometryReader { geo in
|
ZStack(alignment: .bottomTrailing) {
|
||||||
let offset = geo.frame(in: .global).minY
|
|
||||||
ZStack(alignment: .top) {
|
|
||||||
CachedAsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl))
|
CachedAsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl))
|
||||||
.aspectRatio(contentMode: .fill)
|
.aspectRatio(contentMode: .fill)
|
||||||
.frame(width: geometry.size.width, height: headerHeight + (offset > 0 ? offset : 0))
|
.frame(width: width, height: headerHeight)
|
||||||
.clipped()
|
.clipped()
|
||||||
.offset(y: (offset > 0 ? -offset : 0))
|
|
||||||
|
|
||||||
// Tap area and zoom icon
|
// Zoom icon
|
||||||
VStack {
|
|
||||||
Spacer()
|
|
||||||
HStack {
|
|
||||||
Spacer()
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
showingImageViewer = true
|
showingImageViewer = true
|
||||||
}) {
|
}) {
|
||||||
@ -218,11 +223,6 @@ struct BookmarkDetailView: View {
|
|||||||
.padding(.trailing, 16)
|
.padding(.trailing, 16)
|
||||||
.padding(.bottom, 16)
|
.padding(.bottom, 16)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.frame(height: headerHeight + (offset > 0 ? offset : 0))
|
|
||||||
.offset(y: (offset > 0 ? -offset : 0))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(height: headerHeight)
|
.frame(height: headerHeight)
|
||||||
.ignoresSafeArea(edges: .top)
|
.ignoresSafeArea(edges: .top)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
@ -252,9 +252,10 @@ struct BookmarkDetailView: View {
|
|||||||
webViewHeight = height
|
webViewHeight = height
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
.frame(height: webViewHeight)
|
.frame(height: webViewHeight)
|
||||||
.cornerRadius(14)
|
.cornerRadius(14)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal, 4)
|
||||||
.animation(.easeInOut, value: webViewHeight)
|
.animation(.easeInOut, value: webViewHeight)
|
||||||
} else if viewModel.isLoadingArticle {
|
} else if viewModel.isLoadingArticle {
|
||||||
ProgressView("Loading article...")
|
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 {
|
#Preview {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
BookmarkDetailView(bookmarkId: "123",
|
BookmarkDetailView(bookmarkId: "123",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user