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 refactor our SwiftUI views to be testable in previews without mocking the model.

00:06 In this episode, we'll see how SwiftUI previews help us design and implement our UI while using real model data.

00:22 As part of a new Mac app we're developing, we're going to create a permissions view. This view shows the status of three permissions our app needs from the user: access to the camera, access to the microphone, and the ability to capture the screen.

00:39 The process of requesting permission or checking a previously granted authorization isn't complicated, but it involves multiple steps with system calls and callbacks. And we have to run a terminal command to reset previously granted permissions, which makes iteratively designing and trying out our UI a tedious job.

Permissions View

01:18 So far, we've set up a view with three icons. The plan is to guide the user through the needed permissions one-by-one and to highlight the icon for the current permission. So, if we're asking for permission to access the camera, we want to fade out the microphone and screen icons:

01:35 We already have a global Permissions object that can be observed to find out which device is currently selected:

struct ContentView: View {
    @ObservedObject var  permissions = Permissions.global
    
    var body: some View {
        HStack(spacing: 30) {
            Image(systemName: "camera")
                .opacity(permissions.currentDevice == .camera ? 1 : 0.6)
            Image(systemName: "mic")
                .opacity(permissions.currentDevice == .microphone ? 1 : 0.6)
            Image(systemName: "rectangle.on.rectangle")
                .opacity(permissions.currentDevice == .screen ? 1 : 0.6)
        }
        .font(.largeTitle)
        .padding(30)
    }
}

02:17 If we run the app or open the preview, we can see the camera is selected.

02:33 Below the three icons, we add a button to trigger the permission request for the current device:

struct ContentView: View {
    @ObservedObject var permissions = Permissions.global
    
    var body: some View {
        VStack(spacing: 30) {
            HStack(spacing: 30) {
                Image(systemName: "camera")
                    .opacity(permissions.currentDevice == .camera ? 1 : 0.6)
                Image(systemName: "mic")
                    .opacity(permissions.currentDevice == .microphone ? 1 : 0.6)
                Image(systemName: "rectangle.on.rectangle")
                    .opacity(permissions.currentDevice == .screen ? 1 : 0.6)
            }
            .font(.largeTitle)
            
            Button("Authorize") {
                permissions.authorize(permissions.currentDevice)
            }
        }
        .padding(30)
    }
}

03:07 When we run the app and click the button, the system asks for permission to access the camera. After we click OK, the microphone becomes the current device.

03:23 That works well, but we can only see the UI in the state that's dictated by the Permissions object. It'd be nice if we could see the view in any state we want without having to click through the authorization requests or reset the permissions.

03:54 A typical approach to breaking up the tight coupling between the view and the system is writing a protocol that describes the API of Permissions and then conforming a new TestPermissions type to the same protocol. This way, we'd replace the system with an object whose state we can manipulate more easily. But this approach introduces a lot of overhead, given that we'd also have to change the singleton pattern of Permissions.global.

04:28 A different solution is to separate the permissions view into two views: one that holds the global Permissions object, and another one containing the UI logic. This way, the latter view doesn't have to depend on any global state.

Separate Views

05:08 We move the entire VStack into a new view, and we give it a property for the current device. The view hierarchy can now use this value instead of the currentDevice from the global state. In the authorize button's closure, we still call out to the global Permissions object, passing on the view's currentDevice value:

struct PermissionsView: View {
    var currentDevice: Permissions.Device
    
    var body: some View {
        VStack(spacing: 30) {
            HStack(spacing: 30) {
                Image(systemName: "camera")
                    .opacity(permissions.currentDevice == .camera ? 1 : 0.6)
                Image(systemName: "mic")
                    .opacity(permissions.currentDevice == .microphone ? 1 : 0.6)
                Image(systemName: "rectangle.on.rectangle")
                    .opacity(permissions.currentDevice == .screen ? 1 : 0.6)
            }
            .font(.largeTitle)
            Button("Authorize") {
                Permissions.global.authorize(currentDevice)
            }
        }
        .padding(30)
    }
}

06:13 The observed object stays in the main view, and we pass its current device value to the new view:

struct ContentView: View {
    @ObservedObject var permissions = Permissions.global

    var body: some View {
        PermissionsView(currentDevice: permissions.currentDevice)
    }
}

06:47 Everything still works the same way, but now we can preview the PermissionsView on its own, specifying any device we want as the current one. We can even preview multiple versions of the view at once, each with its own configuration. This makes it easy to get an overview of all possible states the view can be in — not only having different devices selected, but also using a dark or a light appearance, for example:

struct PermissionsView_Previews: PreviewProvider {
    static var previews: some View {
        PermissionsView(currentDevice: .camera)
        PermissionsView(currentDevice: .microphone)
        PermissionsView(currentDevice: .screen)
            .preferredColorScheme(.light)
    }
}

We can now continue to implement design details, and we'll instantly see how our changes affect various states of the view.

Showing Permission Status

07:42 Once we authorize access to the camera, we want to display a green circle with a checkmark as a badge on top of the camera icon.

08:01 We add a new status property containing a dictionary of device authorization statuses:

struct PermissionsView: View {
    var currentDevice: Permissions.Device
    var status: [Permissions.Device: AVAuthorizationStatus]
    
    var body: some View {
        // ...
    }
}

08:52 The Permissions object already has a status property of the same type, so we can make ContentView pass that status value to the permissions view:

struct ContentView: View {
    @ObservedObject var permissions = Permissions.global

    var body: some View {
        PermissionsView(currentDevice: permissions.currentDevice, status: permissions.status)
    }
}

09:02 For the previews, we can specify the statuses we want to see:

struct PermissionsView_Previews: PreviewProvider {
    static var previews: some View {
        PermissionsView(currentDevice: .camera, status: [:])
        PermissionsView(currentDevice: .microphone, status: [.camera: .authorized])
        PermissionsView(currentDevice: .screen, status:
                            [.camera: .authorized, .microphone: .authorized])
            .preferredColorScheme(.light)
    }
}

09:23 Before we add a green badge, we pull the icon out of the permissions view. Otherwise, we'd be writing the same code three times:

struct Icon: View {
    var device: Permissions.Device
    var isAuthorized: Bool
    
    var body: some View {
        device.icon
    }
}

struct PermissionsView: View {
    var currentDevice: Permissions.Device
    
    var body: some View {
        VStack(spacing: 30) {
            HStack(spacing: 30) {
                Icon(device: .camera, isAuthorized: status[.camera] == .authorized)
                    .opacity(permissions.currentDevice == .camera ? 1 : 0.6)
                Icon(device: .microphone, isAuthorized: status[.microphone] == .authorized)
                    .opacity(permissions.currentDevice == .microphone ? 1 : 0.6)
                Icon(device: .screen, isAuthorized: status[.screen] == .authorized)
                    .opacity(permissions.currentDevice == .screen ? 1 : 0.6)
            }
            .font(.largeTitle)
            Button("Authorize") {
                Permissions.global.authorize(currentDevice)
            }
        }
        .padding(30)
    }
}

11:09 We extend Permission.Device to return the icon image for each device:

extension Permissions.Device {
    var icon: Image {
        switch self {
        case .camera: return Image(systemName: "camera")
        case .microphone: return Image(systemName: "mic")
        case .screen: return Image(systemName: "rectangle.on.rectangle")
        }
    }
}

12:37 Now we can add the badge to the Icon view. We place a Circle shape in an overlay on the icon image, we give the circle a fixed frame and an offset, and we align the overlay to the top-trailing corner of the icon:

struct Icon: View {
    var device: Permissions.Device
    var isAuthorized: Bool
    
    var body: some View {
        device.icon
            .overlay(Circle()
                        .fill(Color.green)
                        .frame(width: 16, height: 16)
                        .offset(x: 8, y: -8),
                     alignment: .topTrailing)
    }
}

14:06 The icons now all show a green badge, but only the authorized devices should do this. By wrapping the badge in a Group, we can write an if statement inside the Group's view builder to make the badge's appearance depend on the isAuthorized property:

struct Icon: View {
    var device: Permissions.Device
    var isAuthorized: Bool
    
    var body: some View {
        device.icon
            .overlay(Group {
                if isAuthorized {
                    Circle()
                        .fill(Color.green)
                        .frame(width: 16, height: 16)
                        .offset(x: 8, y: -8)
                }
            }, alignment: .topTrailing)
    }
}

15:05 Before we draw a checkmark in the green badge, we want to clean up the permissions view, because it contains a lot of duplicate code. Instead of manually adding each icon to the HStack, we can use ForEach with an array of devices:

struct PermissionsView: View {
    var currentDevice: Permissions.Device
    var status: [Permissions.Device: AVAuthorizationStatus]
    
    var body: some View {
        VStack(spacing: 30) {
            HStack(spacing: 30) {
                ForEach([Permissions.Device.camera, .microphone, .screen]) { dev in
                    Icon(device: dev, isAuthorized: status[dev] == .authorized)
                        .opacity(currentDevice == dev ? 1 : 0.6)
                }
            }
            .font(.largeTitle)

            Button("Authorize") {
                Permissions.global.authorize(currentDevice)
            }
        }
        .padding(30)
    }
}

16:14 To pass an array of devices to ForEach, we need to either specify a key path to a Hashable property on the Permissions.Device type or conform the type to Identifiable. We choose the latter, and we add the conformance by returning self as the identifier, which is possible because the Permissions.Device type is Hashable:

extension Permissions.Device: Identifiable {
    var id: Self { self }
}

Drawing a Checkmark

17:02 To make the green badge look more like an indicator of success, we want to add a checkmark to it. In another overlay of the green circle, we add a checkmark image with a font that's small enough to fit inside the circle:

struct Icon: View {
    var device: Permissions.Device
    var isAuthorized: Bool
    
    var body: some View {
        device.icon
            .overlay(Group {
                if isAuthorized {
                    Circle()
                        .fill(Color.green)
                        .frame(width: 16, height: 16)
                        .overlay(Image(systemName: "checkmark").font(.caption))
                        .offset(x: 8, y: -8)
                }
            }, alignment: .topTrailing)
    }
}

18:20 Because of the previews we have in place, we immediately notice that the checkmark doesn't look as good in the context of a light appearance. There, it takes on the default black text color, which is a bit too dark on the green background. This is easily fixed by setting a white foreground color for the checkmark:

struct Icon: View {
    var device: Permissions.Device
    var isAuthorized: Bool
    
    var body: some View {
        device.icon
            .overlay(Group {
                if isAuthorized {
                    Circle()
                        .fill(Color.green)
                        .frame(width: 16, height: 16)
                        .overlay(Image(systemName: "checkmark")
                                    .font(.caption)
                                    .foregroundColor(.white)
                        )
                        .offset(x: 8, y: -8)
                }
            }, alignment: .topTrailing)
    }
}

19:11 Next time, we'll do some more work on the icons and add animations. We'll keep using previews to try out the animations without having to actually launch the app and go through the permissions flow.

Resources

  • Sample Code

    Written in Swift 5.3

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

167 Episodes · 58h12min

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