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’.