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.
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
topLeading
,value
is -1.0 - In
identity
,value
is 0.0 - In
bottomTrailing
,value
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!