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.