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,78 +38,90 @@ 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)
|
||||
titleSection
|
||||
Divider().padding(.horizontal)
|
||||
if showJumpToProgressButton {
|
||||
JumpButton()
|
||||
}
|
||||
if let settings = viewModel.settings, !viewModel.articleContent.isEmpty {
|
||||
WebView(htmlContent: viewModel.articleContent, settings: settings, onHeightChange: { height in
|
||||
if webViewHeight != height {
|
||||
webViewHeight = height
|
||||
}
|
||||
})
|
||||
.frame(height: webViewHeight)
|
||||
.cornerRadius(14)
|
||||
.padding(.horizontal, 4)
|
||||
} else if viewModel.isLoadingArticle {
|
||||
ProgressView("Loading article...")
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding()
|
||||
} else {
|
||||
Button(action: {
|
||||
URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener)
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "safari")
|
||||
Text((URLUtil.extractDomain(from: "open " + viewModel.bookmarkDetail.url) ?? "Open original page"))
|
||||
}
|
||||
.font(.title3.bold())
|
||||
.frame(maxWidth: .infinity)
|
||||
Color.clear.frame(width: geometry.size.width, height: viewModel.bookmarkDetail.imageUrl.isEmpty ? 84 : headerHeight)
|
||||
titleSection
|
||||
Divider().padding(.horizontal)
|
||||
if showJumpToProgressButton {
|
||||
JumpButton()
|
||||
}
|
||||
if let settings = viewModel.settings, !viewModel.articleContent.isEmpty {
|
||||
WebView(htmlContent: viewModel.articleContent, settings: settings, onHeightChange: { height in
|
||||
if webViewHeight != height {
|
||||
webViewHeight = height
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 0)
|
||||
}
|
||||
|
||||
if viewModel.isLoadingArticle == false && viewModel.isLoading == false {
|
||||
VStack(alignment: .center) {
|
||||
archiveSection
|
||||
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
||||
.animation(.easeInOut, value: viewModel.articleContent)
|
||||
})
|
||||
.frame(height: webViewHeight)
|
||||
.cornerRadius(14)
|
||||
.padding(.horizontal, 4)
|
||||
} else if viewModel.isLoadingArticle {
|
||||
ProgressView("Loading article...")
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding()
|
||||
} else {
|
||||
Button(action: {
|
||||
URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener)
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "safari")
|
||||
Text((URLUtil.extractDomain(from: "open " + viewModel.bookmarkDetail.url) ?? "Open original page"))
|
||||
}
|
||||
.font(.title3.bold())
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 0)
|
||||
}
|
||||
|
||||
if viewModel.isLoadingArticle == false && viewModel.isLoading == false {
|
||||
VStack(alignment: .center) {
|
||||
archiveSection
|
||||
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
||||
.animation(.easeInOut, value: viewModel.articleContent)
|
||||
}
|
||||
.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,45 +195,33 @@ 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) {
|
||||
CachedAsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl))
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: geometry.size.width, height: headerHeight + (offset > 0 ? offset : 0))
|
||||
.clipped()
|
||||
.offset(y: (offset > 0 ? -offset : 0))
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
CachedAsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl))
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: width, height: headerHeight)
|
||||
.clipped()
|
||||
|
||||
// Tap area and zoom icon
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(action: {
|
||||
showingImageViewer = true
|
||||
}) {
|
||||
Image(systemName: "arrow.up.left.and.arrow.down.right")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
.padding(8)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(Color.black.opacity(0.6))
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(Color.white.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
.padding(.trailing, 16)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
}
|
||||
.frame(height: headerHeight + (offset > 0 ? offset : 0))
|
||||
.offset(y: (offset > 0 ? -offset : 0))
|
||||
// Zoom icon
|
||||
Button(action: {
|
||||
showingImageViewer = true
|
||||
}) {
|
||||
Image(systemName: "arrow.up.left.and.arrow.down.right")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
.padding(8)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(Color.black.opacity(0.6))
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(Color.white.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
.padding(.trailing, 16)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
.frame(height: headerHeight)
|
||||
.ignoresSafeArea(edges: .top)
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user