Skip to content

Instantly share code, notes, and snippets.

@cwalo
Created October 8, 2024 23:59
Show Gist options
  • Save cwalo/46833f5e93f51cb23ad1f8cae88893c7 to your computer and use it in GitHub Desktop.
Save cwalo/46833f5e93f51cb23ad1f8cae88893c7 to your computer and use it in GitHub Desktop.
A SwiftUI ScrollView wrapper allowing tracking and setting the scroll position (< iOS 17)
import Foundation
import SwiftUI
struct ScrollViewTracking: Equatable {
/// A stable identifier to name the view's coordinate space
let namespace: AnyHashable = UUID()
/// The current position of the scroll view
var position: CGPoint = .zero
/// The scroll view's content size
var contentSize: CGSize = .zero
/// The size of the scroll view's container
var containerSize: CGSize = .zero
}
/// A ScrollView wrapper that allows for tracking and setting the scroll view's position
struct PositionTrackingScrollView<Content: View>: View {
let axes: Axis.Set
let showsIndicators: Bool
let maintainScrollPosition: Bool
@Binding var tracking: ScrollViewTracking
@ViewBuilder var content: (ScrollViewProxy) -> Content
/// An identifier applied to an invisible view used to scroll the content
private let scrollOffsetContentId: AnyHashable = UUID()
/// Internal state that is written back to ScrollViewTracking binding
@State private var scrollPosition: CGPoint = .zero
@State private var containerSize: CGSize = .zero
@State private var contentSize: CGSize = .zero
init(_ axes: Axis.Set = .vertical,
showsIndicators: Bool = true,
maintainScrollPosition: Bool = true,
tracking: Binding<ScrollViewTracking>,
content: @escaping (ScrollViewProxy) -> Content)
{
self.axes = axes
self.showsIndicators = showsIndicators
self.maintainScrollPosition = maintainScrollPosition
self.content = content
_tracking = tracking
}
var body: some View {
ScrollViewReader { proxy in
ScrollView(axes, showsIndicators: showsIndicators) {
// zero-height spacer used to update the content offset
if axes == .horizontal {
Spacer()
.frame(maxWidth: .infinity)
.frame(height: .leastNonzeroMagnitude)
.id(scrollOffsetContentId)
} else {
Spacer()
.frame(maxHeight: .infinity)
.frame(width: .leastNonzeroMagnitude)
.id(scrollOffsetContentId)
}
content(proxy)
.onSizeChanged(in: tracking.namespace) { size in
contentSize = size
}
.onScrollPositionChanged(in: tracking.namespace) { position in
scrollPosition = position
}
.onAppear {
scrollIfNeeded(with: proxy)
}
.onDisappear {
tracking.position = scrollPosition
tracking.containerSize = containerSize
tracking.contentSize = contentSize
}
}
}
.coordinateSpace(name: tracking.namespace)
.onSizeChanged(in: tracking.namespace) { size in
containerSize = size
}
}
private func scrollIfNeeded(with proxy: ScrollViewProxy) {
let position = tracking.position
guard maintainScrollPosition, position != scrollPosition, position != .zero else { return }
let contentSize = tracking.contentSize
let containerSize = tracking.containerSize
let scrollWidth = contentSize.width - containerSize.width
let scrollHeight = contentSize.height - containerSize.height
// Create a UnitPoint with normalized x/y values 0-1
let anchorX = abs(position.x / scrollWidth)
let anchorY = abs(position.y / scrollHeight)
guard anchorX <= 1, anchorY <= 1 else { return }
let anchor = UnitPoint(x: anchorX, y: anchorY)
// Scroll to the anchor point
proxy.scrollTo(scrollOffsetContentId, anchor: anchor)
}
}
public extension View {
/// Track the size of modified View for the given namespace
func onSizeChanged(in namespace: AnyHashable, perform: @escaping (CGSize) -> Void) -> some View {
background(
Color.clear
.onGeometryChange(for: CGSize.self) { geometry in
geometry.frame(in: .named(namespace)).size
} action: { value in
perform(value)
}
)
}
/// Track the scroll position of the modified View for the given namespace
///
/// Apply to the content contained by a ScrollView to track its origin offset
func onScrollPositionChanged(in namespace: AnyHashable, perform: @escaping (CGPoint) -> Void) -> some View {
background(
Color.clear
.onGeometryChange(for: CGPoint.self) { geometry in
geometry.frame(in: .named(namespace)).origin
} action: { value in
perform(value)
}
)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment