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.
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
}
}
}
}
}
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:
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.
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:
@State var title = ""
in the View.viewModel.title
..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.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:
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.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With