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 start to implement the MVC sample app from our App Architecture book in SwiftUI reusing the original model.

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.

Resources

  • Sample Code

    Written in Swift 5

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

162 Episodes · 56h09min

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