SwiftUI and MVI

Vyacheslav Ansimov
8 min readOct 13, 2021

--

UIKit first appeared in iOS 2 and has remained with us ever since. Over time, we have become accustomed to it and learned how to work with it. We have discovered various architectural approaches, and in my opinion, MVVM has become the most popular. With the release of SwiftUI, MVVM has further strengthened its position, while other architectures don’t perform as well with SwiftUI.

However, it is possible to adapt Clean Swift, VIPER, and other approaches to SwiftUI, and they might even be more convenient than MVVM.

I want to talk about one of these architectures — MVI. But first, a bit of theory.

For those who prefer to dive straight into the code, I’ll leave a link to the repository at the beginning of the article. You can explore the project written in the MVI architecture here.

Bidirectional and Unidirectional architectures

All existing architectures can be divided into two types:

  1. Bidirectional
  2. Unidirectional

In Bidirectional architectures, information flows between modules that can both transmit and receive data from one another. Each module has the capability to both send and receive data.

A Module is an independent component that performs a specific role and has an interface for interacting with other modules.

The primary drawback of such architectures is the management of data flow. In large and complex screens, it becomes challenging to navigate, and it’s nearly impossible to keep track of where the data originated, where it undergoes changes, and what the final state of the screen reflects. These architectures are suitable for small to medium-sized applications and are generally simpler compared to unidirectional architectures.

Unidirectional architectures, on the other hand, are structured such that data flows in one direction. Importantly, one module remains oblivious to the existence of another module and cannot directly transmit data back to the module from which it received data.

Working with unidirectional architecture often leads to complaints about unnecessary modules for simple screens. Another complaint is that even small changes require data to be transfered through all the modules, and some modules act as proxies and do nothing.

However, these drawbacks are offset by larger screens with complex logic. In such architectures, responsibilities are better distributed than in bidirectional architectures. Working with the code is simplified because it is easy to track where the data comes from, where it changes, and where it goes.

I was a bit unfair when I initially said that there are architectures that are not inferior to MVVM or even better. They do exist, but they are best suited for large projects. I’m talking about MVI, Clean Swift, and other unidirectional approaches

For small projects, bidirectional architectures are well suited because they are easier to understand and do not require creating and maintaining unnecessary modules.

With the theory covered, let’s delve into one of the unidirectional architectures.

MVI — brief history and principle of operation

This pattern was first described by JavaScript developer Andre Staltz. The general principles can be found here

In MVI architecture, we break down the components as follows:

  • Intent: function from Observable of user events to Observable of “actions
  • Model: function from Observable of actions to Observable of state
  • View: function from Observable of state to Observable of rendering
  • Custom element: subsection of the rendering which is in itself a UI program. May be implemented as MVI. Is optional to use in a View.

MVI adopts a reactive approach where each module functions by expecting events, processing them, and passing them along to the next module, creating a unidirectional flow.

In a mobile app, the MVI diagram closely resembles the original, with only minor adjustments:

  • Intent receives an event from View and communicates with the business logic
  • Model receives data from Intent and prepares it for display. The Model also keeps the current state of the View.
  • View displays the prepared data.

To ensure a unidirectional data flow, it’s essential for the View to have a reference to the Intent, the Intent to the Model, and the Model to the View. However, implementing this in SwiftUI poses a challenge because the View is a structure, and the Model can’t directly reference it.

To overcome this hurdle, you can introduce an additional module called Container. The Container’s primary role is to maintain references to the Intent and Model and facilitate accessibility among the modules, ensuring a truly unidirectional data flow.

While this may sound complex in theory, it’s quite straightforward in practice.

Implementation of Container

Let’s write a screen showing a small list of WWDC video. I’ll describe the basics, and you’ll be able to see the full implementation on GitHub.

Let’s start with the Container, and since this class will be used frequently, let’s write a universal class for all screens.

/* The container will provide the View with access to the screen’s 
properties but will not allow them to be changed directly,
only through Intents */
final class MVIContainer<Intent, Model>: ObservableObject {

let intent: Intent
let model: Model

private var cancellable: Set<AnyCancellable> = []

/* Unfortunately, you can’t specify the type ObjectWillChangePublisher
through generics, so we’ll specify it with an additional property */
init(intent: Intent, model: Model, modelChangePublisher: ObjectWillChangePublisher) {
self.intent = intent
self.model = model

/* It's necessary to ensure that changes in the Model will
receive View, and not just Container */
modelChangePublisher
.receive(on: RunLoop.main)
.sink(receiveValue: objectWillChange.send)
.store(in: &cancellable)
}
}

View

The initialization of View will look like this:

/* ListView will display a list of videos from WWDC. */
struct ListView: View{

@StateObject var container: MVIContainer<ListIntentProtocol, ListModelStatePotocol>

/* You don't have to write these properties, but they
simplify access to Intent and View, otherwise there would be
container.intent and container.state */
private var intent: ListIntentProtocol { container.intent }
private var state: ListModelStatePotocol { container.model }

init() {
let model = ListModel()
let intent = ListIntent(model: model)
let container = MVIContainer(
intent: intent as ListIntentProtocol,
model: model as ListModelStatePotocol,
modelChangePublisher: model.objectWillChange
)
self._container = StateObject(wrappedValue: container)
}

...
}

Let’s see how View works:

struct ListView: View {

@StateObject private var container: MVIContainer<ListIntent, ListModelStatePotocol>

/* You don't have to write these properties, but they
simplify access to Intent and View, otherwise there would be
container.intent and container.state */
private var intent: ListIntentProtocol { container.intent }
private var state: ListModelStatePotocol { container.model }

...

var body: some View {

/* It receives the ready-to-display data from the model */
Text(state.text)
.padding()
.onAppear(perform: {

/* Notifies the Intent of events occurring in the View */
intent.viewOnAppear()
})
}
}

In this code example, the View receives data from the Model but cannot modify it. View also notifies the Intent of an event. What the Intent will do with this event, the View doesn’t know.

Intent

The Intent expects events from the View for further actions. It also handles business logic and databases, makes requests to the server, and so on.

final class ListIntent {

/* ListModelActionsProtocol hides screen properties from Intent
and allows Intent to pass data to Model */
private weak var model: ListModelActionsProtocol?

private let numberService: NumberServiceProtocol

init(
model: ListModelActionsProtocol,
numberService: NumberServiceProtocol
) {
self.model = model
self.numberService = numberService
}

func viewOnAppear() {
/* Intent synchronously or asynchronously gets business data */
numberService.getNumber(completion: { [weak self] number in

/* After getting the data it sends the data to the Model */
self?.model?.parse(number: number)
})
}
}

In this code example, the function viewOnAppear was called by the View, thereby notifying the Intent of the screen display event. The Intent asynchronously fetched the data and passed it to the Model.

Model

The Model receives data from the Intent and prepares it for display. The Model also saves the current screen state.

The model will have two protocols: one for the Intent, which allows the Intent to pass data to the Model, and the other for the View, which provides access to the current screen state. The ObservableObject protocol allows the View to reactively request data updates.

Let’s take a closer look at all of this.

/* Through this protocol Model gives access to the current state of the screen. 
The View sees only properties. */
protocol ListModelStatePotocol {

var text: String { get }
}

/* Through this protocol, the Intent can pass data to the Model.
And this protocol hides all properties from the Intent. */
protocol ListModelActionsProtocol: AnyObject {

func parse(number: Int)
}

Model Implementation:

/* To leverage the full power of SwiftUI, let’s sign the model to 
the ObservableObject protocol, and when we modify any property
marked as @Published, all changes will automatically update
the view and display it. */
final class ListModel: ObservableObject, ListModelStatePotocol {

@Published var text: String = ""
}

extension ListModel: ListModelActionsProtocol {

func parse(number: Int) {

/* The Model prepares the received data for display */
let showText = "Random number: " + String(number)

/* Once prepared, the Model updates the screen state.
Since the text property is marked @Published,
the View will receive the data almost as soon as it changes */
self.text = showText
}
}

p.s.

I wrote about this very briefly, so it could have been missed. Just in case, I’ll mention it again: MVI can be used not only for screens but also for buttons, cells, and so on. It’s not mandatory, but some might find it appealing.

A question might arise: Why reinvent the container? Why can’t we do it like this:

struct ListView {

let intent: ListIntentProtocol

@StateObject var model: ListModel

...
}

Working `var model` through the ListModelProtocol won’t work because it requires the type to be ObservableObject. Without the protocol, the View can modify data in the Model, which violates the unidirectional data flow. This is precisely why a container is needed.

Briefly about the container: Since View is a structure and Model cannot hold a reference to View, the logic of Model had to be divided into protocols (one for View, another for Intent). The container holds references to both Model and Intent, provides access for the View to the screen properties, and prevents the View from changing them.

Diagrams

Class Diagram
Sequence Diagram

Conclusion

I’ve described the basic principles of operation; for more details, you can check out the project on GitHub.

MVI is a reactive and unidirectional architecture. It allows for the implementation of complex screens and dynamic state changes while effectively dividing responsibilities. This implementation is certainly not the only correct one; there are always alternatives, and you can experiment, add, or simplify as you see fit. In any case, this architecture aligns well with reactive SwiftUI and helps simplify working with heavy screens.

--

--