/Mvi-SwiftUI

Mvi Architecture for SwiftUI Apps. MVI is a unidirectional data flow architecture.

Primary LanguageSwift

Mvi-SwiftUI

If you like to read this on Medium , you can find it here MVI Architecture for SwiftUI Apps

MVI Architecture

Model-View-Intent (MVI) is a popular architecture in the Android world. It was introduced by Hannes Dorfmann. You can find it here. For the purpose of this article, a brief introduction to MVI is presented below.

MVI is a cyclical and unidirectional data-flow architecture.

  • The Model represents the state of the application. It contains the properties necessary to render the screen.
  • An Intent is an event to change the state of the system — e.g., user click is an event to change the state of the system.
  • The View observes the state change and updates itself accordingly.

Graphical Representation

Graphial representation

Working

  1. The user interacts with the View to create an Intent.
  2. The Intent changes the state of the application.
  3. A change in state updates the View. The cycle repeats.

Note : Additionally, API and Push notifications also change the state of the application.

MVI Implementation in SwiftUI

For demonstration purposes, we’ll implement a movie-search screen. It has a TextField to type in the keyword to search. Below is the list of movies matching the keyword.

Model (State of the Application)

First up, let’s consider the possible states of the application. At any given time, the application will be in one of the following states. Later, you’ll see that we have a view for each of the corresponding state.

  • InitState: It’s an initial state. Nothing has been queried.
  • Loading: The keyword has been entered, and movies are being fetched from the API.
  • SuccessfullyFetched : When the API returns the matching movies.
  • NoMatchingResults: When no results are found for the query.
  • ApiError: The HTTP request failed or the API returned an error

We will use Swift’s Enum to hold the states.

enum SearchPageState {
    case Init
    case Loading(String) // String is the message to be shown while laoding
    case SuccessfullyFetched([Movie]) // List of matching Movies
    case NoResultsFound
    case ApiError(String) // Error message to be shown
}

SearchPageViewModel(Business layer)

  • It contains the State of the Application.
  • Receives the Intent from the View and updates the State.
  • Publishes the updated State to the View.

Let’s see it’s implementation. SearchPageViewModel is a data-holder class that holds the state of the application. It conforms to the ObservableObject protocol. Anything inside the ObservableObject protocol can announce when its values have changed so the SwiftUI view can update itself.

It has a variable named uiState of the type SearchPageState. It’s wrapped with the Published property wrapper. This way it can notify the view when its value changes.

This class receives the intent, makes API calls to fetch the data, and updates the state of the application.

loadMovies is the function called when there’s an intent to fetch the matching movies. The comments below show how that function is updating the state of the application.

class SearchPageViewModel : ObservableObject {
    
    @Published var uiState: SearchPageState = .Init
    
    let repository: MovieRepository = MovieRepository()
    
    func loadMovies(query: String) {
        
        // 1. state is changed to Loading
        self.uiState = .Loading("Querying for \(query)")
        repository
            .searchMovies(query: query)
            .subscribe(
                onNext: { [weak self] response in
                    
                    if response.results.count == 0 {
                        // 2. State is updated to NoResultsFound
                        self?.uiState = .NoResultsFound
                    } else {
                        // 3. state is updated to SuccesffullyFetched
                        self?.uiState = .Fetched(response)
                    }
                    
                },
                onError: { error in
                    // 4. state is updated to ApiError
                    self.uiState = .ApiError("Results couldnot be fetched")
            }
        )
    }
}

View

A TextField to enter the keyword. Below that, we have a group that shows the view as per the state of the application.

Dry Run: When we the commit (hit the search button), it fires an intent to the SearchPageViewModel to update the state. SearchPageViewModel then makes an API to update the state.

Init → Loading → [SuccessfullyFetched | NoResultsFound | ApiError]

The group has exhaustive cases of the SearchPageState, a corresponding view for each state of the application.

struct SearchPageView: View {
    @ObservedObject var vm: SearchPageViewModel
    
    @State private var query: String = ""
    
    init(viewModel: SearchPageViewModel) {
        self.vm = viewModel
    }
    
    
    var body: some View {
        ScrollView {
            
            // Enter the keyword here and when commited fire an Intent to load the movies
            TextField("search movies", text: $query, onCommit: {
                // Fires an Intent to load the movies for the keyword 
                self.vm.loadMovies(query: self.query)
            })
            
            /**
            * It contains the exhaustive cases. It observes the uiState and
            * updates it's view accordingly
            */
            Group { () -> AnyView in
                
                switch vm.uiState {
                
                case .Init:
                    return AnyView(Text("Please type in to query"))
                
                case .Loading(let message):
                    return AnyView(Text(message))
                
                case .SuccessfullyFetched(let movies):
                    // This displays the list of Movies  matching the keyword
                    return AnyView(SearchedMoviesView(movies: movies))
                    
                case .NoResultsFound:
                    return AnyView(Text("No matching movies found"))
                    
                case .ApiError(let errorMessage):
                    return AnyView(Text(errorMessage))
                }
            }
        }
    }
}

Intent

It is an interaction on the View. The intent is propagated to the SearchPageViewModel which consequently alters it’s state. Example: Pressing the search icon after entering the query is an Intent to load the matching movies.

vm.loadMovies(query: self.query) is an Intent.

struct SearchPageView: View {
  ...
  ...
  var body: some View {
    ..
    ..
      // Enter the keyword here and when commited fire an Intent to load the movies
      TextField("search movies", text: $query, onCommit: {
          // Fires an Intent to load the movies for the keyword 
          self.vm.loadMovies(query: self.query)
      })
  }

}

Closing Points

Now that you have an understanding of MVI and it’s implementation, we’ll see why is this relevant for SwiftUI.

UI applications like mobile apps always try to keep the view in sync with the state of the application. Example: Imagine driving a car. The speed reading on the dashboard is nothing but the state (speed) of the car. If the dashboard shows a wrong reading, then it’s an inconsistency.

UI = f(AppState).

SwiftUI has an infrastructure wherein data is bound to the UI (data binding). In SwiftUI, showing an alert is controlled through binding. The performance impact of rerendering (invalidating) the view consequent to data change is handled by the framework. So using the data to drive the UI (data-driven UI) will wipe out the inconsistencies that exist between the data and the UI.