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))
}
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))
}
}
}
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.