EN VI

Problem in SwiftUI with views getting redrawn, interrupting animation?

2024-03-10 09:30:04
How to Problem in SwiftUI with views getting redrawn, interrupting animation

The following code displays a number that is increased every second, along with 5 circles that animate between blue and red colors:

struct ContentView: View {
    
    let timer = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()
    
    @State private var num = 0
    var body: some View {
        VStack {
            Text("\(num)")
                .onReceive(timer) { _ in
                    num += 1. //commenting this line out allows the CirclesView to keep animating properly.
                }
            CirclesView()
        }
    }
}

#Preview {
    ContentView()
}

struct CirclesView: View {
    let timer = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()
    @State private var colorOn = false
    
    var body: some View {
        ForEach(0...5, id: \.self) { n in
            CircleView(size: 30, isOn: n % 2 == 0 ? colorOn : !colorOn)
            
                .onReceive(timer) { _ in
                    colorOn.toggle()
                }
        }
    }
}

struct CircleView: View {
    var size: CGFloat
    var isOn: Bool

    var body: some View {
        Circle()
            .fill(isOn ? .red : .blue)
            .frame(width: size, height: size)
            .animation(.easeInOut(duration: 0.5), value: isOn)
    }
}

The problem is (I think) that the ContentView is redrawn every time the timer ticks because num is a @State var. If I don't update num in the onReceive modifier, the circles animate properly, but if I do update num, the Circles keep getting redrawn before they have a chance for their own timer to run.

Is it possible to have the CirclesView not get redrawn? I need its animation to continue to run and not be interrupted when some other state in ContentView changes. Or what is the right way to go about having views that should not be updated due to some continuous animation that should happen? Is the only way to do it to have some entirely different view "own" the CirclesView so that it's not affected?

Solution:

There are two main issues with the code:

  1. When you declare your Timer as a let property on the View, whenever that View gets re-evaluated, the Timer gets recreated. If you want the Timer to continue through re-evaluations, you need to store it. You could use @State for that.

  2. You have your .onReceive(timer) on each CircleView. I think that you'll find that if you run the code as provided, it doesn't produce the results you want even with that line commented.

Here's a simple example with those elements adjusted:

struct ContentView: View {
    
    let timer = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()
    
    @State private var num = 0
    var body: some View {
        VStack {
            Text("\(num)")
                .onReceive(timer) { _ in
                    num += 1
                }
            CirclesView()
        }
    }
}

struct CirclesView: View {
    @State private var timer = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()
    @State private var colorOn = false
    
    var body: some View {
        ForEach(0...5, id: \.self) { n in
            CircleView(size: 30, isOn: n % 2 == 0 ? colorOn : !colorOn)
        }
        .onReceive(timer) { _ in
            colorOn.toggle()
        }
    }
}

struct CircleView: View {
    var size: CGFloat
    var isOn: Bool

    var body: some View {
        Circle()
            .fill(isOn ? .red : .blue)
            .frame(width: size, height: size)
            .animation(.easeInOut(duration: 0.5), value: isOn)
    }
}

Modern code

That being said, a more modern, SwiftUI-y approach would be to use a task and async/await rather than the Timer/Combine approach. Here's what that would look like:

struct ContentView: View {
    @State private var num = 0
    
    var body: some View {
        VStack {
            Text("\(num)")
            CirclesView()
        }
        .task {
            while !Task.isCancelled {
                try? await Task.sleep(for: .seconds(1))
                num += 1
            }
        }
    }
}

struct CirclesView: View {
    @State private var colorOn = false
    
    var body: some View {
        ForEach(0...5, id: \.self) { n in
            CircleView(size: 30, isOn: n % 2 == 0 ? colorOn : !colorOn)
        }
        .task {
            while !Task.isCancelled {
                try? await Task.sleep(for: .seconds(1))
                colorOn.toggle()
            }
        }
    }
}

Equatable View, just to prove a point

Finally, just for kicks, here's further proof about the re-evaluation of the original view and the let Timer. I do not recommend doing this, but you can force the view to not re-evaluate by conforming to Equatable, and telling the system that the views are always equal. Then, they won't be re-evaluated when the parent is.

struct CirclesView: View {
    let timer = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()
    @State private var colorOn = false
    
    var body: some View {
        ForEach(0...5, id: \.self) { n in
            CircleView(size: 30, isOn: n % 2 == 0 ? colorOn : !colorOn)
        }
        .onReceive(timer) { _ in
            colorOn.toggle()
        }
    }
}

extension CirclesView: Equatable {
    static func == (lhs: CirclesView, rhs: CirclesView) -> Bool {
        true
    }
}

struct CircleView: View {
    var size: CGFloat
    var isOn: Bool

    var body: some View {
        Circle()
            .fill(isOn ? .red : .blue)
            .frame(width: size, height: size)
            .animation(.easeInOut(duration: 0.5), value: isOn)
    }
}

The above is for learning only! The recommended approach is the async/await in the second code block.

Answer

Login


Forgot Your Password?

Create Account


Lost your password? Please enter your email address. You will receive a link to create a new password.

Reset Password

Back to login