00:27 In our App
Architecture book, we discuss how
a single app can be implemented in various architectures such as MVC, MVVM, and
TEA. The one pattern missing from this list is
SwiftUI, which only came out
after we published App Architecture.
01:02 So we would like to spend a few episodes to see what it takes to
build the sample app, called Recordings, in SwiftUI. First, we'll try to quickly
get the app working by reusing as much code as we can. Later, we will refactor
and use more SwiftUI-specific APIs.
01:27 Let's take a look at a finished version of the app. We're looking
at the version that was written in MVC, but it doesn't really matter because
each version looks and does more or less the same as the others: it lets us
record and play back voice memos and organize these recordings in folders.
02:55 We've copied over the model files we'll be reusing: the Store
class from the MVC version of the app, and the Item
base class with its two
subclasses, Folder
and Recording
.
Folder List
03:32 As a first step, we want to translate the folder view controller
to SwiftUI by writing a FolderList
view:
struct FolderList: View {
let folder: Folder
var body: some View {
}
}
04:08 In ContentView
, we pass the store's root folder to a
FolderList
. And in FolderList
, we display the folder's contents
array as a
list:
struct FolderList: View {
let folder: Folder
var body: some View {
List {
ForEach(folder.contents) { item in
Text(item.name)
}
}
}
}
struct ContentView: View {
let store = Store.shared
var body: some View {
NavigationView {
FolderList(folder: store.rootFolder)
}
}
}
05:15 We can use ForEach
like this if we conform Item
to
Identifiable
, which we do by returning the existing uuid
property as the
item's identifier:
class Item: Identifiable, ObservableObject {
let uuid: UUID
private(set) var name: String
weak var store: Store?
weak var parent: Folder? {
didSet {
store = parent?.store
}
}
var id: UUID { uuid }
}
06:34 The app now shows an empty list, so it's time to add the ability
to create new folders and navigate into them.
Creating Folders
06:43 We add a navigation bar title and a bar item to FolderList
.
Because we'll be adding multiple buttons to the bar item, we choose an HStack
for the item's view, but it starts out containing only the button that creates a
folder:
struct FolderList: View {
let folder: Folder
var body: some View {
List {
ForEach(folder.contents) { item in
Text(item.name)
}
}
.navigationBarTitle("Recordings")
.navigationBarItems(trailing: HStack {
Button(action: {
self.folder.add(Folder(name: "New Folder \(self.folder.contents.count)", uuid: UUID()))
}, label: {
Image(systemName: "folder.badge.plus")
})
})
}
}
08:13 Nothing happens when we tap the button because SwiftUI doesn't yet
know that the folder has changed. For that, we need to make Folder
an
observable object to which SwiftUI can subscribe:
struct FolderList: View {
@ObservedObject var folder: Folder
}
And since we are working with a class hierarchy, it makes sense to add the
conformance to ObservableObject
to Folder
's superclass, Item
:
class Item: Identifiable, ObservableObject {
}
09:39 Any time we change the model object, we need to tell SwiftUI about
it by triggering the model's publisher. Later, we'll use the @Published
property wrapper to automatically publish changes, but for now, we take a
shortcut and manually call objectWillChange.send
for every change:
class Item: Identifiable, ObservableObject {
func setName(_ newName: String) {
objectWillChange.send()
name = newName
}
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 })
}
func reSort(changedItem: Item) -> (oldIndex: Int, newIndex: Int) {
objectWillChange.send()
let oldIndex = contents.firstIndex { $0 === changedItem }!
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)
}
}
10:57 If we now tap the button to create another folder, the new folder
appears in the list with an animation.
Navigation
11:10 Now we need to be able to navigate into a folder, so we wrap each
list item in a NavigationLink
:
struct FolderList: View {
@ObservedObject var folder: Folder
var body: some View {
List {
ForEach(folder.contents) { item in
NavigationLink(destination: item.destination) {
Text(item.name)
}
}
}
}
}
12:17 The link's destination depends on whether the item is a Folder
or a Recording
. If we select an item that is a folder, we want to navigate to
another FolderList
view, and if it's a recording, we go to another type of
view. We can write this logic in a computed property on Item
:
extension Item {
var destination: some View {
Group {
if self is Folder {
FolderList(folder: self as! Folder)
} else {
Text("Player: \(self.name)")
}
}
}
}
15:03 We run the app and we see that the folders now have disclosure
triangles indicating the option to select and navigate into them. If we navigate
into a folder, another FolderList
view opens and we can create subfolders in
there.
Deleting Items
15:34 Next, we want to be able to swipe on a list item in order to
remove it. We do so by adding the onDelete
modifier with a closure that
receives the indices to be deleted. We look up the objects that belong to those
indices and we remove them from the folder:
struct FolderList: View {
@ObservedObject var folder: Folder
var body: some View {
List {
ForEach(folder.contents) { item in
NavigationLink(destination: item.destination) {
Text(item.name)
}
}
.onDelete(perform: { indices in
let items = indices.map { self.folder.contents[$0] }
for item in items {
self.folder.remove(item)
}
})
}
}
}
16:50 The folder's API to remove elements doesn't take indices, but
rather the elements themselves, which is why we have to first look up the
objects for the given indices. Otherwise, we could have sorted the indices in
descending order and then looped over them in that order to remove the elements
— in this way, we wouldn't risk invalidating an index before using it.
Adding a Recording
17:29 In order to create a new recording, we add a second button to the
navigation bar item's HStack
. And in order to present the recording view as a
sheet, we make this button flip a state variable to true
:
struct FolderList: View {
@ObservedObject var folder: Folder
@State var presentsNewRecording = false
var body: some View {
List {
}
.navigationBarTitle("Recordings")
.navigationBarItems(trailing: HStack {
Button(action: {
self.folder.add(Folder(name: "New Folder \(self.folder.contents.count)", uuid: UUID()))
}, label: {
Image(systemName: "folder.badge.plus")
})
Button(action: {
self.presentsNewRecording = true
}, label: {
Image(systemName: "waveform.path.badge.plus")
})
})
}
}
18:28 And we ask SwiftUI to present a RecordingView
in a sheet by
binding to this state variable:
struct FolderList: View {
@ObservedObject var folder: Folder
@State var presentsNewRecording = false
var body: some View {
List {
}
.navigationBarTitle("Recordings")
.navigationBarItems(trailing: HStack {
})
.sheet(isPresented: $presentsNewRecording) {
RecordingView()
}
}
}
struct RecordingView: View {
var body: some View {
Text("Recording")
.font(.title)
}
}
}
20:05 The RecordingView
will create an instance of the Recorder
class. Its initializer takes two parameters: a URL
at which it can store the
recording, and a callback that receives the current time. We want to use the
Recorder
class as is, but this means we can't create an instance when the view
initializes.
20:58 We will almost certainly want to modify self
in the callback,
which means we can't create the Recorder
instance until self
is fully
initialized. Also, we should wait until the view actually appears onscreen to
create the instance; otherwise, we'd be unnecessarily creating objects.
21:17 So we create the Recorder
in an onAppear
action, and in order
to assign to the recorder
property from this action, the property needs to be
a @State
variable:
struct RecordingView: View {
@State private var recorder: Recorder? = nil
var body: some View {
VStack {
Text("Recording")
.font(.title)
}
.onAppear {
self.recorder = Recorder()
}
}
}
21:46 We need access to a Folder
in which the recording can be
stored. After creating a new Recording
, we can ask the folder for the
recording's URL that needs to be passed to the Recorder
:
struct RecordingView: View {
let folder: Folder
private let recording = Recording(name: "", uuid: UUID())
@State private var recorder: Recorder? = nil
var body: some View {
VStack {
Text("Recording")
.font(.title)
}
.onAppear {
guard let s = self.folder.store, let url = s.fileURL(for: self.recording) else { return }
self.recorder = Recorder(url: url) { time in
}
}
}
}
23:10 The URL we get from the folder is optional, and in the MVC
version of the app, we dismiss the recording view if we don't have a URL. But
for now, we just do nothing.
24:11 In SwiftUI, we would normally observe the Recorder
to update
our view. But in this case, we receive the recorder's current time in a
callback. So we assign the time value to a state property, which means that the
callback automatically triggers a UI update. We then show the time using a
formatting function we wrote in earlier versions of the app:
struct RecordingView: View {
let folder: Folder
private let recording = Recording(name: "", uuid: UUID())
@State private var recorder: Recorder? = nil
@State private var time: TimeInterval = 0
var body: some View {
VStack {
Text("Recording")
.font(.title)
Text(timeString(time))
}
.onAppear {
guard let s = self.folder.store, let url = s.fileURL(for: self.recording) else { return }
self.recorder = Recorder(url: url) { time in
self.time = time ?? 0
}
}
}
}
25:37 The time parameter we receive in the recorder's callback is
optional, so we substitute it with zero if it's nil
.
26:04 Finally, we have to pass a folder to the RecordingView
:
struct FolderList: View {
@ObservedObject var folder: Folder
@State var presentsNewRecording = false
var body: some View {
List {
}
.navigationBarTitle("Recordings")
.navigationBarItems(trailing: HStack {
})
.sheet(isPresented: $presentsNewRecording) {
RecordingView(folder: self.folder)
}
}
}
26:27 When the recording view is first presented, we have to allow the
app to use the microphone. After doing so, we start a new recording and we see
that the recorder's time is running.
Coming Up
26:52 We're well on our way, but there are quite a few things still
needed. We need a way to stop recording, and we should cancel the recording when
the sheet is dismissed. Also, the entire UI still needs to be styled. Let's
continue with these in the next episode.