I'm working on an app where I have one or more featured, full screen views presented before the rest of the "explore" content (think home page on Spotify).
I have the following structure at the moment:
GeometryReader { proxy in
let h = proxy.size.height
ScrollView {
if data == nil {
Recommended(event: nil)
.padding(.vertical, 8)
.frame(height: h)
.frame(maxWidth: .infinity)
} else {
LazyVStack(spacing: proxy.safeAreaInsets.bottom / 2) {
ForEach(data?.recommended ?? []) { _ in
Recommended(event: events[0])
.padding(.vertical, 8)
.frame(height: h)
.frame(maxWidth: .infinity)
.padding(.bottom, proxy.safeAreaInsets.bottom / 2)
}
}
.scrollTargetLayout()
}
if let sections = data?.sections {
LazyVStack(spacing: 32) {
ForEach(Array(sections.enumerated()), id: \.offset) { e in
let section = e.element
EventRow(title: section.title, events: section.events)
}
}
.scrollTargetLayout()
}
Spacer()
}
// .scrollPosition(id: $scrollID) this was an attempt
// .scrollTargetBehavior(.custom) this was another attempt
.scrollDisabled(data == nil)
.scrollIndicators(.hidden)
.ignoresSafeArea(.container)
.refreshable {
print("refresh")
}
.onChange(of: scrollID) { _, newValue in
print(newValue ?? "")
}
}
I attempted to switch the paging style based off the scrollID
but got the following after writing a ternary
.scrollTargetBehavior(true ? .paging : .viewAligned)
// Member 'viewAligned' in 'PagingScrollTargetBehavior' produces result of type 'ViewAlignedScrollTargetBehavior', but context expects 'PagingScrollTargetBehavior'
I was also looking into a custom implementation of scroll target behavior but my experimentation yielded few results.
Minimal Reproducible Example
struct ContentView: View {
let colors: [Color] = [.blue, .green, .yellow, .red]
var body: some View {
GeometryReader { proxy in
ScrollView(.vertical) {
LazyVStack(spacing: proxy.safeAreaInsets.bottom / 2) {
ForEach(0 ..< 2) { i in
ZStack {
Rectangle()
.fill(colors[i % colors.count].opacity(0.6))
.containerRelativeFrame([.horizontal, .vertical])
.padding(.bottom, proxy.safeAreaInsets.bottom / 2)
.frame(
width: proxy.size.width,
height: proxy.size.height + (proxy.safeAreaInsets.bottom / 2)
)
Text("Video \(i + 1)")
.font(.title)
.bold()
}
}
}
.scrollTargetLayout()
// these elements should scroll normally without snapping
ForEach(0 ..< 20) { i in
Rectangle()
.fill(.purple)
.frame(width: 100, height: 100)
.overlay {
Text("\(i)")
}
}
}
.scrollTargetBehavior(.paging)
.ignoresSafeArea(edges: .bottom)
}
}
}
#Preview {
ContentView()
}
You can write your own ScrollTargetBehaviour
. Its updateTarget
will check where the scroll view would naturally scroll to, and if it is still in the "paging area", delegate to PagingScrollTargetBehavior
. Otherwise it would do nothing, i.e. scroll naturally.
struct PartlyPagingBehaviour: ScrollTargetBehavior {
let pageCount: Int
func updateTarget(_ target: inout ScrollTarget, context: TargetContext) {
let targetDimension = context.axes == .vertical ? target.rect.maxY : target.rect.maxX
let containerDimension = context.axes == .vertical ? context.containerSize.height : context.containerSize.width
if targetDimension < containerDimension * CGFloat(pageCount) {
PagingScrollTargetBehavior.paging.updateTarget(&target, context: context)
}
}
}
This implementation uses the natural behaviour if any part of the non-paging area is visible, by checking target.rect.maxY
. You can check target.rect.minY
instead, if you want the paging area to be entirely invisible before the natural behaviour starts.
For the example in the MRE, you can use
.scrollTargetBehavior(PartlyPagingBehaviour(pageCount: 2))
That said, the snapping to each page is a bit slower than the actual .paging
behaviour. I assume PagingScrollTargetBehavior
is a special case that SwiftUI checks for, and handles it a bit differently.