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)

Categories
Combine iOS Swift SwiftUI

Creating chat view with Combine and SwiftUI

Let’s build a conversation view which shows a list of messages and has input text field with send button. Sent and received messages are managed by Conversation object. Conversation object manages a Session object which is simulating networking stack. This kind of setup allows us to look into how to propagate received messages from Session object to Conversation and then to the list view. We’ll jump into using types Combine and SwiftUI provide therefore if you need more information, definitely watch WWDC videos about Combine and SwiftUI.

Data layer

In the UI we are going to show a list of messages, therefore let’s define a struct for a Message. We’ll make the Message to conform to protocol defined in SwiftUI – Identifiable. We can add conformance by adding id property with type UUID what provides us unique identifier whenever we create a message. Identification is used by SwiftUI to identify messages and finding changes in the messages list.

struct Message: Identifiable {
let id = UUID()
let sender: String
let text: String
}
view raw .swift hosted with ❤ by GitHub

Session is owned by Conversation and simulates a networking stack dealing with sending and receiving messages. This like a place were we could use delegate pattern for forwarding received messages back to the Conversation. Instead of delegation pattern, we can use Combine’s PassthroughSubject. It enables us to publish new messages which we can then collect on the Conversation side. Great, but let’s see how to receive messages which are published by PassthroughSubject.

struct Session {
let messageFeed = PassthroughSubject<Message, Never>()
func send(_ message: Message) {
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
self.messageFeed.send(message)
self.simulateReceivingMessages()
}
}
private func simulateReceivingMessages() {
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(200)) {
let receivedMessage = Message(sender: "Person B", text: UUID().uuidString)
self.messageFeed.send(receivedMessage)
}
}
}
view raw .swift hosted with ❤ by GitHub

Conversation is responsible of receiving messages from the Session and keeping the current history: list of messages. For receiving messages published by Session, we can use a subscriber called sink, which just gives access to values flowing through the channel. Subscribers are added directly to publishers, then publisher sends a subscription object back to the subscriber what subscriber can use for communicating with publisher. Here, communicating means requesting values from publisher. To recap: Session owns PassthroughSubject what Conversation starts to listen by attaching subscriber to it.

Conversation conforms to SwiftUI’s ObservableObject. When marking properties with @Published property wrapper, changes in those properties trigger updates in SwiftUI.

final class Conversation: ObservableObject {
private let session = Session()
private var messageSubscriber: AnyCancellable?
init() {
messageSubscriber = session.messageFeed.sink { [weak self] (receivedMessage) in
self?.messages.append(receivedMessage)
}
}
@Published private(set) var messages = [Message]()
func send(_ message: Message) {
session.send(message)
}
}
view raw .swift hosted with ❤ by GitHub

Creating simple list view

In SwiftUI, views are described by value types conforming to View protocol. Every view return their content in the body property. Our UI is simple enough and requires to add navigation view, list and then input view. List is the table view construct which creates new rows whenever it needs to. As we made Message to conform to Identifiable, then we can pass the messages directly to the List.

struct ContentView: View {
@ObjectBinding var conversation: Conversation
var body: some View {
NavigationView {
VStack {
List(self.conversation.messages) { message in
Text(message.text)
}
InputView(conversation: self.conversation)
}.navigationBarTitle(Text("Conversation"))
}
}
}
view raw .swift hosted with ❤ by GitHub

Input view contains text field and button for sending the entered message. Input text is local state owned by the view itself. @State is a property wrapper and internally it creates a separate storage where the input text is stored and read during view updates.

import Combine
import SwiftUI
struct InputView: View {
let conversation: Conversation
@State private var inputText = ""
var body: some View {
HStack {
TextField("", text: $inputText)
.padding(6)
.background(Color.white)
Button(action: sendMessage) {
Text("Send")
}
}.padding(12).background(Color.init(white: 0.75))
}
private func sendMessage() {
self.conversation.send(Message(sender: "PersonA", text: self.inputText))
self.inputText = ""
}
}
view raw .swift hosted with ❤ by GitHub

Now we have a the whole picture put together. Conversation object manages messages and lets SwiftUI know when it changes by using @Published property wrapper. When property wrapper dispatches change to SwiftUI, it compares the changes in the view hierarchy and updates only what is needed.

Summary

We created a basic list view what displays messages in the conversation object. We used simple constructs for passing on the data down from the Session to the SwiftUI layer. The aim of the sample project was to try out some of the ways Combine and SwiftUI allow us to build views.

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

ConversationInSwiftUI (Xcode 11, Swift 5.1)

Resources