Customise Consent Preferences

We use cookies to help you navigate efficiently and perform certain functions. You will find detailed information about all cookies under each consent category below.

The cookies that are categorised as "Necessary" are stored on your browser as they are essential for enabling the basic functionalities of the site. ... 

Always Active

Necessary cookies are required to enable the basic features of this site, such as providing secure log-in or adjusting your consent preferences. These cookies do not store any personally identifiable data.

No cookies to display.

Functional cookies help perform certain functionalities like sharing the content of the website on social media platforms, collecting feedback, and other third-party features.

No cookies to display.

Analytical cookies are used to understand how visitors interact with the website. These cookies help provide information on metrics such as the number of visitors, bounce rate, traffic source, etc.

No cookies to display.

Performance cookies are used to understand and analyse the key performance indexes of the website which helps in delivering a better user experience for the visitors.

No cookies to display.

Advertisement cookies are used to provide visitors with customised advertisements based on the pages you visited previously and to analyse the effectiveness of the ad campaigns.

No cookies to display.

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