Moving dots in Text view (loading animation) Swiftui

1.4k Views Asked by At

I want to make Text View with dots that increase and decrease over time.

Now it looks like that: enter image description here

but it looks twitchy and smudged. And also the text itself is shifted every time. How to get rid of it?

Here is my code:

struct TextViewTest: View {
@State var dotsSwitcher = 0
var body: some View {
    Text("Loading\(dots)")
        .animation(.easeOut(duration: 0.1), value: dotsSwitcher)
        .onReceive(Timer.publish(every: 1, on: .main, in: .common)
            .autoconnect()) { _ in dotsAnimation() }
        .onAppear(perform: dotsAnimation)
}

var dots: String {
    switch dotsSwitcher {
    case 1: return "."
    case 2: return ".."
    case 3: return "..."
    default: return ""
    }
}

func dotsAnimation() {
    withAnimation {
        dotsSwitcher = 0
    }
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
        withAnimation {
            dotsSwitcher = 1
        }
    }
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
        withAnimation {
            dotsSwitcher = 2
        }
    }
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
        withAnimation {
            dotsSwitcher = 3
        }
    }
}

}

4

There are 4 best solutions below

0
Max Potapov On

Try sliding transition on text.

Also, you can do it without using GCD, here is a complete solution for text based animated loading indicator with some options:

struct AnimatedTextLoadingIndicatorView: View {
    @State private var text: String = ""
    @State private var dots: String = ""
    private let now: Date = .now
    private static let every: TimeInterval = 0.2
    private let timer = Timer.publish(every: Self.every, on: .main, in: .common).autoconnect()
    private let characters = Array("⣾⣽⣻⢿⡿⣟⣯⣷")

    var body: some View {
        VStack {
            Text(text)
                .font(.largeTitle)
                .transition(.slide)
            Text("Loading" + dots)
                .font(.footnote)
                .transition(.slide)
        }
        .onReceive(timer) { time in
            let tick: Int = .init(time.timeIntervalSince(now) / Self.every) - 1
            let index: Int = tick % characters.count
            text = "\(characters[index])"
            let count: Int = tick % 4
            dots = String(Array(repeating: ".", count: count))
        }
        .onDisappear {
            timer.upstream.connect().cancel()
        }
    }
}
0
Виталий Фадеев On

I modified the view so that the number of dots does not change, I only change their transparency

struct LoadingText: View {
    var text: String
    var color: Color = .black

    @State var dotsCount = 0

    var body: some View {
        HStack(alignment: .bottom, spacing: 0) {
            Text(text)
                .foregroundColor(color) +
            Text(".")
                .foregroundColor(dotsCount > 0 ? color : .clear) +
            Text(".")
                .foregroundColor(dotsCount > 1 ? color : .clear) +
            Text(".")
                .foregroundColor(dotsCount > 2 ? color : .clear)
        }
        .animation(.easeOut(duration: 0.2), value: dotsCount)
        .onReceive(Timer.publish(every: 1, on: .main, in: .common)
            .autoconnect()) { _ in dotsAnimation() }
        .onAppear(perform: dotsAnimation)
    }

    func dotsAnimation() {
        withAnimation {
            dotsCount = 0
        }
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
            withAnimation {
                dotsCount = 1
            }
        }
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
            withAnimation {
                dotsCount = 2
            }
        }
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.9) {
            withAnimation {
                dotsCount = 3
            }
        }
    }
}
0
PAULMAX On

I agree with @MaxPotapov solution and have one additional code:

  Text("Loading")
                    .font(.headline)
                    .fontWeight(.heavy)
                    .foregroundColor(.theme.launchAccent)
                    .overlay {
                        GeometryReader { geo in
                            Text(dots)
                                .font(.headline)
                                .fontWeight(.heavy)
                                .foregroundColor(.theme.launchAccent)
                                .offset(x: geo.size.width + 2)
                        }
                    }

This approach allows you to control the size of Text("Loading") every time and add centered Text(dots) without a dynamic offset that looks like a BUG when adding dots.

0
Gamec On

Based on some of the answers from above I came up with this simple overlay which you can use to add three dots to any view:

struct AnimatedThreeDotsOverlay: ViewModifier {
    final private class Model: ObservableObject {
        private static let interval: TimeInterval = 0.3
        
        @Published private(set) var dots: String = ""
        
        private var timer: Timer?
        
        init() {
            let now = Date.now
            timer = Timer.scheduledTimer(withTimeInterval: Self.interval, repeats: true) { [weak self] timer in
                let tick = Int(timer.fireDate.timeIntervalSince(now) / Self.interval) - 1
                let count = tick % 4
                self?.dots = String(Array(repeating: ".", count: count))
            }
        }
        
        deinit {
            timer?.invalidate()
            timer = nil
        }
    }
    
    @StateObject private var model = Model()
    
    func body(content: Content) -> some View {
        content.overlay {
            GeometryReader { geo in
                Text(model.dots)
                    .font(.footnote)
                    .offset(x: geo.size.width)
            }
        }
    }
}

extension View {
    func animatedThreeDots() -> some View {
       modifier(AnimatedThreeDotsOverlay())
    }
}

You can use like this:

Text("Loading")
    .animatedThreeDots()

This way it will keep animating and keep current state even if parent view reloads.