Exploring SwiftUI: Understanding TextRenderer to Animate Words
One of the coolest WWDC sessions from this year is about creating custom visual effects in SwiftUI.
It has so much amazing stuff to add to your app, one of which is based on the TextRenderer
protocol for custom beautiful text transitions. I use it in my app Meshing to animate words:
In this post, we will explore and understand all about it!
TextRenderer
This protocol helps to replace the default text view rendering behaviour and helps you to customise how SwiftUI text is drawn for an entire view tree. Just typing that out felt cool.
/// A value that can replace the default text view rendering behavior.
@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
public protocol TextRenderer : Animatable {
Text in SwiftUI is straightforward. You provide the text, and it draws it on the screen. But that makes it harder to animate, especially when transitioning between states. The idea of TextRenderer
is that it allows you to control how transitions are applied to text layers. You can even apply transitions to specific portions of text, like one single character!
Drawing
The main method for working with the renderer is the draw(layout:in:)
method. I had to search for what "ctx" means only to realise it means context.
/// Draws `layout` into `ctx`.
func draw(layout: Text.Layout, in ctx: inout GraphicsContext)
It tells the renderer how to draw the text on the screen.
Text.Layout: This is the layout of the text, which contains information about where each glyph (individual letter or symbol) is placed.
CharacterIndex
: Represents the index of a character in the text. It helps you locate specific characters for effects or animations.Line
: Represents a single line of text, containing multiple glyphs (characters). You can use this to apply effects to entire lines of text.Run
: A sequence of glyphs, or characters, in the text. You can style or animate specific groups of characters within a line using this structure.TypographicBounds
: Provides the size and dimensions of the text, which is useful for positioning and aligning text within your layout.isTruncated
: A Boolean that tells if the text is truncated due to space constraints.
By playing around with the different structures within the Text.Layout
, you can control everything from positioning individual characters to styling entire lines of text.
For example, if you wanted to apply an animation that affects every third word in a line of text, you could use the Line
and Run
structures to identify the words and apply custom drawing or animation logic.
struct CustomTextRenderer: TextRenderer {
func draw(layout: Text.Layout, in: inout GraphicsContext) {
for line in layout {
for run in line {
// Apply custom effects to the run of glyphs
}
}
}
}
GraphicsContext: This context represents the canvas on which the text will be drawn. You use it to perform custom drawing operations, apply transformations, or add special effects.
This method would allow you to draw each glyph individually or apply effects like colour gradients to the text.
The default rendering is only iterating over the lines of the layout:
struct DefaultTextRenderer: TextRenderer {
func draw(layout: Text.Layout, in: inout GraphicsContext) {
for line in layout {
context.draw(line)
}
}
}
Creating a Custom Text Renderer
You will create an OnboardingAppearanceEffectRenderer
that is the foundation for the code on the posts on animated onboarding and paywall screens:
struct OnboardingAppearanceEffectRenderer: TextRenderer, Animatable { }
The code is similar to the one shown in the WWDC video, but the detailed explanation ahead finally made me understand how this complex protocol works.
OnboardingAppearanceEffectRenderer
conforms to TextRenderer
and Animatable
protocols, where Animatable
allows the animation of properties within the struct.
var elapsedTime: TimeInterval
: This variable represents the time that has passed since the start of the animation. Since the structure is marked Animatable
, its value changes over time, driving the animation.
var elementDuration: TimeInterval
: Defines how long it takes to animate an individual text element, like a word or character.
var totalDuration: TimeInterval
: Specifies the total duration of the entire animation, from start to finish.
var spring: Spring
: This property defines the type of spring animation used. It uses the .snappy spring type with a specified duration and extra bounce.
var animatableData: Double
: This is the required property for Animatable conforming structs, which SwiftUI uses to animate the elapsedTime
.
struct OnboardingAppearanceEffectRenderer: TextRenderer, Animatable {
var elapsedTime: TimeInterval
var elementDuration: TimeInterval
var totalDuration: TimeInterval
var spring: Spring {
.snappy(duration: elementDuration - 0.05, extraBounce: 0.4)
}
var animatableData: Double {
get { elapsedTime }
set { elapsedTime = newValue }
}
}
Then, you have the initialiser:
init(elapsedTime: TimeInterval, elementDuration: Double = 1.5, totalDuration: TimeInterval) {
self.elapsedTime = min(elapsedTime, totalDuration)
self.elementDuration = min(elementDuration, totalDuration)
self.totalDuration = totalDuration
}
This sets the elapsedTime
, elementDuration
, and totalDuration
and ensures that they do not exceed totalDuration
.
Finally, the final thing: the draw(layout:in:)
method. Everything and anything related to the drawing and animation happen in this one.
func draw(layout: Text.Layout, in context: inout GraphicsContext) {
for run in layout.flattenedRuns {
if run[OnboardingEmphasisAttribute.self] != nil {
let delay = elementDelay(count: run.count)
for (index, slice) in run.enumerated() {
let timeOffset = TimeInterval(index) * delay
let elementTime = max(0, min(elapsedTime - timeOffset, elementDuration))
var copy = context
draw(slice, at: elementTime, in: ©)
}
} else {
var copy = context
copy.opacity = UnitCurve.easeIn.value(at: elapsedTime / 0.2)
copy.draw(run)
}
}
}
for run in layout.flattenedRuns
: This line iterates over all the text “runs” in the layout. As I mentioned, a “run” is a sequence of characters or glyphs drawn together.
The extension on Text.Layout
with property flattenedRuns
helps to loop through every text segment in the layout, which you can then individually animate.
extension Text.Layout {
var flattenedRuns: some RandomAccessCollection<Text.Layout.Run> {
self.flatMap { line in
line
}
}
var flattenedRunSlices: some RandomAccessCollection<Text.Layout.RunSlice> {
flattenedRuns.flatMap(\.self)
}
}
if run[OnboardingEmphasisAttribute.self] != nil
: Here, you check if the run has been tagged with the custom OnboardingEmphasisAttribute
. If it has, this portion of the text should be animated differently.
struct OnboardingEmphasisAttribute: TextAttribute {}
let delay = elementDelay(count: run.count)
: This calculates the delay between animating each character or word in the run and spaces out the animations so that each character animates sequentially rather than all at once.
/// Calculates how much time passes between the start of two consecutive
/// element animations.
///
/// For example, if there's a total duration of 1 s and an element
/// duration of 0.5 s, the delay for two elements is 0.5 s.
/// The first element starts at 0 s, and the second element starts at 0.5 s
/// and finishes at 1 s.
///
/// However, to animate three elements in the same duration,
/// the delay is 0.25 s, with the elements starting at 0.0 s, 0.25 s,
/// and 0.5 s, respectively.
func elementDelay(count: Int) -> TimeInterval {
let count = TimeInterval(count)
let remainingTime = totalDuration - count * elementDuration
return max(remainingTime / (count + 1), (totalDuration - elementDuration) / count)
}
for (index, slice) in run.enumerated()
: This loops through each character or glyph (called a “slice”) within the run, along with its index.
let timeOffset = TimeInterval(index) * delay
: This calculates when each individual slice (character) should start animating based on its position and the calculated delay.
let elementTime = max(0, min(elapsedTime - timeOffset, elementDuration))
: This line calculates how much time has passed for the current character. It subtracts the timeOffset
to give each character its timing for a staggered animation.
var copy = context
: A copy of the drawing context is created before any transformations or animations are applied. This is important because GraphicsContext
has value semantics, meaning changes to one context won’t affect others. By copying it, each character’s transformation stays isolated.
draw(slice, at: elementTime, in: ©)
: Finally, you call the helper method to apply the animation and draw each character on the screen at its specific time.
The next method, draw(slice:at:in:)
, is where the detailed animation happens for each character or word:
func draw(_ slice: Text.Layout.RunSlice, at time: TimeInterval, in context: inout GraphicsContext) {
let progress = time / elementDuration
let opacity = UnitCurve.easeIn.value(at: 1.4 * progress)
let blurRadius =
slice.typographicBounds.rect.height / 16 *
UnitCurve.easeIn.value(at: 1 - progress)
// The y-translation derives from a spring, which requires a
// time in seconds.
let translationY = spring.value(
fromValue: -slice.typographicBounds.descent,
toValue: 0,
initialVelocity: 0,
time: time)
context.translateBy(x: 0, y: translationY)
context.addFilter(.blur(radius: blurRadius))
context.opacity = opacity
context.draw(slice, options: .disablesSubpixelQuantization)
}
let progress = time / elementDuration
: This calculates how far along you are in the animation based on how much time has passed (time
) and the total duration of the animation for this element (`elementDuration`). The result is a value between 0 and 1.
let opacity = UnitCurve.easeIn.value(at: 1.4 * progress)
: You calculate the opacity using an easing curve. As progress increases (as the character gets further along in the animation), the opacity goes from 0 (invisible) to 1 (fully visible), making the text fade in smoothly.
let blurRadius = slice.typographicBounds.rect.height / 16 * UnitCurve.easeIn.value(at: 1 - progress)
: This line gives the character a blur effect that decreases as the animation progresses. Initially, the text is blurred, but the blur gradually disappears as the character becomes clearer.
let translationY = spring.value(fromValue: -slice.typographicBounds.descent, toValue: 0, initialVelocity: 0, time: time)
: This creates a spring-based vertical movement. The character starts slightly above its final position (based on the text’s “descent”), and the spring effect makes it smoothly fall into place.
context.translateBy(x: 0, y: translationY)
: This applies the vertical translation (movement) to the text slice.
context.addFilter(.blur(radius: blurRadius))
: The blur effect is applied to the text slice, using the calculated blurRadius
.
context.opacity = opacity
: This sets the opacity for the text slice, making it fade in.
context.draw(slice, options: .disablesSubpixelQuantization)
: Finally, the text slice is drawn on the screen with all the applied effects. The .disablesSubpixelQuantization
option ensures the text does not jitter while the spring animation settles.
With the combination of these two methods (draw(layout:in:)
and draw(slice:at:in:)
, you control the timing, blur, fade-in, and movement of each character or word.
Applying the Custom Transition
With the OnboardingAppearanceEffectRenderer
set up to animate text, the next step is to define how the transition between text states is animated and how the TextRenderer
is applied.
struct OnboardingTextTransition: Transition {
static var properties: TransitionProperties {
TransitionProperties(hasMotion: true)
}
func body(content: Content, phase: TransitionPhase) -> some View {
let duration = 2.0
let elapsedTime = phase.isIdentity ? duration : 0
let renderer = OnboardingAppearanceEffectRenderer(
elapsedTime: elapsedTime,
totalDuration: duration
)
content.transaction { transaction in
if !transaction.disablesAnimations {
transaction.animation = .easeInOut(duration: 2)
}
} body: { view in
view.textRenderer(renderer)
}
}
}
func body(content: Content, phase: TransitionPhase)
: This method controls how the transition behaves based on the current phase (whether the text is entering or leaving).
elapsedTime = phase.isIdentity ? duration : 0
: If the transition is in the initial state (i.e., text is entering), it animates over the full duration. If the phase is not the initial state, elapsedTime
is set to 0.
OnboardingAppearanceEffectRenderer
: A custom renderer is created with the calculated elapsedTime
and duration for the transition.
content.transaction
: This modifier ensures that the transition uses a smooth easeInOut
animation over 2 seconds unless animations are disabled.
view.textRenderer(renderer)
: This applies the custom OnboardingAppearanceEffectRenderer
to the text, animating it with the effects we defined earlier.
Using in Your View
You can now apply this transition to any text element in your SwiftUI app. Here is an example of how it is used:
if showFeatureGrid {
Text(LocalizedStringKey("Meshing Pro"), bundle: .module)
.customAttribute(OnboardingEmphasisAttribute())
.transition(OnboardingTextTransition())
}
And here is a video of how it works:
Moving Forward
It is all about experimentation. Experimenting with the timing and effects, you can mess around and find out transitions that match the overall aesthetic of your app.
In the next blog post, you will apply this custom text renderer to an onboarding screen!
Happy rendering!