27

My view is determined by state stored in a ViewModel. Sometimes the view might call a function on its ViewModel, causing an asynchronous state change.

How can I animate the effect of that state change in the View?

Here's a contrived example, where the call to viewModel.change() will cause the view to change colour.

  • Expected behaviour: slow dissolve from blue to red.
  • Actual behaviour: immediate change from blue to red.
class ViewModel: ObservableObject {

    @Published var color: UIColor = .blue

    func change() {
        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
            self.color = .red
        }
    }
}

struct ContentView: View {

    @ObservedObject var viewModel = ViewModel()

    var body: some View {
        Color(viewModel.color).onAppear {
            withAnimation(.easeInOut(duration: 1.0)) {
                self.viewModel.change()
            }
        }
    }
}

If I remove the ViewModel and store the state in the view itself, everything works as expected. That's not a great solution, however, because I want to encapsulate state in the ViewModel.

struct ContentView: View {

    @State var color: UIColor = .blue

    var body: some View {
        Color(color).onAppear {
            DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
                withAnimation(.easeInOut(duration: 1.0)) {
                    self.color = .red
                }
            }
        }
    }
}
sam-w
  • 7,478
  • 1
  • 47
  • 77
  • 1
    Your first code example with the the view model actually slowly faded/dissolved from blue to red as you intend, when I tested with iOS 13.3, Xcode 11.3. Is there any other parent view, or other code that could be influencing this in your project? – Seb Jachec Jan 08 '20 at 13:35
  • @Seb, you're absolutely right, my bad. I've updated the example so that it better reflects what's going on – sam-w Jan 08 '20 at 13:48

3 Answers3

47

Using .animation()

You can use .animation(...) on a body content or on any subview but it will animate all changes of the view.

Let's consider an example when we have two API calls through the ViewModel and use .animation(.default) on body content:

import SwiftUI
import Foundation

class ArticleViewModel: ObservableObject {
    @Published var title = ""
    @Published var content = ""

    func fetchArticle() {
        DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
            self?.title = "Article Title"
        }
    }

    func fetchContent() {
        DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
            self?.content = "Content"
        }
    }
}

struct ArticleView: View {
    @ObservedObject var viewModel = ArticleViewModel()

    var body: some View {
        VStack(alignment: .leading) {
            if viewModel.title.isEmpty {
                Button("Load Article") {
                    self.viewModel.fetchArticle()
                }
            } else {
                Text(viewModel.title).font(.title)

                if viewModel.content.isEmpty {
                    Button("Load content...") {
                        self.viewModel.fetchContent()
                    }
                    .padding(.vertical, 5)
                    .frame(maxWidth: .infinity, alignment: .center)
                } else {
                    Rectangle()
                        .foregroundColor(Color.blue.opacity(0.2))
                        .frame(height: 80)
                        .overlay(Text(viewModel.content))
                }
            }
        }
        .padding()
        .frame(width: 300)
        .background(Color.gray.opacity(0.2))
        .animation(.default) // animate all changes of the view
    }
}

The result would be next:

Animation modifier on body

You can see that we have animation on both actions. It might be preferred behavior but in some cases, you may want to control each action separately.

Using .onReceive()

Let say we want animate the view after first API call (fetchArticle), but on the second (fetchContent) - just redraw view without animation. In other words - animate the view when the title received but does not animate view when the content received.

To implement this we need:

  1. Create a separate property @State var title = "" in the View.
  2. Use this new property all over the view instead of viewModel.title.
  3. Declare .onReceive(viewModel.$title) { newTitle in ... }. This closure will execute when the publisher (viewModel.$title) sends a new value. On this step, we have control over properties in the View. In our case, we will update the title property of the View.
  4. Use withAnimation {...} inside the closure to animate the changes.

So we will have animation when the title updates. While receiving a new content value of the ViewModel our View just updates without animation.

struct ArticleView: View {
    @ObservedObject var viewModel = ArticleViewModel()
    // 1
    @State var title = ""

    var body: some View {
        VStack(alignment: .leading) {
            // 2
            if title.isEmpty {
                Button("Load Article") {
                    self.viewModel.fetchArticle()
                }
            } else {
                // 2
                Text(title).font(.title)

                if viewModel.content.isEmpty {
                    Button("Load content...") {
                        self.viewModel.fetchContent()
                    }
                    .padding(.vertical, 5)
                    .frame(maxWidth: .infinity, alignment: .center)
                } else {
                    Rectangle()
                        .foregroundColor(Color.blue.opacity(0.2))
                        .frame(height: 80)
                        .overlay(Text(viewModel.content))
                }
            }
        }
        .padding()
        .frame(width: 300)
        .background(Color.gray.opacity(0.2))
        // 3
        .onReceive(viewModel.$title) { newTitle in
            // 4
            withAnimation {
                self.title = newTitle
            }
        }
    }
}

The result would be next:

Useing onReceive

Andrew Bogaevskyi
  • 2,251
  • 21
  • 25
  • 2
    That's a very thorough and well written answer. Thumbs up! – CodingMeSwiftly Jun 25 '20 at 09:16
  • The life savior is solution 2, onRecieve is only supported in iOS 14 and upwards, I ended up using PublishedObjects to mutate title property from outside for my solution – Kunal Balani Dec 30 '20 at 22:42
  • Given that `.animation(...)` will animate every child views, I think `.onReceive` approach might be better preventing unnecessary side effect (unintended animation). – user482594 Jan 02 '21 at 15:11
  • remark: if I extract the code in VStack and put them in a ViewBuilder function, and put .animation() after the call of the ViewBuilder, the animation does not work. Put .animation() inside the ViewBuilder works. – Bill Chan Mar 27 '23 at 15:17
4

It looks as though using withAnimation inside an async closure causes the color not to animate, but instead to change instantly.

Either removing the wrapping asyncAfter, or removing the withAnimation call and adding an animation modifier in the body of your ContentView (as follows) should fix the issue:

Color(viewModel.color).onAppear {
    self.viewModel.change()
}.animation(.easeInOut(duration: 1))

Tested locally (iOS 13.3, Xcode 11.3) and this also appears to dissolve/fade from blue to red as you intend.

Seb Jachec
  • 3,003
  • 2
  • 30
  • 56
  • Yep, this does the trick. I suppose the fact that `viewModel.change` is async must break `withAnimation { ... }`, which makes sense I guess. I'd still rather have an explicit animation on a call to `viewModel.doSomething()`, rather than an implicit animation modifier, but beggars can't be choosers! – sam-w Jan 08 '20 at 13:55
  • if you agree with my assessment that the async update is the real cause of the problem, it might be worth updating your answer for any future Googlers. If not I'd be happy to do make the edit myself :) – sam-w Jan 08 '20 at 13:57
  • Yep, agreed on that assessment. Edited my answer, but open to yourself or anyone else to further reword/tweak as needed :) – Seb Jachec Jan 08 '20 at 14:06
1

Update @Seb Jachec's answer for Xcode 14.2, to fix the deprecated warning:

        Color(viewModel.color)
            .onAppear {
                viewModel.change()
            }
            .animation(.easeInOut(duration: 1), value: viewModel.color)
Bill Chan
  • 3,199
  • 36
  • 32