Pull to refresh

SwiftUI and MVI

Reading time5 min
Views7.8K

UIKit first appeared in iOS 2, and it is still here. Eventually we got to know it well and learned how to work with it. We have found many architectural approaches. MVVM, the most popular architecture in my opinion, has strengthened its position with the release of SwiftUI, while other architectures seemed to have some kind of problematic relationships with SwiftUI.

But what if I told you that Clean Swift, VIPER and other approaches can be adapted to SwiftUI. What if I told you that there are some modern architectures which might be as good as MVVM or even better.

We will talk about MVI.

I will break down what it is and use it as an example to show how you can adapt architectures to make them friendly with SwiftUI.

Bidirectional and Unidirectional architectures

All known architectures can be divided into two types:

  1. Bidirectional

  2. Unidirectional

Bidirectional architectures are divided into layers that transmit with each other. One layer can transmit and receive data from another layer.

The main drawback of such architectures is the management of data flow. In large and complex screens it is difficult to navigate and almost impossible to keep in mind where the data came from, where it changes, and what the screen finally receives. These architectures are good for small to medium sized applications. They are generally simpler than Unidirectional architectures.

Unidirectional architectures are divided into layers that transmit data to one type of layers and receive data from another type.

Working with Unidirectional architecture may often cause the complaint, that some layers are excessive on simple screens, but you have to keep them. Another complaint is that with small changes you have to proxy data through all layers. All these disadvantages are compensated on large screens with complex logic. In such architectures duties are distributed better than in Bidirectional architectures. Working with code is simplified because it is easy to track where the data comes from, where it changes and where it goes away.

I was a bit unfair when I said at the beginning that there are architectures as good as MVVM or even better. They do exist, but work best for large projects. I’m talking about MVI, Clean Swift and other Unidirectional approaches.

Let’s take a closer look at one of these 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

  • 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, or as a Web Component. Is optional to use in a View.

MVI has a reactive approach. Each module (function) expects some event, and after receiving and processing it, it passes this event to the next module. It turns out an unidirectional flow.

In the mobile app the diagram looks very close to the original with only minor changes:

  • 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 provide a unidirectional data flow, you need to make sure that the View has a reference to the Intent, the Intent has a reference to the Model, which in turn has a reference to the View.

The main problem in implementing this approach in SwiftUI is View. View is a structure and Model cannot have references to View. To solve this problem, you can introduce an additional layer Container, which main task is to keep references to Intent and Model, and provide accessibility to the layers so that the unidirectional data flow is truly unidirectional.

It sounds complicated, but it is quite simple in practice.

Implementation of Container

Let’s write a screen that shows a small list of WWDC videos. I will describe the basics, and you can see the full implementation on GitHub.

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

// 1
final class MVIContainer<Intent, Model>: ObservableObject {

    // 2
    let intent: Intent
    let model: Model

    private var cancellable: Set<AnyCancellable> = []

    init(intent: Intent, model: Model, modelChangePublisher: ObjectWillChangePublisher) {
        self.intent = intent
        self.model = model

        // 3
        modelChangePublisher
            .receive(on: RunLoop.main)
            .sink(receiveValue: objectWillChange.send)
            .store(in: &cancellable)
    }
}
  1. A class that takes two universal types Intent and Model as input.

  2. References to Intent and to Model. For the universal type Model, we will have a protocol, which will give access to properties and hide functions. I’ll tell you about it later.

  3. It’s necessary to ensure that changes in the Model will receive View, and not Container.

Creating the Container and View will look like this:

extension ListView {

    static func build() -> some View {
        let model = ListModel()
        let intent = ListIntent(model: model)

        let container = MVIContainer(
            intent: intent,
            model: model as ListModelStatePotocol,
            modelChangePublisher: model.objectWillChange)

        return ListView(container: container)
    }
}

Let’s see Container in action in View:

struct ListView: View {

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

    var body: some View {
        // 2
        Text(container.model.text)
            .padding()
            .onAppear(perform: {
                // 3
                self.container.intent.viewOnAppear()
            })
    }
}
  1. Container:

  • ListModel is a Model, it performs the logic of data preparation and holds all the properties of the View. View communicates with Model through the ListModelStateProtocol, which hides the logic and gives access only to the properties

  • @StateObject is needed so that the container together with the Intent and the Model are not recreated when recreating the View

  2. Gets actual data from Model via container

  3. Notifies Intent about events via container

To avoid constantly writing container.model and container.intent, you can add these lines:

private var intent: ListIntent { container.intent }
private var properties: ListModelStatePotocol { container.model }

In a similar way, through Container you can adapt Clean Swift, VIPER.

Intent

Intent waits for events from View for further actions. It works with business logic and databases, makes requests to the server, etc.

final class ListIntent {

    // 1
    private weak var model: ListModelActionsProtocol?

    init(model: ListModelActionsProtocol) {
        self.model = model
    }

    func viewOnAppear() {
        let number = Int.random(in: 0 ..< 100)

        // 2
        model?.parse(number: number)
    }
}
  1. ListModelActionsProtocol is another protocol to which Model subscribes, this protocol hides properties for View from Intent.

  2. After Intent receives an event from the View, it receives data synchronously or asynchronously and passes it to the Model

Model

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

The Model protocols we’ve already seen:

// 1
protocol ListModelStatePotocol {
    var text: String { get }
}

// 2
protocol ListModelActionsProtocol: AnyObject {
    func parse(number: Int)
}
  1. ListModelStatePotocol. Through this protocol the View communicates with the Model. There are only properties of the View

  2. ListModelActionsProtocol. Through this protocol Intent communicates with the Model. There are only functions.

Model Implementation:

// 1
final class ListModel: ObservableObject, ListModelStatePotocol {
    @Published var text: String = ""
}

// 2
extension ListModel: ListModelActionsProtocol {

    func parse(number: Int) {
        text = "Random number: " + String(number)
    }
}
  1. To use the full power of SwiftUI, let’s sign the Model under the protocol ObservableObject and when we change any property marked as @Published all the changes will automatically receive the View and display it.

  2. Here is the logic to prepare the data for display

Conclusion

I decided not to write a lot of unnecessary code and instead described the principle of operation. In more detail you can see it here.

SwiftUI, like MVI, is built around reactivity, so they fit together well. MVI allows you to implement complex screens and change the state of the screen very dynamically and with minimal effort. This implementation, of course, is not the only correct one, there are always alternatives. However, the pattern fits nicely with Apple’s new UI approach. One class for all screen states makes it much easier to work with the screen.

Tags:
Hubs:
Rating0
Comments0

Articles