Some tips & tricks with the new iOS 18 ScrollView API
Pagination, hiding navigation and tab bar & more.
iOS 18 brings a host of new APIs for the ScrollView; it’s now better than ever! From reading the exact position to listening to the scroll view status, you can (mostly) do it all!
So, I’ve decided to demo some of those new APIs with a small Xcode 16 + iOS 18 project. Here is a demo of the project in action. It’s a simple ScrollView
+ LazyVStack
Implementation with infinite scrolling (pagination) and a way to hide the navigation bar and the tab bar when scrolling. This is quite a standard behavior when using UIKit, but until iOS 18, it was hard to reproduce using SwiftUI.
Pagination
Until iOS 18, the best way to do pagination was to have on onAppear
on the last item or on a ProgressView below the content of your List / ScrollView which would trigger the load of the next page from there.
With iOS 18, you can now use onScrollTargetVisibilityChange
, which is the perfect API for this kind of job. Because my Post
model is Identifiable
, I can use this API to get the currently visible ids. This is because within the ScrollView
we have a LazyVStack
with a ForEach
of those posts. You also need to add scrollTargetLayout
to the outermost layout of your ScrollView
.
struct PaginatedScrollView: View {
@State private var posts: [Post] = []
@State private var page: Int = 0
var body: some View {
TabView {
Tab {
NavigationStack {
ScrollView {
LazyVStack {
ForEach(posts) { post in
VStack {
Text(post.content)
}
}
}
.scrollTargetLayout()
}
.onScrollTargetVisibilityChange(idType: Post.ID.self) { postsIds in
if let lastPost = posts.last, postsIds.contains(where: { $0 == lastPost.id }) {
page += 1
}
}
.task(id: page) {
await loadNextPage()
}
}
} label: {
Label("Home", systemImage: "house")
}
}
}
private func loadNextPage() async {
posts.append(contentsOf: [Post(), Post(), Post(), Post(), Post()])
}
}
struct Post: Identifiable {
let id = UUID()
let content = "Post content"
}
As you can see in the code above, I’m checking if the visible postIds
contain the last post.id
of my posts
datasource. If that’s the case, I increment my page of one.
By doing this, I trigger the .task(id: page)
, which will load the next page from your repository/network / whatever. For the demo purpose, I simply appended new posts to my current datasource.
And that’s how you can do pagination, infinite scrolling, and anything else related to items scrolling in and out of your viewport.
Hiding the NavigationBar and TabBar
The second thing I want to show you using those new APIs is how to hide the UI when scrolling. Many apps already do that, and it is now perfectly doable with the native SwiftUI API.
Apple added a new API in iOS 18, onScrollPhaseChange
, which gives us information about the scrolling state of the ScrollView
. By using this in combination with toolbarVisibility
we can effectively hide the bars when the ScrollView
is scrolled. Here is the code:
struct PaginatedScrollView: View {
@State private var posts: [Post] = []
@State private var page: Int = 0
@State private var isScrolling: Bool = false
var body: some View {
TabView {
Tab {
NavigationStack {
ScrollView {
LazyVStack {
ForEach(posts) { post in
VStack {
Text(post.content)
}
}
}
.scrollTargetLayout()
}
.onScrollPhaseChange({ _, newPhase in
withAnimation {
switch newPhase {
case .idle:
isScrolling = false
case .tracking, .interacting, .decelerating, .animating:
isScrolling = true
}
}
})
.toolbarVisibility(isScrolling ? .hidden : .visible, for: .navigationBar)
.toolbarVisibility(isScrolling ? .hidden : .visible, for: .tabBar)
}
} label: {
Label("Home", systemImage: "house")
}
}
}
}
struct Post: Identifiable {
let id = UUID()
let content = "Post content"
}
#Preview {
PaginatedScrollView()
}
The important part is the isScrolling
flag, it’s set to false when the ScrollView
is idle and to true when it’s in any other state. And then I have two toolbarVisibility
modifiers which hide or show the top and bottom bars according to the flag.
The bonus added in this code is the withAnimation
; as you can see in the GIF above, the UI hides and shows with animation, which is how I did the trick.
To finish, here is the full code of this sample project. Remember, you’ll need Xcode 16 to run it.
import SwiftUI
struct PaginatedScrollView: View {
@State private var posts: [Post] = []
@State private var page: Int = 0
@State private var isScrolling: Bool = false
var body: some View {
TabView {
Tab {
NavigationStack {
ScrollView {
LazyVStack {
ForEach(posts) { post in
VStack {
Text(post.content)
}
.frame(maxWidth: .infinity, minHeight: 80)
.background(.gray)
.background(in: RoundedRectangle(cornerRadius: 8))
.clipShape(RoundedRectangle(cornerRadius: 8))
.padding(.horizontal, 16)
}
}
.scrollTargetLayout()
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
Task {
await prependPage()
}
} label: {
Text("Prepend")
}
}
}
.navigationTitle("Page: \(page)")
.onScrollPhaseChange({ _, newPhase in
withAnimation {
switch newPhase {
case .idle:
isScrolling = false
case .tracking, .interacting, .decelerating, .animating:
isScrolling = true
}
}
})
.toolbarVisibility(isScrolling ? .hidden : .visible, for: .navigationBar)
.toolbarVisibility(isScrolling ? .hidden : .visible,
for: .tabBar)
.onScrollTargetVisibilityChange(idType: Post.ID.self) { postsIds in
if let lastPost = posts.last, postsIds.contains(where: { $0 == lastPost.id }) {
page += 1
}
}
.task(id: page) {
await loadNextPage()
}
}
} label: {
Label("Home", systemImage: "house")
}
}
}
private func loadNextPage() async {
posts.append(contentsOf: [Post(), Post(), Post(), Post(), Post()])
}
private func prependPage() async {
posts.insert(contentsOf: [Post(), Post(), Post(), Post(), Post()], at: 0)
}
}
struct Post: Identifiable {
let id = UUID()
let content = "Post content"
}
#Preview {
PaginatedScrollView()
}
Thanks for reading! 🚀