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.

LyricsLink: Translate Music for Everyone!
LyricsLink is an app for music lovers who want to understand and enjoy song lyrics in any language. With integration with Apple Music, LyricsLink allows you to search for songs, view their lyrics, and instantly translate them into the language of your choice!Key Features:Apple Music Integration: Easily search for songs using the vast Apple Music library.Instant Lyrics: Get the lyrics of your favourite songs displayed beautifully.Real-Time Translations: Instantly translate lyrics into your desired language.Note: An active Apple Music subscription is required to access the song library and lyrics. Requires macOS 14.4+, iOS 17.4 or iPadOS 17.4+Contains:Zip file to the app.TestFlight link for automatic updates.Pricing:You can pay what you want, or put $0, or support my budding indie dev journey! 😊

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:

0:00
/0:20

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!