How to create custom text effects and animations
How to create custom text effects and animations 관련
Updated for Xcode 16
New in iOS 18
SwiftUI's TextRenderer
protocol combines with the textRenderer()
modifier to give us complete control over how text is rendered, including the ability to smooth animate rendering based on our custom logic.
To explain how this all works, I'll start by giving you a simple example, then explain how the various components fit together, and finish up with more examples so you can see what's possible.
First, let's look at a simple example that adjusts every other line in rendered text, so that even-numbered lines are opaque and odd-numbered lines are slightly translucent:
struct ZebraStripeRenderer: TextRenderer {
func draw(layout: Text.Layout, in context: inout GraphicsContext) {
for (index, line) in layout.enumerated() {
if index.isMultiple(of: 2) {
context.opacity = 1
} else {
context.opacity = 0.5
}
context.draw(line)
}
}
}
struct ContentView: View {
var body: some View {
Text("He thrusts his fists against the posts and still insists he sees the ghosts.")
.font(.largeTitle)
.textRenderer(ZebraStripeRenderer())
}
}
Almost all the work there is in the ZebraStripeRenderer
struct. That conforms to the TextRenderer
protocol, which has only one requirement: a draw(layout:in:)
method that handles text rendering into a graphics context.
SwiftUI's Text.Layout
type can be used as a sequence, so in the code above we loop over all the lines, adjusting opacity as we go, then rendering each line one at a time.
Each line is itself a sequence containing zero or more runs, which are groups of letters with the same styling, and inside runs are individual glyphs, which are the actual letters being rendered.
To help you visualize how this all fits together, we could create a simple text renderer that draws boxes around lines, runs, and glyphs, like this:
struct BoxedRenderer: TextRenderer {
func draw(layout: Text.Layout, in context: inout GraphicsContext) {
for line in layout {
for run in line {
for glyph in run {
context.stroke(Rectangle().path(in: glyph.typographicBounds.rect), with: .color(.blue), lineWidth: 2)
}
context.stroke(Rectangle().path(in: run.typographicBounds.rect), with: .color(.green), lineWidth: 2)
}
context.stroke(Rectangle().path(in: line.typographicBounds.rect), with: .color(.red), lineWidth: 2)
context.draw(line)
}
}
}
struct ContentView: View {
var body: some View {
VStack {
(
Text("This is a **very** important string") +
Text(" with lots of text inside.")
.foregroundStyle(.secondary)
)
.font(.largeTitle)
.textRenderer(BoxedRenderer())
}
}
}
The text we're rendering this time has Markdown styling inside and a SwiftUI modifier, which splits the lines up into several runs. When that code runs, you'll see red, green, and blue lines drawn around the various components, so you can see exactly what they mean.
Animating TextRenderer
When you conform to TextRenderer
, you can add an animatableData
property to control how values change over time. This can then be animated using regular SwiftUI animations.
Important
When rendering text that moves, it's a good idea to use the .disablesSubpixelQuantization
option, which allows letter shapes to be rendered at floating-point positions rather than being snapped to the nearest integer, making for smoother movement.
As an example, we could make a simple WaveRenderer
struct that bends letters up and down based on strength
and frequency
values:
struct WaveRenderer: TextRenderer {
var strength: Double
var frequency: Double
var animatableData: Double {
get { strength }
set { strength = newValue }
}
func draw(layout: Text.Layout, in context: inout GraphicsContext) {
for line in layout {
for run in line {
for (index, glyph) in run.enumerated() {
let yOffset = strength * sin(Double(index) * frequency)
var copy = context
copy.translateBy(x: 0, y: yOffset)
copy.draw(glyph, options: .disablesSubpixelQuantization)
}
}
}
}
}
Tips
Because GraphicsContext
uses value semantics, taking a copy of your context allows you to make changes such as translating and scaling without affecting other drawing.
Using that in a SwiftUI view means passing in some properties that change over time, for example using an animation that moves from strength -10 to +10:
struct ContentView: View {
@State private var amount = -10.0
var body: some View {
Text("This is a very important string with lots of text inside. This is a very important string with lots of text inside.")
.font(.largeTitle)
.textRenderer(WaveRenderer(strength: amount, frequency: 0.5))
.onAppear {
withAnimation(.easeInOut(duration: 1).repeatForever(autoreverses: true)) {
amount = 10
}
}
}
}
Or we could create an earthquake-style effect by using random Y offsets for each letter:
struct QuakeRenderer: TextRenderer {
var moveAmount: Double
var animatableData: Double {
get { moveAmount }
set { moveAmount = newValue }
}
func draw(layout: Text.Layout, in context: inout GraphicsContext) {
for line in layout {
for run in line {
for glyph in run {
var copy = context
let yOffset = Double.random(in: -moveAmount...moveAmount)
copy.translateBy(x: 0, y: yOffset)
copy.draw(glyph, options: .disablesSubpixelQuantization)
}
}
}
}
}
struct ContentView: View {
@State private var strength = 0.0
var body: some View {
Text("SHOCKWAVE")
.font(.largeTitle.weight(.black).width(.compressed))
.textRenderer(QuakeRenderer(moveAmount: strength))
.onAppear {
withAnimation(.easeInOut(duration: 1).repeatForever(autoreverses: true)) {
strength = 10
}
}
}
}
There's really no limit to the kinds of animation you can perform!