Better Programming

Advice for programmers.

Follow publication

SwiftUI: How to trigger animations from outside the View scope?

https://pngtree.com/freebackground/porto-s-old-town-with-boat_3502778.html

Currently in SwiftUI we have only one way of triggering animations, which is from the View scope side. We should execute something like withAnimation and then perform the required changes in its callback. Sometimes, when relying on MVVM, we want some changes to be triggered from events in the ViewModel that are reflected to the View state and then it's updated. Unfortunately we don't have the power of animation routines outside SwiftUI views, but there is a great design pattern that helps us to trigger it using Combine. I will illustrate it with an example.

Our scenario

Imagine we are relying on a ML-MVVM and a child ViewModel should receive events from the parent ViewModel. However, we desire those updates are performed with animations. We don't have the View side to call withAnimation or something like that, so how to solve this?

import SwiftUI
import Combine

class ContentViewModel: ObservableObject {
@Published var shouldExpand: Bool = false
@Published var buttonTitle: String = "Collapse"

let rectangleViewModel = RectangleViewModel()

func didClick() {
if shouldExpand {
rectangleViewModel.expandView()
} else {
rectangleViewModel.collapseView()
}
buttonTitle = shouldExpand ? "Collapse" : "Expand"
shouldExpand.toggle()
}
}

class RectangleViewModel: ObservableObject {
@Published var width: CGFloat = .infinity

let expandSubject = PassthroughSubject<Void, Never>()
let collapseSubject = PassthroughSubject<Void, Never>()

func expandView() {
width = .infinity
}

func collapseView() {
width = 200
}
}

struct ContentView: View {

@StateObject var viewModel = ContentViewModel()

var body: some View {
VStack {
Button(viewModel.buttonTitle,
action: viewModel.didClick)
RectangleView(viewModel: viewModel.rectangleViewModel)
}
}
}

struct RectangleView: View {
@StateObject var viewModel: RectangleViewModel

var body: some View {
Rectangle()
.fill(Color.yellow)
.frame(maxWidth: viewModel.width, maxHeight: 100)
}
}

This is what we are doing visually:

Our classes relate in the following structure:

When RectangleViewModel receives events from ContentViewModel, it's responsible for updating the width of the Rectangle, however, we don't have a way of triggering withAnimation from there and this way we can never animate our interaction. We would need to pass through the RectangleView layer somehow to do that. Fortunately there is a new design pattern to achieve this objective with Combine:

View Callback

The solution I provide for that is triggering an event to the RectangleView itself and make it perform the update in its ViewModel using withAnimation. We may propagate this event to the view through onReceive modifier in SwiftUI:

class RectangleViewModel: ObservableObject {
@Published var width: CGFloat = .infinity

// Add these two publishers
let expandSubject = PassthroughSubject<Void, Never>()
let collapseSubject = PassthroughSubject<Void, Never>()

func expandView() {
expandSubject.send()
}

func collapseView() {
collapseSubject.send()
}

// Add these two methods bellow
func expandEvent() {
width = .infinity
}

func collapseEvent() {
width = 200
}
}
struct RectangleView: View {
@StateObject var viewModel: RectangleViewModel

var body: some View {
Rectangle()
.fill(Color.yellow)
.frame(maxWidth: viewModel.width, maxHeight: 100)
// Add these two listeners to the view model publishers
.onReceive(viewModel.expandSubject) {
withAnimation {
viewModel.expandEvent()
}
}
.onReceive(viewModel.collapseSubject) {
withAnimation {
viewModel.collapseEvent()
}
}
}
}

For your information, onReceive Modifier receives a publisher as parameter and triggers a callback with a value in the View once this publisher emits an event. You can see it like it was a sink subscriber inside a SwiftUI View.

What we are doing now is making the View listen to the event publishers that are triggered by the ViewModel once it receives the collapse/expand events. After that, the View itself embeds the ViewModel true updates within an animation closure. Now observe the outcome:

That's how it works:

The main advantage of this approach is that you are able to rely on the declarative scope of a view to trigger animations or use any resources that exist only in SwiftUI. However, the disadvantage is that you increase some coupling between View and ViewModel by making it mandatory to pass through the View just to use a single resource. It may also severely increase the verbosity of your scene, which should naturally be avoided in SwiftUI.

Conclusion

This article introduced a new patter, View Callback, that allows you to enter the scope of SwiftUI and use some of its resources to trigger animations in the logic layer. You could also use it to use other wrappers and modifiers such as environment. I hope that improved the way you create complex animations in SwiftUI and eliminated any blockers in your development ;)

Free

Distraction-free reading. No ads.

Organize your knowledge with lists and highlights.

Tell your story. Find your audience.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

Pedro Alvarez
Pedro Alvarez

Written by Pedro Alvarez

Mobile Engineer | iOS | Android | KMP | Flutter | WWDC19 scholarship winner | Blockchain enthusiast https://www.linkedin.com/in/pedro-alvarez94/

No responses yet

Write a response