Search code examples
animationswiftuiswiftui-animation

SwiftUI Animation from @Published property changing from outside the View


SwiftUI offers .animation() on bindings that will animate changes in the view. But if an @Published property from an @ObserveredObject changes 'autonomously' (e.g., from a timer), while the view will update in response to the change, there is no obvious way to get the view to animate the change.

In the example below, when isOn is changed from the Toggle, it animates, but when changed from the Timer it does not. Interestingly, if I use a ternary conditional here rather than if/else even the toggle will not trigger animation.

struct ContentView: View {
    @ObservedObject var model: Model
    var body: some View {
        VStack {
            if model.isOn {
                MyImage(color: .blue)
            } else {
                MyImage(color: .clear)
            }
            Spacer()
            Toggle("switch", isOn: $model.isOn.animation(.easeIn(duration: 0.5)))
            Spacer()
        }
    }
}

struct MyImage: View {
    var color: Color
    var body: some View {
        Image(systemName: "pencil.circle.fill")
            .resizable()
            .frame(width: 100, height: 100)
            .foregroundColor(color)
    }
}

class Model: ObservableObject {
    @Published var isOn: Bool = false
    var timer = Timer()
    init() {
        timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true, block: { [unowned self] _ in
            isOn.toggle()
        })
    }
}

How can I trigger animations when the value changes are not coming from a binding?


Solution

  • The easiest option is to add a withAnimation block inside your timer closure:

    withAnimation(.easeIn(duration: 0.5)) {
      isOn.toggle()
    }
    

    If you don't have the ability to change the @ObservableObject closure, you could add a local variable to mirror the changes:

    struct ContentView: View {
        @ObservedObject var model: Model
        @State var localIsOn = false
        var body: some View {
            VStack {
                if localIsOn {
                    MyImage(color: .blue)
                } else {
                    MyImage(color: .clear)
                }
                Spacer()
                Toggle("switch", isOn: $model.isOn.animation(.easeIn(duration: 0.5)))
                Spacer()
            }.onChange(of: model.isOn) { (on) in
                withAnimation {
                    localIsOn = on
                }
            }
        }
    }
    

    You could also do a similar trick with a mirrored variable inside your ObservableObject:

    
    struct ContentView: View {
        @ObservedObject var model: Model
        var body: some View {
            VStack {
                if model.animatedOn {
                    MyImage(color: .blue)
                } else {
                    MyImage(color: .clear)
                }
                Spacer()
                Toggle("switch", isOn: $model.isOn.animation(.easeIn(duration: 0.5)))
                Spacer()
            }
        }
    }
    
    
    class Model: ObservableObject {
        @Published var isOn: Bool = false
        @Published var animatedOn : Bool = false
        
        var cancellable : AnyCancellable?
        
        var timer = Timer()
        init() {
            timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true, block: { [unowned self] _ in
                    isOn.toggle()
            })
            cancellable = $isOn.sink(receiveValue: { (on) in
                withAnimation {
                    self.animatedOn = on
                }
            })
        }
    }