Some tips & tricks with the new iOS 18 ScrollView API

Pagination, hiding navigation and tab bar & more.

Thomas Ricouard
4 min readJul 8, 2024

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! 🚀

--

--

Thomas Ricouard

📱 🚀 🇫🇷 [Entrepreneur, iOS/Mac & Web dev] | Now @Medium, @Glose 📖| Past @google 🔍 | Co-founded few companies before, a movies 🎥 app and smart browser one.