Exploring SwiftUI: Using onScrollPhaseChange for Scroll Changes in iOS 18
I love how the Apple Music app hides everything when you are looking at lyrics. So clean and focused. I wanted to create something similar in my own app, LyricsLink.
While exploring, I found a new method in iOS 18 calledonScrollPhaseChange
. It seemed perfect for what I needed, so I decided to give it a try. It lets you track how a scroll view is behaving and you can use it to make your app respond to scrolling in different ways.
ScrollPhase
First, let us start by understanding scroll phases. A scroll phase is a way to describe what is happening with a scroll view at any moment.
enum ScrollPhase: Equatable {
case idle
case tracking
case interacting
case decelerating
case animating
public var isScrolling: Bool { get }
}
There are five main phases:
idle
: This is when nothing is happening. The scroll view is just sitting there.tracking
: This is when the user might be about to scroll. Maybe they have put their finger on the screen, but have not moved it yet.interacting
: This is when the user is actively scrolling. Their finger is moving on the screen.decelerating
: This is when the user has lifted their finger, but the scroll view is still moving. It is slowing down naturally.animating
: This is when the app is controlling the scrolling. Maybe you have told it to scroll to a specific spot.
onScrollPhaseChange
Modifier
Now, how do we use this? We attach onScrollPhaseChange
to our scroll view. It gives us three pieces of info: the old phase, the new phase, and context about what is happening.
Let's look at the example provided by Apple to hide a tab bar and break it down. Here is the code:
@Binding var hidesToolbarContent: Bool
@State private var lastOffset: CGFloat = 0.0
ScrollView {
// ...
}
.onScrollPhaseChange { oldPhase, newPhase, context in
if newPhase == .interacting {
lastOffset = context.geometry.contentOffset.y
}
if oldPhase == .interacting, newPhase != .animating,
context.geometry.contentOffset.y - lastOffset < 0.0
{
hidesToolbarContent = true
} else {
hidesToolbarContent = false
}
}
We start with one important variable,lastOffset
that keeps track of where the scroll view was last time we checked.
We attach onScrollPhaseChange
to the ScrollView
. This means it will run every time the scroll phase changes.
Inside onScrollPhaseChange
, we get three pieces of info:
oldPhase
: What the scroll phase was before.newPhase
: What the scroll phase is now.context
: Extra information about the scroll view.
We check if the new phase is .interacting
. This means the user just started scrolling. If so, we save the current position.
Next, we check three things:
- Was the old phase
.interacting
? (The user was scrolling before) - Is the new phase not
.animating
? (We are not doing an automatic scroll) - Is the current position higher than where we started? (We scrolled up)
- If all those are true, we hide the toolbar. If not, we show it.
This code hides the toolbar when you scroll up, and shows it when you scroll down. It is a simple way to make more room for content when the user is reading, but keep the toolbar when they might want to use it.
Lyrics View
I built upon Apple's example and made some changes to animated it smoothly. Here is what I have in the main tab view:
struct ContentView: View {
@State private var toolbarVisibility: Visibility = .visible
@StateObject private var appState = AppState()
var body: some View {
TabView(selection: $appState.selectedTab) {
Group {
if #available(iOS 18, *) {
NewNowPlayingView(toolbarVisibility: $toolbarVisibility)
.toolbarVisibility(toolbarVisibility, for: .tabBar, .navigationBar)
.statusBarHidden(toolbarVisibility != .visible)
} else {
NowPlayingView()
}
}
// Other tab items would go here
}
}
}
The variable toolbarVisibility
controls whether the toolbars (tab bar and navigation bar) are visible or not. I use #available(iOS 18, *)
to check as the onScrollPhaseChange
only works on iOS 18+.
I pass $toolbarVisibility
to NewNowPlayingView
. This lets the view control the visibility and use .toolbarVisibility()
modifier to apply the visibility state to both the tab bar and navigation bar.
I also hide the status bar when the toolbars are not visible, for a good immersive experience.
For older iOS versions, sigh, I fall back to a NowPlayingView
that does not have the new fancy scroll behavior.
Now, in the NewNowPlayingView
, I implemented the onScrollPhaseChange
like this:
struct NewNowPlayingView: View {
@Binding var toolbarVisibility: Visibility
@State private var lastContentOffset: CGFloat = 0
var body: some View {
ScrollView {
// Lyrics content here
}
.onScrollPhaseChange { oldPhase, newPhase, context in
let currentOffset = context.geometry.contentOffset
let scrollingDown = currentOffset.y > lastContentOffset.y
switch newPhase {
case .interacting, .tracking:
break
case .animating, .decelerating:
if oldPhase == .interacting || oldPhase == .tracking {
withAnimation(.easeInOut(duration: 0.2)) {
toolbarVisibility = scrollingDown ? .hidden : .visible
}
}
case .idle:
if oldPhase != .idle && abs(currentOffset.y - lastContentOffset.y) > 20 {
withAnimation(.easeInOut(duration: 0.2)) {
toolbarVisibility = scrollingDown ? .hidden : .visible
}
}
@unknown default:
break
}
lastContentOffset = currentOffset
}
}
}
The view takes a binding to toolbarVisibility
, allowing it to change the visibility from a parent view. The lastContentOffset
keeps track of the previous scroll position.
I get the current scroll position and determine if it is scrolling down by comparing it to the last position.
I then handle different scroll phases differently:
- For
.interacting
and.tracking
, I do nothing. This prevents jittery behavior while the user is actively touching the screen. - For
.animating
and.decelerating
: If the user just finished interacting, I animate the toolbar visibility change. We hide it when scrolling down, show it when scrolling up. - For
.idle
: I just stopped scrolling and moved more than 20 points, I change the toolbar visibility. This catches quick flick gestures.
This creates a smooth interface that hides or shows the toolbar/status bar based on scroll direction, similar to the Apple Music lyrics view. It is a bit more complex than Apple's example, but more to my liking.
Here is how it looks:
Moving Forward
With the basic scrolling behavior down, one area I will explore is adding visual effects to complement the scrolling behavior.
As the toolbar starts to hide or show, I will gradually apply a blur and opacity effect to the content at the edges. This would create a smooth transition and draw attention to the change. The idea is to create a delightful design that pleases me and everyone who gets their hands on my app.
Happy scrolling!