iosanimationswiftui

SwiftUI animate multiple lines of text


How can I animate lines of text in order to achieve the animation seen here in the notes app: https://imgur.com/a/eC62tmJ

The height of the text gradually increases during the animation while also having some sort of opacity as well as gradient applied which offset from top to bottom. I'm not sure how to achieve this in conjunction with the wavey sort of effect where the lines seem to offset their position vertically as they appear. I have the following code:

struct TextAnimation: View {
    @State var animate = false
    @State var removeGradient = false
    let text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
    
    var body: some View {
        Text(text)
            .padding()
            .frame(height: 300)
            .foregroundStyle(
                LinearGradient(colors: removeGradient ? [Color.black] : [.blue, .red, .green, .yellow], startPoint: .top, endPoint: .bottom)
                    .opacity(animate ? 1 : 0.5)
            )
            .overlay {
                LinearGradient(colors: [.white.opacity(0.8)], startPoint: .top, endPoint: .bottom)
                    .offset(y: animate ? 300 : 0)
                    .animation(.easeIn(duration: 1), value: animate)
            }
            .onAppear(perform: {
                DispatchQueue.main.asyncAfter(wallDeadline: .now() + 1.0) {
                    animate.toggle()
                }
                
                DispatchQueue.main.asyncAfter(wallDeadline: .now() + 2.0) {
                    removeGradient.toggle()
                }
            })
    }
}

Modified code using Apple's TextRenderer sample code:

struct ContentView: View {
    @State var isVisible: Bool = false

    let text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. "
    
    var body: some View {
        VStack {
            GroupBox {
                Toggle("Visible", isOn: $isVisible.animation())
            }

            if isVisible {
                Text(text)
                    .font(.title2)
                    .bold()
                    .foregroundStyle(.blue)
                    .transition(TextTransition())
            }
            Spacer()
        }
        .padding()
    }
}

struct AppearanceEffectRenderer: TextRenderer, Animatable {
    /// The amount of time that passes from the start of the animation.
    /// Animatable.
    var elapsedTime: TimeInterval

    /// The amount of time the app spends animating an individual element.
    var elementDuration: TimeInterval

    /// The amount of time the entire animation takes.
    var totalDuration: TimeInterval

    var spring: Spring {
        .snappy(duration: elementDuration - 0.05, extraBounce: 0.4)
    }

    var animatableData: Double {
        get { elapsedTime }
        set { elapsedTime = newValue }
    }

    init(elapsedTime: TimeInterval, elementDuration: Double = 0.4, totalDuration: TimeInterval) {
        self.elapsedTime = min(elapsedTime, totalDuration)
        self.elementDuration = min(elementDuration, totalDuration)
        self.totalDuration = totalDuration
    }

    func draw(layout: Text.Layout, in context: inout GraphicsContext) {
        for (index, line) in layout.flattenedRuns.enumerated() {
            let delay = elementDelay(count: line.count)
            // The time that the current element starts animating,
            // relative to the start of the animation.
            let timeOffset = TimeInterval(index) * delay
            
            // The amount of time that passes for the current element.
            let elementTime = max(0, min(elapsedTime - timeOffset, elementDuration))
            let progress = elementTime / elementDuration
            // Make a copy of the context so that individual slices
            // don't affect each other.
            var copy = context
            let translationY = spring.value(
                fromValue: -line.typographicBounds.descent,
                toValue: 0,
                initialVelocity: 0,
                time: elementTime)
            copy.translateBy(x: 0, y: translationY)
            copy.opacity = UnitCurve.easeIn.value(at: elapsedTime / 0.2)
            copy.draw(line)
        }
    }

    /// 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)
    }
}

extension Text.Layout {
    /// A helper function for easier access to all runs in a layout.
    var flattenedRuns: some RandomAccessCollection<Text.Layout.Run> {
        self.flatMap { line in
            line
        }
    }
}

struct TextTransition: Transition {
    static var properties: TransitionProperties {
        TransitionProperties(hasMotion: true)
    }

    func body(content: Content, phase: TransitionPhase) -> some View {
        let duration = 1.0
        let elapsedTime = phase.isIdentity ? duration : 0
        let renderer = AppearanceEffectRenderer(
            elapsedTime: elapsedTime,
            totalDuration: duration
        )

        content.transaction { transaction in
            // Force the animation of `elapsedTime` to pace linearly and
            // drive per-glyph springs based on its value.
            if !transaction.disablesAnimations {
                transaction.animation = .linear(duration: duration)
            }
        } body: { view in
            view.textRenderer(renderer)
        }
    }
}

Solution

  • This animation is probably easier to achieve if the lines of text can be animated separately. One way to do this is to cut the view into slices representing individual lines. This just requires knowing, how many lines there are.

    Once the text has ben dissected into individual lines, the wave effect can be achieved by animating the removal of padding from each line, using a progressively longer delay for each line.

    In the animation you linked to, the lines start out as filled blocks in the style of redacted text. This can be achieved by showing a rounded rectangle in the background of each line. The rectangle can be faded out at the same time as the text is faded in.

    The opacity animation probably wants to run faster than the wave animation. One way to achieve this is to apply the opacity change using a separate .animation modifier with body closure, see animation(_:body:).

    So here is an attempt to implement a similar animation. It includes the colored gradient that you were using as foregroundStyle before, but omits the white gradient that you were applying as an overay. This overlay wasn't working very well and I'm not sure if it is actually needed.

    The animation probably needs some more tweaking, but I hope it gets you further:

    struct TextAnimation: View {
        @State private var animate = false
        @ScaledMetric(relativeTo: .body) private var estimatedHeightOfTenLines = 218.0
    
        let text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
    
        private func textLine(index: Int, frameSize: CGSize, lineHeight: CGFloat) -> some View {
            Text(text)
                .frame(width: frameSize.width, height: frameSize.height)
                .offset(y: CGFloat(-index) * lineHeight)
                .frame(height: lineHeight, alignment: .top)
                .clipped()
                .geometryGroup()
        }
    
        var body: some View {
            Text(text)
                .hidden()
                .overlay(alignment: .top) {
                    GeometryReader { proxy in
                        let nLines = Int(round(10.0 * proxy.size.height / estimatedHeightOfTenLines))
                        let lineHeight = nLines > 0 ? proxy.size.height / CGFloat(nLines) : 0
                        VStack(spacing: 0) {
                            ForEach(0..<nLines, id: \.self) { index in
                                textLine(index: index, frameSize: proxy.size, lineHeight: lineHeight)
                                    .animation(.easeInOut(duration: 1)) { view in
                                        view.opacity(animate ? 1 : 0)
                                    }
                                    .background {
                                        RoundedRectangle(cornerRadius: 4)
                                            .fill(.gray.opacity(0.5))
                                            .padding(.vertical, 2)
                                            .animation(.easeInOut(duration: 1)) { view in
                                                view.opacity(animate ? 0 : 1)
                                            }
                                    }
                                    .padding(.top, animate ? 0 : 3)
                                    .animation(.easeInOut(duration: 0.75).delay(Double(index) * 0.05), value: animate)
                            }
                        }
                        .foregroundStyle(
                            LinearGradient(
                                colors: animate ? [Color.black] : [.blue, .red, .green, .yellow],
                                startPoint: .top,
                                endPoint: .bottom
                            )
                        )
                        .animation(.easeInOut(duration: 1), value: animate)
                        .fixedSize(horizontal: false, vertical: true)
                    }
                }
                .padding()
                .onAppear { animate.toggle() }
        }
    }
    

    Example use:

    struct ContentView: View {
        @State private var isVisible = false
    
        var body: some View {
            VStack(spacing: 20) {
                Toggle("Show", isOn: $isVisible.animation())
                    .fixedSize()
                    .padding()
                if isVisible {
                    TextAnimation()
                }
                Spacer()
            }
        }
    }
    

    Animation