Swift Talk # 201

From MVC to SwiftUI — Refactoring Model APIs (Part 2)

This episode is freely available thanks to the support of our subscribers

Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 30% discount for team members. Become a Subscriber

We use published properties and property observers to clean up our model code, and we fix some issues on the iPad.

00:06 Today we'll continue the refactoring of the model layer of the Recordings app in order to make the architecture a better fit for SwiftUI.

00:22 To quickly make things work, we started out by manually calling objectWillChange whenever the model changed. The plan is to now use @Published for some properties of the model so that changes are emitted automatically and we can get rid of most objectWillChange calls.

Deleting Code

01:21 Store is the foundational model class that holds all of the app's data, and it sends out a notification whenever the data changes. This notification holds information about what exactly changed. We use this information to update the UI if, for example, a recording is renamed or a folder is deleted:

final class Store {
    static let changedNotification = Notification.Name("StoreChanged")
    // ...
    func save(_ notifying: Item, userInfo: [AnyHashable: Any]) {
        if let url = baseURL, let data = try? JSONEncoder().encode(rootFolder) {
            try! data.write(to: url.appendingPathComponent(.storeLocation))
            // error handling ommitted
        }
        NotificationCenter.default.post(name: Store.changedNotification, object: notifying, userInfo: userInfo)
    }
    // ...
}
class Item: Identifiable, ObservableObject {
    // ...
    func setName(_ newName: String) {
        objectWillChange.send()
        name = newName
        if let p = parent {
            let (oldIndex, newIndex) = p.reSort(changedItem: self)
            store?.save(self, userInfo: [Item.changeReasonKey: Item.renamed, Item.oldValueKey: oldIndex, Item.newValueKey: newIndex, Item.parentFolderKey: p])
        }
    }
    
    func deleted() {
        objectWillChange.send()
        parent = nil
    }
    // ...
}
class Folder: Item, Codable {
    // ...
    func add(_ item: Item) {
        objectWillChange.send()
        assert(contents.contains { $0 === item } == false)
        contents.append(item)
        contents.sort(by: { $0.name < $1.name })
        let newIndex = contents.firstIndex { $0 === item }!
        item.parent = self
        store?.save(item, userInfo: [Item.changeReasonKey: Item.added, Item.newValueKey: newIndex, Item.parentFolderKey: self])
    }
    
    func reSort(changedItem: Item) -> (oldIndex: Int, newIndex: Int) {
        objectWillChange.send()
        let oldIndex = contents.firstIndex { $0 === changedItem }!
        contents.sort(by: { $0.name < $1.name })
        let newIndex = contents.firstIndex { $0 === changedItem }!
        return (oldIndex, newIndex)
    }
    
    func remove(_ item: Item) {
        guard let index = contents.firstIndex(where: { $0 === item }) else { return }
        objectWillChange.send()
        item.deleted()
        contents.remove(at: index)
        store?.save(item, userInfo: [
            Item.changeReasonKey: Item.removed,
            Item.oldValueKey: index,
            Item.parentFolderKey: self
        ])
    }
    // ...
}

02:14 But because we've already made our folder and recording items conform to ObservableObject, we no longer need that notification or information about changes, because SwiftUI observes the model and it does the diffing for us.

02:25 So we strip away the notification, along with the diffing calculations in the mutating methods of Item and Folder:

final class Store {
    // ...
    func save() {
        if let url = baseURL, let data = try? JSONEncoder().encode(rootFolder) {
            try! data.write(to: url.appendingPathComponent(.storeLocation))
            // error handling ommitted
        }
    }
    // ...
}
class Item: Identifiable, ObservableObject {
    // ...
    func setName(_ newName: String) {
        objectWillChange.send()
        name = newName
        if let p = parent {
            p.reSort(changedItem: self)
            store?.save()
        }
    }
    
    func deleted() {
        objectWillChange.send()
        parent = nil
    }
    // ...
}
class Folder: Item, Codable {
    // ...
    func add(_ item: Item) {
        objectWillChange.send()
        assert(contents.contains { $0 === item } == false)
        contents.append(item)
        contents.sort(by: { $0.name < $1.name })
        item.parent = self
        store?.save()
    }
    
    func reSort(changedItem: Item) {
        objectWillChange.send()
        contents.sort(by: { $0.name < $1.name })
    }
    
    func remove(_ item: Item) {
        guard let index = contents.firstIndex(where: { $0 === item }) else { return }
        objectWillChange.send()
        item.deleted()
        contents.remove(at: index)
        store?.save()
    }
    // ...
}

04:43 Our code gets much simpler because SwiftUI really handles a lot for us. In particular, its diffing of a folder's contents allows us to remove a lot of boilerplate code.

Published Properties

05:37 For our concrete model objects, we currently have three classes: a base class (Item), and two subclasses (Folder and Recording). This is a classic way to share properties and functionalities. Because both Folder and Recording need a name and a uuid, we've implemented these two properties on their shared superclass, Item.

06:05 In Item, the setName method calls objectWillChange before it updates the item's name:

class Item: Identifiable, ObservableObject {
    // ...
    func setName(_ newName: String) {
        objectWillChange.send()
        name = newName
        if let p = parent {
            p.reSort(changedItem: self)
            store?.save()
        }
    }
    // ...
}

06:19 By making name a published property, observers automatically get notified if the value changes, so now we can remove the objectWillChange call:

class Item: Identifiable, ObservableObject {
    // ...
    @Published private(set) var name: String
    // ...
    func setName(_ newName: String) {
        name = newName
        if let p = parent {
            p.reSort(changedItem: self)
            store?.save()
        }
    }
    // ...
}

06:36 We also call objectWillChange in the deleted method, which is where we update the item's parent:

class Item: Identifiable, ObservableObject {
    // ...
    weak var parent: Folder? {
        didSet {
            store = parent?.store
        }
    }
    // ...
    func deleted() {
        objectWillChange.send()
        parent = nil
    }
    // ...
}

06:54 Here, we can't repeat the same trick of making parent into a published property because we wouldn't be able to use a weak reference. Instead, we can call objectWillChange in a property observer:

class Item: Identifiable, ObservableObject {
    // ...
    weak var parent: Folder? {
        willSet {
            objectWillChange.send()
        }
        didSet {
            store = parent?.store
        }
    }
    // ...
    func setName(_ newName: String) {
        name = newName
        if let p = parent {
            p.reSort(changedItem: self)
            store?.save()
        }
    }
    // ...
}

07:18 Although we can't use @Published, having the objectWillChange call inside the property observer is still a big improvement: this way, observers automatically get notified, and we don't have to worry about calling the publisher everywhere we mutate the property.

07:38 We can also remove the following constants used for notifications:

extension Item {
    static let changeReasonKey = "reason"
    static let newValueKey = "newValue"
    static let oldValueKey = "oldValue"
    static let parentFolderKey = "parentFolder"
    static let renamed = "renamed"
    static let added = "added"
    static let removed = "removed"
}

07:59 The Recording subclass doesn't have any manual objectWillChange calls of its own. But we can eliminate some calls in the Folder subclass, as the contents array of this subclass is mutated in many places:

class Folder: Item, Codable {
    @Published private(set) var contents: [Item] = []
    // ...
}

09:38 By publishing contents, we can skip the objectWillChange calls when we add and remove items and when we sort the contents array:

class Folder: Item, Codable {
    // ...
    func add(_ item: Item) {
        assert(contents.contains { $0 === item } == false)
        contents.append(item)
        contents.sort(by: { $0.name < $1.name })
        item.parent = self
        store?.save()
    }
    
    func reSort(changedItem: Item) {
        contents.sort(by: { $0.name < $1.name })
    }
    
    func remove(_ item: Item) {
        guard let index = contents.firstIndex(where: { $0 === item }) else { return }
        item.deleted()
        contents.remove(at: index)
        store?.save()
    }
    // ...
}

10:05 Mutations of the contents array should now automatically update the UI, but when we try this out by creating a new folder, nothing seems to happen. When we relaunch the app, we see that the folder was actually created — it just didn't appear. It took us a while to figure out what goes wrong, and it has to do with the class hierarchy.

10:29 The problem is that SwiftUI is subscribed to an Item, and this subscription doesn't receive changes to published properties of the Folder subclass.

11:04 Changes to a folder's name are picked up just fine because the name property is defined on Item. We could move Folder's contents array to Item as well, but that would be a rather ugly solution. Instead, we leave the property where it is and we call objectWillChange in willSet, which fixes the issue:

class Folder: Item, Codable {
    private(set) var contents: [Item] = [] {
        willSet {
            objectWillChange.send()
        }
    }
    // ...
}

Split View

11:54 Next, we can make some improvements in how the app works on iPad. There, the navigation view turns into a split view, which displays the folder list and the player view side by side as master and detail views. We need to fix two issues for this presentation style.

12:43 First, when we select a folder, we expect to navigate to the folder in the master view, but the nested folder is sent to the detail view instead. And second, we should show a placeholder in the detail view if no recording is selected.

13:22 The latter is easily fixed — we show the placeholder by simply adding a second view to the navigation view:

struct NoRecordingSelected: View {
    let body = Text("No recording selected.")
}

struct ContentView: View {
    let store = Store.shared
    var body: some View {
        NavigationView {
            FolderList(folder: store.rootFolder)
            NoRecordingSelected()
        }
    }
}

13:56 In order to make a selected folder appear in the master view instead of in the detail view, we modify the navigation link to only act as a detail link if the item is a recording:

struct FolderList: View {
    // ...
    var body: some View {
        List {
            ForEach(folder.contents) { item in
                NavigationLink(destination: item.destination) {
                    HStack {
                        Image(systemName: item.symbolName)
                            .frame(width: 20, alignment: .leading)
                        Text(item.name)
                    }
                }
                .isDetailLink(item is Recording)
            }
            // ...
        }
        // ...
    }
}

14:35 Now we can navigate through folder lists in the master view, and the player view opens in the detail view.

Deleting Recordings

14:54 When we delete a recording, it disappears from the master list, but the player view on the right-hand side doesn't go away. Ideally, we should remove the player view at this point and show the placeholder view again.

15:18 In the MVC version of the app, we use a UINavigationController and we pop the player view off the navigation stack if its recording is deleted. However, we couldn't find a reliable way to do the same in SwiftUI.

15:38 It's possible to control a navigation link with a Binding<Bool>, and we've tried setting the binding to false when a recording gets deleted. Unfortunately, this doesn't work if the recording's parent folder or another ancestor folder gets deleted. If that happens, the navigation link itself no longer exists, and so the binding has no effect.

16:11 After trying some more complicated approaches, we've settled on a relatively easy one: by making the player view observe its recording, it can show the placeholder if the recording is deleted. This isn't ideal because it adds some duplication, so we'd love to hear if we're overlooking a more native solution in SwiftUI.

16:52 We don't want to build this behavior into PlayerView itself, so we write a little wrapper view instead:

extension Item {
    var isDeleted: Bool { parent == nil }
}

struct PlayerWrapper: View {
    @ObservedObject var recording: Recording
    
    var body: some View {
        Group {
            if recording.isDeleted {
                NoRecordingSelected()
            } else {
                PlayerView(recording: recording) ?? Text("Something went wrong.")
            }
        }
    }
}

18:47 And we replace the player view with the wrapper as the destination of a recording:

extension Item {
    var destination: some View {
        Group {
            if self is Folder {
                FolderList(folder: self as! Folder)
            } else {
                PlayerWrapper(recording: self as! Recording)
            }
        }
    }
}

19:42 Deleting a recording now makes the view go away, and the same happens if we delete the folder that holds the selected recording.

20:38 If we need this behavior in more places, we could work on making the wrapper generic, so that it not only works with recordings and player views, but also with any combination of an observable object and a wrapped view.

20:54 We've come a long way to make our model objects a better fit for SwiftUI. Next week, we'll see what happens when we completely change the model layer by converting most of the classes into structs.

Resources

  • Sample Code

    Written in Swift 5

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

165 Episodes · 57h18min

See All Collections

Episode Details

Recent Episodes

See All

Unlock Full Access

Subscribe to Swift Talk

  • Watch All Episodes

    A new episode every week

  • icon-benefit-download Created with Sketch.

    Download Episodes

    Take Swift Talk with you when you're offline

  • Support Us

    With your help we can keep producing new episodes