SwiftUI view not animating when bound to @Published var if action isn't completed immediately

3.9k views Asked by At

I have a SwiftUI view that is swapping out certain controls depending on state. I'm trying to use MVVM, so most/all of my logic has been pushed off to a view model. I have found that when doing a complex action that modifies a @Published var on the view model, the View will not animate.

Here's an example where a 1.0s timer in the view model simulates other work being done before changing the @Published var value:

struct ContentView: View {
    @State var showCircle = true
    @ObservedObject var viewModel = ViewModel()

    var body: some View {

        VStack {
            VStack {
                if showCircle {
                    Circle().frame(width: 100, height: 100)
                }

                Button(action: {
                    withAnimation {
                        self.showCircle.toggle()
                    }

                }) {
                    Text("With State Variable")
                }
            }

            VStack {
                if viewModel.showCircle {
                    Circle().frame(width: 100, height: 100)
                }
                Button(action: {
                    withAnimation {
                        self.viewModel.toggle()
                    }
                }) {
                    Text("With ViewModel Observation")
                }
            }
        }
    }


    class ViewModel: ObservableObject {
        @Published var showCircle = true

        public func toggle() {
            // Do some amount of work here. The Time is just to simulate work being done that may not complete immediately.
            Timer.scheduledTimer(withTimeInterval: 1.0, repeats: false) { [weak self] _ in
                self?.showCircle.toggle()
            }

        }
    }
3

There are 3 answers

4
Asperi On BEST ANSWER

In the case of view model workflow your withAnimation does nothing, because not state is changed during this case (it is just a function call), only timer is scheduled, so you'd rather need it as

Button(action: {
    self.viewModel.toggle()  // removed from here
}) {
    Text("With ViewModel Observation")
}

...

Timer.scheduledTimer(withTimeInterval: 1.0, repeats: false) { [weak self] _ in
    withAnimation { // << added here
        self?.showCircle.toggle()
    }
}

However I would rather recommend to rethink view design... like

VStack {
    if showCircle2 { // same declaration as showCircle
        Circle().frame(width: 100, height: 100)
    }
    Button(action: {
        self.viewModel.toggle()
    }) {
        Text("With ViewModel Observation")
    }
    .onReceive(viewModel.$showCircle) { value in
        withAnimation {
            self.showCircle2 = value
        }
    }
}

Tested with Xcode 11.2 / iOS 13.2

2
Bijoy Thangaraj On

You must use delay for Animation instead of using a Timer to delay your animations.

Button(action: {
    withAnimation(Animation.linear.delay(1.0)) {
        self.viewModel.showCircle.toggle()
    }
}) {
    Text("With ViewModel Observation")
}

Otherwise, changes in your view model will behave just as expected when you are not using a Timer.

class ViewModel: ObservableObject {
    @Published var showCircle = true

    public func toggle() {

        /* other operations */

        self.showCircle.toggle()
    }

}
1
Dan O'Leary On

The parent view animates the hiding and the showing of its child views. If you place an .animation(.easeIn) (or .easeOut or whatever you like) at the end of your first VStack it should work as expected.

As such...

struct ContentView: View {
@State var showCircle = true
@ObservedObject var viewModel = ViewModel()

var body: some View {
    VStack {
        VStack {
            if showCircle {
                Circle().frame(width: 100, height: 100)
            }

            Button(action: {
                withAnimation {
                    self.showCircle.toggle()
                }

            }) {
                Text("With State Variable")
            }
        }

        VStack {
            if viewModel.showCircle {
                Circle().frame(width: 100, height: 100)
            }
            Button(action: {
                withAnimation {
                    self.viewModel.toggle()
                }
            }) {
                Text("With ViewModel Observation")
            }
        }
    }.animation(.easeIn)
  }
}