SwiftUI Blog

Mastering SwiftUI: Your Guide to Building Beautiful, Intuitive Apps.

Text animation

In this post, we want to learn how to use the TextRender protocol to create an animation similar to the one used in the onboarding of the Medium mobile application. We can call this animation “progressive revelation,” where characters are displayed one by one.

Before looking at the code, we need to understand how TextRender works. It operates on the Text.Layout, which contains three levels of slices:

  • The first level (Lines): A single line with spacing and other settings
  • The second level (Runs): Contains text and attributes (e.g., bold, italic).
  • The third level (Slices/Glyphs): The base rendering components (such as individual characters or symbols)
Layout
  ├── Line 1
       ├── Run 1.1 (es: normal text)
            ├── Slice 1.1.1 (glyph "c")
            ├── Slice 1.1.2 (glyph "i")
            └── ...
       └── Run 1.2 (es: bold text)
             ├── Slice 1.2.1 (glyph "a")
             └── ...
  └── Line 2
        └── ...

We want create this:

Let’s see the code:

import Combine

struct ContentView: View {
    private let timerInterval = 0.05
    private let durationPerElement = 0.1
    private let fontSize = 32.0
    var timer: Publishers.Autoconnect<Timer.TimerPublisher>
    @State var elapsedTime: Double = 0

    init() {
        timer = Timer.publish(every: timerInterval, on: .main, in: .common).autoconnect()
    }
    
    var body: some View {
        VStack {
            Text("We love SwiftUI")
                .font(.system(size: fontSize))
                .fontWeight(.heavy)
                .padding(.all, 8)
                .textRenderer(AnimateTextRenderer(durationPerElement: durationPerElement, elapsedTime: elapsedTime))
                .onReceive(timer) { _ in
                    elapsedTime += timerInterval
                }

            Button(action: {
                elapsedTime = 0
            }, label: {
                Text("Repeat")
            })
        }
    }
}

The durationPerElement is the time it takes to display each character.

Now take a look at the render:

private struct AnimateTextRenderer: TextRenderer {
    var durationPerElement: Double
    var elapsedTime: Double

    func draw(layout: Text.Layout, in context: inout GraphicsContext) {
        let flattenedRuns = layout.flatMap({$0})
        let flattenedSlices = flattenedRuns.flatMap({$0})
        
        for (index, slice) in flattenedSlices.enumerated() {
            let indexDouble = Double(index)
            let animatedIndex = ceil(elapsedTime / durationPerElement)
            let opacity: Double
            if indexDouble < animatedIndex {
                opacity = 1.0
            } else if indexDouble == animatedIndex {
                let currentSliceElapsedTime = elapsedTime - durationPerElement * (indexDouble - 1)
                let elapsedFraction = currentSliceElapsedTime / durationPerElement
                opacity = elapsedFraction
            } else {
                opacity = 0.0
            }
            context.opacity = opacity
            context.draw(slice)
        }
    }
}

What happens in the draw function?
First, all the levels are flattened into one. Then, the current index is calculated based on the elapsedTime and the total animation duration. The opacity is set according to this index.

Here’s an alternative way to write the ContentView:

struct ContentView: View {
    static private let timerInterval = 0.05
    private let durationPerElement = 0.1
    private let fontSize = 32.0
    let timer = Timer.publish(every: ContentView.timerInterval, on: .main, in: .common).autoconnect()
    @State var elapsedTime: Double = 0

    var body: some View {
        VStack {
            Text("We love SwiftUI")
                .font(.system(size: fontSize))
                .fontWeight(.heavy)
                .padding(.all, 8)
                .textRenderer(AnimateTextRenderer(durationPerElement: durationPerElement, elapsedTime: elapsedTime))
                .onReceive(timer) { _ in
                    elapsedTime += ContentView.timerInterval
                }

            Button(action: {
                elapsedTime = 0
            }, label: {
                Text("Repeat")
            })
        }
    }
}

We introduced the static keyword for timerInterval, removed the init function, and now use timerInterval like this:

ContentView.timerInterval

If you don’t like using the name of the struct, you can use:

Self.timerInterval

Note the uppercase ‘S’.