Using the brand new onScrollGeometryChange and onScrollVisibilityChange introduced in WWDC 2024!

In my previous article SwiftUI: Mastering List (Programmatically scroll, Set initial Visible Item, Check if an Item is reached), I have shared with you on how we can check if an Item is reached by using the onAppear modifier and load more items if we are at the top and bottom of a list.

I always feel like there should be a better and cleaner way of achieving this and here we go! Apple introduced us these two brand new modifiers, onScrollGeometryChange and onScrollVisibilityChange, in this year’s WWDC to give us a finer grained control over our ScrollView!

In this article, Let’s take a look at how we can use those to check item visibility within a Scrollview and load more items when we are at the top or bottom of it!

Unfortunately, those two modifiers are NOT as good as they sound. I will also share with you some of the buggy features and limitations I found while trying to get this thing working!

As a bonus, I will be using the new ScrollPosition type to manage our ScrollView position, so that we don’t have to embed our ScrollView within ScrollViewReader anymore to programmatically scroll to a position by calling proxy.scrollTo!

Set Up

We will be starting by creating a simple Identifiable ScrollViewItem struct so that we can use the ID for the ScrollView position.

private struct ScrollViewItem: Identifiable {
var id = UUID()
var row: Int
}

onScrollGeometryChange

Let’s first take a look at onScrollGeometryChange and see how far we can go with it! (By the way, not all the way, unfortunately!)

This modifier adds an action to be performed when a value, created from a scroll geometry, changes.

func onScrollGeometryChange<T>(
for type: T.Type,
of transform: @escaping (ScrollGeometry) -> T,
action: @escaping (T, T) -> Void
) -> some View where T : Equatable

There are three parameters here.

type: The type of value transformed from a ScrollGeometry. Normally a Bool, for example, indicating if we are at the top of our ScrollView.transform: A closure that transforms a ScrollGeometry to your type. We will be using it to find out whether if we are at the top by comparing geometry.contentOffset.y to geometry.contentInsets.top.action: A closure to run when the transformed data changes. In our case, if we are at top, we want to load more items.

Check Top

Let’s see it in the following example, where we will load more items if we are at the top of our ScrollView.

(Yes, iOS 18+ is required!)

import SwiftUI

@available(iOS 18.0, *)
struct NewScrollViewFeaturesDemo: View {
@State private var items = (0…30).map { ScrollViewItem(row: $0) }
@State private var position: ScrollPosition = .init(idType: ScrollViewItem.ID.self)

var body: some View {
ScrollView {
VStack(spacing: 20) {
ForEach(items) { item in
Text(“Item: (item.row)”)
.font(.system(size: 24, weight: .bold))
.foregroundStyle(.white)
.padding()
.frame(maxWidth: .infinity)
.frame(height: 100)
.background(RoundedRectangle(cornerRadius: 16).fill(.black))
}
}
.scrollTargetLayout()
}
.scrollPosition($position)
.onAppear {
position.scrollTo(id: items[15].id)
}
// to check if we are at top
.onScrollGeometryChange(for: Bool.self) { geometry in
geometry.contentOffset.y < geometry.contentInsets.top
} action: { wasScrolledToTop, isScrolledToTop in
if isScrolledToTop {
guard let previousFirstItem = items.first else {return}
let newItemsList = (previousFirstItem.row – 30..<previousFirstItem.row).map { ScrollViewItem(row: $0) }
items.insert(contentsOf: newItemsList, at: 0)
position.scrollTo(id: items[35].id)
}
}
.padding()
.backgroundStyle(.gray.opacity(0.2))
.frame(maxWidth: .infinity, maxHeight: .infinity)

}
}

Buggy #1

In my onScrollGeometryChange, I have position.scrollTo(id: items[35].id). What the hell was I doing here? Why didn’t I just use position.scrollTo(id: previousFirstItem.id, anchor: .top)?

I think it is an Apple bug in this new ScrollPosition (At least at the time I am writing this article. If I am wrong, please let me know!). But for some reason, scrollTo will always align the item to the bottom (that is anchor: bottom) regardless of what I actually specify. That is if we do position.scrollTo(id: previousFirstItem.id, anchor: .top) , our item 0 will end up at the bottom of the view!

Check Bottom: Limitation #1

It did not work out! End!

You might want to simply change the transform to geometry.contentOffset.y > geometry.contentInsets.bottom, but NO! The content insets, in our example, is actually always EdgeInsets(top: 0.0, leading: 0.0, bottom: 0.0, trailing: 0.0) (the left upper corner).

I have also tried to check against the following but non of those give what I want!

geometry.contentSizegeometry.containerSizegeometry.visibleRectgeometry.bounds

You can print the value of those out to inspect what you have for each and try it out by yourself or you can just trust me(which is probably not a great decision)!

onScrollVisibilityChange

Time for onScrollVisibilityChange!

This will add an action to be called when the view crosses the threshold to be considered on/off screen. Sounds like a combination of onAppear and onDisappear with the ability to set a threshold ! Great!

Unfortunately, did not work out as well as I was hoping for!

This modifier is a lot simpler than the one above.

func onScrollVisibilityChange(
threshold: Double = 0.5,
_ action: @escaping (Bool) -> Void
) -> some View

All we have to specify is the action we want to perform when the threshold is reached.

We can also optionally set the threshold, the amount required to be visible within the viewport of the parent view before the action is fired. Default to 0.5, corresponding to more than 50% on-screen.

Buggy #2

Has nothing to do with the functionality itself. BUT! Apple, where is your new AI? Isn’t that all you want to tell us in this year’s WWDC?

https://developer.apple.com/documentation/swiftui/view/onscrollvisibilitychange(threshold:_:)

Anyway, let’s try it out! Pretty much what we had previously using onAppear, but using it as the action for onScrollVisibilityChange.

@available(iOS 18.0, *)
struct NewScrollViewFeaturesDemo: View {
@State private var items = (0…30).map { ScrollViewItem(row: $0) }
@State private var position: ScrollPosition = .init(idType: ScrollViewItem.ID.self)
@State private var isInitial: Bool = true

var body: some View {
ScrollView {
LazyVStack(spacing: 20) {
ForEach(items) { item in
Text(“Item: (item.row)”)
.font(.system(size: 24, weight: .bold))
.foregroundStyle(.white)
.padding()
.frame(maxWidth: .infinity)
.frame(height: 100)
.background(RoundedRectangle(cornerRadius: 16).fill(.black))
.onScrollVisibilityChange(threshold: 0.2) { isVisible in
guard let index = items.firstIndex(where: {$0.id == item.id}) else { return }
guard isVisible else {return}
if index == 0 {
if isInitial {
isInitial = false
return
}
guard let previousFirstItem = items.first else {return}
let newItemsList = (previousFirstItem.row – 30..<previousFirstItem.row).map { ScrollViewItem(row: $0) }
items.insert(contentsOf: newItemsList, at: 0)
isInitial = true
position.scrollTo(id: items[35].id)
return
}
if index == items.count – 1 {
guard let previousLastItem = items.last else {return}
let newItemsList = (previousLastItem.row..<previousLastItem.row + 30).map { ScrollViewItem(row: $0) }
items.append(contentsOf: newItemsList)

}
}
}
}
.scrollTargetLayout()
}
.scrollPosition($position)
.onAppear {
position.scrollTo(id: items[15].id)
}
.padding()
.backgroundStyle(.gray.opacity(0.2))
.frame(maxWidth: .infinity, maxHeight: .infinity)

}
}

Why do I have that isInitial variable? If we don’t have it we will have endless infinite loading when we are at top.

However, as you can see, even with the isInitial, the load more at bottom works great, the top is still sketchy!

Solution

onScrollGeometryChange works great for top, onScrollVisibilityChange is perfect for bottom. So what we will do here is to simply combine the two!

@available(iOS 18.0, *)
struct NewScrollViewFeaturesDemo: View {
@State private var items = (0…30).map { ScrollViewItem(row: $0) }
@State private var position: ScrollPosition = .init(idType: ScrollViewItem.ID.self)

var body: some View {
ScrollView {
LazyVStack(spacing: 20) {
ForEach(items) { item in
Text(“Item: (item.row)”)
.font(.system(size: 24, weight: .bold))
.foregroundStyle(.white)
.padding()
.frame(maxWidth: .infinity)
.frame(height: 100)
.background(RoundedRectangle(cornerRadius: 16).fill(.black))
// to check if we are at bottom
.onScrollVisibilityChange(threshold: 0.2) { isVisible in
guard let index = items.firstIndex(where: {$0.id == item.id}) else { return }
guard isVisible else {return}
if index == items.count – 1 {
guard let previousLastItem = items.last else {return}
let newItemsList = (previousLastItem.row..<previousLastItem.row + 30).map { ScrollViewItem(row: $0) }
items.append(contentsOf: newItemsList)

}
}
}
}
.scrollTargetLayout()
}
.scrollPosition($position)
.onAppear {
position.scrollTo(id: items[15].id)
}
// to check if we are at top
.onScrollGeometryChange(for: Bool.self) { geometry in
geometry.contentOffset.y < geometry.contentInsets.top
} action: { wasScrolledToTop, isScrolledToTop in
if isScrolledToTop {
guard let previousFirstItem = items.first else {return}
let newItemsList = (previousFirstItem.row – 30..<previousFirstItem.row).map { ScrollViewItem(row: $0) }
items.insert(contentsOf: newItemsList, at: 0)
position.scrollTo(id: items[35].id)
}
}
.padding()
.backgroundStyle(.gray.opacity(0.2))
.frame(maxWidth: .infinity, maxHeight: .infinity)

}
}

Conclusion

Will I go for the approach above instead of the onAppear approach I shared with you previously like following?

struct NewScrollViewFeaturesDemo: View {
// …

var body: some View {
ScrollView {
LazyVStack(spacing: 20) {
ForEach(items) { item in
Text(“Item: (item.row)”)
// …
.onAppear {
guard let index = items.firstIndex(where: {$0.id == item.id}) else { return }
if index == items.count – 1 {
guard let previousLastItem = items.last else {return}
let newItemsList = (previousLastItem.row..<previousLastItem.row + 30).map { ScrollViewItem(row: $0) }
items.append(contentsOf: newItemsList)

}
if index == 0 {
if isInitial {
isInitial = false
return
}
guard let previousFirstItem = items.first else {return}
let newItemsList = (previousFirstItem.row – 30..<previousFirstItem.row).map { ScrollViewItem(row: $0) }
items.insert(contentsOf: newItemsList, at: 0)
isInitial = true
position.scrollTo(id: items[35].id)
return
}
}
}
}
.scrollTargetLayout()
}
.scrollPosition($position)
.onAppear {
position.scrollTo(id: items[15].id)
}
.padding()
.backgroundStyle(.gray.opacity(0.2))
.frame(maxWidth: .infinity, maxHeight: .infinity)

}
}

No! At least not for the purpose of checking an item is reached for loading more data!

It requires iOS 18.0+, it’s beta and it is hard to organize code (since we are using two different modifier in two different places)!

Thank you for reading!

Happy Scrolling!

SwiftUI ScrollView: Check Item Visibility and Load More If Needed was originally published in Level Up Coding on Medium, where people are continuing the conversation by highlighting and responding to this story.

​ Level Up Coding – Medium

about Infinite Loop Digital

We support businesses by identifying requirements and helping clients integrate AI seamlessly into their operations.

Gartner
Gartner Digital Workplace Summit Generative Al

GenAI sessions:

  • 4 Use Cases for Generative AI and ChatGPT in the Digital Workplace
  • How the Power of Generative AI Will Transform Knowledge Management
  • The Perils and Promises of Microsoft 365 Copilot
  • How to Be the Generative AI Champion Your CIO and Organization Need
  • How to Shift Organizational Culture Today to Embrace Generative AI Tomorrow
  • Mitigate the Risks of Generative AI by Enhancing Your Information Governance
  • Cultivate Essential Skills for Collaborating With Artificial Intelligence
  • Ask the Expert: Microsoft 365 Copilot
  • Generative AI Across Digital Workplace Markets
10 – 11 June 2024

London, U.K.