Categories
Combine Foundation iOS Swift SwiftUI

MVVM in SwiftUI

Let’s build a simple app using MVVM (model-view-view model) where every SwiftUI view has its own view model. It’s going to be an app with two views: list of movies and add a movie view what utilises Form view. Added movies are stored in MovieStore which is shared by the two view models. We will use environment for sharing the MovieStore. It will be read from the environment when we need to create AddMovieView with its view model.

Movie and MovieStore representing data

Movie is a small struct and just stores the title and rating. Title and rating are mutable as we are going to update those in AddMovieView. We also conform to protocol Identifiable because we are going to use List view for showing all the movies. List needs a way of identifiyng the content and its the simplest way of satisfiying the requirement.

struct Movie: Equatable, Identifiable {
    let id = UUID()
    var fullTitle: String
    var givenRating: Rating = .notSeen
}

extension Movie {
    enum Rating: Int, CaseIterable {
        case notSeen, terrible, poor, decent, good, excellent
    }
}

MovieStore is also a pretty simple although in a more sophisticated app it would contain much more logic: persistence, deleting etc. We use Published property wrapper which automatically provides a publisher we can use to subscribe against.

final class MovieStore {
    @Published private(set) var allMovies = [Movie]()
    
    func add(_ movie: Movie) {
        allMovies.append(movie)
    }
}

For inserting shared MovieStore to environment, we’ll use custom EnvironmentKey. Custom key is just an object conforming to EnvironmentKey protocol. We need to provide the type and default value.

struct MovieStoreKey: EnvironmentKey {
    typealias Value = MovieStore
    static var defaultValue = MovieStore()
}

extension EnvironmentValues {
    var movieStore: MovieStore {
        get {
            return self[MovieStoreKey]
        }
        set {
            self[MovieStoreKey] = newValue
        }
    }
}

If we do not insert our own instance of MovieStore to the environment, the instance returned by defaultValue is used. Typically we would like to use a specific instance initialised outside of the view hierarchy. Therefore let’s take a look how to do that next.

SceneDelegate and MovieScene presentation

MovieStore dependency is passed into view models with initialiser. We’ll use the instance stored in SceneDelegate. Yet again, in a real app, it would probably live in a separate dependency container or in something similar. MovieListView is the first view we need to present, therefore we’ll initialise view model, view and insert instance of MovieStore to environment for later use (movieStore keypath is the one we just defined in EnvironmentValues‘ extension).

final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?
    private let movieStore = MovieStore()

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        let viewModel = MovieListView.ViewModel(movieStore: movieStore)
        let contentView = MovieListView(viewModel: viewModel).environment(\.movieStore, movieStore)
        
        guard let windowScene = scene as? UIWindowScene else { return }
        let window = UIWindow(windowScene: windowScene)
        window.rootViewController = UIHostingController(rootView: contentView)
        self.window = window
        window.makeKeyAndVisible()
    }
}

MovieListView and its ViewModel

We still haven’t taken a look on MovieListView and its view model, let’s do it now. View model conforms to protocol ObservableObject and uses @Published property wrappers. ObservableObject’s default implementation provides objectWillChange publisher. @Published property wrapper automatically fires the publisher when the property value is about to change. On MovieListView we have declared view model property with @ObservedObject property wrapper. This will make the view to subscribe to objectWillChange publisher and will refresh the view when-ever objectWillChange fires.

extension MovieListView {
    final class ViewModel: ObservableObject {
        private let movieStore: MovieStore
        private var cancellables = [AnyCancellable]()
        
        init(movieStore: MovieStore) {
            self.movieStore = movieStore
            cancellables.append(movieStore.$allMovies.assign(to: \.movies, on: self))
        }
        
        @Published private(set) var movies = [Movie]()
        @Published var isPresentingAddMovie = false
    }
}
struct MovieListView: View {
    @Environment(\.self) var environment
    @ObservedObject var viewModel: ViewModel
    
    var body: some View {
        NavigationView {
            List(self.viewModel.movies) { movie in
                Text(movie.fullTitle)
            }.navigationBarTitle("Movies")
                .navigationBarItems(trailing: navigationBarTrailingItem)
        }
    }
    
    private var navigationBarTrailingItem: some View {
        Button(action: {
            self.viewModel.isPresentingAddMovie = true
        }, label: {
            Image(systemName: "plus").frame(minWidth: 32, minHeight: 32)
        }).sheet(isPresented: self.$viewModel.isPresentingAddMovie) {
            self.makeAddMovieView()
        }
    }
    
    private func makeAddMovieView() -> AddMovieView {
        let movieStore = environment[MovieStoreKey]
        let viewModel = AddMovieView.ViewModel(movieStore: movieStore)
        return AddMovieView(viewModel: viewModel)
    }
}

Changes in MovieStore are observed by subscribing to allMovies subscriber and then assigning the new list of movies to view model’s own property. Note that assignment is triggered on subscribing and when changes happen: like KVO with initial option. Only downside is that now the list is duplicated but that’s OK. We would need to do that anyway when we would like to sort or filter the list later on.

AddMovieView and its view model are created when user taps on the plus button in the navigation bar. Environment property wrapper can be used to get the whole environment or any of the values using a specific key. In current case I went for accessing the whole environment object and then getting MovieStore using a MovieStoreKey later when needed. Then the MovieStore is not available in the whole view scope and only when creating the AddMovieView. Other option would be to use @Environment(\.movieStore) var movieStore instead.

AddMovieView and its ViewModel

AddMovieView’s view model is initialised with MovieStore and internally it represents and instance of Movie. Published property wrapper is used similarly like in MovieListView’s view model. The model object is a private property and instead of direct access, two bindings are provded for TextField and Picker. Binding represents a two way connection between the view and model. In addition, there is canSave property what is used for enabling the save button in the navigation bar. Save button should be enabled only when title is filled. To recap the view update flow: TextField or Picker will use Binding to update private property newMovie. As newMovie property uses @Published property wrapper, it will fire ObservableObject’s objectWillChange publisher. SwiftUI automatically subscribes to objectWillChange because view model’s property uses @ObservedObject.

extension AddMovieView {
    class ViewModel: ObservableObject {
        private let movieStore: MovieStore
        
        init(movieStore: MovieStore) {
            self.movieStore = movieStore
        }
        
        @Published private var newMovie = Movie(fullTitle: "")
        
        lazy var title = Binding<String>(get: {
            self.newMovie.fullTitle
        }, set: {
            self.newMovie.fullTitle = $0
        })
        
        lazy var rating = Binding<Movie.Rating>(get: {
            self.newMovie.givenRating
        }, set: {
            self.newMovie.givenRating = $0
        })
        
        var canSave: Bool {
            return !newMovie.fullTitle.isEmpty
        }
        
        func save() {
            movieStore.add(newMovie)
        }
    }
}

struct AddMovieView: View {
    @Environment(\.presentationMode) private var presentationMode
    @ObservedObject var viewModel: ViewModel
    
    var body: some View {
        NavigationView {
            Form {
                titleSection
                ratingSection
            }.navigationBarTitle("Add Movie", displayMode: .inline)
                .navigationBarItems(leading: leadingBarItem, trailing: trailingBarItem)
                .navigationViewStyle(StackNavigationViewStyle())
            
        }
    }
    
    private var titleSection: some View {
        Section() {
            TextField("Title", text: viewModel.title)
        }
    }

    private var ratingSection: some View {
        Section() {
            Picker(LocalizedStringKey("Rating"), selection: viewModel.rating) {
                ForEach(Movie.Rating.allCases, id: \.rawValue) {
                    Text($0.localizedName).tag($0)
                }
            }
        }
    }
    
    private var leadingBarItem: some View {
        Button(action: { self.presentationMode.wrappedValue.dismiss() }, label: {
            Text("Cancel")
        })
    }
    
    private var trailingBarItem: some View {
        Button(action: {
            self.viewModel.save()
            self.presentationMode.wrappedValue.dismiss()
        }, label: {
            Text("Save").disabled(!self.viewModel.canSave)
        })
    }
}

Summary

We created a simple app with two views. Both views had its own view model and both view models used the same dependency: MovieStore. One view model triggered changes in MovieStore and those changes were observed by the other view model. In addition, we looked into how to use SwiftUI’s environment and how to trigger view updates from view models.

If this was helpful, please let me know on Mastodon@toomasvahter orĀ Twitter @toomasvahter. Feel free to subscribe to RSS feed. Thank you for reading.

Example

SwiftUICombineMVVMExample (GitHub, Xcode 11.3, Swift 5)