Exploring SwiftUI: Understanding TextRenderer to Animate Words

One of the coolest WWDC sessions from this year is about creating custom visual effects in SwiftUI.

Create custom visual effects with SwiftUI - WWDC24 - Videos - Apple Developer
Discover how to create stunning visual effects in SwiftUI. Learn to build unique scroll effects, rich color treatments, and custom…

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:

‎AI Mesh Gradient Tool: Meshing
‎Meshing lets you quickly and easily transform your ideas into beautiful colour gradients. Perfect for creating wallpapers, designing websites and app backgrounds, or just playing with colours. What makes Meshing special: INTUITIVE DESIGN - Tap and swipe to craft amazing gradients - Choose from 2x…

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: &copy)
            }
        } 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: &copy): 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:

0:00
/0:01

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!