Animating rows of text in Swiftui?

119 views Asked by At

My code works, but I'm not sure if this is the proper way of doing MVVM. I have some lines of text which I want to appear line by line, with a sliding animation. One line per second.

This is my ViewModel:

@MainActor
class MyViewModel: ObservableObject {
    @Published var lines: [String] = []
    
    init() {
        let myLines = [ "This", "is", "just", "a", "test" ]
        
        for (index, line) in myLines.enumerated() {
            DispatchQueue.main.asyncAfter(deadline: .now() + Double(index)) {
                withAnimation {
                    self.lines.append(line)
                }
            }
        }
    }
}

And this is my ContentView:

import SwiftUI

struct ContentView: View {
    @StateObject var vm = MyViewModel()
    
    var body: some View {

        VStack {
            ForEach(vm.lines, id: \.self) { line in
                Text(line)
                    .transition(.move(edge: .leading))
            }
        }
        .padding()
    }
}

#Preview {
    ContentView()
}

Somehow I'm not liking the idea of delaying the population of lines in the ViewModel. Maybe I want to use this array for something else that doesn't require them to appear one by one. At the same time if I move the animation to the ContentView I have to write some ugly code in .onAppear to populate a @State-var with a delay, instead of using vm.lines in my ForEach-loop.

Thoughts on this please?

1

There are 1 answers

0
Benzy Neez On

One way to stagger the appearance of the text would be to apply an offset which is animated with a delay.

In order that the text container (the VStack) occupies the minimum possible width and the text slides in from its side, the animated part can be shown in an overlay and a GeometryReader can be used to measure the width of the container. The footprint for the container is established by showing the same text, but hidden.

When doing it this way, the hardest part is actually controlling the height, because you (presumably) don't want to reserve space for text that is not yet visible. One way to solve this is to apply negative padding to the content that follows. A GeometryReader can be used here too, to get the height of the text block.

The following example shows the text appearing in much the same way as your original post, but it does not use transitions and the view model no longer has to append the lines one by one. The .onAppear updates are for demo purposes only, in real use I would expect the model to be updated in some other way:

class MyViewModel: ObservableObject {
    @Published var lines: [String] = []
}

struct ContentView: View {
    @StateObject var vm = MyViewModel()
    @State private var runningAnimation = false
    @Namespace private var namespace

    var body: some View {
        VStack {
            Color.orange
            VStack {
                VStack {
                    ForEach(Array(vm.lines.enumerated()), id: \.offset) { index, line in
                        Text(line)
                    }
                }
                .hidden()
                .overlay {
                    GeometryReader { proxy in
                        VStack {
                            ForEach(Array(vm.lines.enumerated()), id: \.offset) { index, line in
                                Text(line)
                                    .offset(x: runningAnimation ? 0 : -proxy.size.width)
                                    .animation(.easeInOut.delay(TimeInterval(index) + 0.5), value: runningAnimation)
                            }
                        }
                        .clipped()
                    }
                }
                GeometryReader { proxy in
                    Color.yellow
                        .padding(.top, runningAnimation ? 0 : -proxy.frame(in: .named(namespace)).minY)
                        .animation(.linear(duration: TimeInterval(vm.lines.count)), value: runningAnimation)
                }
            }
            .coordinateSpace(name: namespace)
        }
        .padding()
        .onAppear {
            runningAnimation = !vm.lines.isEmpty
            DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 1) {
                vm.lines = [ "This", "is", "just", "a", "test" ]
            }
            DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 7) {
                vm.lines = []
                DispatchQueue.main.async {
                    vm.lines = ["The quick brown fox", "jumps over the lazy dog"]
                }
            }
        }
        .onChange(of: vm.lines) { oldLines, newLines in
            runningAnimation = !newLines.isEmpty
        }
    }
}

Animation1

If the text were to slide in from the side of the screen, instead of from the side of the container, then the code can be simplified. A hidden footprint is not needed and the animation can be performed on the lines of text without needing to use an overlay:

var body: some View {
    GeometryReader { proxy in
        VStack {
            Color.orange
            VStack {
                VStack {
                    ForEach(Array(vm.lines.enumerated()), id: \.offset) { index, line in
                        Text(line)
                            .offset(x: runningAnimation ? 0 : -proxy.size.width)
                            .animation(.easeInOut.delay(TimeInterval(index) + 0.5), value: runningAnimation)
                    }
                }
                GeometryReader { proxy in
                    Color.yellow
                        .padding(.top, runningAnimation ? 0 : -proxy.frame(in: .named(namespace)).minY)
                        .animation(.linear(duration: TimeInterval(vm.lines.count)), value: runningAnimation)
                }
            }
            .coordinateSpace(name: namespace)
        }
        .padding()
    }
    // .onAppear and .onChange as before
}

Animation2


EDIT Another variation would be to reveal each line of text by changing the frame width from 0 to the required width, with animation. The footprint is determined by showing the text hidden, then the visible text is shown as an overlay. It works best if the speed of reveal is dependent on the length of the text:

VStack {
    Color.orange
    VStack {
        VStack {
            ForEach(Array(lines.enumerated()), id: \.offset) { index, line in
                Text(line)
                    .hidden()
                    .overlay {
                        GeometryReader { proxy in
                            Text(line)
                                .lineLimit(1)
                                .fixedSize(horizontal: true, vertical: false)
                                .frame(width: runningAnimation ? proxy.size.width : 0, alignment: .leading)
                                .clipped()
                                .animation(
                                    .easeInOut(duration: (proxy.size.width / 100) * 0.5)
                                    .delay(TimeInterval(index) + 0.5),
                                    value: runningAnimation
                                )
                        }
                    }
            }
        }
        GeometryReader { proxy in
            Color.yellow
                .padding(.top, runningAnimation ? 0 : -proxy.frame(in: .named(namespace)).minY)
                .animation(.linear(duration: TimeInterval(lines.count)), value: runningAnimation)
        }
    }
    .coordinateSpace(name: namespace)
}
.padding()
// .onAppear and .onChange as before

Animation3