In an effort to make the UI more subtle for the lyrics, I decided to explore the last year's scroll transition to create a combined blur, scale and opacity effect. This pleased my eyes by providing a smooth visual feedback as content scrolls in and out of view.

0:00
/0:19

The scrollTransition Modifier

To achive the above behaviour, we use the scrollTransition modifier. It allows us to apply custom transitions to views as they scroll:

func scrollTransition(_ configuration: ScrollTransitionConfiguration = .interactive, axis: Axis? = nil, transition: @escaping @Sendable (EmptyVisualEffect, ScrollTransitionPhase) -> some VisualEffect) -> some View

In my case, I am using a custom animated transition:

.scrollTransition(.animated(.easeInOut(duration: 0.5))) { content, phase in
    // Custom effects
}

The Three Effects: Blur, Scale and Opacity

In my implementation, I used the three visual effects: opacity, scale, and blur. Let's look at the signatures of these effect modifiers:

Blur:

func blur(radius: CGFloat, opaque: Bool = false) -> some VisualEffect

Scale:

func scaleEffect(_ scale: CGSize, anchor: UnitPoint = .center) -> some VisualEffect

Opacity:

func opacity(_ opacity: Double) -> some VisualEffect

Understanding ScrollTransitionPhase

The ScrollTransitionPhase represents the different phases that a view transitions between when it scrolls among other views:

@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
@frozen public enum ScrollTransitionPhase {
    case topLeading
    case identity
    case bottomTrailing

    public var isIdentity: Bool { get }
    public var value: Double { get }
}
  • topLeading: This is the state of the lyric line when it is about to enter the screen from the top in the vertical scroll.
  • identity: This represents the "normal" state of the lyric line when it is fully visible on the screen.
  • bottomTrailing: This is the state of the lyric line when it is about to exit the screen at the bottom in the vertical scroll.

As users scroll through the song lyrics, each line of lyrics will transition through these phases. The value property is what allows us to create smooth, continuous transitions:

  • In topLeadingvalue is -1.0
  • In identityvalue is 0.0
  • In bottomTrailingvalue is 1.0

While value is just these three discrete numbers, it smoothly transitions between them as the view scrolls based on the configuration provided to the scrollTransition modifier.

When the view approaches the visible region of the containing scroll view, the effect will first be applied with either the topLeading or bottomTrailing phase, depending on which edge the view is approaching. Then, it will transition to the identity phase as the view moves into the visible area.

Implementing the Scroll Transition Effect

With the fundamentals down, here is the code that I used to achieve this effect:

ForEach(paragraph.lines) { line in
    VStack(alignment: .leading, spacing: 5) {
        Text(line.originalText)
            .font(sizeClass == .regular ? .largeTitle : .title)
            .bold()
            .padding(.vertical, 4)
            .foregroundStyle(.white)

        TranslatedTextView(text: line.translatedText)
    }
    .scrollTransition(.animated(.easeInOut(duration: 0.5))) { content, phase in
        content
            .opacity(opacityAmount(for: phase))
            .scaleEffect(scaleAmount(for: phase))
            .blur(radius: blurAmount(for: phase))
    }
    .padding(.vertical, 4)
}

This code iterates through each line of a paragraph, creating a VStack for each line. The VStack contains the original text and its translation. The key part of this implementation is the .scrollTransition modifier, which applies our custom transition effects.

private func blurAmount(for phase: ScrollTransitionPhase) -> CGFloat {
    10 * abs(phase.value)
}

private func opacityAmount(for phase: ScrollTransitionPhase) -> CGFloat {
    1 - abs(phase.value)
}

private func scaleAmount(for phase: ScrollTransitionPhase) -> CGFloat {
    1 - (abs(phase.value) * 0.1)
}

Each function takes a ScrollTransitionPhase parameter and returns a CGFloat value that determines the intensity of the effect based on the current phase of the transition.

  • Blur Effect: As a lyric line enters or exits the screen (value approaches 1 or -1), it becomes more blurred (up to 10 points). When it is fully on screen (value is 0), there is no blur.
  • Opacity Effect: The line is fully opaque when centered on screen and fades out as it enters or exits.
  • Scale Effect: The line is at its original size when centered and shrinks slightly (up to 10%) as it enters or exits.

Addressing the Swift 6 Bug

As my app targets iOS 17,4 I stumbled upon this bug:

Call to main actor-isolated instance method 'opacityAmount(for:)' in a synchronous nonisolated context; this is an error in the Swift 6 language mode

This error occurs because in Swift 6, methods in a View are automatically considered to be isolated to the main actor.

  • iOS 17 and earlier: By default, only the body property of a SwiftUI view is implicitly attached to the main actor. Other properties and methods within the view are not automatically main-actor-isolated.
  • iOS 18+ / Swift 6: SwiftUI views will be entirely main-actor-isolated by default. This means all properties and methods within the view will automatically run on the main actor.
@MainActor
struct NowPlayingView: View {
  var body: some View {
    // ... existing view code ...
  }
}

To fix this, we explicitly mark the helper methods as nonisolated:

@MainActor
struct ContentView: View {
    var body: some View {
        // ... existing view code ...
    }

    nonisolated private func blurAmount(for phase: ScrollTransitionPhase) -> CGFloat {
        10 * abs(phase.value)
    }

    nonisolated private func opacityAmount(for phase: ScrollTransitionPhase) -> CGFloat {
        1 - abs(phase.value)
    }

    nonisolated private func scaleAmount(for phase: ScrollTransitionPhase) -> CGFloat {
        1 - (abs(phase.value) * 0.1)
    }
}

By marking these methods as nonisolated, we are telling the compiler that these functions do not need to run on the main actor, as they do not access any mutable state or perform UI updates directly. This approach works well for iOS 17.4 and will continue to work correctly when we eventually move to the cursed Swift 6.

Moving Forwrd

It was fun creating this subtle effect for the lyrics screen with a few lines of code. While the phases only apply the effect on the top and bottom lines, the next step for me is to apply it gradually across each line of lyrics, so the effect is smoother than ever.

Do I know how to do it? No.

Will I find around, explore, and write a post about it? Absolutely yes.

Till then, happy scrolling!

String Catalog

String Catalog - App Localization on Autopilot

Push to GitHub, and we'll automatically localize your app for 40+ languages, saving you hours of manual work.

Tagged in: