Join us for part two of our Code-Along series as we use SwiftUI to build a Mac app from start to finish. The journey continues as we explore how our sample gardening app can adapt to a person's preferences and specific workflows. Learn how SwiftUI apps can automatically react to system settings, and discover how you can use that information to add more personality to an app. We'll show you how you can give people the flexibility to customize an app through Settings, and explore how to use different workflows for manipulating someone's data (like drag and drop). To finish, we'll show you how you can move data to and from an app, incorporating features like Continuity Camera to provide a simple workflow for importing images.
This is the second session in a two-part Code-Along series. To get the most out of this session, we recommend first watching “SwiftUI on the Mac: Build the fundamentals.” And for more background on working with these frameworks, watch Introduction to SwiftUI from WWDC20.
♪ Bass music playing ♪ ♪ Jeff Robertson: Welcome to the second part of our talk on building a great Mac app in SwiftUI. I'm Jeff, an engineer on the SwiftUI team. I hope you enjoyed the first part of this talk by my colleague Mathieu. If you have not yet watched part one, please stop here and do so now, as we will be building on top of the changes that are discussed in that talk. Our gardening app has come a long way since the start of Mathieu's talk. SwiftUI enabled us to build an app with quite a bit of functionality in very little time. However, users tend to use our apps in many different ways, and a particularly great macOS app will account for this. With that in mind, let's take a look at some of the ways that we can build an app for everyone while still maintaining the principles Mathieu outlined in part one. First, we'll take a look at what it means to be a fully customizable macOS app, by handling changes to the system as a whole as well as within our own app. Adding an additional workflow for our users to manipulate their data through drag and drop is another way for us to provide a flexible user experience. Then, we'll explore how to work with the file system, by allowing our app's data to be exported. And finally, we'll add support for Continuity Camera to create a seamless workflow for importing images into our app. The first thing I'd like to talk to you about isn't any specific API at all, but more about how an app built with SwiftUI fits in with the customizability of macOS. Here, I have our gardening app open as well as System Preferences. I'm going to switch to Dark mode, and you can see our app updates its interface automatically. While I have the System Preferences open, I'm also going to update my sidebar icon size to be large. Just as with Dark mode, our app adjusted itself to what I set in System Preferences. I love these little touches, both as a developer -- since I get them automatically -- and as a user -- since it means the apps I'm using will be taking into account my own personal tastes. Before I leave System Preferences to focus on our app, I'd like to point out that I have my accent color set to be multicolor. This feature allows developers to configure an app-specific accent color; and the operating system will customize your app's buttons, selection highlighting, and sidebar glyphs. I'd like to support an accent color in my app, so I'm going to open the asset catalog in our project and select the AccentColor. And I'm going to change its content to be system green to match our app's theme. As you can see, our sidebar icons and selection have all picked up the change. We've seen how our app can automatically react to changes which affect the entire operating system, but what about app-specific settings? Let's walk through adding an interface to let users customize our gardening app. Here, I've opened our GardenApp file. And alongside the WindowGroup scene I'm going to add a Settings scene. And this scene will give us a menu item which, when selected, will open a window with our view. I'm going to use the SettingsView that I've set up, and I'm going to pass it my model as well. Additionally, the Settings scene will also add the appropriate menu item to your app's main menu and configure it with the standard keyboard shortcut of Command-comma. This gives us a great start. Now, let's take a look at the SettingsView, where I'll define the interface. On macOS, it's common for apps to provide a settings interface with toolbar icons to allow switching between the different panes, particularly if the app provides a lot of settings which can be divided up into different categories. So for my main view here, I'm going to use a TabView. And I'm going to give it two children: one for GeneralSettings, and one for ViewingSettings. And for the content of the tab in the window toolbar area, I'm going to use a tabItem. And the contents of this can just be a Label. We'll give it the text to be displayed -- in this case, "General" -- and a systemImage; I'm going to use "gear" for GeneralSettings. And then let's do the same for our ViewingSettings. I'll add a tabItem and a Label -- we'll call it "Viewing" -- and a systemImage of, I think, "eyeglasses." All right. So now we have the contents of our two tabs. Let's fill out the GeneralSettings now. Something that could be nice for our users is the ability to define a garden to be used as the default when no garden is currently selected. For this, I'm going to add a Picker... And the first item, I think, will be the Text("None"). And for the others, I'm going to add a ForEach over all the gardens in our data. And for each garden, I'm just going to add a Text and give it the garden's name as well as its displayYear. We'll also need to provide a tag here with the ID of the garden. So for our "None" value, we can give it a tag of none. And for our other gardens, we'll give it a tag corresponding to the garden's ID. Lastly, we need to provide some state for the Picker's selection. When providing a settings interface like this, it's important to persist the state so that your app remembers the user's selection across launches and OS updates. In SwiftUI, this can be accomplished by using the AppStorage property wrapper. This property wrapper will persist our value using the UserDefaults system, which is exactly what we want here. So for our selection binding, I'm going to add the AppStorage property wrapper. This takes a key. We'll give it "defaultGarden", and we'll call it "selection". It is also an optional Garden.ID. And what this will do is persist our selection value using the user default system. I'm also going to add a fixedSize to my Picker and some padding to the form. I'm going to switch over to our ContentView here, and I'll add our AppStorage and give it that same key we used. We'll call it "defaultGardenID", and it's also an optional Garden.ID. And then here where I have this binding for selection, I'm going to replace this with a Binding, and the "get" will be to first use the selectedGardenID. And then if that isn't set, we're going to fall back to the defaultGardenID. And then for the setter, we only want to update our selectedGardenID. We don't actually want to update our defaultGarden. So I'm just going to say "selectedGardenID = $0". All right. I'm going to run our app. Open the Preferences menu item here, and I'm going to select Indoor Plants as my default garden. And I'll open a new window, and we see Indoor Plants is selected. Providing customization support via settings is one nice way of building a flexible experience for our users. Another way is providing alternative workflows for the same action. In the first part of our talk, Mathieu showed you how to add a main menu item for adding a plant to the selected garden. This is great functionality for our app, but let's look at another way that we can provide similar functionality via a common macOS user interaction: drag and drop. Since we're using table here, I'm going to make a couple of adjustments to support it being a drag source and drop destination. The first thing I'm going to do is remove this "plants" from the initializer. Then I'm going to go down at the end and add a row builder. For the contents of this row builder, I'm going to add a ForEach and use those plants that we had from before. And for each plant, I'm just going to create a TableRow with it. So now I'm going to customize each of our TableRows by adding the itemProvider modifier. And I'm going to return just plant.itemProvider here, which is a computed property I set up on my model. So now each of these rows supports being a drag source. I've made enough changes now to allow me to drag out my plants, but this is not very useful if nothing will accept it. Let's fix that by also adding drop support to our table. The onInsert modifier is the other half of our drag and drop equation. It takes a list of content types, and I'm going to pass Plant.draggableType here, which is a custom type I set up on my model. It also takes a closure, which is passed two parameters. One is the index where the drop occurred, and another is the list of item providers. We're going to then call Plant.fromItemProviders to create our model, and we'll pass those item providers here. This will give us back a list of plants, which we can use to update our model. I'll call garden.plants.insert (contentsOf: plants) at the index where it occurred.
Now, I can open a new window with my Indoor Plants, select a few flowers from my Backyard Flower Bed, and drag to copy them over.
Drag and drop is a great way to move data around inside our app, but what about moving data between our app and the operating system? Our users would appreciate being able to export all this data -- perhaps for backup purposes or importing into another app. To facilitate this workflow, let's add a main menu item for exporting our database in a common file format that can be shared with other applications. I've already created a type to contain my menu item, which conforms to the commands protocol. In our commands here, I'm going to add an ImportExportCommands And also pass it our store. Let's switch over to that file now. And for the body, I'm going to add a CommandGroup and I'm going to replace the system-provided importExport placement. And what this will do is add our menu item in the expected place in the File menu. So for the contents of our CommandGroup, I'll add a Section and a Button. We'll give it a label of "Export" followed by the ellipses. The ellipses indicate to the user that selecting that item will open a window or a save dialog. And the Button can just modify some state -- say, "isShowingExport = true" -- and let's add that state up here as well. So now we have our Button, which is modifying some state. I'm also going to add the fileExporter modifier here. And I'm going to give it a binding to our state in its isPresented parameter. isShowingExport. It also takes a document. This is a type that needs to conform to either the file document protocol or the reference file document protocol. I've already added conformance to my store, so we'll just pass that here. Additionally, it takes a content type. We'll give it Store.readableContentTypes.first which is just the CSV type. And lastly, it takes a closure, which is passed the result of the operation, indicating success or failure. Now, I can select our Export menu item, give it a file to save as, say "plants.csv", hit Export, and the file's been saved to disk. While we're on the topic of moving data between our app and the operating system, there is one last thing I'd like to discuss. Our app has lots of textual data about our plants, but it would be great to add images as well. Users could take pictures of their plants over time to track their progress. One way to enable this import flow is with Continuity Camera. This feature would allow our users to take a picture of their plant with their iOS device and have it import directly into our app. Let's take a look at how we can add a menu item to enable this flow when a user has selected a plant in the gallery view. After my importExportCommands, I'm going to add ImportFromDevicesCommands, and that'll give us our main menu item. Additionally, I'm going to switch over to our GardenDetail file, and at the end of our body here, I'm going to add the importsItemProviders modifier, and this takes a list of types that we want to support importing. I'm going to base this off of whether anything is selected, so I'm going to use our selection here. And if it's empty, I'm going to return an empty array. And if it's not empty, I'm going to return Plant.importImageTypes, which is a list of all the image types on the system. And this modifier takes a closure, which is passed a list of item providers. We'll take those providers and call Plant.importImageFromProviders, pass it the providers, and this is going to give us back a URL where it saved the image to disk. We'll then update our model by looping through all the IDs that are selected... ...and get a reference to the plant and update its imageURL. I'm going to select my Indoor Plants garden and switch to Gallery mode. And I'm going to make things a little bit bigger. Select my plant here. In the main menu item, choose Import from iPhone > Take Photo.
And you can see our gallery updated. I hope this was a nice tour of some of the various ways which define a great Mac app. I'm looking forward to all the ways your apps will make the macOS platform better. Have a wonderful WWDC 2021. ♪
Looking for something specific? Enter a topic above and jump straight to the good stuff.
An error occurred when submitting your query. Please check your Internet connection and try again.