Categories
Combine Foundation iOS Swift SwiftUI

Observing a KVO compatible model in SwiftUI and MVVM

In model-view-view model (MVVM) architecture the view model observes the model and provides data for the view by transforming it when needed. When the user interacts with the view and changes the data in it then the view model’s responsibility is to propagate those mutations back to the model object. Therefore, the important part in MVVM is how to manage data flows between objects. This time we’ll take a look on observing key-value observing (KVO) compatible model objects with Combine framework in SwiftUI view. The example view what we’ll build looks like this:

SwiftUI view with text fields for setting first name, last name, and street address. Under that is a label with recipient, postal address, and package contents descriptions. Under that is a button for adding more items to the package.
SwiftUI view which enables editing package related information and displays a summary of the package.

Model layer

The model object represents a package which contains information about the recipient, the sender, and the contents. The recipient and the sender are represented by a Person object which includes a first name, a last name, and a postal address. The contents is an array of immutable PackageContent objects. In Swift, we can use KVO by specifying @objc and dynamic modifiers on properties. Dynamic means that method dispatch is using objective-c runtime and therefore all the types must be representable in objective-c runtime. This immediately adds restrictions to the types we can use. When writing pure Swift code I do not recommend using KVO but sometimes we just need to use it. One example is NSManagedObject from the CoreData framework. But in this app we are not dealing with NSManagedObject but with a simple NSObject subclass instead.

final class Package: NSObject {
@objc dynamic var recipient = Person()
@objc dynamic var sender = Person()
@objc dynamic var contents = [PackageContent]()
}
final class Person: NSObject {
@objc dynamic var firstName: String = ""
@objc dynamic var lastName: String = ""
@objc dynamic var postalAddress = CNPostalAddress()
}
final class PackageContent: NSObject {
init(title: String, weight: Int) {
self.title = title
self.weight = weight
}
let title: String
let weight: Int
}
KVO compatible model object.

View Layer

The view object is responsible for describing the UI and rendering data represented by the view model. We have a simple form for modifying the recipient’s first name, last name, and the street name (for keeping this UI simple I left out all the other postal address related properties). At the bottom of the view we have a text object which just describes the package and a button for adding new items to the package’s contents. Whenever any of the package’s properties change, the view needs to reload. View reload is done through the @StateObject property wrapper (read mode about observing view models in MVVM in SwiftUI and @StateObject and MVVM inĀ SwiftUI).

struct ContentView: View {
@StateObject var viewModel = ViewModel(package: .makeExample())
var body: some View {
ScrollView {
VStack(spacing: 16) {
Text("Recipient")
.font(.headline)
InputView(title: "First name",
value: $viewModel.recipientFirstName)
InputView(title: "Last name",
value: $viewModel.recipientLastName)
Text("Address")
.font(.headline)
InputView(title: "Street",
placeholder: "e.g. 37 Christie St",
value: viewModel.street)
Text("Summary")
.font(.headline)
Text(viewModel.summary)
.frame(maxWidth: .infinity, alignment: .leading)
Button("Add item", action: viewModel.addRandomItem)
}
.padding()
}
}
}

View Model layer

The view model’s responsibility is to observe the model object and propagating view changes to the model. It acts as a transformation layer where we can transform any data in the model to anything suitable for displaying. In the example below we are reading CNPostalAddress and only returning street name and reading multiple properties and returning a summary string. View models make it easy to contain such logic and also make it more easy to test.

Foundation framework defines a publisher named NSObject.KeyValueObservingPublisher which can be used for observing KVO compatible properties. One of the approaches is to use this publisher and then bind the model changes to the view model’s own property. Combine framework provides a convenient assign operator which takes a target publisher as an argument. Convenient because we can connect it with @Published properties in the view model. @Published properties automatically notify the ObservableObject’s objectWillChange publisher which is observed by a SwiftUI view. As soon as the property changes, SwiftUI view picks up the change and reloads itself. Note that we’ll also need to propagate changes back to the model when user updates the view and therfore the @Published property. This can be achieved by connecting property’s publisher with dropFirst, removeDuplicates and assign publishers where the latter assigns the value to the model object. Drop first is used for ignoring the initial value of the property. One downside is that now we can have the same information both in the view model and in the model. But on the other hand it makes the data streams easy to read and no need to have extra observation for triggering the view reload by manually calling send() on the objectWillChange publisher.

@Published var recipientFirstName: String = ""
// Model -> View Model
package.recipient.publisher(for: \.firstName)
.assign(to: &$recipientFirstName)
// View Model -> Model
$recipientFirstName.dropFirst()
.removeDuplicates()
.assign(to: \.firstName, on: package.recipient)
.store(in: &cancellables)
view raw ViewModel.swift hosted with ❤ by GitHub
Observing model and view changes.

Another approach what we can use is providing properties in the view model which return a Binding. This allows us to write the transformation code inside the get and set closures. This is what we have done with the street property. Note that we’ll still need to observe the model as the model can change at any point. Binding just provides a way of accessing the value. Therefore, we’ll need to set up an observation and calling send() on the objectWillChange publisher.

// Observing changes
package.recipient.publisher(for: \.postalAddress)
.notifyObjectWillChange(objectWillChange)
.store(in: &cancellables)
// Providing a binding for the view
var street: Binding<String> {
let package = self.package
return Binding<String>(
get: {
package.recipient.postalAddress.street
},
set: { newValue in
let postalAddress = package.recipient.postalAddress.mutableCopy() as! CNMutablePostalAddress
postalAddress.street = newValue
package.recipient.postalAddress = postalAddress
}
)
}
extension Publisher where Self.Failure == Never {
public func notifyObjectWillChange(_ objectWillChange: ObservableObjectPublisher) -> AnyCancellable {
return self.sink { _ in
objectWillChange.send()
}
}
}
view raw ViewModel.swift hosted with ❤ by GitHub
Providing a binding for the view.

If we go back to the SwiftUI view and connect all the properties then the full implementation of the view model looks like this:

extension ContentView {
final class ViewModel: ObservableObject {
private let package: Package
private var cancellables = [AnyCancellable]()
init(package: Package) {
self.package = package
// Model -> View Model
package.recipient.publisher(for: \.firstName)
.assign(to: &$recipientFirstName)
package.recipient.publisher(for: \.lastName)
.assign(to: &$recipientLastName)
package.recipient.publisher(for: \.postalAddress)
.notifyObjectWillChange(objectWillChange)
.store(in: &cancellables)
package.publisher(for: \.contents)
.notifyObjectWillChange(objectWillChange)
.store(in: &cancellables)
// View Model -> Model
$recipientFirstName.dropFirst()
.removeDuplicates()
.assign(to: \.firstName, on: package.recipient)
.store(in: &cancellables)
$recipientLastName.dropFirst()
.removeDuplicates()
.assign(to: \.lastName, on: package.recipient)
.store(in: &cancellables)
}
// Example of using published property
@Published var recipientFirstName: String = ""
@Published var recipientLastName: String = ""
// Example of using bindings for propagating values
var street: Binding<String> {
let package = self.package
return Binding<String>(
get: {
package.recipient.postalAddress.street
},
set: { newValue in
let postalAddress = package.recipient.postalAddress.mutableCopy() as! CNMutablePostalAddress
postalAddress.street = newValue
package.recipient.postalAddress = postalAddress
}
)
}
var summary: String {
let contents = package.contents
.map({ "\($0.title) \($0.weight)" })
.joined(separator: ", ")
return """
Recipient: \(package.recipient.firstName) \(package.recipient.lastName)
Postal address: \(CNPostalAddressFormatter().string(from: package.recipient.postalAddress))
Contents: \(contents)
"""
}
func addRandomItem() {
let weight = Int.random(in: 200300)
let item = PackageContent(title: "Light bulb", weight: weight)
package.contents.append(item)
}
}
}
view raw ViewModel.swift hosted with ❤ by GitHub
View model implementation for the view.

Summary

Key-value observing is getting less and less used after the introduction of Combine and SwiftUI. But there are still times when we need to connect good old KVO compatible NSObject subclasses with a SwiftUI view. Therefore, it is good to know how to handle KVO in SwiftUI views as well.

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.

Project

SwiftUIMVVMKVOObserving (GitHub, Xcode 12.1)

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