macOS by Tutorials macOS App Development for iOS Developers 1950325660, 9781950325665

Learn macOS development! You're an experienced iOS developer and have always wanted to develop a native macOS app.

144 48 51MB

English Pages 431 [350] Year 2022

Report DMCA / Copyright

DOWNLOAD PDF FILE

Table of contents :
What You Need
Book Source Code & Forums
Dedications
About the Team
About the Authors
About the Editors
Introduction
How to read this book
Chapter 1: Designing the Data Model
Where is the Data Coming From?
Saving a Sample Data Set
Using the Sample Data
Exploring the JSON
Decoding the Top Level
Going Deeper
Processing the Links
Making Day Easier to Use
Identifying Data Objects
Tidying up the Event Text
Bringing it All Together
Testing with Live Data
Key Points
Where to Go From Here?
Chapter 2: Working With Windows
A New Mac App
Designing the Window
Setting up the Sidebar
Previewing macOS Views
Creating an Event Card
Using Live Data
Showing Data in a Grid
Styling the Event Card
Working with Multiple Windows
Challenge
Key Points
Where to Go From Here?
Chapter 3: Adding Menus & Toolbars
What Is a Menu?
Setting up Menu Commands
Using Pre-built Menu Groups
Inserting a Menu Item
Creating a New Menu
Adding a Toggle
Using a Picker
Adding a Toolbar
Searching the Grid
Making Your Toolbar Customizable
Challenges
Key Points
Where to Go From Here?
Chapter 4: Using Tables & Custom Views
Why Use a Table?
Adding a Table
Switching Views
Storing Window Settings
Sorting the Table
Selecting Events
Custom Views
Adding to the Sidebar
Challenges
Key Points
Where to Go From Here?
Chapter 5: Setting Preferences & Icons
Preferences
Creating a Preferences View
Adding Tabs
Setting up the Show Options
Designing the Appearance Tab
Editing the App Name
App Icons
Configuring the About Box
Help?
Challenge
Key points
Where to Go From Here?
Chapter 6: Why Write a macOS App?
What Makes a Mac App?
What are the Alternatives?
And the Winner Is…
Code Sharing
Challenge
Key Points
Where to Go From Here?
Chapter 7: Using the Menu Bar for an App
Setting up the App
Converting the App into a Menu Bar App
Why AppKit?
Adding the Models
Static Menu Items
Dynamic Menu Items
Styling the Menu Items
Creating a Custom View
Using the Custom View
Challenge
Key Points
Where to Go From Here?
Chapter 8: Working with Timers, Alerts & Notifications
Linking Outlets and Actions
Managing the Tasks
Timers
Tracking the Timer State
Starting and Stopping Tasks
Updating the Menu Title
Updating the Tasks
Checking the Timer
Creating Alerts
Using Local Notifications
Key Points
Where to Go From Here?
Chapter 9: Adding Your Own Tasks
Storing Data
Retrieving Data
Opening the Sandbox
Editing the Tasks
Showing the Edit Window
Saving and Reloading
Using Notification Center
Launching on Login
Using the App
Challenges
Key Points
Where to Go From Here?
Chapter 10: Creating A Document-Based App
Setting Up a Document-based App
The Default Document App
Configuring for Markdown
Markdown and HTML
Embedding an AppKit View
Displaying the HTML
Limiting the Frames
Adding a Toolbar
Configuring the Preview
Challenges
Key Points
Where to Go From Here?
Chapter 11: Adding Menu Controls
Adding the Style Files
Creating a New Menu
Styling the HTML
Adding Keyboard Shortcuts
Inserting a Submenu
Using the Help Menu
Focusing on a Window
Adding a Window-specific Menu
Exporting the HTML
Coding for the Touch Bar
Challenges
Key Points
Where to Go From Here?
Chapter 12: Diving Deeper Into Your Mac
Terminal Commands
Running Commands in a Playground
Wrapping it in Functions
Manipulating Images
Challenges
Key Points
Where to Go From Here?
Chapter 13: Adding the Interface
The Starter Project
Choosing Files and Folders
Dragging and Dropping
Showing the File Path
Using sips
Showing the Terminal Output
Resizing Images
The Mac Sandbox Again
Locking the Aspect Ratio
Creating Thumbnails
Challenge
Key Points
Where to Go From Here?
Chapter 14: Automation for Your App
What is Automation?
Adding a Service
Testing the Services Menu
Handling the Service Call
Using the Service
Adding a Shortcut
Using the Shortcut
Key Points
Where to Go From Here?
Chapter 15: Using the Mac App Store
Distribution Options
Setting up your Developer Account
Identifying Your App
Code Signing
Uploading Your App
Configuring the App
TestFlight
Releasing Your App
Checking your Crash Logs
Updating the App
Key Points
Where to Go From Here?
Chapter 16: Distributing Externally
Apple’s Gatekeeper
Exporting the App
Notarizing the App
Wrapping Your App
Selling Your App
Releasing Updates
Troubleshooting
Key Points
Conclusion
Recommend Papers

macOS by Tutorials  macOS App Development for iOS Developers
 1950325660, 9781950325665

  • 0 0 0
  • Like this paper and download? You can publish your own PDF file online for free in a few minutes! Sign Up
File loading please wait...
Citation preview

macOS by Tutorials

i

What You Need Written by Sarah Reichelt

To follow along with this book, you’ll need the following: A Mac computer with an Intel or ARM processor. Any Mac that you’ve bought in the last few years will do, even a Mac mini or MacBook Air. Xcode 13.2 or later. Xcode is the main development environment for building macOS Apps. It includes the Swift compiler, the debugger and other development tools you’ll need. You can download the latest version of Xcode for free from the Mac App Store.

2

macOS by Tutorials

ii

Book Source Code & Forums Written by Sarah Reichelt

Where to download the materials for this book The materials for this book can be cloned or downloaded from the GitHub book materials repository: https://github.com/raywenderlich/mos-materials/tree/editions/1.0

Forums We’ve also set up an official forum for the book at https://forums.raywenderlich.com/c/books/macos-by-tutorials. This is a great place to ask questions about the book or to submit any errors you may find.

3

macOS by Tutorials

iii

Dedications Written by Sarah Reichelt

To Tim who is endlessly supportive, even when listening to me complain about all my bugs. — Sarah Reichelt

4

macOS by Tutorials

iv

About the Team Written by Sarah Reichelt

About the Authors Sarah Reichelt is the author of this book. She got hooked onto trying to make computers do what she told them a very long time ago and has never stopped loving it. She is a keen evangelist for developing native Mac apps. When not at her computer, Sarah loves coffee, puzzles, reading and cooking — the day hasn’t started until the first cup of coffee is drunk and the crossword is done!

About the Editors Audrey Tam is a tech editor on this book. As a retired computer science academic, she’s a technology generalist with expertise in translating new knowledge into learning materials. Audrey attends many concerts at Tempo Rubato and does most of her writing and zooming at Rubato Upstairs. She also enjoys long train journeys, knitting, and trekking in the Aussie wilderness.

Ehab Amer is tech editor on book. He is a very enthusiastic Lead iOS developer with a very diverse experience from building games to enterprise applications and POCs with new technologies. In his spare time, TV shows take the majority of his time, followed by video games. When away from the screen, he goes with his wife and friends to explore the underwater world through diving.

5

macOS by Tutorials

About the Team

Richard Critz did double duty as editor and final pass editor for this book. He has been doing software professionally for over 40 years, working on products as diverse as CNC machinery, network infrastructure, and operating systems. He discovered the joys of working with iOS beginning with iOS 6. Yes, he dates back to punch cards and paper tape. He’s a dinosaur; just ask his kids. On Twitter, while being mainly read-only, he can be found @rcritz. The rest of his professional life can be found either at www.rwcfoto.com or at his Mathnasium franchise.

6

macOS by Tutorials

v

Introduction Written by Sarah Reichelt

What do I love about programming for the Mac? I use three Apple devices every day. My iPhone is primarily for communication; my iPad is mostly for entertainment. But the device where I spend most of my time is my Mac. The Mac is the most powerful, flexible and unrestricted device Apple makes, and I love using it. When writing Mac apps, I get to do so many things that iOS apps cannot do, are not allowed to do, or are not suited to. I can make beautiful, intricate, powerful apps that I use every day. I use and write iOS apps, too. There is definitely a place for both, but I feel sad that so many developers don’t even consider the enormous possibilities of the Mac app development world. I’m so happy that you’ve decided to consider those possibilities and join me on this journey!

How to read this book The chapters in each section are designed to take you from start to finish building a particular kind of app. While the book is fun from the first page to the last, if one section especially piques your interest, you’re free to dive right in there. This book is split into five sections.

Section I: Your First App: On This Day Begin your journey developing for macOS by building a full-featured app using SwiftUI. The app, On This Day, accesses a public network API to collect information about events, births and deaths for a given date. Along the way, you’ll learn how to manage multiple windows, add menu and toolbar commands and choose multiple display options. You’ll experience first-hand the power of SwiftUI and see just how easy it is to build an app that has all of the look and feel you expect in a macOS app.

7

macOS by Tutorials

Introduction

Section II: Building a Menu Bar App In this section, you’ll use AppKit to build a Pomodoro-style time tracking app that lives only in the macOS menu bar. Along the way, you’ll learn how to manage timers, update the menu in real-time, and integrate a SwiftUI view into an AppKit app. You’ll also learn about how macOS “sandboxes” apps to protect both them and the system itself.

Section III: Building a Document-based App In this section, you’ll return to using SwiftUI and explore how to build a document-based app. You’ll create a Markdown editor — there can never be enough Markdown editors in the world! — that allows you to preview your text in real time. Along the way, you’ll add menu commands to change the styling of the preview and add formatting to your Markdown text.

Section IV: Advanced Wizardry Because macOS has its roots in Unix, it provides a vast array of command line tools which allow power users to perform tasks ranging from system management to image manipulation. In this section, you’ll learn how to build a graphical front-end for one such command: sips . Once you’ve built your sips GUI, you’ll enable automation to allow your new command to appear in

the Services menu and Shortcuts app. When you complete this section, you, too, will be a wizard!

Section V: Distributing Your macOS Apps Once you’ve written your app, you’ll want to distribute it to others so they can benefit from your creativity. On macOS, you have more distribution options than you do on iOS. In this section, you’ll explore the pros and cons of those options so you can choose which is best for you.

8

macOS by Tutorials

1

Designing the Data Model Written by Sarah Reichelt

In this section, you’ll build a SwiftUI app called On This Day that pulls notable events for a day from an API and displays them in various ways. The app uses an interface style that appears in many macOS apps with a navigation sidebar, details area, toolbar, menus and preferences window. You’ll end up with an app that looks like this:

The finished app

When starting a new app, it’s very tempting to jump right into the interface design, but this time you’re going to start by working out the data models. There are two reasons for this. First, in a SwiftUI app, the data drives the display, so it makes sense to work out the data structure before you start laying out the interface. Secondly, this data comes from an external source and may not have the structure you’d like. Spending some time now to analyze and parse will save you a lot of time and effort later on. In this chapter, you’ll use a playground to fetch the data, analyze the structure and create the data models for the app. Data model design is a vital first step in the development of any app, so working through this chapter will be a valuable experience. But, if you’re already familiar with downloading data, parsing JSON and creating data structures and classes, feel free to skip ahead. In Chapter 2, “Working With Windows”, you’ll download and import the data model files used in this section and start building the user interface.

9

macOS by Tutorials

Chapter 1: Designing the Data Model

Where is the Data Coming From? You’re going to use the API from ZenQuotes.io. Go to today.zenquotes.io in your browser and have a look around the page. At the top, you’ll see an interesting event that happened on this day in some year and you can scroll down to see more:

today.zenquotes.io

Keep scrolling and you’ll get to the Quick Start where they show you the format of the link to get the data. Check out the Usage Limits, too. When testing, it’s easy to hit this limit, so one of your first tasks is to download a sample set of data to work with. Click the documentation link to get more information on the structure of the JSON returned by the API call. You’ll get to explore that in detail over the rest of this chapter.

Saving a Sample Data Set Open the playground from the starter folder. It’s a macOS playground set up with some functions to get you going. The most important one is getDataForDay(month:day:) , which takes in a numeric month and day,

assembles them into a URL and then uses URLSession to download the JSON from that URL . If the data returned can be converted into a String , you’re going to save it to a file. But where should you save it? Unlike iOS, macOS gives you full access to the file system. The app sandbox may restrict this, as you’ll learn in later chapters, but in a playground, you can access everything. Since this is data you’re downloading, saving it to the Downloads folder makes the most sense, so now you need to work out the file path for the Downloads folder so you can use it.

10

macOS by Tutorials

Chapter 1: Designing the Data Model

Working with the File System Your first thought might be to build up the file path as a string. Maybe ~/Downloads would work. But remember not everyone uses English as their system language. My Downloads folder is at /Users/sarah/Downloads, but if I switch my system language to French, it’s at Utilisateurs/sarah/Téléchargements. So, you can’t assume there will be a folder called Downloads. FileManager is a utility class that provides an interface to the file system, and that’s what you’ll use to get this path. The Sources section of the playground contains Files.swift, which holds functions for saving and reading the sample data. Expand the Sources section, if it’s not already expanded, and open Files.swift. The first function is sampleFileURL() which returns an optional URL . At the moment it returns nil so replace return nil with this:

// 1 let fileManager = FileManager.default do { // 2 let downloadsFolder = try fileManager.url( // 3 for: .downloadsDirectory, // 4 in: .userDomainMask, // 5 appropriateFor: nil, create: true) // 6 let jsonFile = downloadsFolder .appendingPathComponent("SampleData.json") return jsonFile } catch { // 7 print(error) return nil }

There’s quite a bit here that may be unfamiliar to you: 1. To work with files and folders, you need to use the default FileManager . Presumably due to its Unix background, FileManager refers to folders as directories. 2. FileManager can try to find the URL to a standard folder. 3. downloadsDirectory is one of the folders listed in the FileManager.SearchPathDirectory enumeration, which assigns constants

to all the usual folders and avoids any translation problems. 11

macOS by Tutorials

Chapter 1: Designing the Data Model

4. FileManager.SearchPathDomainMask lists the possible domains to search. Here, you want to search in the the user’s folder, so userDomainMask is the right one to choose. 5. FileManager ignores the appropriateFor parameter in nearly all searches, but create: true tells it to make this folder if it’s missing. 6. If FileManager has found the user’s Downloads folder, append the sample data file name to create the final URL. 7. If FileManager had a problem, catch will print the error and return nil . Save Files.swift and return to the playground page.

Getting the Data Since URLSession is using await , getDataForDay(month:day:) is marked as async , and you must call it asynchronously, so its usage is wrapped in a Task .

Click the Play button in the gutter beside the last line of the playground and wait while it goes off to the API server, gathers the data and returns it.

Note: If you don’t see a play button in the gutter, your playground is set to run automatically. Long click the play button at the bottom of the code and select Manually Run.

Once the download is complete, you’ll see a message in the console saying the playground has saved the sample data to your Downloads folder:

Saving sample data

Go to your Downloads folder and open SampleData.json. On my computer, it opens in Xcode, but you may have a different app set up to open JSON files:

12

macOS by Tutorials

Chapter 1: Designing the Data Model

Downloaded JSON

Formatting the JSON Depending on the app you used, it may have formatted the JSON into a more readable form, but as you can see, Xcode has not, so here’s a trick that makes formatting JSON a breeze on any Mac. Select all the text in the JSON file and copy it. Open Terminal and type in the following line (don’t copy and paste it or you’ll overwrite the JSON that’s in the clipboard). pbpaste | json_pp | pbcopy

Press Return. This sequence of three shell commands pastes the clipboard contents to the json_pp command, which “pretty prints” it, then uses pbcopy to copy the

neatly formatted JSON back into the clipboard. Under the hood, macOS calls the clipboard the pasteboard which is why it uses pbpaste and pbcopy . Return to your original SampleData.json file, delete the contents and press Command-V to paste in the pretty printed JSON, then save the file again:

13

macOS by Tutorials

Chapter 1: Designing the Data Model

Pretty printed JSON

If you make a mistake and lose the sample data, run the playground again to get it back.

Note: To know more about json_pp , go back to your Terminal, right-click the command and choose Open man Page to open the built-in help page in a new window. You can also type man json_pp , but this shows the information in your working Terminal window. Using a new window makes it easier to read and test the command.

Using the Sample Data Now that you have the data saved and formatted, you can start using it instead of calling the API server every time. This is faster and avoids hitting the usage limit. First, comment out the entire Task section but don’t delete it in case you need to re-fetch the data at any time. Next, add this code to access the saved data instead: if let data = readSampleData() { print(data.count) }

Finally, run the playground again and you’ll see a number showing the amount of data in the sample file:

14

macOS by Tutorials

Chapter 1: Designing the Data Model

Reading sample data

I deliberately chose February 29 for the sample day to minimize the amount of data. Presumably only a quarter of the usual number of interesting events happened on February 29th. :] You may get a different number as the site adds and deletes events.

Exploring the JSON To make it easier to examine the structure of the JSON data returned, turn on code folding. If you’re using Xcode, go to Preferences ▸ Text Editing ▸ Display and check Code folding ribbon. Now you’ll be able to click in the code folding ribbon beside the line numbers to collapse and expand the data nodes.

Code folding

By collapsing nearly all the nodes, you can see the root structure of the layout which contains four elements. data and date are the ones you need here. No way of confusing those two. :] You can ignore the info and updated elements.

15

macOS by Tutorials

Chapter 1: Designing the Data Model

Collapsed JSON

Inside data , there are three nodes for the three different types of event: Births , Deaths and Events . The data inside each of them has the same

structure, so after expanding Births to show the first one, you’ll see this structure:

JSON event data

The three top level elements are html , links and text . If this was for display in a web page, html would be important, but for an app, text is much more useful. Notice how it includes HTML entities and that it starts with the year. The links section is oddly structured with the keys being numbers inside strings. Each link has three elements with "0" being the full HTML link, "1" containing the URL and "2" holding the text for the link.

Decoding the Top Level Now that you’ve explored the JSON and know what you’re getting back from the API server, it’s time to start decoding it. The overall data model for this JSON will be a structure called Day since it contains the information for a specific day. It’ll have data and date properties. date is a string, so start with that. First, add this code to the playground: struct Day: Decodable { let date: String

16

macOS by Tutorials

Chapter 1: Designing the Data Model

}

This establishes Day as a structure that conforms to the Decodable protocol. Since this data will never be re-encoded, there is no need to conform to Codable which is a type alias for Decodable & Encodable .

To test this, replace print(data.count) with this: do { let day = try JSONDecoder().decode(Day.self, from: data) print(day.date) } catch { print(error) }

Then run the playground again and you’ll see “February_29” printed out in the console.

Note: If you ever get an error when running the playground saying that some type cannot be found in scope, this is because you’re running code that comes before the type declaration in the playground. Use the Execute Playground button in the divider between the code and the console instead. You may need to click it once to stop and again to run the playground.

Going Deeper Decoding the data element isn’t so straightforward as there are different types of data inside. So now, it’s time to think about the lower level data models. You can decode each entry in the “Births”, “Deaths” and “Events” elements into an Event data model. Event needs two properties: text and links — you can ignore html . To set this up, add a new structure to the playground: struct Event: Decodable { let text: String let links: [String: [String: String]] }

For now, links is an ugly dictionary containing arrays of dictionaries, but this is enough to get it decoding. Next, insert the new data property into Day : let data: [String: [Event]]

17

macOS by Tutorials

Chapter 1: Designing the Data Model

And lastly, add a second debug print after print(day.date) : print(day.data["Births"]?.count ?? 0)

Run the playground again and you’ll see the date and a number showing how many notable births fell on that day:

Decoding Day & Event

The final piece in the puzzle is the links, so create a new structure called EventLink to handle them:

struct EventLink: Decodable { let title: String let url: URL }

This is the important data for each link, but the incoming JSON isn’t structured like this. To process the data as it comes in, Event is going to have do some more work.

Processing the Links Right now, your Event structure is storing its links in a dictionary, which decodes them, but doesn’t make the links easy for the app to use. By adding a custom init(from:) to Event , you can process the incoming JSON into a more usable format. Replace Event with this version: 18

macOS by Tutorials

Chapter 1: Designing the Data Model

struct Event: Decodable { let text: String // 1 let links: [EventLink] // 2 enum CodingKeys: String, CodingKey { case text case links } // 3 init(from decoder: Decoder) throws { // 4 let values = try decoder.container(keyedBy: CodingKeys.self) // 5 text = try values.decode(String.self, forKey: .text) // 6 let allLinks = try values.decode( [String: [String: String]].self, forKey: .links) // 7 var processedLinks: [EventLink] = [] for (_, link) in allLinks { if let title = link["2"], let address = link["1"], let url = URL(string: address) { processedLinks.append(EventLink(title: title, url: url)) } } // 8 links = processedLinks } }

It was so clean and simple a moment ago and now look at it! So what’s all this doing? 1. links has changed into an array of EventLink objects. 2. As the structure is going to decode the JSON manually, the decoder has to know what keys to use. 3. The structure now has a custom init(from:) for decoding. 4. Use CodingKeys to get the data values from the decoder’s container for the specified keys. 5. Decode the text element from values . This doesn’t need any further processing before assigning it to the text property. 6. Decode the links element as a dictionary. 7. Loop through the values in the dictionary and try to create an EventLink object from each one. 8. Assign the valid entries to links . To test this, add a third debug print statement under the other two. It force19

macOS by Tutorials

Chapter 1: Designing the Data Model

unwraps the array of Births , which is a bad idea in production but is fine in a testing playground: print(day.data["Births"]![0].links)

Now run the playground and this time, it’ll take a while to finish. As Event.init(from:) loops, you’ll be able to see the counters on the right. Doing

multiple loops is something that playgrounds struggle with, but this is very fast inside an app. The link output isn’t very readable, but you can see they’re all there, each with a title and a URL:

Decoding EventLink

Making Day Easier to Use Now that you’re decoding the JSON and have set up the basic data structure, it’s time to consider how the app will use this data and what you can add to make this easier. Looking at Day first, it would be convenient to have a more direct way of accessing the various categories of events instead of using an optional like day.data["Births"] each time.

There are three types of events, so to avoid using magic strings as dictionary keys, start by adding this enumeration which describes them: enum EventType: case events = case births = case deaths = }

String { "Events" "Births" "Deaths"

Conventionally, the cases in an enumeration start with a lower case letter, but the raw string values are set to the title case strings that appear in the JSON so 20

macOS by Tutorials

Chapter 1: Designing the Data Model

they’ll work as keys to the data dictionary. With the enumeration in place, add these computed properties to Day : var events: [Event] { data[EventType.events.rawValue] ?? [] } var births: [Event] { data[EventType.births.rawValue] ?? [] } var deaths: [Event] { data[EventType.deaths.rawValue] ?? [] }

These properties use the raw value to return an array of the relevant events or an empty array. Now, you can change the inelegant debug print statements so they use no optionals and no force unwrapping: print(day.births.count) print(day.births[0].links)

The second feature that would be useful in Day is a nicer way to show the date. Right now, there’s an underscore between the month and the day. You could use a custom init(from:) to change the way you decode it, but you’re going to use another computed property. Add this to Day : var displayDate: String { date.replacingOccurrences(of: "_", with: " ") }

To test this, change the first of the debug print statements to: print(day.displayDate)

Run the playground again to see updated date string:

Formatting the date

Not much different to see here except for the formatted date, but you accessed the information much more easily.

Identifying Data Objects Take a moment to think about how your app might display the information in 21

macOS by Tutorials

Chapter 1: Designing the Data Model

Day . displayDate is a String and all ready for use. Then you have the

arrays containing Event s and EventLink s, which the views in your app will need to loop through in some manner. When looping through arrays of data in SwiftUI, it’s important that each element has a unique identifier. This allows the SwiftUI engine to track which elements have changed, moved or disappeared, so it can update the display as efficiently as possible. The best way to do this is to make the model structures conform to Identifiable . This protocol requires the conforming type to contain a

property called id , which can be anything, but is usually a string, a number or a unique ID. Some data might arrive with IDs already. In this case there’s nothing obviously unique, so you’re going to add a UUID to each Event and EventLink .

Starting with EventLink , edit the structure declaration to include Identifiable and add an id property:

struct EventLink: Decodable, Identifiable { let id: UUID let title: String let url: URL }

This causes a “Missing argument” error in init(from:) where EventLink objects are created. Let Xcode apply its suggested fix and replace the placeholder with UUID() so the code that creates each EventLink ends up like this: processedLinks.append( EventLink(id: UUID(), title: title, url: url))

For Event , you want to do something similar. Add Identifiable and an id property but, in this case, the declaration will initialize the UUID . Replace struct Event: Decodable { with:

struct Event: Decodable, Identifiable { let id = UUID()

If you had used this technique for EventLink , you’d have seen a warning about an immutable property which won’t be decoded. This isn’t a problem with Event , because you’ve set up the CodingKeys , which tell the decoder which

properties to use and which to ignore.

22

macOS by Tutorials

Chapter 1: Designing the Data Model

Tidying up the Event Text Now that you’re prepared for looping through the events and links, it’s time to look at the text for the events. In your debugging print statements, replace the line printing out the links with this one and run the playground again: print(day.births[0].text)

In the console, you’ll see “1468 – Pope Paul III (d. 1549)” or something similar. You can see the text string starts with the year and then uses the HTML entity for an en dash to separate this from the information. For display purposes, it seems like it’d be useful to separate out these two parts into distinct properties. First, add a year property to Event . You may be tempted to convert the year into an Int , but remember that some events will have happened a long time ago and may include “BC” or “BCE”, so the years need to remain as strings. let year: String

Replace the line in init(from:) that sets text with this: // 1 let rawText = try values.decode(String.self, forKey: .text) // 2 let textParts = rawText.components(separatedBy: " – ") // 3 if textParts.count == 2 { year = textParts[0] // 4 text = textParts[1].decoded } else { year = "?" // 4 text = rawText.decoded }

What’s going on here? 1. Decode the text element from values exactly as before, but assign it to a constant. 2. Split rawText using the HTML entity with a space on either side. 3. If the split resulted in two parts, assign the first to year and the second to text . If the text didn’t contain the entity or contained it more than once, set year to a question mark and text to the complete value from the decoder.

23

macOS by Tutorials

Chapter 1: Designing the Data Model

4. Decode any HTML entities in the text using the String extension from the start of the playground. Time to add yet another debug print statement: print(day.births[0].year)

Run the playground again and you’ll see something like this:

Splitting the text

Bringing it All Together So far, you’ve created a series of data structures: Day , Event and EventLink . Now, it’s time to pull them all together into an ObservableObject , which is the primary data model in your app. Add this definition to the playground: // 1 class AppState: ObservableObject { // 2 @Published var days: [String: Day] = [:] // 3 func getDataFor(month: Int, day: Int) -> Day? { let monthName = Calendar.current.monthSymbols[month - 1] let dateString = "\(monthName) \(day)" return days[dateString] } }

There are a few important things to look at here: 1. Unlike the other data objects, this object is a class and it conforms to ObservableObject so your SwiftUI views can watch it and respond to any

changes. 24

macOS by Tutorials

Chapter 1: Designing the Data Model

2. days holds a dictionary of Day data objects, indexed on their date. This uses the @Published property wrapper, which means any SwiftUI views observing this object get notified whenever this property changes. 3. Finally, there is a convenience method for returning a Day for the supplied month number and day number, if it’s available. To test this, go to the top of the playground and add these lines just after the import line:

let appState = AppState() let monthNum = 2 let dayNum = 29 func testData() { if let day = appState.getDataFor( month: monthNum, day: dayNum ) { print(day.displayDate) print("\(day.deaths.count) deaths") } else { print("No data available for that month & day.") } }

This creates an AppState object, sets a test month and day and then adds a function for testing the result. These definitions need to be at the top because playgrounds run from top to bottom and these have to be set up before anything tries to use them. Scroll back down to where you read the sample data file and printed out some debugging information. Replace all of the print statements with the following: appState.days[day.displayDate] = day testData()

Run the playground and you’ll see a result like this in the console:

Testing AppState

25

macOS by Tutorials

Chapter 1: Designing the Data Model

Testing with Live Data As a final check, how about re-enabling the actual download and making sure your code can process live data correctly? Right now, the download saves the data to a text file, so you need to change the download function to make it decode this data into a Day and return it. First, find getDateForDay(month:day:) and replace its signature line with this one which sets it to return a Day : func getDataForDay(month: Int, day: Int) async throws -> Day {

Next, below where you save the data to a file, add this chunk, which attempts to decode the downloaded data into a Day and throws an error if it can’t: do { let day = try JSONDecoder().decode(Day.self, from: data) return day } catch { throw FetchError.badJSON }

Finally, comment out the entire code block that begins if let data = readSampleData() { and add the following after it:

Task { do { let day = try await getDataForDay( month: monthNum, day: dayNum) appState.days[day.displayDate] = day testData() } catch { print(error) } }

Xcode tip: Click the code folding ribbon to the left of if let data = readSampleData() { to collapse the block into a single line. Double-click the

collapsed line to select the entire block, then press Command-/ to comment it out.

This is very similar to the Task you used to get the sample data, but this version waits for the decoded Day to come back, adds it to appState ’s days and calls the test function.

26

macOS by Tutorials

Chapter 1: Designing the Data Model

If there was a download error or a decoding error, the catch block will print it out. Click the Execute Playground button again. You’ll see a message reporting the saved file path, and then you’ll see the debug report.

Testing live data

For fun, try changing monthNum and dayNum at the top of the playground and running it again to fetch some different data. Add more print statements to testData() if you want to see what you’ve got.

Key Points Designing your data model is an important step in app building. Playgrounds make iterating through the data design much easier than doing it in an app where you have to build and run after each change. macOS gives you much more access to the file system, which you can work with using FileManager . When getting data from an external source, you have no control over the format, but it’s still possible to process the data to suit your app. Computed properties are useful for making specific data easily accessible. If you were building an iOS app, you could have gone through a similar process with similar code. This is a solid way to start developing any app.

Where to Go From Here? This chapter may have seemed like hard work when you wanted to get started building a real macOS app, but in the next chapter, you’ll see how this preliminary work means that the app can start taking shape quickly. If you’re interested in learning more about async/await networking, check out these links:

27

macOS by Tutorials

Chapter 1: Designing the Data Model

Apple — Meet async/await in Swift raywenderlich.com — async/await in SwiftUI raywenderlich.com — WWDC 2021: Intro to async/await

28

macOS by Tutorials

2

Working With Windows Written by Sarah Reichelt

In the previous chapter, you looked at the API that will provide the data for the On This Day app. You worked out how to parse the incoming JSON, and you designed the data models. In this chapter, you’ll get to use all that preparatory work as you create the main window for your app. A great number of macOS apps like Finder, Mail and even Xcode, have a window design with a navigation side bar on the left and a larger detail view on the right. You’re going to use that style in the app you’ll build in this chapter.

A New Mac App So at last you get to the fun bit — creating your own macOS app. Start Xcode and create a new project.

Create a new macOS project.

Choose the macOS App template, click Next and name the app OnThisDay. Select SwiftUI for the interface and Swift for the language. Click Next again and save your new project. Before making any changes, build and run the app to see what features you already have. At first glance, it might seem that you’ve very little — just a rather small window showing the default “Hello, world!” text — but take a closer look:

29

macOS by Tutorials

Chapter 2: Working With Windows

Build and run the app template.

You can resize the window by a small amount, minimize it, make it full screen (which looks rather odd at this stage). You can close the window and open a new one. In fact, you can open lots of new windows. When you have a few windows open, take a look at the Window menu. All the usual window and tab tools are already there and available to you.

Window menu

The only control on this window is a static Text view, so there is nothing to edit, but the Edit menu is there and ready for action, as are all the other standard menus that people expect to see in a Mac app.

Comparing with an iOS App Quit the app and go back to look at the Xcode project, which has two Swift files:

Swift files in template

30

macOS by Tutorials

Chapter 2: Working With Windows

Interestingly, these two files are absolutely identical to the same two files you’d get if you created an iOS app from the basic iOS app template. This serves to emphasize an important point. Swift and SwiftUI are extremely similar across all Apple’s platforms. The differences are in how SwiftUI displays the different views based on the selected platform. Your job as a macOS developer is to understand the expectations of the users on the macOS platform so that your app ends up looking like a macOS app and not like an iOS port. Windows are a major area where the platforms diverge. iPhone apps are single window apps. iPad apps can have multiple windows but without the enormous flexibility that macOS apps can have. In both iPhones and iPads, apps always use the full screen, unlike the Mac. As you work through the chapters in this section, you’ll encounter more areas where Mac users have different expectations. However, in this chapter, you’re going to concentrate on the main window and its design. But first, it’s time to add in all your hard work from the last chapter.

Adding the Data Models Download the materials for this chapter and open the assets folder. Select and drag the Models and Utilities folders into Xcode’s Project navigator, checking Copy items if needed and selecting Create groups:

Options when adding Models and Utilities

Make sure that you have selected the OnThisDay target and click Finish. After expanding the new groups, your Project navigator looks like this:

31

macOS by Tutorials

Chapter 2: Working With Windows

After adding the models

Note: If you don’t see the file extensions, change the setting in Xcode preferences:

Hide or show file extensions.

These files contain the data models that you developed in the last chapter but split up into separate files for clarity and maintainability. I wrapped getDataForDay(month:day:) in a Networker enumeration for easy use.

AppState.swift contains more methods that you’ll use later on. They are not macOS-specific so this book won’t cover them in detail, but they are all documented in the comments if you want to check them out.

32

macOS by Tutorials

Chapter 2: Working With Windows

Designing the Window Your app’s main window is going to have a sidebar and a detail area. The sidebar will allow you to select which of the three types of events to display and the detail area will show these events in a grid. In an iOS app, you’d create such a system using a NavigationView with a NavigationLink for each event type leading to a detail view. This works really

well on iOS devices where the detail view either replaces or overlays the navigation view, but it doesn’t work well on Macs. So forget everything you know about NavigationLink ; you aren’t going to use it here. :] In a macOS app with multiple panes, you still start with a NavigationView but, because the window can show all the panes all the time, you have to initialize NavigationView with all its panes already in place. Then, you use a selection

property to work out what to display in the detail view. To illustrate this, open ContentView.swift and replace the contents of body with this: // 1 NavigationView { // 2 Text("Fake sidebar") Text("Fake details") } // 3 .frame( minWidth: 700, idealWidth: 1000, maxWidth: .infinity, minHeight: 400, idealHeight: 800, maxHeight: .infinity)

What does this do? 1. Wraps the entire display inside a NavigationView . 2. Adds placeholder views for each of the panes. 3. Sets limits for the size of a window. This frame modifier sets a lower limit and an ideal size but allows the window to expand infinitely. Build and run the app, and you’ll see that already you’ve got what looks like a real macOS app with two panes:

33

macOS by Tutorials

Chapter 2: Working With Windows

Two pane layout

Note: This also appears in the preview canvas, without the app title, but you need the actual app window for the next task.

Sizing the Window You used a frame modifier to set the size for your window. This isn’t something that iOS developers need to worry about — iOS apps use all the available space — but it’s very important for a Mac app. The modifier sets three values for the width and three for the height: the minimum, ideal and maximum sizes. The maximum settings are easy; for the main window of an app, there’s rarely any reason not to allow infinite expansion. For particular sub-windows, you might prefer to set an exact size or to limit the size, but you can nearly always set maxWidth and maxHeight to .infinity . The ideal settings are your suggestions to the SwiftUI layout system for what every new window could use. In practice, the ideal sizes are not applied, but put them in anyway as this may change. Open windows will store their sizes and reapply them when the app is re-opened. The minimum values are the most important: They set the smallest allowed size for the window. You need to make sure these numbers are big enough to show everything important but not any bigger than necessary. You don’t know what size screens people are working with. But how can you determine the numbers to use? One way is to take a screenshot of the window. Set it to the size you want, press Shift-Command-4, mouse over the window and press Space to highlight it. Hold down Option to turn off the usual screenshot shadow and then click the window to capture it. 34

macOS by Tutorials

Chapter 2: Working With Windows

To see the screenshot’s details, open it in Preview and press Command-I or select it in Finder and press Command-I, then open More Info:

Screenshot info

Image size shows width by height in pixels. If you’re using a Retina screen, divide these numbers by 2 to get SwiftUI size units. Do this twice to get the minimum and ideal sizes and plug your numbers into the frame modifier. You may want to repeat this procedure later, when you have finalized the interface. With the app running, you can resize the window within the set limits, make new windows, close them, merge them into tabs and do all the usual Mac window operations. It’s also possible to resize the panes by dragging the divider sideways. If you drag the divider all the way to the right and let it go, the details pane disappears, but you can position your pointer at the edge of the window and drag it back. However, there is a bug in SwiftUI that means if you hide the sidebar, it’s not possible to get it back! If you lose your sidebar, close the window and open a new one. In the next chapter, you’ll learn about menus and toolbars and find out better ways to restore the sidebar, but for now, simply make a new window.

Setting up the Sidebar Now that you’ve got the skeleton of the window in place, you can start replacing those placeholders with the real thing.

35

macOS by Tutorials

Chapter 2: Working With Windows

For the sidebar, create a new SwiftUI View file called SidebarView.swift. This view will loop through the cases in EventType you added as part of Event.swift, display the title for each and allow the user to switch between them. But if you want to loop through the cases of an enum , it must conform to CaseIterable .

Over in Event.swift, edit the EventType declaration so it looks like this: enum EventType: String, CaseIterable {

There is no need to do anything else, but now you can use EventType.allCases to loop through these types. Back in SidebarView.swift, replace the contents of SidebarView with this: // 1 @Binding var selection: EventType? var body: some View { // 2 List(selection: $selection) { // 2 Section("TODAY") { // 3 ForEach(EventType.allCases, id: \.self) { type in Text(type.rawValue) } } } // 4 .listStyle(.sidebar) }

What’s happening here? 1. In order to track the selected event type, you need a property. The parent ContentView will supply this.

2. The property is bound to the list as its selection. 3. A Section adds a collapsible heading. 4. ForEach loops through the event types and shows their names. 5. Setting the list style to .sidebar makes it slightly translucent. The preview code shows an error now because it has no selection property. And since this is going to be in a sidebar, which will usually be quite narrow, it makes sense to make the preview narrow, so edit previews in SidebarView_Previews to contain this:

36

macOS by Tutorials

Chapter 2: Working With Windows

SidebarView(selection: .constant(nil)) .frame(width: 200)

Instead of building and running now, make sure that the preview canvas is showing and click Resume or press Option-Command-P to make it display your changes:

Sidebar Preview

Previewing macOS Views You’ve just used the preview canvas to see how your sidebar view will look in your app. When you’re working on an iPhone app, you see an outline of the iPhone in the preview, and you’re able to turn on Live Preview to make this view interactive. The same features are available when developing macOS apps, but they work a bit differently. As with iOS apps, you can click the Plus button in the toolbar over the preview to create a second preview. Then, you can use the Modifiers button to set up how you want the previews to look. Having one preview showing the light color scheme and one the dark can be very useful. The Mac differences appear when you want to have a live preview. With your sidebar showing in the preview canvas, click the Live Preview button. I’m not sure what you expected to see, but I’ll bet it wasn’t this!

Live Preview

37

macOS by Tutorials

Chapter 2: Working With Windows

Click Bring Forward to see another surprising sight:

Bring preview forward.

Why are there two windows? When you run a live preview in an iOS app, Xcode runs the simulator in the background so you can interact with your view from inside the preview canvas. In a macOS app, Xcode actually runs the complete app and that is the larger window you see here. The smaller window titled Xcode Preview, which always appears in the bottom left corner of the screen, is the actual live preview. Position your Xcode window so that you can watch the sidebar in the Xcode Preview window while you change the section header. Your change appears immediately.

38

macOS by Tutorials

Chapter 2: Working With Windows

Change section header during Live Preview.

Since it’s faster to build and run a macOS app than an iOS app running in the simulator, use the preview to build component views, but actually run the app to test interactions.

Note: For anyone interested in why the live preview window always appears anchored to the bottom left of the screen, I presume that this is because in AppKit (the macOS equivalent of UIKit), the screen coordinates origin is at the bottom left, unlike in UIKit where the origin is at the top left. Thankfully, SwiftUI saves you from having to wrestle with two different coordinate systems.

Now that you’ve designed your sidebar, it’s time to get ContentView to display it. In ContentView.swift, add a property to track the sidebar’s selection and set its default value to events : @State private var eventType: EventType? = .events

Next, replace the first placeholder Text view with this: SidebarView(selection: $eventType)

39

macOS by Tutorials

Chapter 2: Working With Windows

Now build and run the app. Select an event type: It highlights with your system accent color. Open System Preferences ▸ General and change the Accent color: The sidebar highlight color changes too. Move the window over a strongly colored background (like this bright orange wallpaper) to see that the sidebar is slightly translucent:

Build & run with the sidebar.

Note: If your sidebar isn’t translucent, verify your System Preferences ▸ Accessibility ▸ Display and uncheck Reduce transparency.

Creating an Event Card The right pane is going to display the events in a grid, but before building the grid itself, you need to design a view for each event in the grid. Add a new SwiftUI View file and call it EventView.swift. Now that you have three view files, it would be good to put them into a group like the Models group. Select the three …View.swift files, right-click and choose New Group from Selection. Set the name of this new group to Views.

40

macOS by Tutorials

Chapter 2: Working With Windows

Create Views group.

Each EventView will need an Event to display, so open EventView.swift and add this property definition at the top of the structure: var event: Event

After a while, Xcode will work out that this causes an error in EventView_Previews . The preview’s EventView(event:) needs an Event

value.

Adding Preview Content To avoid hitting the usage limit on the API, you’ll use a sample event in the preview. Looking at the Xcode Project navigator, there is a group at the bottom called Preview Content. This is where you can put sample code and data for use in the previews but not in the final app. Go back to the assets folder you downloaded and look for SampleEvent.swift. Drag it into the Preview Content group using the same settings as before. This file contains the JSON for a single event decoded into an Event . Since this is only used in the previews and not in the actual app, it uses force-unwrapping to avoid returning an optional. And now your previews will be able to access Event.sampleEvent whenever they need to display a preview with an event.

To fix the preview in EventView.swift, change the content of previews to: EventView(event: Event.sampleEvent)

Building the Event Card Take a look at Event.swift to remind yourself of its properties. You don’t need to display id as it’s only used for looping. You can show the text and year strings in Text views, but links will require some special treatment. Start by displaying the strings: Replace the default Text view in EventView.swift with this: 41

macOS by Tutorials

Chapter 2: Working With Windows

VStack { Text(event.year) Text(event.text) }

You wrap two Text views in a VStack for a very simple display. Now, resume the preview canvas and you’ll see this:

Event card preview

The view is getting the data, but the formatting needs work.

Note: If you can’t see it all, select Editor ▸ Layout ▸ Canvas on Bottom or use the zoom controls underneath the preview canvas to adjust the preview .

Formatting the Event Card Now to make EventView look like a card. Replace the VStack with this styled version: // 1 HStack { // 2 VStack(alignment: .leading, spacing: 30) { // 3 Text(event.year) .font(.title) Text(event.text) .font(.title3) // links go here // 4 Spacer() } // 5 Spacer() } // 6 .padding() .frame(width: 250)

42

macOS by Tutorials

Chapter 2: Working With Windows

This looks like a lot of code, but here’s what each bit is doing: 1. Wrap the whole thing in an HStack so that a spacer can push everything to the left. This will be important for lining up views in cards with different amounts of text. 2. Wrap the contents in a VStack , aligning everything to the left (or right, for right-to-left languages) and with a larger than standard space between subviews. 3. Set the two Text views to use larger fonts. 4. Push them to the top of the VStack with a Spacer . 5. Another Spacer pushes the VStack to the left of the HStack . 6. Set the HStack to a fixed width with padding all around. And, when you resume the preview, you’ll see this, which already looks better:

Event card styled

Adding Links Moving on to the EventLinks , each has a title and a URL. SwiftUI has a view that is perfect for this. Link takes a title and a URL and creates a clickable link that will open the URL in the appropriate default app. In this case, since the links are all web links, they will open in the user’s default browser. If you’d been writing this app for iOS, you might have preferred to keep the user inside your app and used SFSafariViewController to display the linked web pages. This view is not available in macOS apps, but even if it was, Mac app users are quite accustomed to swapping between apps, where iOS app users tend to prefer to stay inside the current app. On a Mac, it’s easier when you can see windows from multiple apps at the same time. Replace // links go here with this code: // 1

43

macOS by Tutorials

Chapter 2: Working With Windows

VStack(alignment: .leading, spacing: 10) { // 2 Text("Related Links:") .font(.title2) // 3 ForEach(event.links) { link in Link(link.title, destination: link.url) // modifier goes here } }

And what does this do? 1. Wrap the links section in its own VStack so you can set a different spacing .

2. Add a subheading for the links with a larger font. 3. Loop through the event’s links and add a Link view for each. Because you made EventLink conform to Identifiable in the previous chapter, you don’t have to add an id parameter. Resume the preview again to see this:

Event card with links

Now start Live Preview, click Bring Forward and, in the Xcode Preview window, click one of the links to open it in your browser.

Changing the Cursor Over Links The styling is looking good now, but there’s a neat touch you can add. Users 44

macOS by Tutorials

Chapter 2: Working With Windows

expect clickable links to change the mouse pointer as they mouse over them. SwiftUI has an onHover modifier that’s perfect for this. Add this modifier to the Link view replacing // modifier goes here : // 1 .onHover { inside in if inside { // 2 NSCursor.pointingHand.push() } else { // 3 NSCursor.pop() } }

So how is this setting the cursor? 1. The onHover action is called whenever the mouse pointer enters or leaves the view’s frame. The parameter passed to the closure is true if the mouse pointer is inside and false if it’s outside the view’s frame. 2. If the mouse pointer is inside the frame, push the pointing hand cursor on to the top of the cursor stack, making it the active cursor. 3. When the mouse pointer leaves the view, pop the cursor off the stack, reverting to the default cursor. Test this in the live preview and watch the cursor change as you move the mouse around.

Pointing hand cursor

45

macOS by Tutorials

Chapter 2: Working With Windows

Using Live Data You’re nearly ready to start displaying a grid, but first the app needs to download some data: the dictionary of Day objects in AppState . Since the entire app will use AppState , you’ll initialize it in OnThisDayApp.swift and access it as an EnvironmentObject . Open OnThisDayApp.swift and add a StateObject at the top of the structure: @StateObject var appState = AppState()

Next, you need to pass this on to ContentView , so add an environmentObject modifier to ContentView : .environmentObject(appState)

The final piece of this data trail is to tell ContentView to expect this EnvironmentObject . Jump over to ContentView.swift and add this at the top of

the structure: @EnvironmentObject var appState: AppState

Now ContentView , and any of its subviews, can use appState . ContentView will detect when appState publishes any changes to days but

after that, it needs a way to query appState for the relevant data to use in its display. Add this computed property to ContentView : var events: [Event] { appState.dataFor(eventType: eventType) }

This uses an AppState method for getting the data for a specific event type. It returns an empty array if there is nothing to show. To prove this is working, replace the Text("Fake Details") placeholder with this: Text("\(events.count)")

Before you can test this, you need to take care of something that’s specific to

macOS apps. 46

macOS by Tutorials

Chapter 2: Working With Windows

Sandboxing When the app initializes appState , it downloads the data for the current day, and ContentView updates its display to match. But if you try it right now, it won’t work because your app is sandboxed. iOS apps are sandboxed into their own area of memory and storage, so they cannot interfere with other apps or data without your permission. A macOS app has a similar sandbox but by default, it won’t even let you download data from the internet. iOS apps can download from any secure address without changing anything but, for some reason, macOS is more restrictive about this. To allow downloads, select the project at the top of the Project navigator and then click the OnThisDay target in the sidebar. Choose the Signing & Capabilities tab. Check Outgoing Connections (Client) in the App Sandbox section.

Check Outgoing Connections.

This enables the app to request and download data. You might think you need to turn on Incoming Connections (Server) to allow downloads, but the client setting is enough as your app initiates the download request. Now build and run the app. You’ll see a zero in the middle of the screen and after a few seconds, this changes to a higher number, which tells you that ContentView now has data to display. Select the different event types in the

sidebar to see different numbers:

47

macOS by Tutorials

Chapter 2: Working With Windows

Data check

Showing Data in a Grid Now, you’re ready to put it all together. Add a new SwiftUI View file to the Views group and call it GridView.swift. When setting up a grid layout, the first tasks are to specify the data and the column arrangement for the grid, so add these properties to GridView : // 1 var gridData: [Event] // 2 var columns: [GridItem] { [GridItem(.adaptive(minimum: 250, maximum: 250), spacing: 20)] }

What do these properties give you? 1. The parent view will pass an array of Events to GridView . 2. The grid uses the columns property to work out its layout. Instead of setting a fixed number of rows or columns, this tells the grid to work out the best arrangement to fit the available space, setting the width of each column to 250 and spacing the columns at least 20 apart. Now, you get an error in the preview because it doesn’t have a gridData parameter. Since you’re going to run the app to test this view, delete GridView_Previews to make Xcode happy. :]

Next, replace the default Text in body with this code: // 1 ScrollView { // 2

48

macOS by Tutorials

Chapter 2: Working With Windows

LazyVGrid(columns: columns, spacing: 15) { // 3 ForEach(gridData) { EventView(event: $0) // 4 .frame(height: 350, alignment: .topLeading) // styling modifiers go here } } }

Not much code here for a lot of action, but stepping through, you: 1. Wrap the content in a ScrollView . This allows scrolling to see entries that are outside the visible area of the window. 2. Display the data in a LazyVGrid . This is a grid that draws itself in rows down the window, but it draws the components lazily — on demand — rather than all at the start. You configure the LazyVGrid to use the columns defined earlier and give it a spacing value to separate the rows. 3. Inside the grid, loop through the events and display each using the EventView that you created earlier. As with the links, this is easier because

the events all have an id property. 4. Set the frame of each EventView so they are all the same height and aligned to the top left corner. Back in Content View.swift, replace Text("\(events.count)") with: GridView(gridData: events)

Build and run the app again. It starts up with a blank detail area but once the data arrive, you’ll see something like this:

49

macOS by Tutorials

Chapter 2: Working With Windows

Grid starter

It needs more styling, but the events are there. You can select different event types and open the related links. Notice how the spacers you added to EventView make the different events line up at their tops and left sides,

regardless of the amount of text or the number of links. Great work!

Styling the Event Card To style the grid elements into a more card-like view, you’re going to add a border with a shadow. Go back to GridView.swift and replace // styling modifiers go here with: // 1 .border(.secondary, width: 1) // 2 .padding(.bottom, 5) // 3 .shadow(color: .primary.opacity(0.3), radius: 3, x: 3, y: 3)

What do these modifiers do? 1. You add a border using the secondary color, which will change depending on whether your Mac is in dark mode or light mode and in response to some accessibility settings. 2. Since you’re about to add a shadow, you need some padding on the bottom to stop the grid cutting it off on the last row. 50

macOS by Tutorials

Chapter 2: Working With Windows

3. You apply a shadow using the primary color with reduced opacity. This will also change with the color scheme and accessibility settings. Build and run the app again. When it has some data, you’ll see a display like this:

Event cards with borders

The borders look great, but every element in the card has a shadow, not just the border. Also, you can now see that the grid starts too high up in the pane and, if you scroll to the end, it goes too close to the bottom, too.

Fixing the Styling In GridView.swift, fix the positioning by adding this modifier to the ScrollView :

.padding(.vertical)

Note: Double-click on the curly brace at the end of the ScrollView line to select the entire view. This tells you where that view ends, so you know to put the new modifier on the line after that. Or use the code folding ribbon, if you have that enabled.

For the shadow problem, insert these modifiers before the border modifier 51

macOS by Tutorials

Chapter 2: Working With Windows

you just added: .background() .clipped()

Without any parameters, background() fills the view with the default background color for the current color scheme. clipped() stops the view contents overflowing the view’s frame, which is good

for events with lots of text or links, but it also stops the shadow being applied to the inner views.

Note: It’s not clear why this works. The shadow being applied to all the internal views is apparently a bug and clipped() works around this bug, at least for now. Even after Apple fixes the shadow bug, you’ll still need to use clipped() to keep the view contents inside the frame.

Now build and run again. Select different event types, resize the window, scroll up and down the grid and test some links. You really have a useful app now.

Grid and event cards

Note: You may be wondering why you didn’t apply these modifiers to EventView directly. Later on, you’re going to reuse EventView for another

purpose, and the modifiers you added in GridView won’t be used there.

Working with Multiple Windows When you ran the app after creating it from the project template, you investigated opening multiple windows, and maybe you tried merging them into tabs.

52

macOS by Tutorials

Chapter 2: Working With Windows

Run the app again if it isn’t already running and open a second window. Notice how it shows the data immediately because it’s sharing the data in appState , which already contains today’s events. Now change one of the windows to show a different type of event. See how this only changes the active window? Although the windows are sharing the appState EnvironmentObject , each one has its own ContentView and its own eventType selection.

Multiple windows, same title

Take a look in the Window menu. It lists all the open windows, but they all have the same title, so it’s impossible to determine which one is which. To solve this, you’re going to give each one a title based on the type of data it’s showing.

Setting the Window Title In ContentView.swift, add this computed property to derive the window’s title from the selected event type, if there is one: var windowTitle: String { if let eventType = eventType { return "On This Day - \(eventType.rawValue)" } return "On This Day" }

This checks for a selection and appends its raw string value to the app name to create a window title. If there is no selection, it uses the app name as a default. List selection properties are always optionals, so even though you set eventType to a default value, you must still make this check.

To use the new property as the window’s title, add this modifier to the NavigationView below where you set its frame :

53

macOS by Tutorials

Chapter 2: Working With Windows

.navigationTitle(windowTitle)

Build and run the app again. If you had multiple windows open, they will all open again but reset to show Events as that’s the default type. Change the event types and notice how the window titles change to match. Open the Window menu and see how it lists the new titles. To confirm that the default title appears if there is no selection, Command-click on the selected event type in one of your windows to clear the selection.

Multiple windows

Use Environment Overrides or change the color scheme of your Mac from light to dark or the other way round and confirm that your app adjusts too, and that everything still looks good.

Challenge Take a look at EventView.swift, where you designed the cards for each event and think about how you could add some more style. Would some of the text look good in a different color? Make sure you test your colors in both light and dark mode — you can add a second preview to show the second mode. Add some SF Symbol icons to the card to decorate the various text elements. Download and install Apple’s SF Symbols app if you don’t have it already. Have a look at the project in the challenge folder if you need some ideas, but there’s no wrong answer here — you make it look the way you would like it.

54

macOS by Tutorials

Key Points

Chapter 2: Working With Windows

Having already planned out all the data models, a SwiftUI app can come together quickly. This multi-pane display style is common in macOS apps, but you don’t put it together the way you would in an iOS app. Setting size limits for your windows is important. SwiftUI Live Preview works differently for a macOS app. The Mac sandbox blocks all internet connections by default. Grids are a great way to display lots of data.

Where to Go From Here? You started this chapter with some data models that could have worked just as well in an iOS app as a macOS app. You’ve ended up with an app that looks like a real native macOS app. Well done! Along the way, you’ve learned important things about Mac apps and windows and how to work with SwiftUI in a Mac app. In the next chapter, you’ll get to work with menus and toolbars to make your app into an even more Mac-like experience.

55

macOS by Tutorials

3

Adding Menus & Toolbars Written by Sarah Reichelt

In Chapter 2, “Working With Windows”, you built a macOS app with support for multiple windows, a sidebar and a details pane. You connected it to the API using the data models you designed in the first chapter. Now it’s time to take another step towards a real Mac app by adding menus and toolbars. Mac users expect to be able to perform nearly every function in the app via a menu, preferably with a keyboard shortcut. Toolbars allow easy access to more window-specific controls. You’re going to continue with the app you built in the last chapter and learn how to add menus and different types of menu items, as well as how to add a toolbar to the windows.

What Is a Menu? Menu is the term applied to any user interface element that expands to show a selection of choices, but the implementation of a menu is very platform dependent. In SwiftUI for macOS, there are three ways to show a menu: 1. With a Menu view, which allows you to insert a clickable menu anywhere in your app’s user interface. 2. Using contextMenu to pop up a menu when a user right-clicks another UI element. 3. Via the Mac’s system-wide menu bar. While the first two options will look different on a Mac than in an iOS app, using them is no different. But the third option, the fixed menu bar that appears at the top of your Mac’s screen, has no iOS equivalent. It contains a standard set of menus and menu items that appear in almost all apps, and they follow a keyboard shortcut convention that users come to know. In this chapter, you’ll learn about customizing the Mac menu bar for your app by adding app-specific menus and menu items.

Setting up Menu Commands Open your project from the last chapter or download the materials for this chapter and open the starter project. 56

macOS by Tutorials

Chapter 3: Adding Menus & Toolbars

Your app already has a default set of menus, but to change these, you need to add a commands modifier to the WindowGroup in OnThisDayApp.swift. You can insert all your menu code there, but it makes for more maintainable code if you take it out into its own file. Create a new Swift file — not a SwiftUI View file — called Menus.swift and add this to it: // 1 import SwiftUI // 2 struct Menus: Commands { var body: some Commands { // 3 EmptyCommands() } }

What’s all this? 1. Commands is a SwiftUI protocol, so you need to import SwiftUI. 2. Both Menus and its body must conform to Commands so that SwiftUI recognizes them as menus and menu items. 3. Because body has to return something, you use one of the pre-built menu sets — in this case, one that does nothing. Next, you need to connect Menus to your app’s WindowGroup . In OnThisDayApp.swift, add this modifier to WindowGroup : .commands { Menus() }

This tells SwiftUI to attach your new Menus to the menu bar. As you add to Menus , this will apply the new menus and menu items automatically, but

OnThisDayApp.swift remains uncluttered and easy to read.

Using Pre-built Menu Groups One of the easiest ways to add menu items is to include some of the pre-built menu groups Apple has supplied. Not all apps need all these groups, but this method allows you to insert consistent groups of menu items if they’re relevant. You’ve already added and tested EmptyCommands but here are the others you 57

macOS by Tutorials

Chapter 3: Adding Menus & Toolbars

can include, which you’ll probably find slightly more useful. :] SidebarCommands ToolbarCommands TextEditingCommands TextFormattingCommands ImportFromDevicesCommands

Note: Check out Xcode’s documentation for CommandGroup and scroll to Standard Command Groups to check if Apple has added any more.

This app doesn’t have any text editing and won’t need to import any photos or scans from nearby iOS devices, so you can ignore those choices. But, it does have a sidebar and will have a toolbar, so the first two look useful. Why does EmptyCommands exist? Imagine you were building a menu based on some condition and you only wanted your menu to appear in certain circumstances. Since body must return something, you can use this to effectively return nothing. In ordinary SwiftUI views, you use EmptyView for the same purpose. In Menus.swift, replace EmptyCommands() with: SidebarCommands()

Build and run the app. Remember in the last chapter you found that it was possible to hide the sidebar but impossible to retrieve it? The View menu now contains a Toggle Sidebar menu item:

Sidebar commands

Drag the vertical divider to hide the sidebar and then use this menu item to show it again. Try out the keyboard shortcut — Control-Command-S — to toggle 58

macOS by Tutorials

Chapter 3: Adding Menus & Toolbars

the sidebar in and out. You don’t have a toolbar yet, but in preparation, add the following on the next line after SidebarCommands() : ToolbarCommands()

Unlike with SwiftUI views, you don’t need to wrap sets of commands in a container. Build and run now, and you’ll see the View menu has got another new group of items. Since you don’t have a toolbar yet, your app has disabled them automatically, but they’re ready for when you add the toolbar:

Toolbar commands

With these pre-built command groups, the system decides where to put them, and you can’t adjust that. But, you don’t have to worry about connecting them to actions, setting keyboard shortcuts or localizing them. And they’ll always follow Apple’s guidelines, so you should prefer using these over building your own, if they’re appropriate to your app.

Inserting a Menu Item Now that you know how to apply a pre-built set of menu items, it’s time to look into adding your own menu item to an existing menu. For this, you’re going to add a link to the API site in the Help menu. To add menu items, you wrap them in a CommandGroup . This is similar to the pre-built menu commands; each of them is also a CommandGroup . When you initialize a CommandGroup , you must tell it where to put its items. In Menus.swift, underneath ToolbarCommands , type CommandGroup( . After you’ve typed the opening parenthesis, you’ll see the auto-complete suggestions showing your three options: 59

macOS by Tutorials

Chapter 3: Adding Menus & Toolbars

CommandGroup auto-complete

As you can see, you get to place your group after, before or replacing something of type CommandGroupPlacement . If you look up the documentation for CommandGroupPlacement , you’ll see a list of placements for standard menu items that you can use to position your new items. Since you want to place your new item in the Help menu, positioning it before the help CommandGroupPlacement , looks like the way to go. Replace your half-typed CommandGroup line with: CommandGroup(before: .help) { }

Now you have your own CommandGroup but what are you going to put into it? A SwiftUI view! There are several view types that you can use, but the most common is a Button . Put this code inside your CommandGroup : // 1 Button("ZenQuotes.io web site") { // 2 showAPIWebSite() } // 3 .keyboardShortcut("/", modifiers: .command)

Stepping through these lines: 1. Create a Button with a title. 2. Add an action that will call a method. 3. Assign a keyboard shortcut. This causes an error because you haven’t defined showAPIWebSite() yet, so 60

macOS by Tutorials

Chapter 3: Adding Menus & Toolbars

insert this method into Menus after body : func showAPIWebSite() { // 1 let address = "https://today.zenquotes.io" guard let url = URL(string: address) else { fatalError("Invalid address") } // 2 NSWorkspace.shared.open(url) }

Some of this will be familiar but the last line may be new to you: 1. Try to create a URL from the ZenQuotes.io web address. If this fails, it’ll be due to an error when typing the address, so catch it during development with a fatalError . 2. NSWorkspace is an AppKit class that gives access to other apps and system services. Each app automatically gets a shared instance of NSWorkspace it can use. Its open method will open a supplied URL in the system’s default app for that URL type. In this case, it’ll be the default browser. Build and run the app and open the Help menu to see your new menu item. Select it to open the URL in your browser. Go back to your app and press Command-/ to return to the page in your browser via the keyboard shortcut.

Help menu item

You’ve only added one control to this new CommandGroup but you could have added up to ten.

Creating a New Menu To insert a menu item, you used CommandGroup but to add a completely new menu, you’ll use CommandMenu . Within your CommandMenu you can add views for the menu items, arranged as you like, but you can’t set the position of your menu in the menu bar. You’re going to add a Display menu that controls various aspects of the app’s appearance. Inside Menus.swift, add this to body after your CommandGroup : 61

macOS by Tutorials

Chapter 3: Adding Menus & Toolbars

CommandMenu("Display") { // display menu items go here }

Build and run and you’ll see a Display menu between the View and Window menus, but since it has no menu items yet, it’s a bit boring. When you added the menu item to the Help menu, you used a Button . That’s going to be the most common view type for a menu item, but you can use others. One that works particularly well is Toggle . You’ve probably seen menu items in other apps that have a check mark beside them that gets turned off and on as you select the menu item — look in Xcode’s Editor menu for some examples. This is a perfect use case for a Toggle . Before you can set up a Toggle view, you need a property to bind to it. In this case, you’re going to add a setting that shows or hides the number of each type of event in the sidebar. And since you want this setting to persist between app launches, you’re going to use @AppStorage .

Saving App Settings If you’ve already used @AppStorage , then you can skip this section, but for those who haven’t encountered it before, @AppStorage is a property wrapper that gives easy access to UserDefaults. Every app, in both macOS and iOS, stores its internal settings in UserDefaults . For a macOS app, this is things like window sizes and positions, sidebar visibility and width, toolbar settings and so on. You can use this system to store small chunks of data like preferences, but working with UserDefaults is not easy. You have to manually read and write the data. You must provide default settings for when the user hasn’t made any choices. And you have to try to keep the display in sync with the settings. With SwiftUI and @AppStorage , this becomes much simpler. You initialize a property using the @AppStorage property wrapper, giving it a default value. SwiftUI will handle saving and retrieving its value and, whenever the value changes, your views will automatically update to match. You can only store certain data types in @AppStorage — Int , Bool , String , Float , Double , Data , or URL — but this covers many possibilities. You can

also store enumeration values if they conform to one of these types.

62

macOS by Tutorials

Adding a Toggle

Chapter 3: Adding Menus & Toolbars

In Menus.swift, outside body but inside the struct , insert this: @AppStorage("showTotals") var showTotals = true

This line does a lot of work! It declares a Boolean property called showTotals and sets it to true by default. It wraps this property in the @AppStorage property wrapper, assigning it the UserDefaults key of showTotals. Now that you have a property, you can add your Toggle . Replace // display menu items go here with this: // 1 Toggle(isOn: $showTotals) { // 2 Text("Show Totals") } // 3 .keyboardShortcut("t", modifiers: .command) // more menu items go here

What does this snippet do? 1. Insert a Toggle view with its isOn value bound to showTotals . 2. Give the Toggle a text label. 3. Add a keyboard shortcut of Command-T. You can set your shortcuts using uppercase or lowercase letters. The menus will always show them in uppercase, but you don’t need to hold down Shift to trigger them. SwiftUI won’t let you overwrite a standard shortcut. If you try to use something like Command-C, the menu won’t show it or respond to it. If you apply your own shortcut to more than one menu item, they’ll all appear but only one of them will work. If you want to use more than one modifier in your keyboard shortcut, you supply an array of them, like this: .keyboardShortcut("t", modifiers: [.command, .shift, .option])

And if you want every possible modifier — Shift-Command-Option-Control — you can use .all . Build and run the app and admire your new Display menu. Pop it down and 63

macOS by Tutorials

Chapter 3: Adding Menus & Toolbars

confirm that the toggle has checked Show Totals:

Toggle menu item

Select the menu item either with your mouse pointer or with Command-T. Check the menu again and confirm that Show Totals is now unchecked. Quit the app, restart it and you’ll see that the menu item is still unchecked because the @AppStorage property wrapper saved it. You’re editing and storing this setting, but it isn’t changing any part of your app’s display yet. To fix this, go to SidebarView.swift and add these declarations: @EnvironmentObject var appState: AppState @AppStorage("showTotals") var showTotals = true

SidebarView can now access appState and showTotals . It may seem wrong

to declare showTotals more than once, but you’re only declaring a link to the value in the user’s settings. With these in place, add this modifier to the Text view inside the ForEach : .badge( showTotals ? appState.countFor(eventType: type) : 0)

badge attaches a number to the rows in a list. If the number is set to zero, no

badge appears. This code uses the ternary operator to check showTotals . If it’s true , this queries appState for the count for the current event type. If it’s false , set the number to zero.

Build and run the app. Toggle the Show Totals menu item and see the numbers in the sidebar hide and show:

64

macOS by Tutorials

Chapter 3: Adding Menus & Toolbars

Sidebar badges

The SidebarView preview is not going to work any more because it doesn’t have access to the @EnvironmentObject . Using AppState in a preview may hit the API usage limits, so delete SidebarView_Previews now.

Using a Picker You’ve used a Button and a Toggle in your menus, but another SwiftUI view that can be useful as a menu item is a Picker . As is the case with all SwiftUI views, the system will customize the appearance of the view to suit its purpose, so a Picker in a menu isn’t going to look like a Picker in a window. It’ll have a menu item that pops out a submenu showing the possible options. The selected option in the submenu will have a checkmark beside it. Like iOS devices, Macs support light and dark mode as well as an automatic mode which swaps between the two depending on the time of day. If you do nothing, your app will go along with the user’s choice made in System Preferences ▸ General but it’s also a good idea to give your users a per-app choice for this.

Setting up DisplayMode Before you can add a way to set the display mode for your app, you need to set up an enumeration to hold the options and a method to change the app’s appearance. In the Models group, create a new Swift file called DisplayMode.swift and replace its code with this: // 1 import SwiftUI // 2 enum DisplayMode: String, CaseIterable { // 3

65

macOS by Tutorials

Chapter 3: Adding Menus & Toolbars

case light = "Light" case dark = "Dark" case auto = "Auto" }

Going through this: 1. Import the SwiftUI library because this enumeration is going to use @AppStorage .

2. Set the enumeration’s rawValue type to String and mark it as CaseIterable so you can loop over the cases.

3. List the three options, with the raw values set to what will appear in the menu. That sets up the enumeration itself, but now you need a way to change the appearance of your app and this requires a dip into AppKit. Just like iOS apps can use SwiftUI, UIKit or a mixture of the two, macOS apps can use SwiftUI and AppKit. Add this method to the enumeration: // 1 static func changeDisplayMode(to mode: DisplayMode) { // 2 @AppStorage("displayMode") var displayMode = DisplayMode.auto // 3 displayMode = mode // 4 switch mode { case .light: // 5 NSApp.appearance = NSAppearance(named: .aqua) case .dark: NSApp.appearance = NSAppearance(named: .darkAqua) case .auto: // 6 NSApp.appearance = nil } }

There’s quite a lot happening here: 1. This is a static method, so it’s a method on the enumeration itself, not on any of its cases. 2. Use @AppStorage for the selected value. 3. Store the new setting. 4. Use switch to work out which mode the user selected. 66

macOS by Tutorials

Chapter 3: Adding Menus & Toolbars

5. NSApp is shorthand for NSApplication.shared , and like UIApplication.shared , gives access to the running application. appearance is the app property that dictates the appearance of the app’s

windows. There are two main appearance names: aqua for light mode and darkAqua for dark mode.

6. If you set the app’s appearance to nil , it uses whatever you selected in System Preferences.

Applying the Display Mode When the app first starts and whenever this setting changes, you need to call DisplayMode.changeDisplayMode(to:) to apply the selection. Since this must

happen as the app boots, putting it in OnThisDayApp.swift is a good idea. First, add the @AppStorage definition at the top of OnThisDayApp : @AppStorage("displayMode") var displayMode = DisplayMode.auto

Next, apply these two modifiers to ContentView , after its environmentObject modifier: // 1 .onAppear { DisplayMode.changeDisplayMode(to: displayMode) } // 2 .onChange(of: displayMode) { newValue in DisplayMode.changeDisplayMode(to: newValue) }

These cover the use cases discussed: 1. When the app first starts, check the stored setting for displayMode and apply it to the app. 2. Whenever displayMode changes, apply the new setting.

Expanding the Menu Finally, you have everything set up so that you can add this to the menu. In Menus.swift, add the stored property to Menus : @AppStorage("displayMode") var displayMode = DisplayMode.auto

67

macOS by Tutorials

Chapter 3: Adding Menus & Toolbars

Next, replace // more menu items go here with this: // 1 Divider() // 2 Picker("Appearance", selection: $displayMode) { // 3 ForEach(DisplayMode.allCases, id: \.self) { // 4 Text($0.rawValue) .tag($0) } }

There’s quite a lot going on here, but taking it bit by bit: 1. Use Divider to get a menu separator. It’s not really necessary in this very short menu, but you need to get in the habit of breaking up long menus into related groups. 2. Add a Picker with a title and with its selection bound to the @AppStorage property. 3. Set the contents of the picker by looping through the cases in DisplayMode . 4. For each item, use its rawString as the title and its actual value as the tag. displayMode is set to the tag when the user selects an option.

Build and run the app to see the results of your hard work:

Display mode menu

Change the settings and see your windows all change to match. Choose a different setting to your usual, quit the app and restart it to confirm that your app has saved and re-applied your selection.

Other Possibilities You’ve just used a Picker to add a sub-menu. The advantage of this is that it gives you the checkmark showing the selected option. The disadvantage is that it doesn’t allow for keyboard shortcuts. So what else could you have used? A set of three buttons would allow for keyboard shortcuts, but not the 68

macOS by Tutorials

Chapter 3: Adding Menus & Toolbars

checkmark. You could have set each option up like this: Button("Light") { displayMode = .light } .keyboardShortcut("L", modifiers: .command)

These would appear in the main Display menu by default, but you can group items into a sub-menu like this: Menu("Appearance") { Button("Light") { displayMode = .light } .keyboardShortcut("L", modifiers: .command) // other buttons here }

You can see a sample implementation of this commented out in Menus.swift in the final and challenge project folders. None of these options is wrong; it depends on the app and the menu items. In this case, it’s easy to confuse the auto display mode with whatever would be valid at this time of day, so the checkmark is more valuable than the keyboard shortcuts. But you now have the knowledge to apply whatever type of menu and menu items your app requires.

Adding a Toolbar So far in this chapter, you’ve concentrated on the menu bar and added app-wide controls. For user interface elements at the window level, macOS apps use a toolbar, so adding that is your next task. If you’re familiar with toolbars in iOS apps, you’ll see they’re set up much the same in macOS apps, but they’re more common in the latter. macOS always puts toolbars at the top of the window, and you’ll see some different options for positioning toolbar items. You applied your menus to the main app WindowGroup but you’ll attach the toolbar to a window’s view. In ContentView.swift, add this after the navigationTitle modifier:

.toolbar { // toolbar items go here }

69

macOS by Tutorials

Chapter 3: Adding Menus & Toolbars

This sets ContentView up to have a toolbar, but there’s nothing in it yet. Like you did with the menus, you’re going to add a new file to hold the toolbar content. And like you did with the views, you’re going to make a new group in the Project navigator to hold this sort of file. Select Menus.swift in the Project navigator, right-click and choose New Group from Selection. Call the new group Controls and then add a new Swift file to that group, naming it Toolbar.swift.

Controls Group

Replace the contents of your new file with: // 1 import SwiftUI // 2 struct Toolbar: ToolbarContent { var body: some ToolbarContent { // 3 ToolbarItem(placement: .navigation) { // 4 Button { // button action } label: { // 5 Image(systemName: "sidebar.left") } // 6 .help("Toggle Sidebar") } } }

How does this make a toolbar? 1. Import the SwiftUI library to support toolbars. 2. Conform both Toolbar and body to ToolbarContent to mark them as usable in a toolbar. 3. Add a ToolbarItem , placing it in the navigation position. 4. Inside the ToolbarItem create a button. 5. Use an SF Symbol as the icon for the button.

70

macOS by Tutorials

Chapter 3: Adding Menus & Toolbars

6. Add a tooltip and accessibility description to the button. To make this content appear, go back to where you added the toolbar modifier to ContentView in ContentView.swift and replace the comment with: Toolbar()

Build and run the app so that you can admire your new toolbar even though it doesn’t do anything yet:

Toolbar item

Check out the View menu. The Toggle Toolbar item is now enabled and functional. When adding items to a toolbar, you can use ToolbarItem or ToolbarItemGroup . As you would expect, ToolbarItemGroup is for when you

want to add a related set of controls. Most of the controls you add will be buttons, but you can use pickers, sliders, toggles, menus, even text fields. Positioning an item uses ToolbarItemPlacement . These vary between operating systems, but here are the main ones you’re likely to use in a Mac app: navigation puts the item at the leading edge, before the window title. principal shows the item in the center of the window. primaryAction pushes the item to the trailing edge. automatic lets the system work out the best position.

Now that you have a button in your toolbar, you’ll want to add an action. As you’ve probably guessed, this will provide a second mechanism for handling the sidebar problem. Add this method to Toolbar in Toolbar.swift: func toggleSidebar() { // 1 NSApp.keyWindow? // 2 .contentViewController?

71

macOS by Tutorials

Chapter 3: Adding Menus & Toolbars

// 3 .tryToPerform( // 4 #selector(NSSplitViewController.toggleSidebar(_:)), with: nil) }

This looks weird! What’s it doing? SwiftUI doesn’t have a method for handling the sidebar, so you need to use AppKit again. 1. NSApp.keyWindow gets the frontmost window in the application. 2. If that works, contentViewController gets the view controller for the content in that window. 3. Once you have a view controller, you can try to call a method on it. 4. SwiftUI’s NavigationView is basically an AppKit NSSplitViewController , and that controller has a method to toggle the sidebar. So this isn’t a pretty method, but it demonstrates how SwiftUI is an overlay on top of AppKit and that you can dig down into AppKit if you need to. In body set the button’s action to: toggleSidebar()

Now you can build and run the app and use this toolbar button to toggle the sidebar:

Sidebar hidden by toolbar

Searching the Grid You’ve added an item to the toolbar manually, and you’ll add more in the next chapter, but there is another toolbar feature that SwiftUI can add automatically. Wouldn’t it be useful to be able to search the events and show only a subset of cards in the grid? Adding search functionality to a SwiftUI view has become much easier in macOS 12 and iOS 15. 72

macOS by Tutorials

Chapter 3: Adding Menus & Toolbars

In ContentView.swift, add this property to hold any text that a user enters into the search field: @State private var searchText = ""

Next, add a searchable modifier to NavigationView after the toolbar modifier. The modifier’s text is bound to the property you just created: .searchable(text: $searchText)

Finally, replace the events computed property with this one which passes the search string over to appState : var events: [Event] { appState.dataFor(eventType: eventType, searchText: searchText) }

And that’s all you need to do! The searchable modifier adds a search text field to the toolbar, bound to your searchText property. If appState.dataFor() gets a string in its optional searchText parameter, it uses this to limit the events returned. If your app didn’t already have a toolbar, this would add one for you. Build and run the app and type something into your new search field:

Searching

Open a second window and search for something different. Change the event type and the search automatically applies to the new set of events.

73

macOS by Tutorials

Chapter 3: Adding Menus & Toolbars

Making Your Toolbar Customizable You may have noticed that the View menu has an item called Customize Toolbar…, but it’s not active. You’re going to learn how to activate it, but right now, there is a bug in SwiftUI so that if you put a search field in your toolbar, any edits you make to the toolbar items don’t stick. Hopefully, Apple will have fixed this bug by the time you read this book, but if not, file this information away for future reference. To make a toolbar editable, the toolbar itself and each ToolbarItem must have an id property. In ContentView.swift replace the toolbar modifier with this, ignoring the error that it will cause: .toolbar(id: "mainToolbar") { Toolbar() }

Next, open Toolbar.swift and replace your existing ToolbarItem with: // 1 ToolbarItem( id: "toggleSidebar", placement: .navigation, showsByDefault: true ) { // 2 Button { toggleSidebar() } label: { // 3 Label("Toggle Sidebar", systemImage: "sidebar.left") } .help("Toggle Sidebar") }

What’s changed? 1. Define an id for the ToolbarItem and set it to show by default. The placement is the same as before. 2. Include the same Button with the same action and tooltip. 3. Instead of using an Image , use a Label because one of the toolbar customizations is to decide whether to show the icon, the name or both. One more change to make: Toolbar and its body conform to ToolbarContent , but now you need to change that to CustomizableToolbarContent . Edit the structure so it starts like this:

74

macOS by Tutorials

Chapter 3: Adding Menus & Toolbars

struct Toolbar: CustomizableToolbarContent { var body: some CustomizableToolbarContent {

This fixes the error in ContentView.swift. Build and run the app, choose Customize Toolbar… from the View menu or by right-clicking in the toolbar and try making some changes:

Customizing the toolbar

You can change whether the items show their icons, text or both using the popup menu at the bottom of the sheet. You can drag the default items into the toolbar. If you drag either item out of the toolbar to remove it, it disappears in a cloud of smoke, but as soon as you click Done, it pops it straight back into place. To confirm that this is a bug due to searchable , comment out the searchable modifier in ContentView.swift and try again. This time you’ll be able to remove the Toggle Sidebar toolbar item. Don’t forget to uncomment searchable when you’ve finished. You still won’t be able to adjust its position because you set that specifically to navigation , but if you had more than one item and you used automatic

placement, you’d be able to switch them around. One final point: To make a toolbar customizable, every item in it must have an id . You can define a ToolbarItem with an id but not a ToolbarItemGroup ,

so you’ll have to stick to using ToolbarItem s if you want that flexibility.

75

macOS by Tutorials

Chapter 3: Adding Menus & Toolbars

Challenges Challenge 1: Show the number of displayed events The badges in the sidebar show the number of events in each category, but when you do a search, the grid may show fewer events. Add a Text view to the bottom of GridView that shows the number of displayed events.

Challenge 2: Toggle this counter Once you have this counter showing, use the stored setting for showTotals to toggle its display. Try to do these yourself, but check out the challenge folder if you get stuck.

Key Points In a macOS app, you should make all the important functions available through the menus, with keyboard shortcuts where suitable. Every Mac app has access to the main menu bar. You can add preset menu groups, individual menu items or complete menus. Most menu items will be buttons, but toggles and pickers are also valid as menu items. @AppStorage gives you a way to store your app’s settings that will persist

between app launches. Toolbars provide a more window-specific way of adding controls. They can be set to allow users to customize them. SwiftUI provides a searchable modifier to add a search bar to your app. You need to supply the search mechanism, but SwiftUI provides the user interface.

Where to Go From Here? Now you have an app with data, windows, menus and a toolbar. You know a lot more about Mac menus now, as well as how toolbars work in a Mac app. And you can even search your data! Apple’s Human Interface Guidelines are more relevant for AppKit developers, because SwiftUI removes a lot of choices and does the correct things by default. But if you’d like more information, these sections are relevant to this chapter: 76

macOS by Tutorials

Chapter 3: Adding Menus & Toolbars

Menu Bar Menus Toolbars In the next chapter, you’ll get to display the data in a different format, as well as expand the app to get events for selected dates instead of just for today.

77

macOS by Tutorials

4

Using Tables & Custom Views Written by Sarah Reichelt

In the last chapter, you did a lot of work to make your app look and feel like a real Mac app. Now, you’re going to head off in a different direction and look at alternative ways to display the data and interact with your users. First, you’ll learn how to use SwiftUI’s new Table view, which is only available for macOS. You’ll add to your toolbar and learn how to store window-specific settings. Then, you’ll dive into date pickers and create a custom view that allows your users to select different dates. Along the way, you’ll customize the sidebar to allow swapping between dates.

Why Use a Table? So far in this app, you’ve displayed the events in a grid of cards, and there’s nothing wrong with that. But many apps offer alternative ways of viewing the data to allow for personal preferences, varying screen sizes or different use cases. Think of a Finder window: It has four different view options, all of which are useful at different times. At WWDC 2021, Apple announced a new Table view for macOS only, so now you’re going to offer that as a view option in your app. A lot of data sets are tabular in nature and are very effectively displayed in a table. Your first thought might be of spreadsheets, but what about lists of files in Finder or playlists in Music? SwiftUI has always offered lists, which are like single column tables. You can fake a multi-column look by adding more than one view into each row, but that doesn’t offer all the facilities a real table does. Now, you can add a real table to your macOS SwiftUI app.

Adding a Table Open your project from the previous chapter or download the materials for this chapter and open the starter project. Start by selecting the Views group in the Project navigator and adding a new 78

macOS by Tutorials

Chapter 4: Using Tables & Custom Views

SwiftUI View file called TableView.swift. To construct a table, you need to define the rows and the columns. The rows are the events that ContentView will pass to TableView . Add this declaration at the top of TableView : var tableData: [Event]

This gives an error in TableView_Previews , so change the contents of its previews to:

TableView(tableData: [Event.sampleEvent])

A table with a single row doesn’t make a lot of sense, but you want to minimize the use of live data here. If you provided preview data from an instance of AppState , the frequent view updates could exceed the data usage limits for the

API. Now that you have access to the data that defines the rows, you can set up the columns. Replace the default Text in body with: // 1 Table(tableData) { // 2 TableColumn("Year") { // 3 Text($0.year) } // 4 TableColumn("Title") { Text($0.text) } }

Creating a table doesn’t take much code: 1. Initialize a Table view with its data. This iterates over all the events, like a List does, with one event per row.

2. Create a TableColumn with the label Year. 3. Inside the cell for each row in this column, use a Text view to display the year for the event.

4. Make a second column called Title for for the text . Resume the preview, turn on Live Preview, and click Bring Forward to see your one row table: 79

macOS by Tutorials

Chapter 4: Using Tables & Custom Views

Table preview

Straightaway it looks like a real Mac table with alternating row colors, adjustable column widths and clickable titles, but there are more features to add.

Sizing and Displaying Your Table Drag the column divider around to adjust the column widths, and you’ll see that you can make the year column too small or too big. Fix that by adding a width modifier to the first column: .width(min: 50, ideal: 60, max: 100)

The height of each row is set automatically, but you can set limits for the width of any column. There’s no need to set any width limits for the last column, as it will take up all the remaining space. You’re about to add a way to switch between grid and table views, but for now, set the app to use the table all the time so you can see it in operation. Switch to ContentView.swift and replace the GridView line inside the NavigationView with this:

TableView(tableData: events)

Note: If you’re getting preview errors, or reports of the preview app crashing, delete ContentView_Previews . It’s causing problems because it does not have its EnvironmentObject , but you don’t want the preview to use this object because it will hit the API usage limit. So delete the entire preview structure.

Build and run the app now to see the live data appearing in your table. You can switch between the event types and search the table without any further coding.

80

macOS by Tutorials

Chapter 4: Using Tables & Custom Views

Table view

Switching Views There’s more work to do on the table but, now that you’ve proved it works, you’re going to add a new control to the toolbar. This will allow you to switch between the grid and the table. First, add this enumeration to ContentView.swift, outside ContentView : enum ViewMode: Int { case grid case table }

This defines the two possible view modes. Next you need a property to hold the current setting, so add this line to the top of ContentView : @State private var viewMode: ViewMode = .grid

This sets the view mode to grid by default and gives you a value that you can pass to Toolbar . In Controls/Toolbar.swift, add this declaration to the structure: @Binding var viewMode: ViewMode

This binding allows Toolbar to read the value passed to it and send back any changes to the parent view. You already have one ToolbarItem , so add this new one after it:

81

macOS by Tutorials

Chapter 4: Using Tables & Custom Views

// 1 ToolbarItem(id: "viewMode") { // 2 Picker("View Mode", selection: $viewMode) { // 3 Label("Grid", systemImage: "square.grid.3x2") .tag(ViewMode.grid) Label("Table", systemImage: "tablecells") .tag(ViewMode.table) } // 4 .pickerStyle(.segmented) // 5 .help("Switch between Grid and Table") }

This is what you’re doing here: 1. You create a ToolbarItem with an id property for customization. By default, placement is automatic and showByDefault is true , so there’s no need to specify them. 2. Inside the ToolbarItem , you add a Picker with a title and with its selection bound to the viewMode property. 3. You add two options to the Picker , each one configured with a label using text and an SF Symbol. The tags are set to the respective enumeration cases. 4. You set the pickerStyle to segmented . 5. And you add a tooltip and accessibility description. ContentView has to supply the viewMode property, so go back to

Views/ContentView.swift and replace the call to Toolbar with: Toolbar(viewMode: $viewMode)

Just one thing left to do now, and that’s to implement the choice in the display. Inside NavigationView , replace the TableView line with this code: if viewMode == .table { TableView(tableData: events) } else { GridView(gridData: events) }

This checks the setting of viewMode and displays either TableView or GridView as required.

Build and run the app to test your new control: 82

macOS by Tutorials

Chapter 4: Using Tables & Custom Views

View selector

Storing Window Settings Try this experiment. Run the app and open a second window. Set one window to show Births in a grid view. Set the other window to show Deaths in a table view. Enter some search text in one window. Now quit and restart the app. The windows re-appear in the same locations and with their previous sizes, but they both show Events in a grid, with no search text.

Note: When you quit the app with more than one window open, and then run it again from Xcode, sometimes only one window will come to the front. Click the app in the Dock — or press Control-Shift-A — to bring all its windows into view.

In the last chapter, you used @AppStorage to save app-wide settings. That won’t work here because you want to save different settings for each window. Fortunately, there is another property wrapper that is almost identical to @AppStorage , but designed specifically for this need. @SceneStorage is a

wrapper around UserDefaults just like @AppStorage , but it saves settings for each window. Still in ContentView.swift, replace the three @State properties at the top of ContentView with these:

@SceneStorage("eventType") var eventType: EventType? @SceneStorage("searchText") var searchText = "" @SceneStorage("viewMode") var viewMode: ViewMode = .grid

The syntax for declaring @SceneStorage properties is the same as you used for @AppStorage with a storage key and a property type. For searchText and viewMode , you’re able to set a default value, but eventType is an optional and

you can’t initialize an optional @SceneStorage property with a default value. You do want to have a default value for eventType , so you’re going to set it as the view appears. Add this modifier to NavigationView after searchable : 83

macOS by Tutorials

Chapter 4: Using Tables & Custom Views

.onAppear { if eventType == nil { eventType = .events } }

The onAppear action runs when the view appears and sets eventType to events if it hasn’t been set already.

Repeat the experiment now. You’ll have to set up the windows once more, but on the next start, the app will restore and apply all your window settings. Open a new window, and it’ll use all the defaults, including the one for eventType .

Window settings

Sorting the Table Now, it’s time to get back to the table and implement sorting. To add sorting to a table, you need to create an array of sort descriptors and bind that array to the Table . A sort descriptor is an object that describes a comparison, using a key

and a direction — ascending or descending. First, create your array. In TableView.swift, add this property to the structure: @State private var sortOrder = [KeyPathComparator(\Event.year)]

This creates an array with a single sort descriptor using the keyPath to the year property on Event as its default sort key.

Next, you have to bind this sort descriptor to the table. Change the Table initialization line to this: Table(tableData, sortOrder: $sortOrder) {

This allows the table to store the keyPath to the last selected column as well as whether it’s sorting ascending or descending. 84

macOS by Tutorials

Chapter 4: Using Tables & Custom Views

To configure a TableColumn for sorting, you have to give it a value property — the keyPath to use as the sort key for this column. Change the first TableColumn to this:

TableColumn("Year", value: \.year) { Text($0.year) }

There’s nothing wrong with this, but Apple engineers realized that most columns would use the same property for the value keyPath and for the text contents of the table cell, so they built in a shortcut. Replace the second TableColumn {...} with this: TableColumn("Title", value: \.text)

Here, you don’t specify any cell contents, so the property indicated by the keyPath is automatically used in a Text view. To show something else like a

checkbox or a button, or to style the text differently, you’d have to use the longer format. To display standard text, this is a very convenient feature. Now you have the sorting interface and storage set up, but that doesn’t do the actual sort. Add this computed property to TableView : var sortedTableData: [Event] { return tableData.sorted(using: sortOrder) }

This takes tableData as supplied by ContentView and sorts it using the sort descriptor. The sort descriptor changes whenever you click a column header. When you click the same header again, the sort key stays the same but the sort direction changes. To get the table to use the sorted data, change the Table initialization line to this: Table(sortedTableData, sortOrder: $sortOrder) {

Build and run the app now, switch to table view and click the headers. Notice the bold header text and the caret at the right of one column header showing that it’s the actively sorted column and indicating the sort direction:

85

macOS by Tutorials

Chapter 4: Using Tables & Custom Views

Table sorting

Selecting Events Your table is looking great, and it displays the data in a much more compact form, but it doesn’t show the links for each event, and it doesn’t show the complete title if there is a lot of text. So now you’re going to add the ability to select rows in the table. Then, you’ll reuse EventView to display the selected event at the side. Making a table selectable is a similar process to making it sortable: You create a property to record the selected row or rows and then bind this to the table. In TableView.swift, add this property: @State private var selectedEventID: UUID?

Each Event has an id property that is a UUID . The table uses this UUID to identify each row, so the property that records the selection must also be a UUID . And since there may be no selected row, selectedEventID is an

optional. Then, replace the table declaration line again (this really will be the last time) with this: Table( sortedTableData, selection: $selectedEventID, sortOrder: $sortOrder) {

The new parameter here is selection , which you’re binding to your new selectedEventID property.

Build and run now, and you can click on any row to highlight it:

86

macOS by Tutorials

Chapter 4: Using Tables & Custom Views

Selecting a row.

Selecting Multiple Rows You can only select one row at a time. Shift-clicking or Command-clicking deselects the current row and selects the new one. That’s perfect for this app, but you may have other apps where you need to select multiple rows, so here’s how you set that up. Replace the selectedEventID property with this: @State private var selectedEventID: Set = []

Instead of storing a single event ID, now you’re storing a Set of IDs. Build and run now, and test the multiple selections:

Selecting multiple rows.

Notice how the table maintains the selection through sorts and searches. Now that you know how to set up a table for multiple selections, set it back to using a single selection with:

87

macOS by Tutorials

Chapter 4: Using Tables & Custom Views

@State private var selectedEventID: UUID?

Displaying the Full Event Data Clicking a row in the table sets selectedEventID , which is a UUID but, to display an EventView , you need an Event . To find the Event matching the chosen UUID , add this computed property to TableView : var selectedEvent: Event? { // 1 guard let selectedEventID = selectedEventID else { return nil } // 2 let event = tableData.first { $0.id == selectedEventID } // 3 return event }

What does this property do? 1. It checks to see if there is a selectedEventID and if not, returns nil . 2. It uses first(where:) to find the first event in tableData with a matching ID. 3. Then, it returns the event, which will be nil if no event had that ID. With this property ready for use, you can add the user interface, not forgetting to allow for when there is no selected row. Still in TableView.swift, Command-click Table and select Embed in HStack. After the Table , just before the closing brace of the HStack , add this conditional code: // 1 if let selectedEvent = selectedEvent { // 2 EventView(event: selectedEvent) // 3 .frame(width: 250) } else { // 4 Text("Select an event for more details…") .font(.title3) .padding() .frame(width: 250) }

88

macOS by Tutorials

Chapter 4: Using Tables & Custom Views

What’s this code doing? 1. Check to see if there is an event to display. 2. If there is, use EventView to show it. 3. Set a fixed width so the display doesn’t jump around as you select different events with different amounts of text. 4. If there is no event, it shows some text using the same fixed width. Build and run the app, make sure you’re in table view, and then click any event:

Selecting an event.

The EventView that you used for the grid displays all the information about the selected event, complete with active links and hover cursors. Now you can see why some of the styling for the grid is in GridView and not in EventView . You don’t want a border or shadows in this view.

Custom Views So far in this app, every view has been a standard view. This is almost always the best way to go — unless you’re writing a game — as it makes your app look familiar. This makes it easy to learn and easy to use. It also future-proofs your app. If Apple changes the system font or alters the look and feel of a standard button, your app will adopt the new look because it uses standard fonts and UI elements. But, there are always a few cases where the standard user interface view doesn’t quite do what you want… The next feature you’re going to add to the app is the ability to select a different date. Showing notable events for today is fun, but don’t you want to know how 89

macOS by Tutorials

Chapter 4: Using Tables & Custom Views

many other famous people were born on your birthday?

Looking at Date Pickers When you’re thinking about date selections, your first instinct should be to reach for a DatePicker . To test this, open the downloaded assets folder and drag DatePickerViews.swift into your project. Open the file and resume the preview. Click Live Preview and then Bring Forward and try selecting some dates:

Date Pickers

This shows the two main styles of macOS date picker. There are some variations for the field style, but this is basically it. You can choose a date easily enough, but can you see why this isn’t great for this app? Try selecting February 29th. So the problem here is that there’s no way to take the year out of the selection, while this app only needs month and day. And the day has to include every possible day for each month, regardless of leap years. So the time has come to create a custom view.

Creating a Custom Date Picker Delete DatePickerViews.swift from your project. It was just there as a demonstration. Create a new SwiftUI View file in the Views group and call it DayPicker.swift. This view will have two Picker views: one for selecting the month and the other to select the day. Add these properties to DayPicker : // 1

90

macOS by Tutorials

Chapter 4: Using Tables & Custom Views

@EnvironmentObject var appState: AppState // 2 @State private var month = "January" @State private var day = 1

What are they for? 1. When you select a day, appState will load the events for that day. It also provides a list of month names. 2. Each Picker needs a property to hold the selected value. You’re probably wondering why month is using January instead of asking Calendar for the localized month name. This is to suit the API, which uses

English month names in its date property. You’re going to ignore the system language and use English month names. When the user selects a month, the day Picker should show the correct number of available days. Since you don’t care about leap years, you can derive this manually by adding this computed property to DayPicker : var maxDays: Int { switch month { case "February": return 29 case "April", "June", "September", "November": return 30 default: return 31 } }

This checks the selected month and returns the maximum number of days there can ever be in that month.

Setting up the UI Now that you have the required properties, replace the default Text with this: // 1 VStack { Text("Select a Date") // 2 HStack { // 3 Picker("", selection: $month) { // 4 ForEach(appState.englishMonthNames, id: \.self) { Text($0)

91

macOS by Tutorials

Chapter 4: Using Tables & Custom Views

} } // 5 .pickerStyle(.menu) // 6 Picker("", selection: $day) { ForEach(1 ... maxDays, id: \.self) { Text("\($0)") } } .pickerStyle(.menu) // 7 .frame(maxWidth: 60) .padding(.trailing, 10) } // button goes here } // 8 .padding()

This is standard SwiftUI with nothing macOS-specific, but what does it do? 1. Starts with a VStack to show a header before the two pickers. 2. Uses an HStack to display the pickers side by side. 3. Sets up a Picker bound to the month property. 4. Loops through the English month names, provided by appState , to create the picker items. 5. Sets the picker style to menu so it appears as a popup menu. 6. Does the same for the day picker, using the maxDays computed property. 7. Sets a small width for the day picker and pads it out from the trailing edge. 8. Adds some padding around the VStack . Resume the preview now, and it will fail because you’ve declared an @EnvironmentObject , but not supplied it to the preview.

In previews , add the following modifiers to DayPicker() : .environmentObject(AppState()) .frame(width: 200)

This provides the necessary environment object and sets a narrow width that will be appropriate when you add this view to the sidebar.

Note: This calls the API every time the preview refreshes, so don’t preview this file often.

92

macOS by Tutorials

Chapter 4: Using Tables & Custom Views

Switch on Live Preview and click Bring Forward to see the pickers in action, including setting the maximum number of days:

Day Picker

The last component for your custom day picker is a method to request the new data and a button to trigger it. Add the method first, by inserting this into DayPicker : // 1 func getNewEvents() async { // 2 let monthIndex = appState.englishMonthNames .firstIndex(of: month) ?? 0 // 3 let monthNumber = monthIndex + 1 // 4 await appState.getDataFor(month: monthNumber, day: day) }

What does this method do? 1. It’s calling an async method using await , so must be async itself. 2. Gets the index number for the selected month, using zero as the default. 3. Adds one to the zero-based month index to get the month number. 4. Calls appState.getDataFor() to query the API for the selected date. Now for a Button to use this method; add this in place of // button goes here :

if appState.isLoading { // 1 ProgressView() .frame(height: 28) } else { // 2 Button("Get Events") { // 3 Task { await getNewEvents() } } // 4

93

macOS by Tutorials

Chapter 4: Using Tables & Custom Views

.buttonStyle(.borderedProminent) .controlSize(.large) }

OK, so it’s more than just a button! 1. If appState is already loading from the API, show a ProgressView instead of a button. The standard ProgressView is a spinner. To stop this view resizing vertically, it’s set to the same height as the button will be. 2. If appState is not loading, show a Button with a title. 3. The action for the button is an asynchronous Task that calls the method you just added. 4. Style the Button to make it look big and important. :] Now it’s time to put this custom view into place.

Adding to the Sidebar You created a custom date picker view, but you built it by combining standard views, so although it’s not the usual interface for selecting a date, the components are all familiar. Now, you’ll display your new DayPicker and put it to work downloading new data. Open SidebarView.swift, then Command-click List and select Embed in VStack. Underneath the line that sets the listStyle , add: Spacer() DayPicker()

This code inserts your new view into the sidebar with a Spacer to push it to the bottom of the window. Build and run now to see the new picker. When the app starts, you’ll see the spinner as it loads today’s events, and then you’ll see the button instead:

Day picker in the sidebar

94

macOS by Tutorials

Chapter 4: Using Tables & Custom Views

Pick a month and a day, then click the button. A progress spinner twirls for a few seconds and then the button reappears, but nothing changes in the rest of the display. Also, you can see that the minimum width for the sidebar is too narrow, at least for the months with longer names. In SidebarView.swift, add this modifier to the VStack : .frame(minWidth: 220)

Now to make the Get Events button work…

Using the Selected Date In ContentView.swift, you’ve been getting the data from appState without supplying a date. This makes appState use today’s date, which has been fine so far. Now you want to use the date selected in the DayPicker , if there is one. And this setting needs to be for the window, not for the entire app. Start in ContentView.swift and add this property to the other @SceneStorage properties: @SceneStorage("selectedDate") var selectedDate: String?

Next, change the events computed property to this: var events: [Event] { appState.dataFor( eventType: eventType, date: selectedDate, searchText: searchText) }

You’re supplying all the optional parameters to appState.dataFor( ), allowing for eventType , searchText and, now, date . Finally, you need to link up the date chosen in DayPicker to this @SceneStorage property.

Open DayPicker.swift and add the @SceneStorage property declaration at the top: @SceneStorage("selectedDate") var selectedDate: String?

95

macOS by Tutorials

Chapter 4: Using Tables & Custom Views

Scroll down to getNewEvents() and add this line at the end of the method: selectedDate = "\(month) \(day)"

This sets the @SceneStorage property after the new data downloads. Build and run now, select a different date, click Get Events and this time, you’ll see the data change:

Showing data for a selected date.

Listing Downloaded Dates Now you know your custom day picker is working and new events are downloading. But, you don’t have any way to swap between downloaded sets of data. Time to expand the sidebar even more… In SidebarView.swift, you have a List view with a single Section . After that Section , but still inside the List , add this code: // 1 Section("AVAILABLE DATES") { // 2 ForEach(appState.sortedDates, id: \.self) { date in // 3 Button { selectedDate = date } label: { // 4 HStack { Text(date) Spacer() } } // 5 .controlSize(.large)

96

macOS by Tutorials

Chapter 4: Using Tables & Custom Views

} }

What’s all this doing? 1. Add a new Section with a title. 2. Loop through the dates appState has events for. There’s a computed property in appState that returns the dates sorted by month and day instead of alphabetically. 3. Show a Button for each date that sets the @SceneStorage property. 4. Inside each button, show the date, pushed to the left by a Spacer . 5. Set the controlSize to large, which makes the buttons similar in size to the entries in the list at the top of the sidebar. To get rid of the error this has caused, add the selectedDate property to the top: @SceneStorage("selectedDate") var selectedDate: String?

Build and run the app now. When it starts, the app downloads events for today’s date as well as any dates that were in use when the app shut down. These dates show up in the new section, in ascending order. Use the DayPicker to select a new day and click Get Events. Once the new events download, the new date appears in this second section. Click any of the buttons to swap between the dates.

Sidebar showing multiple dates

97

macOS by Tutorials

Chapter 4: Using Tables & Custom Views

This is all working well, but the buttons don’t show you which is the selected date. Adding some conditional styling will fix this. But there’s a problem: You can’t wrap a modifier in an if statement. Normally, you’d use a ternary operator to switch between two styles, but for some reason, this doesn’t work for ButtonStyle . So you’re going to use a ViewModifier . At the end of SidebarView.swift, outside any structure, add this: // 1 struct DateButtonViewModifier: ViewModifier { // 2 var selected: Bool // 3 func body(content: Content) -> some View { if selected { // 4 content // 5 .buttonStyle(.borderedProminent) } else { // 6 content } } }

In case you haven’t used a ViewModifier before, here’s what this code does: 1. Create a new structure conforming to ViewModifier . 2. Declare a single property that indicates whether this is a selected button. 3. Define the body method required by ViewModifier . Its content parameter is the original unmodified (button) View . 4. Apply a modifier to content if selected is true . 5. Set the style of the button to borderedProminent . This causes SwiftUI to fill the button with the accent color. 6. Return content unmodified, keeping the style the same, if this is not a selected button. With this ViewModifier in place, apply it by adding this modifier to the Button in the AVAILABLE DATES section, after the controlSize modifier:

.modifier(DateButtonViewModifier(selected: date == selectedDate))

This applies the view modifier, passing it the result of comparing the row’s date with the window’s selected date. 98

macOS by Tutorials

Chapter 4: Using Tables & Custom Views

Build and run now to see all this come together:

Sidebar showing selected date

You might have expected to use a custom ButtonStyle instead of a ViewModifier to adjust the buttons. That would work, but a custom ButtonStyle requires you to define the different appearances for when the

user presses or releases the button. In this case, it’s simpler to use a ViewModifier to apply a standard ButtonStyle , which keeps the predefined

pressed styles.

Updating the Top Section Everything is looking good so far, but the app has a flaw. When you select a date, the top of the sidebar still shows TODAY, and the badges show the counts for today’s events. Clicking the event types in that top section shows the correct events for the selected day, but the header and badge counts don’t match. Open SidebarView.swift and change the first Section line to: Section(selectedDate?.uppercased() ?? "TODAY") {

This looks for a selectedDate and, if there is one, converts it to uppercase and uses it as the section header. For a new window, selectedDate will be nil , so the header will use TODAY, just as before. That fixes the header; now for the badge counts. Inside the ForEach loop for that Section , the badge count is set using appState.countFor() . Like appState.dataFor() , this method can take several optional parameters.

You’ve only used eventType so far, but now you’ll add date . 99

macOS by Tutorials

Chapter 4: Using Tables & Custom Views

Replace the appState.countFor() line with: ? appState.countFor(eventType: type, date: selectedDate)

Build and run the app now and, when you get events for different dates, the section header and the badge counts change to match. Open a new window. It shows TODAY as the section header because it has no selected date.

Top section in sidebar showing selected date and counts.

Challenges Challenge 1: Style the Table Like many SwiftUI views, Table has its own modifier: tableStyle . Look up the documentation for this and try out the different styles you can apply. Some of them also let you turn off the alternating row background colors.

Challenge 2: Show the Date in the Window Title In Chapter 2, you set up a windowTitle property to show the selected event type as the window title. Expand this to include the selected date if there is one, or the word “Today” if there is not.

Challenge 3: Count the Rows in the Table In the challenges for the previous chapter, you added a view at the bottom of GridView to display the number of displayed events. Add a similar feature to

100

macOS by Tutorials

Chapter 4: Using Tables & Custom Views

TableView . Don’t forget to hide it whenever showTotals is set to false .

Check out TableView.swift and ContentView.swift in the challenge folder if you need any hints.

Key Points A table is a good option for displaying a lot of data in a compact form. SwiftUI for macOS now provides a built-in Table view. While some user settings are app-wide, others are only relevant to their own window. You can configure tables for sorting and searching. They can be set to allow single or multiple row selections. Views can be reused in different places in your app and, by adding modifiers in different places, you can adjust the views to suit each location. Using standard controls is almost always the best option but, if your app’s needs are different, you can create a custom control by joining standard controls together.

Where to Go From Here? Great work! The app is looking really good now with a lot of functionality, and you got to try out the new Table view. You had to use a custom view instead of a standard DatePicker , but it still has the native look and feel. In the next chapter, you’ll wrap up this app by adding some polishing features. At WWDC 2021, Apple gave two presentations about Mac apps. The sample app they used had an excellent example of using a table. Download the project files and have a look.

101

macOS by Tutorials

5

Setting Preferences & Icons Written by Sarah Reichelt

After finishing the last chapter, your app is feature complete. It downloads events for today or for a selected day. It displays the events in two different formats and allows searching and sorting. And it shows a list of the days with downloaded data, so you can swap between them. In this chapter, you’re going to add the finishing touches that make your sample app into a real app you could distribute. First, you’ll learn about app preferences and how to add a Preferences window. Next, you’ll update the app name and add an app icon. What image sizes do you need for the icon? How should you style the icon? The last task will be adding your own information to the About box that Xcode creates for every Mac app.

Preferences Nearly all macOS apps have a Preferences window, and they’re always accessed in the same way: via the Preferences… menu item in the app menu or with the Command-, shortcut. If you write an iOS app with user settings, you have two choices. You can create your own settings view inside your app, or you can hook into the iOS Settings app and display options for your app in there. For a macOS app, you’ll almost always create your own settings view. Some apps add a pane to System Preferences, but these are apps that are deeply embedded into the system, like mouse drivers or file system utilities.

Creating a Preferences View Open the app project you were working on in the previous chapter or download the materials for this chapter and open the starter project. In the Views group, add a new SwiftUI View file and call it PreferencesView.swift. Leave the default “Hello, World!” text in place for now, but add this size modifier:

102

macOS by Tutorials

Chapter 5: Setting Preferences & Icons

.frame(width: 200, height: 150)

This will make sure that the window is big enough to see when you test it. Next, you’ll hook this view up to the Preferences… menu item. Open OnTheDayApp.swift. Right now, the body contains a WindowGroup with its contents and a commands modifier. You’re going to add a second scene. After the end of the commands modifier, add this: Settings { PreferencesView() }

This tells the app that, as well as having a WindowGroup scene, it now has a Settings scene. When you give Settings a view, SwiftUI will set up a

Preferences… menu item and handle displaying your view whenever a user selects this menu item or presses its keyboard shortcut. Build and run the app. Open the OnThisDay menu and use the new Preferences… menu item to open your preferences window:

Preferences menu item and window

There are a few interesting things about this window that make it different from the other windows you’ve opened so far in this app: 1. You can’t open more than one copy of this window. You’re able to create as many instances of the main app window as you want, but the Preferences window will only ever open once. Select the menu item to open the window, click the main window and then press Command-,. The already-opened Preferences window will come to the front, but there will still only be one. 2. It has a preset title: OnThisDay Preferences, but this is too long to fit in the window. To over-ride it, add this modifier after the frame modifier in PreferencesView.swift: 103

macOS by Tutorials

Chapter 5: Setting Preferences & Icons

.navigationTitle("Settings")

3. The maximize and minimize buttons are greyed out instead of being green and yellow. That’s because you set a fixed size for the view. In ContentView.swift, you set up a flexible frame for the main window, but a Preferences window will have fixed content, so a fixed size is fine.

Adding Tabs Mac apps can have a lot of settings, especially the more complex apps. To make these less confusing, a very common design pattern is to split the settings into groups and use tabs to display each group. Xcode is a good example of this:

Xcode Preferences

OnThisDay is not a very complicated app, and it doesn’t need a lot of settings, but you’re going to use this tabbed interface because it has some oddities you need to be aware of. In PreferencesView.swift, replace Text with: // 1 TabView { // 2 Text("Tab 1 content here") // 3 .tabItem { Image(systemName: "checkmark.circle") Text("Show") } // 4 Text("Tab 2 content here") .tabItem { Image(systemName: "sun.min") Text("Appearance") } }

And what does this do? 1. Create a TabView to set up the tabbed interface. 104

macOS by Tutorials

Chapter 5: Setting Preferences & Icons

2. Set up the content for the first tab — a Text placeholder for now. 3. Add the tabItem that will show at the top, using an Image from SF Symbols and a Text view. 4. Repeat for the second tab. Resume the preview and look at your tabs:

Previewing the tabs.

Nothing very surprising here; this looks like a standard set of Mac tabs. But you’re probably wondering why there’s an image in each tabItem when the tabs are far too small to display them. Build and run the app, open the Preferences window and see what happens:

Preferences tabs in the app.

The tabs are completely different, and now you get to see the images. Also, the title of the window shows the selected tab’s title. This is yet another way a Preferences window differs from a normal window. So the preview is no good for working with the tabs in a Preferences window, but it can still be useful when designing the tab content.

105

macOS by Tutorials

Chapter 5: Setting Preferences & Icons

Setting up the Show Options The first tab is the Show tab, and its options will control what you see in the app. You already have the ability to toggle Show Totals, which you’ll include here, but you’re going to add other controls to show or hide certain categories of events. To keep the TabView simple and to avoid the Pyramid of Doom, add a new structure to PreferencesView.swift at the end of the file: // 1 struct ShowView: View { // 2 @AppStorage("showBirths") var showBirths = true @AppStorage("showDeaths") var showDeaths = true @AppStorage("showTotals") var showTotals = true // 3 var body: some View { // 4 VStack(alignment: .leading) { Toggle("Show Births", isOn: $showBirths) Toggle("Show Deaths", isOn: $showDeaths) Toggle("Show Totals", isOn: $showTotals) } } }

Stepping through this, you: 1. Initialize a new SwiftUI view. 2. Declare three @AppStorage properties. You’ve already used showTotals , but the other two are new. 3. Add a body , just like for any SwiftUI view. 4. Inside the body , use a VStack containing three Toggle views. Each toggle is bound to one of the @AppStorage properties.

Note: The Pyramid of Doom is a phrase used to refer to deeply nested code, which ends up with an enormous pyramid of closing braces at the end.

You’ve seen that the preview isn’t much use for previewing the tabs, so repurpose it to preview this. In PreferencesView_Previews , replace PreferencesView() with: ShowView() .frame(width: 200, height: 150)

106

macOS by Tutorials

Chapter 5: Setting Preferences & Icons

Resume the preview to see your new view:

Previewing Show view.

These settings are live, even in the preview. Run the app, and use the Display menu to change the setting for Show Totals. Come back to Xcode, resume the preview and the checkbox will reflect your new setting.

Applying the Show Choices Your app already handles the different options for showTotals , but you need to apply the other two settings. You’ll do this by changing the event types listed in the sidebar. First, open SidebarView.swift and add the two new @AppStorage properties to the one that’s there already: @AppStorage("showBirths") var showBirths = true @AppStorage("showDeaths") var showDeaths = true

In the top section of the list, you’re looping through EventType.allCases to create the display. Now you need to work out which of these cases to show. So next, add this to SidebarView outside the body : // 1 var validTypes: [EventType] { // 2 var types = [EventType.events] // 3 if showBirths { types.append(.births) } if showDeaths { types.append(.deaths) }

107

macOS by Tutorials

Chapter 5: Setting Preferences & Icons

// 4 return types }

What does this do? 1. Create a computed property that returns an array of EventType s. 2. Define an array containing .events as there must always be something to display. 3. Check the showBirths and showDeaths settings and append the matching cases to the array as required. 4. Return the array for use in the loop. Now, you can use this property. Replace the ForEach(EventType.allCases line with: ForEach(validTypes, id: \.self) { type in

This will loop through your computed array instead of every possible EventType .

The last step is to show your new view in the TabView . Back in PreferencesView.swift, replace the first Text placeholder with: ShowView()

Build and run the app now. Once you have events to display, open the Preferences window. Check and uncheck the three toggles and watch the sidebar change:

Toggling the Show options.

108

macOS by Tutorials

Chapter 5: Setting Preferences & Icons

You may be wondering why Show Totals can be set from both the menu bar and the Preferences window. This is a common pattern in macOS apps. The menu bar will contain some unique features, but it will also provide quick access to commonly used features, usually with keyboard shortcuts. The Preferences window gathers all the app-wide settings into a single location.

Designing the Appearance Tab As you may have guessed, the second tab in the Preferences window is also going to mimic some menu items, in this case the ones that set the app’s appearance: dark, light or automatic. Like you did for the first tab, you’re going to add a new view to hold the contents of the second tab. In PreferencesView.swift, at the end of the file, add this: // 1 struct AppearanceView: View { // 2 @AppStorage("displayMode") var displayMode = DisplayMode.auto // 3 var body: some View { // 4 Picker("", selection: $displayMode) { Text("Light").tag(DisplayMode.light) Text("Dark").tag(DisplayMode.dark) Text("Automatic").tag(DisplayMode.auto) } // 5 .pickerStyle(.radioGroup) } }

This is similar to the last view you added: 1. Define a SwiftUI view. 2. Give it access to the @AppStorage property you already created for this purpose. 3. Add the body to set up what appears in the view. 4. This time, the user can only select one of the possible options at a time, so a Picker is a logical interface option.

5. Set the pickerStyle to display the options as a set of radio buttons. To see how this will look, set the canvas to show a second preview by adding this to previews: 109

macOS by Tutorials

Chapter 5: Setting Preferences & Icons

AppearanceView() .frame(width: 200, height: 150)

You’ve used the preview canvas to show the same view in different configurations, but it can also display different views. Resume the preview to see this:

Previewing both tabs.

Check boxes are perfect for when the user can select any, all or none of the options. Radio buttons are the right interface element for when the user must choose one, and only one, of the possibilities.

Changing Appearances Next, to make the new view show up in the Preferences window, replace the second Text placeholder in PreferencesView with: AppearanceView()

Build and run, open the Preferences window and change the appearance. Open the Display menu and look at the Appearance submenu. The check mark in 110

macOS by Tutorials

Chapter 5: Setting Preferences & Icons

the menu matches the selected radio button in Preferences. And changing either one immediately changes the other, as well as setting the display.

Setting appearance preferences.

But wait just a second… All you did was add some radio buttons. How come this all just works? Remember back when you set up the menus, you added an onChange modifier to ContentView in OnThisDay.swift? This watches for any changes to the @AppStorage property displayMode and applies the new setting.

You connected this new UI directly to displayMode , so whether you change the setting through the menu items or in the Preferences window, it triggers the onChange and applies the selected appearance.

And since the menu items and the radio buttons are all bound to displayMode , their visuals get updated at the same time. This is the beauty of SwiftUI.

Editing the App Name One thing you haven’t addressed yet is the app’s name. As instructed, you called the project OnThisDay. Xcode can get very confused if you use spaces or any unusual characters in project names, so resist the impulse to use emojis or any other unusual characters and stick to very plain names, with no spaces or accented characters, for your project names. But the project name appears all through the app. You’ve overridden the main window title, but look at the OnThisDay menu and the Help menu. Mouse over the app icon in the Dock. They’re all using OnThisDay with no spaces. Fortunately, there’s an easy fix for this without renaming the project — you 111

macOS by Tutorials

Chapter 5: Setting Preferences & Icons

rename the target! Select the project at the top of the Project navigator. Click the target name to select it, then press Return to start editing. Add the spaces to change the target name to On This Day and press Return again to complete the change:

Editing the target name.

After an edit like this, you need to do a clean build to make sure Xcode incorporates the change into the app, so press Command-Shift-K to clean the build folder. Now build and run and check out the menus to see your new app name:

New app name

You can also change the app name by selecting the target, as before, and clicking the Build Settings tab. Scroll down to Packaging and double-click the Product Name value. It’s set to $(TARGET_NAME) by default. You can edit directly here, instead of changing the target name itself.

112

macOS by Tutorials

Chapter 5: Setting Preferences & Icons

Editing the product name.

App Icons So far, your app icon has looked a bit sad in the Dock. Take a look at the other icons in the Dock and open your Applications folder in icon mode to look at the icons there:

Icons in applications folder

The standard Mac app icon is now a rounded square with a slight shadow. Select an icon in the Applications folder and you’ll notice that there is some transparent padding around the icon too. If you’ve published an iOS app, you’ll know that you have to supply the icon image files as complete squares with no transparency. iOS rounds the corners for you. macOS doesn’t process the icons at all, so you have to supply exactly what you want to see. The other difference between iOS and macOS icons is the number and size of image files required. You’ll be happy to hear that you need fewer images for a macOS icon set. :] Open Assets.xcassets and select AppIcon to see the empty spaces you have to fill. The largest one is 1024 x 1024 pixels. 113

macOS by Tutorials

Chapter 5: Setting Preferences & Icons

Creating a macOS App Icon Set Our wonderful artist, Luke Freeman, has designed an icon for this app and he has supplied a 1024 x 1024 pixel image with no transparency or padding. Open the assets folder in the downloads for this chapter to find the file. You could go to the trouble of editing the image and then creating all the different sizes, but there are tools out there to make this easier. Some only make icons for iOS apps, but Bakery is a free app in the Mac App Store which makes icon sets for all the Apple operating systems. Start by downloading and installing Bakery from the Mac App Store. Next, open Bakery and drag app-icon.png from the assets folder on to the large icon at the top of the window:

Adding your image to Bakery.

Then, click Generate icons which puts a floating window at the bottom left of your screen, displaying your icon:

Bakery floating window

In Xcode, drag this floating window into the list of assets in Assets.xcassets. This creates a new icon set called AppIcon-1. Finally, delete AppIcon from the assets list and rename AppIcon-1 to AppIcon. 114

macOS by Tutorials

Chapter 5: Setting Preferences & Icons

This tidies up the assets catalog and means that you don’t have to change any settings to use your new icon. Press Command-Shift-K to clean the build folder again. Build and run the app, then look in the Dock to see your beautiful new icon in action.

App icon in the Dock.

Note: Bakery has a warning about not using these icons for submission to the App Store. This only applies if you use Apple’s symbols or emojis to make an icon. Using your own artwork is perfectly OK.

Now that you have your icon in place, the app is very nearly done.

Con guring the About Box Run the app, go to the On This Day menu and select About On This Day to see the default About box that Xcode has made for you:

Default About box

As you can see, this is a simple display showing your shiny new icon, the app name and its version and build number. You might think that this will be difficult to change, since Xcode generates it automatically, but there is a simple method.

115

macOS by Tutorials

Chapter 5: Setting Preferences & Icons

Back in Xcode, press Command-N to open the New File dialog. In the search box at the top right, type empty, and select Empty from the Other section:

New empty file

Then, click Next and call the file Credits.html. You must use this exact name or it won’t work. This new file allows you to enter HTML and it will show up in the About box. You can include different HTML elements, links, even inline styling. As a demonstration, add this HTML to the file:

Header

Here is some green text.



Ray Wenderlich




Email Me

Build and run the app, open the About box, and you’ll see this:

116

macOS by Tutorials

Chapter 5: Setting Preferences & Icons

About box showing HTML samples.

This is a good example of the sort of things that you can display in the About box. Both the web link and the email link are active and the text is using the styles you set. The About box has grown taller to accommodate the extra text, but after it reaches a certain height, it will wrap the extra information in a scroll box. Now that you know how to add text, links and styling to your About box, think about what you really should be showing there. Use the link in the Help menu to go to the ZenQuotes.io web site. Scroll to the bottom of the page and look at Usage Limits and Attribution. That attribution link looks like the perfect thing to put in the About box. Replace the contents of Credits.html with:

Historical event data provided by ZenQuotes.io

This keeps the styling and uses the suggested attribution text and link. Build and run, open the About box and check the link:

117

macOS by Tutorials

Chapter 5: Setting Preferences & Icons

About box showing attribution.

You’ve taken the default About box, learned how to customize it and used it to give credit to the people providing the data for the app. Nice work.

Note: If you’re more comfortable with Rich Text than with HTML, add a file called Credits.rtf and edit that to add to your About box.

Help? There is one last feature that you might be wondering about: Help. Select On This Day Help from the Help menu to see this rather discouraging message:

No help here

When you use one of Apple’s apps and you look for Help, you see Apple’s help book interface. It’s possible to use this to create help for your app, but learning that would need another complete book. And it ends up with a system that is slow and difficult to use. In a later section of this book, you’ll learn how to hijack the Help menu item and show your own Help window, but this app is easy enough to use without any 118

macOS by Tutorials

Chapter 5: Setting Preferences & Icons

extra help. :] To avoid showing your users this sad help dialog, you’re going to eliminate it. Open Controls/Menus.swift and find where you set up the CommandGroup adding the Button that links to ZenQuotes.io. Right now it’s set to appear before the standard Help menu item. Change the CommandGroup line to: CommandGroup(replacing: .help) {

This will delete the On This Day Help menu item and show your own item in its place:

Edited Help menu

That’s much neater than showing a non-functional help menu item.

Challenge Edit Credits.html so that it shows ways for users to contact you about your app: Use a mailto link for email. Make sure it includes a subject, so you know what the email is about. Add a web link to connect to your GitHub or Twitter page.

Key points Nearly all macOS apps have a Preferences window. These windows operate differently and display tabs differently than normal app windows. Menus are often used as shortcuts to features that are also available elsewhere in the app. Using @AppStorage makes it easy to apply settings changes, no matter what part of the interface changes them. Once you’ve finished the coding part of an app, don’t forget to set up the 119

macOS by Tutorials

Chapter 5: Setting Preferences & Icons

visuals: app icon and app name. An About box comes as standard, but it’s easy to customize it using HTML.

Where to Go From Here? Look at how much you’ve accomplished in these five chapters! You started by designing data models to suit the API. Then, you built a conventional Mac app to display that data. After that you looked at menus, toolbars, tables and custom views. And finally, you added the finishing touches that make an app look polished and complete. Great work! You’ve learned a lot — and you have an interesting app. Apple has a section of their Human Interface Guidelines dedicated to the App Icon. This is well worth a read before you start creating your own icon images. If you use Sketch or Photoshop, download Apple’s macOS Design Resources to get started.

120

macOS by Tutorials

6

Why Write a macOS App? Written by Sarah Reichelt

In the previous five chapters, you’ve created a real, native macOS application. It looks and behaves like a standard Mac app, and it follows all the Apple guidelines. But a lot of Mac apps are not built this way. In this chapter, you’ll look at what distinguishes a Mac app and consider some of the alternative ways of building one. You’ll assess the strengths and weaknesses of each approach. You aren’t going to build anything in this chapter, so skip it if you prefer to keep coding. But it’ll help you see why writing a native Mac app is such a great idea.

What Makes a Mac App? At WWDC 2021, Apple engineers gave two code-along presentations about SwiftUI on the Mac. They started by listing the four key principles of great Mac apps:

Flexible

Familiar

Expansive

Precise

The Key Principles

Flexible because of the different ways we interact with our Macs: keyboards, track pads, mice and controllers. Also, Mac apps offer a lot of customizations. Look at Finder with its different view modes as well as the different parts of the window you can show or hide. Mac apps adapt to all these individual preferences. Familiar because Mac apps all follow similar design patterns. The menus should always contain the expected items. Apps are easier to use when a window has specific zones for navigation, content, details, controls and so on, located in the expected positions.

121

macOS by Tutorials

Chapter 6: Why Write a macOS App?

Expansive to allow for many different screen sizes and window sizes. Your app has to work in a small window on a tiny MacBook Air, in full screen on a 36” monitor or with multiple windows spread over multiple monitors. It can do this by offering optional views that the user can turn off and on and by displaying content in a way that adjusts to suit the view size. Precise because of large windows and the control methods. Mice and track pads are amazingly accurate, unlike a finger. As a result, controls can be smaller and more densely packed. Consider each of these principles and how On This Day conformed with each one.

What are the Alternatives? There are many different options for writing an app that will run on a Mac, so have a look at the main ones and assess whether they follow these key principles.

iOS Apps Apple Silicon Macs can run almost any iOS app directly. As an example, I searched the Mac App Store for calculator, but selected iPhone & iPad Apps:

Searching the Mac App Store

I downloaded a free app called Calculator - Free Calculator that said it was designed for iPad. When I ran it, I saw a simply vast window with huge controls, an ad at the bottom and an immediate request to give the app Bluetooth access. Why any calculator would need this is not clear. I’ve shrunk it down for the screenshot but the window was 1800 x 2382 on my iMac:

122

macOS by Tutorials

Chapter 6: Why Write a macOS App?

iOS Calculator app

The Preferences were interesting, especially the Touch Alternatives section. These settings are auto-generated:

iOS app Preferences

Key principles: Flexible — no Familiar — no, not to a Mac user. Expansive — no Precise — no Overall score: 0 / 4 Should you use this as a way of getting your apps on the Mac? No.

123

macOS by Tutorials

Chapter 6: Why Write a macOS App?

It’s a neat trick, but there is no way that an app designed for an iPhone is going to look good on a Mac screen. If you have an iOS app and never have any intention of making a Mac version, and if you know the app doesn’t use any iPhone hardware like the GPS or accelerometer, then perhaps you could leave it available through the Mac App Store, but it isn’t going to give a good user experience.

Catalyst Apps With iPad app projects, you can check a button in Xcode to have it build as a macOS app too. Apple uses this a lot for their own apps. One example is the Home app. I have a lot of automated lights in my house, so this is an interesting app to me. The Home screen looks almost identical to the iPad version and that works fine. But look at this dialog box:

Home app dialog

This is obviously an exact copy of the iPad version with huge controls and lots of white space. The app uses the standard menu bar, but the menus aren’t showing all the items I would have expected. Key principles:

124

macOS by Tutorials

Chapter 6: Why Write a macOS App?

Flexible — partially Familiar — partially Expansive — partially Precise — no Overall score: 1.5 / 4 Should you use this as a way of getting your apps on the Mac? Maybe. If you already have an iPad app that you want to get on to the Mac without much effort, then this is the way to go. There are workarounds to make Catalyst apps look more Mac-like, and we have a book, Catalyst by Tutorials, to help!

Web Apps & Electron Apps No Apple solution will give you a truly cross-platform app. If you need to get your app on to Windows as well as macOS, then a web app will probably be your best option. You can use Electron to bundle your web app into a standalone app that you can distribute, even through the Mac App Store. Electron apps have a bad reputation for resource usage. If your Mac feels sluggish, open Activity Monitor. You may find your memory or CPU is busy with multiple instances of a single app. This is usually an Electron app. However, there are some excellent Electron apps available. Visual Studio Code is a terrific app for web development. Nobody is going to mistake Visual Studio Code for a native Mac app, but people who use it on both Macs and Windows PCs are going to appreciate the consistency.

125

macOS by Tutorials

Chapter 6: Why Write a macOS App?

Visual Studio Code

Key principles: Flexible — yes Familiar — partially Expansive — yes Precise — yes Overall score: 3.5 / 4 Should you use this as a way of getting your apps on the Mac? Yes, if your app has to be cross-platform. Visual Studio Code is a great example of a cross-platform app that performs well. But Microsoft has announced that Visual Studio, their main development app for creating .NET, C++ and Electron apps, is moving the Mac version away from cross-platform and on to native macOS, so as to improve “performance, reliability, and product quality”. They have the resources to make two versions of the app and have decided that a native Mac app will provide a better experience for their Mac-using customers.

Note: Another possible cross-platform option is Flutter, which is a Google technology that allows cross-platform development. They’re moving into the desktop space, but so far this is only in beta, so I am not able to make an assessment. It will be interesting to see how it develops.

126

macOS by Tutorials

Chapter 6: Why Write a macOS App?

Native Mac Apps The obvious example here is Finder — perhaps the ultimate Mac app. Certainly, it’s the most used.

Finder

Key principles: Flexible — yes Familiar — yes Expansive — yes Precise — yes Overall score: 4 / 4 Should you use this as a way of getting your apps on the Mac? Yes.

And the Winner Is… So now that you’ve had a look at the alternatives, should you write native Mac apps? YES! If your app has to run on Windows as well as macOS, then you must either write two complete apps, or you go with a cross-platform solution, like Electron. Your choices are limited in that situation. If you’re writing for the Apple ecosystem, then write a native Mac app. No other solution will give your app the true Mac look and feel. No other solution will adapt so seamlessly to different devices and input methods. No other solution will stay as modern as the OS with every update. Users care about the apps they use on their Macs. 127

macOS by Tutorials

Chapter 6: Why Write a macOS App?

Setapp is an app subscription service for Macs, and they do annual surveys. You can read the full 2021 survey results yourself, but here is a quote from the report:

Mac apps make users feel more productive We’ve also found that users prefer native apps and apps designed specifically for Mac.

Code Sharing Once you’ve decided to write a native Mac app, you don’t have to start from scratch every time. Do you already have an iOS app? Great! You can reuse a lot of the code. And it goes the other way too. If you start with a native Mac app, you can use it as the basis for an iOS app. Think of all the code that went into the On This Day app. The data models, settings and data flow mechanisms are completely transferable. The main task would be to rethink the interface so it gives iOS users as good an experience as the Mac version gives to Mac users. If you merely duplicate the interface, you miss out on the unique benefits of iOS. Xcode has a Multiplatform option when you create a new project. This sets up a project with shared files, platform-specific files and two targets:

Multiplatform project

This is a much better option than Catalyst, as it allows you to have a single project with shared code files, as well as files that are only members of one of 128

macOS by Tutorials

Chapter 6: Why Write a macOS App?

the targets. You get the code reuse of Catalyst, while maintaining sufficient independence to create two apps, which will both be true native apps on their respective platforms. The multiplatform template uses SwiftUI, which makes sense because SwiftUI is Apple’s multiplatform framework. But this still gives you the flexibility to insert AppKit and UIKit components as required. Some of the SwiftUI views you create will be specific to a particular platform, but others, especially the smaller component views, will be usable in both targets.

Challenge Look at other apps you have on your Mac. Which ones look good? Which ones do you enjoy using? Which ones look like they were written specifically for macOS and which were written originally for other platforms? Can you work out why you enjoy using your favorites?

Key Points There is no one tool that suits every task and every set of circumstances. You have to assess your needs and choose what will give the best result for you and your users. There are several different technologies you can choose for writing a macOS app. Only a native app will deliver the full Mac experience. Native Mac apps have a particular look and feel, and users really do appreciate that. You can start with a multiplatform Xcode project that allows for code sharing and still creates native apps.

Where to Go From Here? The two SwiftUI on the Mac sessions from WWDC 2021 are really useful: SwiftUI on the Mac: Build the fundamentals SwiftUI on the Mac: The finishing touches This wraps things up for the On This Day app. In the next section, you’re going to build an entirely different type of Mac app — a menu bar app.

129

macOS by Tutorials

7

Using the Menu Bar for an App Written by Sarah Reichelt

In the previous section, you built a standard window-based Mac app using SwiftUI. In this section, you’re going off in a completely different direction. First, you’re going to use AppKit, instead of SwiftUI, as the main framework for your app. Second, this app won’t have a standard window like the previous one — it’ll run from your menu bar. Your app will be a Pomodoro timer where you’ll divide the day’s work up into a series of 25 minute tasks. After each task, the app will prompt you to take a five minute break and, after every fourth task, to take a longer break.

Time-ato app

Along the way, you’ll learn about menu bar apps, AppKit, timers, alerts, notifications and how to integrate SwiftUI views into an AppKit app.

130

macOS by Tutorials

Chapter 7: Using the Menu Bar for an App

Setting up the App In Xcode, create a new project using the macOS App template. Set the name to Time-ato and the language to Swift. But this time, set the interface to Storyboard:

Project options

Save the app and take a look at the starting files:

Project files

This time, your starting .swift files are AppDelegate.swift and ViewController.swift. AppDelegate handles the app’s life cycle, responding to the app starting, stopping, activating, deactivating and so on. ViewController deals with the display of the initial window. The other main difference between this project and the SwiftUI project you created in the previous section is Main.storyboard. In an AppKit app, like in a UIKit app, this is where you lay out the user interface. Checking out the storyboard, there are three scenes:

131

macOS by Tutorials

Chapter 7: Using the Menu Bar for an App

Storyboard

Application Scene: The Main Menu and the App Delegate are the most important components. Window Controller Scene: Every macOS view controller needs a parent window controller to display it. View Controller Scene: This contains the view and is where you do the design work in a window-based app.

Converting the App into a Menu Bar App Right now, this app is very similar to the app you created in the previous section except that it’s using AppKit. It has an initial window and a full menu bar. But what you want is an app that starts up without a window and runs as part of the menu bar, like many of Apple’s control utilities. To convert your app project into a menu bar app, you need to do three things: 1. Get rid of the window and most of the menus. 2. Set up a status bar item linked to a menu. 3. Configure a setting in Info.plist.

Getting Rid of the Window and Menus First, get rid of the windows. In Main.storyboard, select View Controller Scene 132

macOS by Tutorials

Chapter 7: Using the Menu Bar for an App

in the document outline and press Delete. Do the same with Window Controller Scene. Expand Application Scene ▸ Application ▸ Main Menu. Select and delete all the menus except for Time-ato:

Delete unwanted menus

Next, expand Time-ato ▸ Menu and delete all the menus between the first Separator and the last Separator:

133

macOS by Tutorials

Chapter 7: Using the Menu Bar for an App

Delete unwanted menu items

Finally, delete ViewController.swift from the Project navigator now that you’ve deleted its scene from the storyboard. You’ve now removed all of the unwanted components.

Setting up the Status Bar Item To create a status bar item, open AppDelegate.swift. Add these two properties to AppDelegate : var statusItem: NSStatusItem? @IBOutlet weak var statusMenu: NSMenu!

An NSStatusItem is an item that macOS displays in the system menu — that part of the menu bar to the right of your screen. It’ll hold the menu for your app. This naming is why you’ll often see apps like this referred to as status bar apps. You’ll link the NSMenu to the menu in the storyboard. @IBOutlet marks this as a view that the interface builder can connect to. Replace the contents of applicationDidFinishLaunching(_:) with: // 1

134

macOS by Tutorials

Chapter 7: Using the Menu Bar for an App

statusItem = NSStatusBar.system.statusItem( withLength: NSStatusItem.variableLength) // 2 statusItem?.menu = statusMenu // 3 statusItem?.button?.title = "Time-ato" statusItem?.button?.imagePosition = .imageLeading statusItem?.button?.image = NSImage( systemSymbolName: "timer", accessibilityDescription: "Time-ato") // 4 statusItem?.button?.font = NSFont.monospacedDigitSystemFont( ofSize: NSFont.systemFontSize, weight: .regular)

So what’s going on here? 1. Add a new statusItem to the system-wide NSStatusBar and set it to have a variable length so that it can adjust to fit its contents. 2. Set the NSMenu you declared earlier as the menu for the status item. 3. Configure the status item with a title and a leading image. The actual display in the menu bar is a button. And using SF Symbols isn’t as easy when you’re not using SwiftUI! 4. Set the font for the status item’s button. This will end up showing numbers as the timer counts down, but in the default system font, digits are proportional, so that 1 is not as wide as 9. This makes the display jump around as the numbers change. NSFont can give you a version of the system font with monospaced digits which will look much better in this case. You’ve declared statusMenu and you’re using it in your statusItem , but now you need to go back to the storyboard and connect it. Open Main.storyboard and Option-click AppDelegate.swift in the Project navigator to open it to the side. If Application Scene is not expanded, Optionclick it to expand it fully. Control-drag from the Menu under Time-ato to statusMenu in AppDelegate.swift. Let go when you see a blue box around statusMenu and that will make the connection:

135

macOS by Tutorials

Chapter 7: Using the Menu Bar for an App

Connecting the menu

Close the secondary editor now to give yourself more room.

Con guring the Info.plist Finally, you need to update Info.plist. Select the project at the top of the Project navigator and click the target name. Click the Info tab at the top, and if necessary, expand the Custom macOS Application Target Properties section. Select any entry and click the Plus button in the center of its row to add a new row. Scroll until you can select Application is agent (UIElement) from the list, and change its value to YES:

Info.plist setting

You’re finally ready to build and run your menu bar app. No window appears and no icon bounces in the Dock. Look at the right side of your menu bar. There is your menu bar app and clicking the app name pops down your menu with two working menu items:

136

macOS by Tutorials

Chapter 7: Using the Menu Bar for an App

App running in the menu bar.

Note: If you try the About Time-ato menu item, it may look like nothing happened. Hide your open apps and you’ll see the About box. It’s visible but not brought to the front. You’ll fix that later.

Why AppKit? Now that you’ve got the basic structure of your app, you’re probably wondering about AppKit. What is AppKit and why is this app using it? Why is the easy question to answer. SwiftUI is still very new, but AppKit has been around for more than 20 years. This means that the majority of macOS apps use AppKit. If you’re looking for a job developing Mac apps, or you want to work on some open-source Mac project, chances are that it’ll be using AppKit. So while SwiftUI is the future, AppKit is still dominant and it’s important to learn something about how it works. Now on to what is AppKit? Explaining this needs a history lesson. In 1985, Steve Jobs was forced out of Apple. He founded NeXT (yes, they really did write it that way), a computer company specializing in high-end computers for business and research. NeXT developed the NeXTSTEP platform, which was an operating system based on Unix. When Jobs returned to Apple in 1997, he brought NeXTSTEP with him, and it became the basis for OS X and later macOS. The application environment for programming Macs was named Cocoa and, if you look at AppDelegate.swift, you’ll see the only library imported in the file is Cocoa. Command-click the word Cocoa and select Jump to Definition. This reveals a file containing three other imports: import AppKit import CoreData import Foundation

137

macOS by Tutorials

Chapter 7: Using the Menu Bar for an App

Cocoa is a super-group containing Foundation, which is the basic library underneath all Apple device programming, CoreData for databases and AppKit for the user interface. Command-click AppKit and again, select Jump to Definition. This time you’ll see an enormous list of all the NS objects that make up AppKit. And now that you know this all came from NeXTSTEP, you can see why they all have the prefix NS. Interestingly, the name Cocoa was originally trademarked by Apple for a multimedia project. When they needed a name for their new application development system, they decided to reuse Cocoa to avoid the delay of registering a new trademark. CocoaTouch came later for programming iOS devices, and in place of AppKit, it used UIKit. Since Apple engineers created UIKit from scratch, they were able to choose the more logical prefix of UI. Many of the interface elements are the same between the two frameworks, just swapping NS and UI . Others appear to be the same but have different properties and methods, which is something you need to check if you’re converting any projects.

Adding the Models Now that you know a little bit about the history of AppKit, it’s time to get back to the app. Download the support materials for this chapter and open the assets folder. Drag the Models folder from assets into your Project navigator, selecting Copy items if needed, Create groups, and checking the Time-ato target:

Import models

Looking in the Models group, Task is the structure that defines the tasks the app will time. It also has an extension with static properties that provide sets of sample tasks. TaskTimes sets the length of each phase in the process, using a set of shorter times in debug mode to make the app faster to test. TaskStatus is an enumeration that defines the possible states of a task. Each state has its 138

macOS by Tutorials

Chapter 7: Using the Menu Bar for an App

own text, color and icon that you’ll eventually display in the menu.

Static Menu Items The menu will contain two types of items: static items to control the app and dynamic items to display the tasks. Start with the static items. Open Main.storyboard and fully expand the Application Scene. Click the + in the top right of the window or press Shift-Command-L to open the Library. Search for “menu” and drag a Menu Item to between the two Separators in the document outline:

Add new menu item

Hold down Option and drag the new Item down one row to duplicate it. Repeat this so that you end up with three new entries labeled Item. Use the same method to duplicate the first Separator. Your Application Scene will now look like this:

Application scene after adding items.

139

macOS by Tutorials

Chapter 7: Using the Menu Bar for an App

Select the first new Item, show the Inspectors on the right, select the Attributes inspector and change the title to Start Next Task:

Rename the first item

In the same way, change the titles of the other items to: Edit Tasks… (use Option-; to type the ellipsis symbol.) Launch on Login Finally, select the Quit Time-ato menu item and click the X to remove the Key Equivalent:

Removing the keyboard shortcut.

Since menu bar apps have no window to accept focus, they can’t respond to keyboard shortcuts. You’re now finished with the storyboard and the preview of your menu in the storyboard looks like this:

140

macOS by Tutorials

Chapter 7: Using the Menu Bar for an App

Menu preview

You’ll place the dynamic items programmatically, between the top two separators.

Dynamic Menu Items To add, remove and update the menu items representing the tasks, you’ll add a MenuManager class. It’ll act as the NSMenuDelegate , detecting when the user

opens or closes the menu and updating the display as needed. Create a new Swift file called MenuManager.swift. Replace its contents with: // 1 import AppKit // 2 class MenuManager: NSObject, NSMenuDelegate { // 3 let statusMenu: NSMenu var menuIsOpen = false // 4 var tasks = Task.sampleTasksWithStatus // 5 let itemsBeforeTasks = 2 let itemsAfterTasks = 6 // 6 init(statusMenu: NSMenu) { self.statusMenu = statusMenu super.init() } // 7 func menuWillOpen(_ menu: NSMenu) { menuIsOpen = true } func menuDidClose(_ menu: NSMenu) { menuIsOpen = false

141

macOS by Tutorials

Chapter 7: Using the Menu Bar for an App

} }

So what is this doing? 1. Import AppKit, which you need for working with NSMenu . 2. Declare the class conforms to NSMenuDelegate . This requires that the class also conforms to NSObject . 3. Set up properties to hold the menu and a flag that will reflect whether the menu is open or not. 4. Import a set of sample tasks for use during development. 5. Tell the class how many menu items come before where the tasks should go, and how many after. 6. Initialize the class with its menu. 7. Add the menu delegate methods, which will detect the menu opening and closing and set the menuIsOpen flag.

Clearing the Menu When the menu closes, you must remove any existing task menu items. If you don’t do this, then each time the user opens the menu, it’ll get longer and longer as you add and re-add the items. Add this method to MenuManager : func clearTasksFromMenu() { // 1 let stopAtIndex = statusMenu.items.count - itemsAfterTasks // 2 for _ in itemsBeforeTasks ..< stopAtIndex { statusMenu.removeItem(at: itemsBeforeTasks) } }

And going through these steps: 1. You’ve already defined two properties dictating where the tasks start and end in the menu. Use the property which defines the end to work out how many items to delete. You use statusMenu.items to get a list of the existing items in the menu. 2. Loop through the items removing the one at index 2 every time until you’ve removed the right number.

142

macOS by Tutorials

Chapter 7: Using the Menu Bar for an App

Now, you can add a call to this method inside menuDidClose(_ :) : clearTasksFromMenu()

Adding to the Menu That’s done the tidying up part, so the next thing is to create new menu items to display the tasks. Start by adding this new method to MenuManager : func showTasksInMenu() { // 1 var index = itemsBeforeTasks var taskCounter = 0 // 2 for task in tasks { // 3 let item = NSMenuItem() item.title = task.title // 4 statusMenu.insertItem(item, at: index) index += 1 taskCounter += 1 // 5 if taskCounter.isMultiple(of: 4) { statusMenu.insertItem(NSMenuItem.separator(), at: index) index += 1 } } }

There’s quite a lot happening here: 1. Use the predefined property as the starting position for the first task entry and add a counter to keep track of how many tasks you add to the menu. 2. Loop through all the tasks. 3. Create an NSMenuItem for each task, setting its title to the title of the task. 4. Insert this menu item into the menu and increment the index for positioning the next one. Increment the task counter too. 5. The Pomodoro technique suggests that you take a longer break after every fourth task. Use taskCounter to check if this is a fourth task and, if so, add a separator line after it. And again, you need to call this method, so add this line to menuWillOpen(_ :) : 143

macOS by Tutorials

Chapter 7: Using the Menu Bar for an App

showTasksInMenu()

Joining it All Up You’ve done some good work, setting up MenuManager and providing methods to clean and populate the menu. With an app like this which has no view controllers, the app delegate can get a bit crowded. Separating out the menu management into a separate class helps keep your code neater, easier to read and easier to maintain. The next step is to connect MenuManager back to AppDelegate . Over in AppDelegate.swift, add this definition: var menuManager: MenuManager?

Next, add these two lines to the end of applicationDidFinishLaunching(_:) : menuManager = MenuManager(statusMenu: statusMenu) statusMenu.delegate = menuManager

Now, you’ve got an instance of MenuManager , initialized with the statusMenu and set up as the delegate for that menu. And now, it’s time to build and run:

Showing tasks as menu items.

144

macOS by Tutorials

Chapter 7: Using the Menu Bar for an App

There are all the sample tasks with a separator after every fourth one, wrapped in your static menu items. And, if you close the menu and re-open it, you still only see one copy of the tasks list. You may be wondering why the menu items are all grayed out. There is a menu setting to auto-enable menu items and because you don’t have actions attached to these menu items, the menu has disabled them for you. To fix this, open Main.storyboard and select the menu under Time-ato. In the Attributes inspector uncheck Auto Enables Items:

Turn off Auto Enables Items.

Styling the Menu Items At the moment, your tasks appear in the menu but only as text, which leaves out a lot of information. Which tasks are complete? If a task is in progress, how long has it been running? While most menu items are text, you can specify any NSView , or subclass of NSView , for a menu item. So, you’ll create a custom view and use it to display

the tasks in the menu. When creating a custom view using AppKit or UIKit, you can use storyboards — or .xib files — to create them visually, or you can create them programmatically. The debate over which is best can get quite heated, almost rivaling the great debate over tabs versus spaces! :] Storyboards allow you to see your design. Xcode provides helpful markers to make sure you follow the Human Interface Guidelines for spacing and positioning. It’s easy to see what settings you can apply to each subview. Writing your view in code can get very verbose, but it gives you very precise control and makes version control clearer and easier to follow. In this case, you could create a new view controller with an .xib file. This would allow you to design your layout visually. Then, for each menu item, you would create an instance of your view controller and use its view in the menu item. You can’t easily create an NSView in the storyboard without a view controller. 145

macOS by Tutorials

Chapter 7: Using the Menu Bar for an App

This will work and make designing the view easier, but when you come to making frequent updates to the views, it’ll be more clumsy. So while visual design is usually more productive, in this case, you’re going to create the view in code, to improve performance.

Creating a Custom View Open the assets folder in the downloaded materials for this chapter. Drag TaskView.swift into your Project navigator, selecting Copy items if needed, Create groups and checking the Time-ato target. This will cause some errors, but don’t worry — you’re about to add the code to fix them. Looking at the file, notice that it is a class that inherits from NSView . This means that you can use it anywhere you could use an NSView — like in a menu item. The class has an optional Task property and four other properties that are all NSView subclasses.

There are four components that you’ll show in this custom view: The task’s title. An icon showing its status. A progress bar if it’s running. Some information text if it’s not running. You’re going to lay out these components as shown in this diagram:

270, 40

Title - 220 x 16

20 Info or progress bar - 220 x 14

10

4

0, 0 10

40

View layout

There are three important things to note: 1. The overall size of the view is 270 x 40. This will suit the expected content. 146

macOS by Tutorials

Chapter 7: Using the Menu Bar for an App

2. The origin point — (0, 0) — is at the bottom left. If you’ve used UIKit, you’re probably expecting the origin to be at the top left. 3. If the progress bar is visible, the info text will be blank, so they are both in the same position. Start by setting up the image. In TaskView.swift, under // set up subviews , add this: // 1 let imageFrame = NSRect(x: 10, y: 10, width: 20, height: 20) // 2 imageView = NSImageView(frame: imageFrame) // 3 imageView.imageScaling = .scaleProportionallyUpOrDown

What are these lines doing? 1. Create an NSRect with an origin and a size. The diagram shows the origin, and the width and height are half the total height. If you were making a UIView , you’d use a CGRect here.

2. InItialize imageView , using the NSRect as its frame. 3. Change one of the default settings so that the image will scale either up or down to fit the frame. Next, for the two text views, insert these lines under the ones you just added: let titleFrame = NSRect(x: 40, y: 20, width: 220, height: 16) titleLabel = NSTextField(frame: titleFrame) let infoProgressFrame = NSRect(x: 40, y: 4, width: 220, height: 14) infoLabel = NSTextField(frame: infoProgressFrame)

These are very similar and set up two more NSRect s to use when initializing the two NSTextField views. Check the diagram to see how the frames line up. The blocks of code supplied below configure these NSTextField views. They’re non-editable, without bezels and with appropriate font sizes. The infoLabel will never have enough text to overflow, but the titleLabel might, so you set it to truncate the tail of the text if needed. The last view is the progress bar, so add this now: // 1 progressBar = NSProgressIndicator(frame: infoProgressFrame) // 2

147

macOS by Tutorials

Chapter 7: Using the Menu Bar for an App

progressBar.minValue = 0 progressBar.maxValue = 100 // 3 progressBar.isIndeterminate = false

This is a bit different: 1. Use the same frame you used for infoLabel to initialize an NSProgressIndicator .

2. Set the minimum and maximum values for showing progress as a percentage. 3. An indeterminate progress indicator is something like a spinner that shows the app is busy but doesn’t provide any information about the state of progress. This progress bar will NOT be indeterminate, so that it can show what percentage of the task is complete. You’ve now initialized and configured the four subviews, but there is one last step. In order to show them, you must add them to the custom view. Replace // add subviews with: addSubview(imageView) addSubview(titleLabel) addSubview(infoLabel) addSubview(progressBar)

Now, when you initialize a TaskView , this will size, configure and place its four subviews. The errors have all gone away, but you’ve still got a warning. Whenever the view needs updating, it calls its draw(_:) method, so here is where you can actually display the task data. Replace // view update code with this, which will silence the warning: // 1 let color = task.status.textColor // 2 imageView.image = NSImage( systemSymbolName: task.status.iconName, accessibilityDescription: task.status.statusText) imageView.contentTintColor = color // 3 titleLabel.stringValue = task.title titleLabel.textColor = color // 4

148

macOS by Tutorials

Chapter 7: Using the Menu Bar for an App

infoLabel.stringValue = task.status.statusText infoLabel.textColor = color // 5 switch task.status { case .notStarted: progressBar.isHidden = true case .inProgress: progressBar.doubleValue = task.progressPercent progressBar.isHidden = false case .complete: progressBar.isHidden = true }

This code will run every time the view changes, if the view has a Task , so what is it doing? 1. Get a color from the task’s status. 2. Set up the image with an SF Symbol from the task’s status and tint it with the color. 3. Show the task’s title with the selected color. 4. Get the task’s status text and display it with the correct color. If the task is in progress, this is an empty string, so there’s no need to hide the view. 5. Use the task’s status to determine whether the progress bar should be visible and to set its value, if the task is in progress. So now you’ve defined a custom NSView , connected it to a Task and it’s all ready for use in your menu.

Using the Custom View To apply this view in the menu, go back to MenuManager.swift and find showTasksInMenu() .

Below where you created the taskCounter property, add this to specify the fixed size for the menu item views: let itemFrame = NSRect(x: 0, y: 0, width: 270, height: 40)

Next, replace item.title = task.title with: let view = TaskView(frame: itemFrame) view.task = task item.view = view

This creates a TaskView with its frame, assigns it a task and sets it as the menu 149

macOS by Tutorials

Chapter 7: Using the Menu Bar for an App

item’s view. You’ve done a lot of work in this section, but at last, it’s time for another build and run:

Custom view in menu.

And now, you can see what your custom view looks like. The view has grayed out the completed tasks and marked them as Complete. The task in progress has a progress bar and uses the system accent color. The remaining tasks have Not started yet as their information text. And each of the three types of tasks has an appropriate icon. Great work! There was a lot to get through in this chapter, but now you have a menu bar app that displays your model in a custom view.

Challenge Challenge: Change the Colors Open TaskStatus.swift and look at the textColor computed property. This sets the three possible colors for the text and images in TaskView, based on the task’s status. The colors used are predefined by NSColor . Open Xcode’s documentation and search for UI Element Colors. Scroll through 150

macOS by Tutorials

Chapter 7: Using the Menu Bar for an App

the list of possibilities. Right now, TaskView uses labelColor , controlAccentColor and placeholderTextColor . What other options could you use in these places? Try

out a few alternatives. Don’t forget to check the menu in light and dark modes and with different system accent colors.

Key Points A menu bar or status bar app seems very different as it runs without a main window or Dock icon, but underneath, it’s quite similar. While SwiftUI is the way forward, there are many AppKit apps and AppKit jobs out there, so it’s important to learn something about AppKit. Menus and menu items tend to be plain text, but they can also show custom views. Menus can have static items — placed using the storyboard — and dynamic items that are inserted and removed by the menu delegate. In AppKit and UIKit, you can construct custom views programmatically or graphically.

Where to Go From Here? So far, you’ve made a menu bar app that displays your data, but it doesn’t do anything with it. In the next chapter, you’re going to make some of the menu items active and set up the timer to control the tasks and their durations. You’ll also learn how to communicate with the user via alerts and notifications. Check out this link if you would like to learn more about the Pomodoro technique.

151

macOS by Tutorials

8

Working with Timers, Alerts & Noti cations Written by Sarah Reichelt

In the previous chapter, you set up a menu bar app using AppKit. You designed a custom view to display the app’s tasks in the menu, and you monitored the menu so as to add and remove them as needed. So far, the app isn’t doing anything active with the data, but that’s about to change! Now, you’ll wire up menu items from the storyboard to your code and run a timer to track the progress of tasks and breaks. Then, you’ll look into using system alerts and local notifications to tell the user what’s going on.

Linking Outlets and Actions You’ve created some static menu items to allow control of the app, but they don’t do anything yet. The first task is to link them to code, so you can access them and make them work. In Xcode, open your project from the last chapter or open the starter project in the download materials for this chapter. Open Main.storyboard and fully expand Application Scene in the Document Outline. Option-click AppDelegate.swift in the Project navigator to open it in a second editor. Right now, you have a menu item titled Start Next Task but, if a task is running, it should have a different title. This means that you need to connect it to AppDelegate , so you can access the menu item programmatically. And since

you want this menu item to do something, you also need to connect an AppDelegate method to it.

When you Control-drag from a storyboard into a .swift file, Xcode offers to create an Outlet or an Action. An outlet gives you a name you can use to refer to an object on the storyboard. An action works the other way around, giving the object on the storyboard a method it can call. Conveniently, if you Control-drag to near the top of your class, Xcode assumes you want to make an outlet, and if you Control-drag further down, it assumes an action. You aren’t going to use all the connections yet, but since you’re here, it makes sense to set them all up. 152

macOS by Tutorials

Chapter 8: Working with Timers, Alerts & Notifications

Connecting the Outlets Control-drag from Start Next Task to underneath where you declared menuManager . Don’t let go until you see the Insert Action or Outlet tooltip. You

may have to move the mouse pointer down a line to get there:

Control-drag

When the dialog pops up, make sure Connection is set to Outlet and Type is NSMenuItem. Set Name to startStopMenuItem and click Connect:

Connecting an outlet

Add a blank line after the newly added one if needed to make the code look neater. It now looks like this:

The outlet in code

Mouse over the black blob in the line numbers gutter. It highlights the Start Next Task item in the storyboard to confirm the connection.

Confirming the connection

153

macOS by Tutorials

Chapter 8: Working with Timers, Alerts & Notifications

Use the same technique to connect the Launch on Login menu item to an outlet called launchOnLoginMenuItem . These are the only outlets you need, and now you’ll create actions. Add some blank lines at the end of the file but still inside AppDelegate . This gives you somewhere to drag into.

Wiring Up the Actions Control-drag from Start Next Task into this blank space and connect an action called startStopTask :

Connecting an action

In the same way, make these other actions: Edit Tasks… — showEditTasksWindow Launch on Login — toggleLaunchOnLogin The end of AppDelegate now looks like this:

The actions in code

Now, when the app is running, selecting one of these static menu items calls its connected method. The methods don’t do anything yet, but you’ve made the links. You’ve finished connecting everything now, so close the secondary editor and move on to making them work. 154

macOS by Tutorials

Chapter 8: Working with Timers, Alerts & Notifications

Managing the Tasks The standard Apple app architecture is MVC, which stands for Model View Controller. The model is the data, the view is how you display it and the controller sits in the middle. Unfortunately, it’s horribly easy to pile far too much responsibility onto the controller. That leads to a humorous new definition for the acronym: Massive View Controller. In this app, you don’t have a view controller at all, but this means that a lot has to go through the app delegate. But just like with MVC, you want to avoid Massive App Delegate. Nobody wants to make their app MAD. :] In the previous chapter, you made a MenuManager class to separate the menu delegate and other menu handling code. You used the sample data set, Task.sampleTasksWithStatus , where some tasks are already completed and

one is in progress. This was useful when you were designing the custom view. Now that you’re going to control the tasks, you’ll use Task.sampleTasks , where none of the tasks has started or completed. First, to manage the tasks with their timings, make another manager class. Add a new Swift file to the project, naming it TaskManager.swift. Add this code to your new file: class TaskManager { var tasks: [Task] = Task.sampleTasks }

This sets up the class and gives it a property to hold the tasks, setting it to the “no status” sample data set. Next, to use your new class, open MenuManager.swift and replace var tasks = Task.sampleTasksWithStatus with:

let taskManager = TaskManager()

Now, scroll down to showTasksInMenu() to see that this change has broken something. Your MenuManager can’t find its tasks any more. Replace the for task in tasks { line with: for task in taskManager.tasks {

So now menuManager has access to the tasks again, but through another class. 155

macOS by Tutorials

Chapter 8: Working with Timers, Alerts & Notifications

And you have a new manager class that can handle the tasks and their timer, without cluttering up the app delegate or the menu manager.

Timers It’s finally time to talk about timers. After all, what’s the point of a timer app that can’t time anything? :] There are two possibilities for using the Timer class: Create a scheduledTimer that can perform an action on a repeating schedule. Use Combine to publish a timer that emits a sequence of Date objects. Either of these will work, but for this app, you’ll use Combine because it’s a newer and more interesting technology. Open TaskManager.swift and add an import statement: import Combine

This gives you access to the Combine framework. Next, add this property to TaskManager : var timerCancellable: AnyCancellable?

You’ll create a subscription to a Timer.TimerPublisher and assign it to timerCancellable . It’s good practice to cancel a subscription when its work is

done, to free up resources. An AnyCancellable object automatically calls cancel() when its deinitialized. Mission accomplished! Plus, you must keep a

reference to the publisher or won’t hang around to give you events! Finally, add this method that lets you start the timer: func startTimer() { // 1 timerCancellable = Timer .publish( // 2 every: 1, // 3 tolerance: 0.5, // 4 on: .current, // 5 in: .common)

156

macOS by Tutorials

Chapter 8: Working with Timers, Alerts & Notifications

// 6 .autoconnect() // 7 .sink { time in print(time) } }

There’s a lot of detail in this method: 1. Create a Timer.TimerPublisher , which is a specialized Combine publisher that emits the current date at intervals. 2. Set the time interval between events, in seconds. 3. Specify the tolerance. This app doesn’t need to be too accurate, so half a second is fine. 4. Every Timer has to run on a RunLoop , which is an object that processes inputs from windows, mice, keyboards and so on. The current RunLoop uses the current thread and that’s perfect for this timer. 5. Now you get to the RunLoop.Mode and things get a bit different. Usually, you’d select default mode, but if you do that for this app, the menu won’t be able to update while it’s open. Using common allows the menu to update whether it’s open or closed. 6. A Timer.TimerPublisher is a ConnectablePublisher . This means it won’t publish events until a subscriber calls its connect() method. The autoconnect() operator automatically connects when the first subscriber

subscribes. 7. To receive data from the publisher, you use sink to create a subscription. It receives the published dates, and for now, only prints them to the console. Later, you’ll call a TaskManager method instead, which is why you can’t simply initialize timerCancellable when you declare it.

Note: The common RunLoop.Mode is a pseudo-mode that registers the timer to the default , modalPanel and eventTracking modes. The modalPanel mode only runs when a dialog like a save or load panel is open. The eventTracking mode only runs when events are being tracked, for example when the menu is open. The default mode only runs if neither of these other condItions are in effect. The common mode combines all these to run no matter what. This allows the Timer to keep publishing all the time.

To test your new timer, add this method to TaskManager : 157

macOS by Tutorials

Chapter 8: Working with Timers, Alerts & Notifications

init() { startTimer() }

Build and run the app, then check the Xcode console to see a list of dates appearing, one every second:

Timer publishing dates

That was a dense section, but you’ve ended up with an active Combine timer, and now, instead of printing the dates, you can set it to work. And you also know exactly when I wrote this section. :]

Tracking the Timer State When running this app, the timer can be in one of four states: Running a task. On a short break. On a long break. Waiting: no task is running, and the user is not on a break. When you want to track a set of interconnected states like this, an enumeration is a great choice. But Swift enumerations can have super powers: associated values. Most of these states have some data that you need to link to them: If you’re running a task, which task is it? And for a break, what time did the break start? Swift allows you to attach an associated value to cases in an enumeration. These values don’t all have to be of the same type and not all cases have to have one. Open the assets folder in the download materials for this chapter. Find TimerState.swift and drag it into the Models group in the Project navigator. Make sure to check Copy items if needed and the Time-ato target. Take a look at the file. It sets up the four possible states but, for runningTask , it uses an associated value to store the task’s index. And each of the two breaks has an associated value to store the start time. 158

macOS by Tutorials

Chapter 8: Working with Timers, Alerts & Notifications

The activeTaskIndex computed property uses the taskIndex associated with runningTask to return an index number if a task is running or nil if not.

This demonstrates how to use switch to get the associated value for the active case in an enumeration. The breakDuration computed property works out the expected duration of a break, depending on the type. You’ll use these soon when tracking how the user’s tasks and breaks are progressing, so open TaskManager.swift and add this property: var timerState = TimerState.waiting

This sets timerState to waiting by default.

Starting and Stopping Tasks Still in TaskManager.swift, add these methods for starting and stopping tasks: // 1 func toggleTask() { // 2 if let activeTaskIndex = timerState.activeTaskIndex { stopRunningTask(at: activeTaskIndex) } else { startNextTask() } } func startNextTask() { // 3 let nextTaskIndex = tasks.firstIndex { $0.status == .notStarted } // 4 if let nextTaskIndex = nextTaskIndex { tasks[nextTaskIndex].start() timerState = .runningTask(taskIndex: nextTaskIndex) } } func stopRunningTask(at taskIndex: Int) { // 5 tasks[taskIndex].complete() timerState = .waiting }

So what are these methods doing? 1. You’ll soon set the startStopMenuItem action to call this method. 159

macOS by Tutorials

Chapter 8: Working with Timers, Alerts & Notifications

2. Use timerState to check if a task is running. If so, it’ll stop it, and if not, it’ll start the next one. 3. To start the next task, find the index for the first one in the list that has a status of notStarted . 4. If there is a valid task, start it and set timerState to runningTask , storing the index number. 5. To stop a running task, use its complete() method and set timerState back to waiting . Now that you’ve got these methods in place, go ahead and call them from the menu. In AppDelegate.swift, insert this into startStopTask(_:) : menuManager?.taskManager.toggleTask()

Build and run, open the menu and select Start Next Task. Wait a few seconds, then open the menu again to see the first task in progress. Select Start Next Task again to mark the first task as complete.

Task in progress

There’s still quite a list of features to implement: updating the menu, checking the timer for completed tasks, and handling breaks. But you’re really starting to see it all come together.

Updating the Menu Title There are three parts of the menu that you need to update: the menu title, the startStopMenuItem title and the tasks themselves.

160

macOS by Tutorials

Chapter 8: Working with Timers, Alerts & Notifications

Looking at the menu title first, it should change depending on the timer state. Open the assets folder you downloaded and add TaskManagerExt.swift to your project, using the same settings. This gives you an extension to TaskManager with a computed property and a method. The computed property checks timerState and returns a tuple containing an appropriate title for the menu and the name of an image to use as the menu icon. It uses all the associated values to do this. The method uses a DateComponentsFormatter to format the remaining time for the task or break into minutes and seconds. You declare the date formatter as a global property so this method doesn’t have to create a new one every second. TaskManager uses these to update the menu title, but all the menu-related

methods are in AppDelegate.swift. In fact, you’ll soon need another method in AppDelegate , so add its stub now:

func updateMenu( title: String, icon: String, taskIsRunning: Bool ) { }

Back in TaskManager.swift, you need to use AppKit to access AppDelegate , so add this line to the other imports at the top of the file: import AppKit

Next, add this method to TaskManager : func checkTimings() { // 1 let taskIsRunning = timerState.activeTaskIndex != nil // more checks here // 2 if let appDelegate = NSApp.delegate as? AppDelegate { // 3 let (title, icon) = menuTitleAndIcon // 4 appDelegate.updateMenu( title: title, icon: icon, taskIsRunning: taskIsRunning) } }

161

macOS by Tutorials

Chapter 8: Working with Timers, Alerts & Notifications

And what does this do? 1. Check to see if any task is running. 2. Get a reference to the app’s delegate. 3. Use the property in TaskManagerExt.swift to find the appropriate title and icon for the menu. 4. Pass this information to your new method in AppDelegate . Now, back to AppDelegate.swift to implement the new method. Replace your stub with this code: func updateMenu( title: String, icon: String, taskIsRunning: Bool ) { // 1 statusItem?.button?.title = title statusItem?.button?.image = NSImage( systemSymbolName: icon, accessibilityDescription: title) // 2 updateMenuItemTitles(taskIsRunning: taskIsRunning) } func updateMenuItemTitles(taskIsRunning: Bool) { // 3 if taskIsRunning { startStopMenuItem.title = "Mark Task as Complete" } else { startStopMenuItem.title = "Start Next Task" } }

These two methods: 1. Use the supplied title and icon name to configure the status item’s button. 2. Call the second method to update the menu item name. 3. Change the startStopMenuItem to show an appropriate title. There’s only one piece left in this puzzle. Go back to TaskManager.swift and, in startTimer() , replace .sink { … } with:

.sink { _ in self.checkTimings() }

Now you can build and run. Open the menu and select Start Next Task. The 162

macOS by Tutorials

Chapter 8: Working with Timers, Alerts & Notifications

menu title now shows your task title and counts down the remaining time. And instead of Start Next Task, that menu item is now labelled Mark Task as Complete.

Menu title counting down

Updating the Tasks You’ve done two of the three updates needed, but the menu item showing the active task is only updating when the menu opens or when you move the mouse pointer over it. Remember in the previous chapter when you created the custom view for the task menu items? You did it programmatically to make for easier updating. And now, you’ll see the result. In MenuManager.swift, add this: func updateMenuItems() { // 1 for item in statusMenu.items { // 2 if let view = item.view as? TaskView { // 3 view.setNeedsDisplay(.infinite) } } }

In these lines, you: 163

macOS by Tutorials

Chapter 8: Working with Timers, Alerts & Notifications

1. Loop through every menu item in statusMenu . 2. Check if it has a view of type TaskView . 3. If so, tell the view it needs to update its entire display. This triggers the view’s draw(_:) method, which refreshes its display with the current task data.

You don’t need to recreate the view and you don’t have to reassign the task. You simply tell the view it needs to refresh, and it does all the work. Now, to use this method, go back to AppDelegate.swift and add this to the end of updateMenu(title:icon:taskIsRunning:) : if menuManager?.menuIsOpen == true { menuManager?.updateMenuItems() }

The menu items only need updating if the menu is open. If it is, this calls the MenuManager method you just added.

Build and run again. Start the next task, then open the menu and leave it open. Now you can see the progress bar advance as the timer counts down.

Menu items updating

The visuals are all looking great now. Your menu is showing the title and remaining time for the running task, and the task menu items are also updating live. Next, it’s time to work out what to do when a task finishes.

Checking the Timer This is another job for TaskManager , so open TaskManager.swift and add these methods: // 1 func checkForTaskFinish(activeTaskIndex: Int) {

164

macOS by Tutorials

Chapter 8: Working with Timers, Alerts & Notifications

let activeTask = tasks[activeTaskIndex] if activeTask.progressPercent >= 100 { // tell user task has finished stopRunningTask(at: activeTaskIndex) } } // 2 func checkForBreakFinish(startTime: Date, duration: TimeInterval) { let elapsedTime = -startTime.timeIntervalSinceNow if elapsedTime >= duration { timerState = .waiting // tell user break has finished } } // 3 func startBreak(after index: Int) { let oneSecondFromNow = Date(timeIntervalSinceNow: 1) if (index + 1).isMultiple(of: 4) { timerState = .takingLongBreak(startTime: oneSecondFromNow) } else { timerState = .takingShortBreak(startTime: oneSecondFromNow) } }

What’s happening here? 1. Get the active task, see if it’s run out of time and, if so, stop it. 2. If the user is on a break, check if the break is over and reset timerState . timeIntervalSinceNow returns the seconds between now and another Date . If the other Date is in the past, this is negative, which is why you set elapsedTime to the negative of that.

3. To start a new break, check if it should be long or short, set timerState and associate the starting time, which you set one second into the future. This makes the timer start showing it’s full duration. To use these new abilities, you need to change a couple of existing methods. Add this to the end of stopRunningTask(at:) : if taskIndex < tasks.count - 1 { startBreak(after: taskIndex) }

Unless you’ve just stopped the final task, this will start a break. Next, in checkTimings() , replace // more checks here with: switch timerState {

165

macOS by Tutorials

Chapter 8: Working with Timers, Alerts & Notifications

case .runningTask(let taskIndex): // 1 checkForTaskFinish(activeTaskIndex: taskIndex) case .takingShortBreak(let startTime), .takingLongBreak(let startTime): // 2 if let breakDuration = timerState.breakDuration { checkForBreakFinish( startTime: startTime, duration: breakDuration) } default: // 3 break }

What are you doing? 1. If there is a task running, check if it has run for its full duration. 2. When the user is on any form of break, get the duration of that break from timerState and use that, with the associated start time, to see if the break is

over. 3. Switches must be exhaustive, but you don’t want to do anything if timerState is waiting , so use break to satisfy the compiler.

And with those changes, you’re ready to build and run again. Start the next task and wait while it counts down. TaskTimes uses short durations when debugging, so you only have to wait for two minutes.

Note: If you feel two minutes is still too long to wait, adjust the debug mode taskTime in TaskTimes.swift. While you’re there, you can shorten the break

times too.

When the task time is up, the task menu item shows as complete, and the menu title switches to a short break, suggesting that you get yourself a very quick cup of coffee:

On a short break

166

macOS by Tutorials

Chapter 8: Working with Timers, Alerts & Notifications

You have to start your next task manually, which you can do either during or after a break, but you can end it faster by selecting Mark Task as Complete. Step through the tasks until you’ve finished the fourth one. Now the app suggests that you go for a walk during your long break.

On a long break

The last part of the timing for this app is telling the user what’s happening and offering to start the next task after each break.

Creating Alerts You’ll use NSAlert to communicate with your users. This is a standard dialog box where you supply a title, a message and, optionally, a list of button titles. In UIKit programming, you’d use UIAlertController for this. Go to the assets folder in the downloaded materials and drag Alerter.swift into your project, using the usual settings. Open the file and take a look at its methods: taskComplete(title:index:) shows an alert when a task timer runs out. It

works out which break type is next and shows that in the message. allTasksComplete() is similar to the first method, but it shows the alert

after the user completes the last task. breakOver() is an incomplete method to show an alert giving the option to

start the next task immediately. openAlert(title:message:buttonTitles:) is the method called by all the

others that actually shows the alert. This one is also incomplete. Start by filling in the blanks in the last method, 167

macOS by Tutorials

Chapter 8: Working with Timers, Alerts & Notifications

openAlert(title:message:buttonTitles:) . Replace its single line return .alertFirstButtonReturn with:

// 1 let alert = NSAlert() // 2 alert.messageText = title alert.informativeText = message // 3 for buttonTitle in buttonTitles { alert.addButton(withTitle: buttonTitle) } // 4 NSApp.activate(ignoringOtherApps: true) // 5 let response = alert.runModal() return response

How does this show an alert? 1. Create a standard NSAlert . 2. Set the main messageText to the supplied title and use the message parameter to set the less prominent informativeText . 3. Add a button for each string in buttonTitles , if there are any. If not, like with allTasksComplete() , the alert shows a single OK button. 4. Make sure the app is active. Remember how your open app windows hid the About box when you tried it? This brings the app to the front, so the alert is not hidden like the About box was. 5. Display the alert and return a result indicating which button the user clicked. The return type is NSApplication.ModalResponse . This is alertFirstButtonReturn for the first button added, alertSecondButtonReturn for the next and alertThirdButtonReturn for the

third.

Note: An alert with more than three buttons may not be great UI, but what if your app needs it? Instead of using the convenient alert...ButtonReturn , you can check the rawValue of the NSApplication.ModalResponse . This is 1000 for the first button, 1001 for the second button and so on.

The two methods used when a task is complete are informative only, and don’t care about the result. So openAlert(title:message:buttonTitles:) is marked with @discardableResult so callers are not obliged to access the return value. 168

macOS by Tutorials

Chapter 8: Working with Timers, Alerts & Notifications

The breakOver() method does want to get a result. To complete it, replace return .alertSecondButtonReturn with:

// 1 let buttonTitles = ["Start Next Task", "OK"] // 2 let response = openAlert( title: "Break Over", message: message, buttonTitles: buttonTitles) // 3 return response

Here’s what you’re doing: 1. Set up the titles for the buttons. The first one is the default button and clicking it or pressing Return sends back alertFirstButtonReturn . 2. Use the same method to display the alert, but this time, read the return value indicating which button the user clicked. 3. Return that value to the caller.

Showing Alerts The alert code is all there now, but you still need to set up an instance of Alerter and add the calls to use it.

To start, open TaskManager.swift and add this property: let interaction = Alerter()

Next, find checkForTaskFinish(activeTaskIndex:) and replace // tell user task has finished with:

// 1 if activeTaskIndex == tasks.count - 1 { // 2 interaction.allTasksComplete() } else { // 3 interaction.taskComplete( title: activeTask.title, index: activeTaskIndex) }

What does this do? 169

macOS by Tutorials

Chapter 8: Working with Timers, Alerts & Notifications

1. Check to see if the task that just ended was the last task in the list. 2. If it was, call allTasksComplete() . 3. If there are more tasks still to do, call taskComplete(title:index:) , passing in the task’s title and its index. This allows the alert to show the information about the task and the type of break to follow. That deals with the end of a task, so next you can handle the end of a break. Scroll down to checkForBreakFinish(startTime:duration:) . In this method, replace // tell user break has finished with: let response = interaction.breakOver() if response == .alertFirstButtonReturn { startNextTask() }

This displays the breakOver() alert, checks which button the user clicked and starts the next task, if requested. With that all done, build and run and start the first task. Wait for it to time out, and you’ll see this:

Task complete alert

Your information is all there, but it’s going to look a lot better with an app icon. Open Assets.xcassets in the Project navigator and delete AppIcon. Look in the downloaded assets folder again and find AppIcon.appiconset. Drag it into your assets list to install the icon. Press Command-Shift-K to clean the build folder. This makes sure that Xcode applies the new icon.

Note: If Xcode is hiding file extensions, you’ll see Assets in the Project navigator. If you have file extensions hidden in Finder, look for AppIcon in the assets folder.

170

macOS by Tutorials

Chapter 8: Working with Timers, Alerts & Notifications

Build and run again, start the first task, then use Mark Task as Complete to jump straight into the break. And after the break finishes, you’ll get this better looking alert:

Break over alert

Click the default button to start the next task immediately. And now, your app is fully interactive and informative.

Using Local Noti cations You may be wondering if local notifications would be a better choice for communicating with the user. To test this, add Notifier.swift from the assets folder. This has the same three methods as Alerter , but breakOver() is different because a notification does not wait for a response. There are also some notification-specific methods for checking permissions, setting up the actions that provide clickable buttons, watching for responses and making sure the notification can appear even if the menu is open. Notifications look different in macOS, but the code is exactly the same as you’d use for iOS. To switch to using notifications, you’ll have to make some changes in TaskManager.swift: First, comment out let interaction = Alerter() and add this to change your interaction to the new class: let interaction = Notifier()

The two completed task calls work without any changes, but you’ll have to 171

macOS by Tutorials

Chapter 8: Working with Timers, Alerts & Notifications

replace checkForBreakFinish(startTime:duration:) with the following method. It includes the code for both options, so you can easily swap: func checkForBreakFinish(startTime: Date, duration: TimeInterval) { let elapsedTime = -startTime.timeIntervalSinceNow if elapsedTime >= duration { timerState = .waiting // Uncomment if using Alerter // let response = interaction.breakOver() // if response == .alertFirstButtonReturn { // startNextTask() // } // Uncomment if using Notifier interaction.startNextTaskFunc = startNextTask interaction.breakOver() } }

Instead of waiting for the user to click a button, you pass Notifier a function to call if it needs to start the next task. Then you call breakOver() without expecting a result. Build and run, start the next task and wait until it completes. The app now tries to show a notification. This is the first time you’re running the app with notifications enabled, so now you’ll see this notification asking for permission:

Asking for notification permissions

Mouse over this notification to see the Options menu and select Allow. So where’s the notification your task completed? Click the clock in the top right of your menu bar to slide out your recent notifications. And here it is:

Task complete notification

172

macOS by Tutorials

Chapter 8: Working with Timers, Alerts & Notifications

It was hidden away in the notifications panel by the permissions request notification. Now wait for the break to finish and you’ll see this:

Break complete notification

Again, you have to mouse over the notification to see the Start Next Task option. If you click that button, the next task starts. If you click anywhere else in the notification, it’ll disappear without starting your task.

Note: If you don’t Allow notifications before the short break ends, you might not see the Break complete notification. To try again, remove Time-ato from the list of allowed apps: Open System Preferences ▸ Notifications & Focus, select Time-ato in the list of apps and press Delete.

Picking a User interaction Which do you think is better: alerts or notifications? They both work, they both tell the user what’s happening and they both allow the user to start the next task after a break. The main difference is that alerts are immediate and intrusive. They stay front and center of your screen until you acknowledge them. Notifications happen much more in the background. If you don’t respond to them within a few seconds, they quietly disappear into the notification panel. If you didn’t notice a notification first appear, you may not notice it for days. While there are use cases for both, this app works much better with alerts. They force you to pay attention, so you’re much more likely to take a break when you should. So change the comments around inside checkForBreakFinish(startTime:duration:) , and switch interaction back

to Alerter() to get back to where you were.

173

macOS by Tutorials

Chapter 8: Working with Timers, Alerts & Notifications

Key Points With AppKit apps, like UIKit apps, you have to make connections between the storyboard and the code. There are two main ways to create a timer. This app uses a Combine TimerPublisher .

Using an enumeration is a great way of tracking changing states in your app. Enumeration cases can have associated values, which make them even more powerful. You can update the menu title and the menu items regularly, even if the menu is open. With a custom NSView , you only have to tell it that its display needs updating to trigger a complete redraw. System alerts provide a standard way of communicating with the user. Local notifications are a way of contacting the user less intrusively.

Where to Go From Here? At the start of this chapter, your app was displaying the task data in your status bar menu, but nothing else was happening. Now the app has a full timing system, with alerts or notifications to communicate with the user. The missing feature is editing the tasks and saving them. In the next chapter, you’ll learn how to use a SwiftUI view in an AppKit app. You’ll edit the tasks, and you’ll find out much more about the Mac app sandbox. To learn more about Combine, have a look at Combine: Getting Started, which includes a section on timers. Combine works the same on any Apple platform. To dive deeper into Combine, check out our book Combine: Asynchronous Programming with Swift. If you’d like more information about local notifications, check out Local Notifications: Getting Started. It uses iOS, but the exact same code works in a macOS app.

174

macOS by Tutorials

9

Adding Your Own Tasks Written by Sarah Reichelt

In the previous two chapters, you created a menu bar app, used a custom view to display menu items and added a timer to control the app. Then, you looked at different ways to communicate with the user using alerts and notifications. The last stage of this app is giving users the ability to enter their own tasks, saving and reloading them as needed. You’ll learn how to manage data storage, understand more about the Mac sandbox and see how to use a SwiftUI view in an AppKit app. Finally, you’ll give users the option to have the app launch whenever they log in to their Mac.

Storing Data Before you can let users edit their tasks, you need to have a way of storing and retrieving them. Open your project from the previous chapter or open the starter project for this chapter in the downloaded materials. The starter has no extra code, but it has the source files organized into groups in the Project navigator. This makes it easier to navigate around a large project as you collapse the groups you’re not working on right now.

175

macOS by Tutorials

Chapter 9: Adding Your Own Tasks

Groups in starter project

Open the assets folder in the downloaded materials and drag DataStore.swift into the Models group. Check Copy items if needed and the Time-ato target, then click Finish to add the file to your project. This file contains three methods: 1. dataFileURL() returns an optional URL to the data storage file. Right now, this returns nil , but you’re going to fix that shortly. 2. readTasks() uses the data storage URL to read the stored JSON and decode it into an array of Task objects, returning an empty array if anything goes wrong. 3. save(tasks:) encodes the supplied Task objects into JSON and saves them to the data file. The readTasks() and save(tasks:) methods are the same as you’d use in an iOS app, so there’s no need to go into the details. But dataFileURL() is going to be interesting.

Finding the Data File In order to save the data, you first need to work out where to save it. Replace return nil in dataFileURL() with:

// 1

176

macOS by Tutorials

Chapter 9: Adding Your Own Tasks

let fileManager = FileManager.default // 2 do { // 3 let docsFolder = try fileManager.url( for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) // 4 let dataURL = docsFolder .appendingPathComponent("Timeato_Tasks.json") return dataURL } catch { // 5 print("URL error: \(error.localizedDescription)") return nil }

If you worked through Chapter 1, “Designing the Data Model”, you’ll remember some of this, but stepping through it you: 1. Use the default FileManager to access files and folders. 2. Put the file management code inside a do block since it can throw. 3. Ask fileManager for the URL to the Documents folder in the current user’s folder. 4. Append a file name to the URL and return it. 5. Print the error and return nil if there was a problem. Now that dataFileURL() returns a file path URL, you can test it by saving the sample tasks. Open TaskManager.swift and add this property declaration: let dataStore = DataStore()

As a test, insert this as the first line in init() : dataStore.save(tasks: tasks)

Build and run the app. There won’t be anything new to see, but TaskManager creates a DataStore and saves the sample tasks to your data file. The code specifically asked FileManager for a path to the Documents folder. You probably thought this was a bad idea. Why clutter up your Documents folder with files like this? Shouldn’t the app hide them away somewhere? 177

macOS by Tutorials

Chapter 9: Adding Your Own Tasks

Go and search your Documents folder for a file called Timeato_Tasks.json. It’s not there! Was there a save error? Check the Xcode console. Do you see any entries labelled URL error or Save error? No, so it looks like the file saved, but where is it?

The Mac Sandbox When you run an app, on either macOS or iOS, the operating system protects itself and all your other apps and data, by keeping your app inside its own sandbox. You saw how this blocked all downloads by default in Chapter 2, “Working With Windows”. Now, you’re running into the way that it protects your files. Open a Finder window, then open Finder’s Go menu. Hold down Option to see Library appear in the menu and select it to open your Library folder. Scroll down until you see Containers and open that. The Containers folder holds a very strange set of folders. Some of them have the names of apps, like Calendar, while some of them use bundle identifiers like com.apple.photolibraryd. And most oddly, there are what appear to be multiple sets of folders with the same name! Whats happening here? Finder is lying to you, but Terminal never lies. Open your Terminal app so that you can see what’s really in this folder. In Terminal, type this command and press Return: cd ~/Library/Containers

You use cd to move into a different directory. In file paths, tilde (~) is a shorthand way of getting the current user’s directory, so in my case ~ is the same as typing /Users/sarah. Then, you’re changing into the Library directory and finally into Containers. Next enter this and press Return: ls -l

The ls command lists the current directory, and the -l argument tells it to list in long format, with one file or folder per line:

178

macOS by Tutorials

Chapter 9: Adding Your Own Tasks

Listing containers in Terminal

You can see that all the folders are really using either a bundle identifier or a unique identifier. Finder is translating these into the associated app names. Now that you know what’s going on, return to your Finder window and scroll down through the containers until you find the Time-ato folder. (Terminal lists this folder as com.raywenderlich.Time-ato). The only thing inside is a folder called Data. Open Data and you’ll see a strange mirror of parts of your user folder:

App container

Some of the folders have a little arrow on the icon that tells you they’re aliases to other folders. If you open the Desktop alias, you’ll see all the files on your actual desktop. Documents is not an alias and, if you open it, you’ll only see one file: Timeato_Tasks.json. So even though you asked FileManager to save the data file in your Documents folder, it saved it in a sandboxed Documents folder, leaving your actual Documents folder untouched. This feels wrong, but it’s actually a great system. It means that you don’t have to worry about any other app using the same file or folder names. You can’t overwrite them and they can’t over-write you. And as a developer, you’ll often want 179

macOS by Tutorials

Chapter 9: Adding Your Own Tasks

to do a complete reset on your app to test it, which you can do by deleting its container.

Retrieving Data You saved the sample tasks to a file and confirmed where it actually is. Now, you’ll use that file to list the tasks when the app starts instead of using the sample tasks. Open TaskManager.swift and replace var tasks: [Task] = Task.sampleTasks with:

var tasks: [Task]

Next, to get rid of the errors, replace dataStore.save(tasks: tasks) in init() with:

tasks = dataStore.readTasks()

Finally, so that you can really be sure that the tasks are coming from the file, open the JSON data file and make a change. The JSON isn’t formatted, but you can see the task titles. Change at least one title.

Editing the data file.

Build and run the app to see your edited task in the menu:

Menu displaying data from file.

180

macOS by Tutorials

Chapter 9: Adding Your Own Tasks

Opening the Sandbox This app works perfectly inside the sandbox, without any extra permissions, but not all apps are the same. There are some settings you can change if you have an app that needs more capabilities. Back in Xcode, select the project at the top of the Project navigator, and then select the Time-ato target. Click Signing & Capabilities across the top and look at the App Sandbox settings:

Sandbox settings

The most common exceptions to the sandbox are accessible here. You’ve already used Outgoing Connections (Client) in Section 1 of this book to allow downloads. Incoming Connections (Server) is only required if your app is going to receive connections that it didn’t initiate. The Hardware and App Data settings are similar to their iOS equivalents, but for iOS apps, you add privacy descriptions in the Info.plist instead of checking buttons. There are some differences to be aware of in the File Access settings. First, these are not on or off settings — you select None, Read Only or Read/Write access using the popup beside each one. 181

macOS by Tutorials

Chapter 9: Adding Your Own Tasks

The User Selected File option means that as long as you show a file dialog and let the user select the file or folder directly, your app can access any folder on the Mac. If you want your app to remember that access, you’ll need to create security-scoped bookmarks. Apple’s documentation on Enabling App Sandbox has all the details. If there are folders or features that your app needs to access but that are not covered in these settings, you can edit the app’s entitlements file to request temporary access. Despite the name, such access does not expire, but it may not always get past the App Store review process. Document why you need the exception in the app review notes to increase your chances of approval. And finally, if your app really can’t operate within the sandbox and you don’t plan to distribute it through the Mac App Store, you can remove the sandbox limitations from your app completely by clicking the X at the top right of the App Sandbox settings.

Editing the Tasks You’ve got the file handling working and tested. Now, it’s time to allow your users to edit their own tasks. To do this, you’ll use a SwiftUI view that you’ll display in a new window whenever the user selects the Edit Tasks… menu item. First, add a new SwiftUI View file to the Views group in your project. Call it EditTasksView.swift. Add these two properties to EditTasksView : @State private var dataStore = DataStore() @State private var tasks: [Task] = []

An editor like this must have the option to cancel without changing anything. As a result, it gets its own DataStore and reads its own list of Task objects. If the user saves the changes, then DataStore can save the edited tasks to the data file. Next, to set up the UI for this view, replace the standard Text with: // 1 VStack { // 2 ForEach($tasks) { $task in // 3 HStack(spacing: 20) { // 4 TextField(

182

macOS by Tutorials

Chapter 9: Adding Your Own Tasks

"", text: $task.title, prompt: Text("Task title")) .textFieldStyle(.squareBorder) // 5 Image(systemName: task.status == .complete ? "checkmark.square" : "square") .font(.title2) // 6 Button { // delete task here } label: { Image(systemName: "trash") } } } // 7 .padding(.top, 12) .padding(.horizontal) // buttons go here } // 8 .frame(minWidth: 400, minHeight: 430)

What is this SwiftUI code doing? 1. Wrap the entire view in a VStack for display down the window. 2. Use a binding property as the data source for the ForEach . This allows changes to the data inside each row to flow back to the @State property. 3. Use a TextField as the editor for each task’s title, styling it with a square border. The TextField has no label, but it has some placeholder text. 4. Add an Image to each row indicating whether the task is complete or not. 5. Set up a Button for deleting each Task . 6. Apply some padding to make it look better. 7. Choose the minimum size for this view.

Showing the Data Right now, this view has nothing to show, so add these methods to EditTasksView :

func getTaskList() { // 1 tasks = dataStore.readTasks() // 2 addEmptyTasks() }

183

macOS by Tutorials

Chapter 9: Adding Your Own Tasks

func addEmptyTasks() { // 3 while tasks.count < 10 { tasks.append(Task(id: UUID(), title: "")) } }

What do they do? 1. Use dataStore to read in the stored list of tasks. 2. Call addEmptyTasks() . 3. Add a new task with a blank title until there are 10 tasks in the list. The Pomodoro technique suggests setting up 10 tasks per day, so the editor will show 10 edit fields. If tasks has fewer than 10 elements, addEmptyTasks() adds extras to fix it. This ensures that the ForEach loop always has 10 entries to show, even if some of them are blank. You’re nearly ready to see what this looks like, but first you need to add this modifier to the VStack , right under where you set the frame : .onAppear { getTaskList() }

This makes it load the tasks from the data file when the view appears. Click Resume in the canvas preview or press Command-Option-P to refresh the preview:

Task editor preview

184

macOS by Tutorials

Chapter 9: Adding Your Own Tasks

Deleting Tasks So far, so good. The new view looks great. Now, you must make the delete buttons work. Add this new method beneath the others: func deleteTask(id: UUID) { // 1 let taskIndex = tasks.firstIndex { $0.id == id } // 2 if let taskIndex = taskIndex { // 3 tasks.remove(at: taskIndex) // 4 addEmptyTasks() } }

What’s this method doing? 1. Look in tasks for the index of a Task with an id matching the parameter. 2. Check if this returns an index. 3. Delete the task at this index in the array. 4. Make sure there are still 10 tasks in the view. Scroll back up to the layout part of the file and replace // delete task here with: deleteTask(id: task.id)

Don’t build and run yet, since you have no way of showing this view. Instead, turn on the Live Preview. Click Bring Forward to see the Xcode Preview window and test the view. Edit some titles and delete some tasks:

185

macOS by Tutorials

Chapter 9: Adding Your Own Tasks

Task editor live preview

Deleting and editing are all working as expected.

Adding the Buttons Next, you need to add three control buttons: Cancel to close the window without saving. Mark All Incomplete to reset all the tasks. This is convenient if you use some of the same tasks every day and don’t want to delete them but need to set their status back to notStarted . Save to store all your changes and close the window. Still in EditTasksView.swift, replace // buttons go here with: Spacer() HStack { }

The Spacer is to push the buttons to the bottom of the window and the HStack is to hold them. But this chunk of SwiftUI code is quite long enough

already, so you’re going to separate the buttons out into their own view. Command-click the HStack you just added and select Extract Subview:

186

macOS by Tutorials

Chapter 9: Adding Your Own Tasks

Extract subview

This replaces the HStack with ExtractedSubview() and adds a new view at the end of the file with this name. Rename this to EditButtonsView in two places: in the original view and in the new view definition.

Note: Xcode used to automatically select both these for one-step editing after extracting a subview. You can achieve this same effect by right-clicking ExtractedSubview() and choosing Refactor ▸ Rename….

Now that you’ve got EditButtonsView , replace the contents of its body with: // 1 HStack { // 2 Button("Cancel", role: .cancel) { // close window } // 3 .keyboardShortcut(.cancelAction) // 4 Spacer() // 5 Button("Mark All Incomplete") { // mark tasks as incomplete } Spacer() Button("Save") { // save tasks & close window } } // 6

187

macOS by Tutorials

Chapter 9: Adding Your Own Tasks

.padding(12)

Stepping through these lines, you: 1. Replace the empty HStack with this one. 2. Add a Cancel button, setting its role. 3. Give it the cancelAction keyboard shortcut so that pressing Escape triggers it. 4. Put in a Spacer to spread the three buttons across the bottom of the view. 5. Create two more buttons, separated by another Spacer . 6. Apply some padding so they’re not too near the edges of the view. Resume the preview to check out how this looks:

Preview with buttons

Your preview is now too wide to see easily, so add this frame modifier to the preview to set it to the minimum size for the view: .frame(width: 400, height: 430)

Coding the Buttons The last task for this view is to make these new buttons do their jobs. 188

macOS by Tutorials

Chapter 9: Adding Your Own Tasks

To handle the Cancel button, first add this method to EditButtonsView : func closeWindow() { NSApp.keyWindow?.close() }

This uses the shared instance of NSApplication to close the key, or frontmost, window, which is the one that the user last interacted with. To call this method, replace // close window in the Cancel button’s action with: closeWindow()

The other two methods need access to tasks and dataStore , so you’ll pass these in to EditButtonsView . Insert these declarations at the top of EditButtonsView before the body : @Binding var tasks: [Task] let dataStore: DataStore

By using @Binding on tasks , you’ve ensured that any changes flow back to the parent view. Scroll back up to EditTasksView to see the error this has caused. Replace the line showing the error with: EditButtonsView(tasks: $tasks, dataStore: dataStore)

Now, EditTasksView is sending the task data and the data store to EditButtonsView .

With that in place, give EditButtonsView the remaining two methods it needs: func saveTasks() { // 1 tasks = tasks.filter { !$0.title.isEmpty } // 2 dataStore.save(tasks: tasks) // 3 closeWindow() } func markAllTasksIncomplete() {

189

macOS by Tutorials

Chapter 9: Adding Your Own Tasks

// 4 for index in 0 ..< tasks.count { tasks[index].reset() } }

These methods: 1. Get rid of any tasks with empty titles. 2. Use dataStore to save the edited data. 3. Close the window. 4. Loop through all the tasks and reset them to notStarted . Finally, set your buttons to call these methods. Replace // mark tasks as incomplete with: markAllTasksIncomplete()

And replace // save tasks & close window with: saveTasks()

Your editing interface is all in place, you’ve hooked it up to the code, so it’s time to make it appear in your app.

Showing the Edit Window Open AppDelegate.swift and find the @IBAction called showEditTasksWindow(_:) . You’ve already connected this to the Edit Tasks…

menu item. Put this code inside that method: // 1 let hostingController = NSHostingController( rootView: EditTasksView()) // 2 let window = NSWindow(contentViewController: hostingController) window.title = "Edit Tasks" // 3 let controller = NSWindowController(window: window) // 4 NSApp.activate(ignoringOtherApps: true) // 5

190

macOS by Tutorials

Chapter 9: Adding Your Own Tasks

controller.showWindow(nil)

Then, add this at the top of the file to remove the error: import SwiftUI

This provides the bridge between AppKit and SwiftUI, so there are a few things to notice: 1. To show a SwiftUI view in AppKit, set up an NSHostingController and assign the SwiftUI view as its rootView . 2. Once you’ve got the hosting controller, which is a subclass of NSViewController , create an NSWindow and set the hosting controller as its contentViewController . You can also configure the window here, so

change its title. 3. In an AppKit app, every window needs an NSWindowController , so the next step configures a controller for the window. This gives you the full chain: NSWindowController — NSWindow — NSHostingController — EditTasksView. 4. Like you did when showing alerts in the previous chapter, make sure the app is the active app. 5. Tell the window controller to show its window. Now you’re ready to try it out. Build and run the app, choose Edit Tasks… from the menu and there is your window, showing a SwiftUI view inside an AppKit app:

Task editor window

191

macOS by Tutorials

Chapter 9: Adding Your Own Tasks

Saving and Reloading Your interface is looking good, so now it’s time to run some tests and check that everything is working as expected. Open the Edit Tasks window and then press Escape. The window closes as expected. Use the menu controls to start the first task, then mark it as completed. Open the Edit Tasks window again. That’s strange. Why is the first task not marked with a checkmark? Remember how EditTasksView loads its tasks from storage? If the main part of the app hasn’t saved any changes, this isn’t going to show them. The app needs to save whenever anything changes so that the tasks and their properties persist across app launches. The best time to do this is after any task starts and after any task ends. Open TaskManager.swift and add this line to the end of startNextTask() : dataStore.save(tasks: tasks)

And add the same line to the end of stopRunningTask(at:) . Run your test again: Build and run the app, start and complete the first task, then open the Edit Tasks window:

Task editor showing completed task.

Success! Your tasks are now saved whenever anything changes. But this means you have some housekeeping to do when the app starts and loads the data. What if there’s a task in progress? Still in TaskManager.swift, insert these lines into init() , after reading the tasks, but before starting the timer: // 1 let activeTaskIndex = tasks.firstIndex { $0.status == .inProgress } if let activeTaskIndex = activeTaskIndex { // 2 timerState = .runningTask(taskIndex: activeTaskIndex)

192

macOS by Tutorials

Chapter 9: Adding Your Own Tasks

}

What is this doing? 1. Look for the index of the first task that has a status of inProgress . 2. If there is a task in progress, set timerState to runningTask , associating the task’s index. With this in place, you’ll be able to start and stop the app while a task is running and it’ll pick up the timing and continue it. Time for another test. Build and run again. Your first task is still marked as completed in the menu. Open the Edit Tasks window and make two changes: Click Mark All Incomplete to remove the checkmark beside the completed task. Edit the title of any task.

Edited tasks

Click Save to save your changes and when the window closes, open the menu. Your edits are not showing up! Quit and restart the app and now your edits appear in the menu:

Edited tasks in menu.

What’s going on? The Edit Tasks window is saving the edited tasks, but when the window closes, TaskManager doesn’t know to reload them and so it shows the old version of tasks until the app restarts.

193

macOS by Tutorials

Chapter 9: Adding Your Own Tasks

Using Noti cation Center To solve this, you’ll send a notification whenever you save the data. This isn’t a visible notification like you used in the last chapter. Instead, you’ll use the NotificationCenter to post an NSNotification . Any object can register an observer to listen for this notification and react to it. Every NSNotification has to have a name. This name is not merely a string, it’s a Notification.Name . There are standard names for notifications sent by the system — for iOS apps, you may have used some of them to detect keyboard changes. But in this case, you’re going to define your own name to send a custom notification. You can define this name anywhere in your project, but since it relates to the stored data, add this extension to DataStore.swift, outside the DataStore structure: extension Notification.Name { static let dataRefreshNeeded = Notification.Name("dataRefreshNeeded") }

This creates a name that you can use to refer to your notification, without using strings, which are subject to error and can’t be auto-completed. There are two parts to using notifications. The first one is post . Open EditTasksView.swift and add this to the end of saveTasks() : NotificationCenter.default.post( name: .dataRefreshNeeded, object: nil)

This uses the default NotificationCenter and posts a notification using the Notification.Name you just created. It sends the notification, but it doesn’t

know or care if anyone receives the message. The second part is to observe this notification, and that’s a job for TaskManager . Open TaskManager.swift and define this new property: var refreshNeededSub: AnyCancellable?

Like with the Timer , you’re going to use Combine to subscribe to a NotificationCenter publisher for this notification name. This property holds

194

macOS by Tutorials

Chapter 9: Adding Your Own Tasks

a reference to the subscription so that it stays active while TaskManager exists and cancels itself when TaskManager is de-initialized. Next, add this to the end of init() : // 1 refreshNeededSub = NotificationCenter.default // 2 .publisher(for: .dataRefreshNeeded) // 3 .sink { _ in // 4 self.tasks = self.dataStore.readTasks() }

What’s happening here? 1. Use the default NotificationCenter . 2. Create a publisher for the Notification.Name you set up earlier. 3. Subscribe to the publisher using sink . 4. Whenever a notification arrives, refresh the data from the storage file. Build and run the app. Edit a task, save your edits and check the menu. Your changes are there right away:

Edits appearing in menu.

So that’s it. Your app is now fully functional. You can start and finish tasks. The timer works out how long you have to go. The menu shows the timers and the progress bars. And finally, you can edit your tasks and you have persistent data storage. There’s only one more thing that would be nice to have…

Launching on Login A utility app like this is the kind of app people want to have running all the time, and that means you need to add a way for the app to start when the user logs in. 195

macOS by Tutorials

Chapter 9: Adding Your Own Tasks

Your app must not apply this setting automatically. You have to provide this as an option the user can choose to enable. Apple’s App Store Review Guidelines contain this relevant section:

They (apps) may not auto-launch or have other code run automatically at startup or login without consent.

The process for setting a sandboxed app to launch on login is a convoluted one that requires creating a helper app and configuring it and the parent app in particular ways. The helper app’s only role is to launch the main app. Conveniently, there is a Swift package called LaunchAtLogin that does all the hard work.

Adding a Swift Package You’ll use the Swift Package Manager to include this package in your app. If you’ve used SwiftPM in an iOS app, then this is a familiar process. Start by selecting the project at the top of the Project navigator and then select the Time-ato project, not the target. Click Package Dependencies at the top, and then click the + button to add a new dependency. Enter this URL into the search field: https://github.com/sindresorhus/LaunchAtLogin

This finds the package you want to install:

196

macOS by Tutorials

Chapter 9: Adding Your Own Tasks

Searching for the package.

When the LaunchAtLogin package appears, click Add Package. Xcode downloads the package and then asks for confirmation. Check the LaunchAtLogin Library and click Add Package again:

Adding the package.

The Project navigator now includes a Package Dependencies section with the LaunchAtLogin library listed:

Package dependencies

197

macOS by Tutorials

Chapter 9: Adding Your Own Tasks

There is one more configuration step before you can start using the package. Select the Time-ato target and click Build Phases. Next, click the + at the top left and select New Run Script Phase:

Adding a build phase.

Expand the newly added Run Script and replace the comment in the script field with: "${BUILT_PRODUCTS_DIR}/LaunchAtLogin_LaunchAtLogin.bundle/Contents/ Resources/copy-helper-swiftpm.sh"

This is a direct copy from the Usage instructions for the package. Your new build phase now looks like this:

Run script phase

That’s all the setup you need to do before you can start using the library. And if you’re wondering how much work that saved you, check out the Before and after description from the library’s GitHub.

198

macOS by Tutorials

Chapter 9: Adding Your Own Tasks

Using the New Library To implement this feature, you need to be able to tell whether the user has enabled launch on login so that you can show a checkmark in its menu item. And, the menu item has to be able to toggle the setting. Start by importing the library. Open AppDelegate.swift and add this line to the other import statements at the top: import LaunchAtLogin

Now that’s in place, you can use it. Scroll down to updateMenuItemTitles(taskIsRunning:) and add this code to the end of that

method: launchOnLoginMenuItem.state = LaunchAtLogin.isEnabled ? .on : .off

This shows or hides the checkmark depending on whether the user has enabled the launch feature. You already made the @IBOutlet connection between the storyboard and AppDelegate for this menu item, so you can refer to it without any further setup. Finally, you can add the code to toggle this setting. You’ve set up an @IBAction for this menu item earlier, so find toggleLaunchOnLogin(_:) and insert this line: LaunchAtLogin.isEnabled.toggle()

Time to test this. Build and run the app. Open the menu, select Launch on Login, then open the menu again to see that it’s now checked:

Launch on Login enabled

For the real test, you need to log out of your user account and log back in again (or you can restart the whole computer). Wait for everything to restart, and there’s the app in your menu bar:

199

macOS by Tutorials

Chapter 9: Adding Your Own Tasks

App launched after login

Troubleshooting If the app didn’t launch on login, it may be due to having too many old builds of the app on your hard drive. Deleting Xcode’s derived data will most likely fix it. Open Terminal and enter this command: rm -rf ~/Library/Developer/Xcode/DerivedData

This also deletes the downloaded version of the LaunchAtLogin package. In Xcode, select File ▸ Packages ▸ Resolve Package Versions to fetch it again:

Resolve package versions

Press Shift-Command-K to clean your build folder and run the app again. Confirm that Launch on Login is still checked before logging out and in again. If you’re still having problems, there are more suggestions in the FAQ section of the package’s ReadMe.

Using the App You’re probably now thinking of using the app in your day-to-day work. In the 200

macOS by Tutorials

Chapter 9: Adding Your Own Tasks

final section of this book, you’ll learn about distributing your app, but for now, you’re going to get it into your Applications folder so you can run it more conveniently. For testing purposes, the app uses shortened times for tasks and breaks. You’re still going to be running a debug build of the app, so you need to change these times manually. Open TaskTimes.swift. You don’t want to delete all the debug times, because you might want to come back and work on improvements to the app later, so replace the enumeration with this: enum TaskTimes { // #if DEBUG // // in debug mode, shorten all the times to make testing faster // static let taskTime: TimeInterval = 2 * 60 // static let shortBreakTime: TimeInterval = 1 * 60 // static let longBreakTime: TimeInterval = 3 * 60 // #else static let taskTime: TimeInterval = 25 * 60 static let shortBreakTime: TimeInterval = 5 * 60 static let longBreakTime: TimeInterval = 30 * 60 // #endif }

This allows you to swap back into debug mode any time you’re working on the app. Next, you’ll install the app in your Applications folder, but how can you do that? Where is the app? When an app is running in the Dock, you can right-click to show it in the Finder, but you can’t do that for this app. The solution is to ask the app itself. Open AppDelegate.swift and add this line to the end of applicationDidFinishLaunching(_:) :

print(Bundle.main.bundlePath)

Run, then quit the app and check in Xcode’s console:

Printing the bundle path.

201

macOS by Tutorials

Chapter 9: Adding Your Own Tasks

Copy this weird looking path, except for Time-ato.app at the end. In Finder, select Go ▸ Go to Folder… and paste in your copied path. Press Return to open the folder that contains the app. Now you can drag it into your Applications folder.

Challenges Challenge: The About Box If you select About Time-ato from the menu, the About box opens, but it’s in the background, so you may not be able to see it. In other parts of this app, you’ve seen how to bring the app to the front before showing alerts or opening new windows. The method to open the About box is: NSApp.orderFrontStandardAboutPanel(nil)

The About Time-ato menu item is calling this method directly. Can you make it call a new @IBAction that brings the app to the front, and then uses this method? Try this yourself, but check out the challenge project for this chapter, if you need any hints.

Key Points macOS apps operate inside a sandbox. This keeps their data and settings inside a container folder. Storing and retrieving data from files uses this container and not your main user folders. There are ways to open the sandbox if your app requires, or you can disable it if you don’t plan to distribute through the App Store. AppKit apps can contain SwiftUI views. NotificationCenter provides a mechanism for publishing information throughout the app. Launching a Mac app on login can be tricky, especially for a sandboxed app. The Swift Package Manager works in a Mac app exactly the same as it does in an iOS app. 202

macOS by Tutorials

Chapter 9: Adding Your Own Tasks

Where to Go From Here? You’ve reached the end of this section and of this app. You can probably think of lots of improvements to make to it, so go for it. Make it into the app that you want to use. In this section, you covered two main concepts: Building an AppKit app and building a menu bar app. Along the way, you learned more than you probably ever wanted to know about the Mac sandbox, you found out how to integrate SwiftUI into an AppKit app and you got to use the Swift Package Manager in a Mac app. In the next section, you’ll start a new app. You’re going back to using SwiftUI but in a completely different type of app.

203

macOS by Tutorials

10

Creating A DocumentBased App Written by Sarah Reichelt

So far in this book, you’ve built two very different Mac apps. First, you made a conventional window-based app using SwiftUI. Next, you created a menu bar app using AppKit. Now, you’ll learn about another class of app: the document-based app. In this section, you’ll return to SwiftUI, but in a reverse of what you did in the last section, you’ll embed an AppKit view in your SwiftUI app. The app for this section is a Markdown editor. Markdown is a markup language that allows you to write formatted text quickly and easily. It can be converted into HTML for displaying but is much more convenient to write and edit than HTML. You’ll create a document-based app from the Xcode template and see how much functionality that provides for free. Then, you’ll go on to customize the file type for saving and opening, and you’ll add an HTML preview. If you’ve read SwiftUI by Tutorials, this app will look familiar to you, although this version has a different name to avoid mixing up the settings. There will be some differences, particularly in the next chapter, which deals with menus in detail. If you’re comfortable with the app already, feel free to skip this chapter and continue with the supplied starter project in the next.

Setting Up a Document-based App Many Mac apps are document-based. Think of apps like TextEdit, Pages, Numbers or Photoshop. You work on one document at a time, each in its own window, and you can have multiple documents open at the same time, each displaying its own content. Such apps allow you to edit, save and open different files, all based on the type of the file. Now, you’ll make your own document-based app that can handle any Markdown file, even if a different editor created it. Start Xcode and create a new project. Select macOS and choose Document App.

204

macOS by Tutorials

Chapter 10: Creating A Document-Based App

Document app template

Make sure that the interface is SwiftUI and the language is Swift. Call the app MarkDowner. Once you’ve saved the project, build and run the app. Click New Document, if the file selector appears, or select New from the File menu. This gives you a single window showing some default text. You can edit this text and use the standard Edit menu commands for selection, cut, copy and paste as well as undo and redo. Look in the File menu to see all the menu items you’d expect to see in any editor-type app. Select Save from the File menu or press Command-S.

Saving the default document.

205

macOS by Tutorials

Chapter 10: Creating A Document-Based App

Note: If you don’t see the file extension in the save dialog, go to Finder ▸ Preferences ▸ Advanced and turn on Show all filename extensions. This will make it easier to follow the next part of this chapter.

Finder Preferences

The default app uses a file extension of .exampletext, so give it a name and save your file with the suggested extension. Close the window and create a new window using Command-N. Now open your saved document by choosing it from File ▸ Open. So you already have an app that edits, saves and opens documents. And you haven’t even looked at the code! Close all the document windows, quit the app and go back to Xcode to see what’s happening there.

The Default Document App There are three .swift files in your project MarkDownerApp.swift is similar to the App.swift files you’ve seen in other SwiftUI projects, but instead of the body containing a WindowGroup , it contains a DocumentGroup . You initialize a DocumentGroup with an instance of the document type, in this case MarkDownerDocument . In the closure, you provide the view that displays the data from this file, passing it a binding to the file’s document, so changes to the document can flow back. If your app supported more than one document type, you’d add more than one DocumentGroup here.

The view is set to ContentView as usual, but with the document parameter. ContentView.swift takes in this document and uses its text property to 206

macOS by Tutorials

Chapter 10: Creating A Document-Based App

populate a TextEditor . This type of view allows editing long chunks of text. The real magic happens in MarkDownerDocument.swift. This is where you configure the document type, and this is what saves and opens documents. Start by looking at the UTType extension. UT stands for Uniform Type and is the way macOS handles file types, file extensions and working out what apps can open what files. You’ll learn more about this soon when you customize the app to handle Markdown files. In MarkDownerDocument , you have a text property that holds the contents of the document. Its initializer sets the default text you saw in each new window when you ran the app. The readableContentTypes property dictates what document types this app can open, using the UTType defined earlier. The init and fileWrapper methods handle all the work of opening and saving the document files. Right now, they’re using the .exampletext file extension, but it’s time to work out how to handle Markdown files.

Con guring for Markdown When you double-click a document file on your Mac, Finder opens it using the default application: TextEdit for .txt files, Preview for .png files and so on. Rightclick any document file and look at the Open With menu. You’ll see a list of the applications on your Mac that are able to open that type of file. Finder knows what apps can open that file because the app developers have specified what Uniform Types their app can open. To set up a document-based app to open a particular file type, you need three pieces of information about the file: The Uniform Type Identifier or UTI. What standard file type this conforms to. The file extension or extensions. Apple provides a list of system-declared uniform types. You should always check here first when working out the file types for an app. But in this case, it doesn’t help, as Markdown isn’t there. However, an internet search for markdown uniform type gets you to John Gruber, the inventor of Markdown. He says the Uniform Type Identifier should be net.daringfireball.markdown, and this conforms to public.plain-text. Searching for Markdown at FileInfo.com tells you the most popular file extensions for Markdown are .markdown and .md. 207

macOS by Tutorials

Chapter 10: Creating A Document-Based App

This gives you all the data you need to switch your app from working with plain text to working with Markdown text.

Setting a Document Type Select the project at the top of the Project navigator list. Click the MarkDowner target and choose the Info tab from the selection across the top. Expand the Document Types section and change Identifier to net.daringfireball.markdown.

Document types

Next, expand the Imported Type Identifiers section and make the following changes: Description: Markdown Text Identifier: net.daringfireball.markdown Extensions: markdown, md

Note: If you use a different extension for your Markdown files, see Challenge 1 below.

All the other settings here can stay the same as the Conforms To field already contains public.plain-text.

208

macOS by Tutorials

Chapter 10: Creating A Document-Based App

Imported types

That’s configured your app; now you have to change MarkDownerDocument to use these new settings. Go back to MarkDownerDocument.swift and replace the UTType line with this: UTType(importedAs: "net.daringfireball.markdown")

Next, right-click exampleText , select Refactor ▸ Rename… and rename it to markdownText. And, so you can tell it’s worked, change the default text in init to # Hello, MarkDowner! which is the Markdown format for a level 1 header.

Testing the File Settings Build and run the app and create a new document. The default text is now # Hello MarkDowner!. Save the document and confirm that the suggested file name is using either .md or .markdown for the file extension.

Note: Which one it chooses seems random, maybe depending on what you’ve used with other apps in the past.

Save and close your new document and then find the file in Finder. Right-click it to show its Open With menu.

209

macOS by Tutorials

Chapter 10: Creating A Document-Based App

Open With Markdowner.app

You see MarkDowner listed there because your settings told Finder that your app opens Markdown files. If you have any Markdown files created by another app, right-click on one of them and open it in MarkDowner. Your app doesn’t do anything with the Markdown text yet, but it can now edit, save and open any Markdown files. Great work! Now to learn more about Markdown.

Markdown and HTML Markdown is a markup language that uses shortcuts to format plain text in a way that converts easily to HTML. As an example, look at the following HTML: Important Header Less Important Header Ray Wenderlich
  • List Item 1
  • List Item 2
  • List Item 3


To write the same in Markdown, you’d use: # Important Header ## Less Important Header [Ray Wenderlich](https://www.raywenderlich.com) - List Item 1 - List Item 2 - List Item 3

I’m sure you’ll agree that the Markdown version is easier to write, easier to read and more likely to be accurate. You can find out more about Markdown from this very helpful cheat sheet.

210

macOS by Tutorials

Chapter 10: Creating A Document-Based App

In MarkDowner, you write text using Markdown. The app will convert it to HTML and display it to the side in a web view. Swift has the ability to convert certain Markdown elements into an AttributedString. This can be really useful for formatting parts of your UI. It’s not what you want here, because it doesn’t create HTML. But there are several Swift Packages that can. The one you’re going to use in this app is Swift MarkdownKit.

Converting Markdown to HTML If you worked through the previous section, or if you’ve used the Swift Package Manager in an iOS app, then you’ll be familiar with this process. In Xcode, select the project in the Project navigator and this time, click the MarkDowner project instead of the target. Go to the Package Dependencies tab and click the plus button to add a new dependency.

Add Package Dependency

Copy this URL into the search field at the top right to search for the package. https://github.com/objecthub/swift-markdownkit

When Xcode has found the package, make sure it’s selected and click Add Package to download it.

211

macOS by Tutorials

Chapter 10: Creating A Document-Based App

Finding the MarkdownKit package.

Once the download is complete, you’ll see a new dialog asking you what parts of the package you want to use. Choose the MarkdownKit Library and click Add Package to add it into your project.

Adding the MarkdownKit package.

The next step is to edit MarkDownerDocument.swift so it can create an HTML version of the document. To use the package you just added, you need to import it. Add this to the other imports at the top of the file: import MarkdownKit

In MarkDownerDocument , under the text property, define an html property: var html: String { let markdown = MarkdownParser.standard.parse(text) return HtmlGenerator.standard.generate(doc: markdown) }

212

macOS by Tutorials

Chapter 10: Creating A Document-Based App

This code creates a computed property that uses MarkdownKit’s MarkdownParser to parse the text and its HtmlGenerator to convert it into

HTML. Your document now has two properties. One is the Markdown text, and this is what each document file saves. The other is the HTML version of this text that’s derived from the text using the MarkdownKit package.

Embedding an AppKit View Now that you’ve set up MarkDownerDocument with an html property, you need a way to display it. The obvious way to display HTML is inside some sort of web view. The problem is that SwiftUI doesn’t have a web view — at least, not yet. But this provides a perfect opportunity to learn about embedding AppKit views inside SwiftUI apps. If you’ve done this in an iOS app, you’ll have used UIViewRespresentable to embed a UIKit view. For embedding an AppKit view, you use NSViewRepresentable , but it works in exactly the same way, if you replace

every UI with NS . Create a new Swift file called WebView.swift and replace its contents with this code: // 1 import SwiftUI import WebKit // 2 struct WebView: NSViewRepresentable { // 3 var html: String init(html: String) { self.html = html } // 4 func makeNSView(context: Context) -> WKWebView { WKWebView() } // 5 func updateNSView(_ nsView: WKWebView, context: Context) { nsView.loadHTMLString( html, baseURL: Bundle.main.resourceURL) } }

213

macOS by Tutorials

Chapter 10: Creating A Document-Based App

Stepping through this: 1. You need the SwiftUI library to use NSViewRepresentable , and the WKWebView you’ll embed is in the WebKit framework.

2. This structure defines a SwiftUI view named WebView . It conforms to NSViewRepresentable .

3. The structure has a single property to hold the HTML text. 4. NSViewRepresentable has two required methods: makeNSView(context:) creates and returns the NSView , in this case a WKWebView . 5. The second required method is updateNSView(_:context:) . Whenever there is a change to the properties that requires a view update, the system calls this method. In this case, every time the HTML changes, the web view reloads the HTML in the WKWebView . Now, you have access to a new SwiftUI view called WebView that contains a WKWebView .

Displaying the HTML Open ContentView.swift and replace the contents of body with this: HSplitView { TextEditor(text: $document.text) WebView(html: document.html) }

You want to display the Markdown and the HTML side-by-side, in resizable panes. SwiftUI for macOS has a view designed specifically for this, called HSplitView . There’s a VSplitView too, if you want to stack the views

vertically, but a horizontal split is better for this app. Inside the HSplitView , TextEditor is exactly as it was before. The new part is the WebView you just created. You’re passing the HTML version of the document’s text to this view. Don’t build and run yet. It may look like everything is set up, but it won’t work.

The Mac Sandbox Again In Section 1, you found you had to open Outgoing Connections (Client) to allow downloads from the internet. You might think this app doesn’t need any such permission, since it handles only local data, but the Mac sandbox doesn’t work 214

macOS by Tutorials

Chapter 10: Creating A Document-Based App

like that. To load anything into a WKWebView , even a local HTML string, you need to open the sandbox in exactly the same way. Click the project at the top of the Project navigator and select the MarkDowner target, then choose the Signing & Capabilities tab. Check Outgoing Connections (Client) to allow your WebView to load the HTML.

Sandbox setting

Now, build and run. Test some Markdown. Copy the sample from above if you want a starter. Try resizing the window and dragging the divider to resize each pane.

Resizing the window.

You can make each subview tiny, or even make it disappear. This is not ideal, so you need to fix that.

Limiting the Frames As you discovered in Chapter 2, “Working With Windows”, it’s important to set frames for your window to limit its size. In this case, you want the TextEditor filling the left side of the window and the WebView filling the right side. They should both resize as the user resizes the

215

macOS by Tutorials

Chapter 10: Creating A Document-Based App

window and as the user drags the divider between them. But the divider should never allow either view to disappear, and the window should have a minimum size. Back in ContentView.swift, replace the contents of body with this: HSplitView { // 2 TextEditor(text: $document.text) // 1 .frame(minWidth: 200) WebView(html: document.html) // 2 .frame(minWidth: 200) } // 3 .frame( minWidth: 400, idealWidth: 600, maxWidth: .infinity, minHeight: 300, idealHeight: 400, maxHeight: .infinity)

The additions are all frame modifiers, but here is what they’re doing: 1. Inside HSplitView , set the minimum width of TextEditor to 200. 2. Apply the same width limit to WebView . 3. Give HSplitView a more complete frame that sets its minimum, ideal and maximum sizes. The minimum width is enough to fit both subviews at their minimum widths. The maximums are infinity so the window can get as large as the user wants. Build and run again and try resizing each pane and the window. That works better. :]

Adding a Toolbar Right now, the app allows you to edit Markdown text and render the equivalent HTML in a web view. But it’s sometimes useful to see the actual HTML code generated. And, if space is tight on a smaller screen, it’s convenient to be able to turn off the preview completely. So now, you’re going to add a toolbar. In the toolbar, you’ll have controls to switch between three possible preview modes: web, HTML code and off. Start by defining an enumeration for the preview modes. Add this to the end of ContentView.swift, outside any structure: 216

macOS by Tutorials

Chapter 10: Creating A Document-Based App

enum PreviewState { case web case code case off }

Next, add this property to ContentView : @State private var previewState = PreviewState.web

This defines a @State property to hold the selected state and sets it to web by default. Finally, add this to HSplitView after the frame modifier: // 1 .toolbar { // 2 ToolbarItem { // 3 Picker("", selection: $previewState) { // 4 Image(systemName: "network") .tag(PreviewState.web) Image(systemName: "chevron.left.forwardslash.chevron.right") .tag(PreviewState.code) Image(systemName: "nosign") .tag(PreviewState.off) } // 5 .pickerStyle(.segmented) // 6 .help("Hide preview, show HTML or web view") } }

What does all this do? 1. Apply a toolbar modifier to HSplitView . 2. Insert a ToolbarItem into the toolbar. 3. The ToolbarItem contains a Picker with its selection bound to the previewState property.

4. Show an image from Apple’s SF Symbols font for each PreviewState and set the tag to the corresponding case. 5. Set the picker to use the segmented style. 6. Apply accessibility text and a tooltip using the help modifier. When you made a toolbar in section 1, you put the toolbar code in its own file. 217

macOS by Tutorials

Chapter 10: Creating A Document-Based App

This is a good idea if your view is complex or the toolbar contains a lot of buttons. In this case, applying it directly still leaves ContentView.swift quite short and readable. Build and run the app to see a toolbar with these three options at the far right. Click each one to see the visual differences that indicate the currently selected option:

Picker in toolbar

Con guring the Preview You’ve got the controls to dictate the preview, but your app isn’t responding to them. Right now, in the HSplitView you have the TextEditor and the WebView . But when you allow for the preview options, there are three possible

combinations: TextEditor alone. TextEditor plus WebView . TextEditor plus something new to display the raw HTML.

First, to let the user turn off the WebView , Command-click WebView inside HSplitView and choose Make Conditional.

Note: If your Xcode preference sets Command-click to Jumps to Definition, use Command-Control-click to show the menu.

Replace the true placeholder with: previewState == .web

Build and run the app. Click the three options in the toolbar. The WebView is only visible when you select the web button in the picker, and it disappears when you click either of the others:

218

macOS by Tutorials

Chapter 10: Creating A Document-Based App

Hiding the web view.

Making the WebView appear conditionally added an EmptyView for when it should not appear. But this is where you want to check for previewState being set to code . Replace } else { EmptyView() }

with this: // 1 } else if previewState == .code { // 2 ScrollView { // 3 Text(document.html) // 4 .frame(minWidth: 200) .frame( maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .padding() // 5 .textSelection(.enabled) } }

Here’s what this code does: 1. Check to see if previewState is set to code . 2. Show a ScrollView so you can see all the text, even if there is more than fits in the window. 3. Display the HTML version of the document text inside a Text view. 4. Set the frame and padding for the Text view so it has the same minimum width as the others, but expands to fill the ScrollView . 219

macOS by Tutorials

Chapter 10: Creating A Document-Based App

5. Make the text selectable, so you can copy it. Build and run now and try out each of the three preview states.

Showing the HTML code.

Challenges Challenge 1: Add a File Extension When you were setting up the file types, you allowed the app to use either .markdown or .md for the file extensions. But some people use .mdown for Markdown files. Edit the project so that this is a valid extension. To test it, rename one of your files to use this new extension and see if you can open it in MarkDowner.

Challenge 2: Apply an App Icon Open the assets folder for this chapter in the downloaded materials, and you’ll find an image file called markdown.png. Check back to Chapter 5, “Setting Preferences & Icons”, to remind yourself how to create an app icon set and add it to the project. Have a go at implementing these yourself, but check out the challenge folder if you need some help.

Key Points Apple provides a starting template for document-based Mac apps. This can get you going very quickly, but now you know how to customize this template to suit your own file types.

220

macOS by Tutorials

Chapter 10: Creating A Document-Based App

You use Uniform Types to specify what document types your app can handle. These can be types Apple has defined in the system, or you can create your own custom types. SwiftUI and AppKit work well together. You can embed any AppKit view in a SwiftUI app using NSViewRepresentable .

Where to Go From Here? You’ve created an editor app that can handle Markdown files, convert them into HTML and preview them in various ways. In the next chapter, you’ll add menu commands to this app. They’ll let you style the HTML, adjust font sizes, show some Markdown help and add Markdown snippets to your document.

221

macOS by Tutorials

11

Adding Menu Controls Written by Sarah Reichelt

In the previous chapter, you created a document-based app. You imported a package to convert Markdown to HTML, and you added controls to allow the user to select a preview style. You even included an AppKit view in the SwiftUI app. In this chapter, you’ll dive deeply into menus. In Section 1, you added menus to your app, so some of this is familiar to you. But those menus applied app-wide settings only. Now you’ll learn some different menu tricks, as well as how to track the active window so you can apply menu actions to that window only.

Adding the Style Files The web view renders the HTML using a default style, but it’d be nice to have the ability to choose your own styles by using Cascading Style Sheets (CSS). Open your project from the last chapter or open the starter project in the downloaded materials for this chapter. Next, open the assets folder in this chapter’s downloads and locate the StyleSheets folder. Drag this folder into your Project navigator, selecting Copy items if needed, Create groups and the MarkDowner target:

Adding the StyleSheets folder.

This new folder contains a set of CSS files, with some different styles, and a .swift file that sets up an enumeration for these styles. You’ll use the data in this enumeration to generate menu items.

222

macOS by Tutorials

Chapter 11: Adding Menu Controls

Creating a New Menu You’re going to have a lot of menu code, so to hold it all, create a new Swift file called MenuCommands.swift. Replace its contents with: // 1 import SwiftUI // 2 struct MenuCommands: Commands { // 3 var body: some Commands { // 4 EmptyCommands() } }

What does this do? 1. Import SwiftUI since the menus are part of the SwiftUI framework. 2. Create a MenuCommands structure, conforming to Commands , so that SwiftUI recognizes this as something that can appear in a menu. 3. Add a body that also conforms to Commands . 4. Return an empty menu to avoid errors. This sets up the basic structure for your menus. Now, you can start to create them. But before you can change the style sheet, you need a way to store the user’s choice. First, add this to the top of MenuCommands , before body : @AppStorage("styleSheet") var styleSheet: StyleSheet = .raywenderlich

This sets up a property using the @AppStorage property wrapper. As you’ve seen earlier, this property wrapper automatically saves its value to UserDefaults and retrieves it when needed.

Generating Menu Items Next, you’re going to use the enumeration to create the items for this new menu. Replace EmptyCommands() with: // 1 CommandMenu("Display") { // 2

223

macOS by Tutorials

Chapter 11: Adding Menu Controls

ForEach(StyleSheet.allCases, id: \.self) { style in // 3 Button { // 4 styleSheet = style } label: { // 5 Text(style.rawValue) } // keyboard shortcut goes here } // more menu items } // more menus

Stepping through these lines, you: 1. Create a new menu, setting its title to Display. 2. Loop through the cases in the StyleSheet enumeration. Since this enumeration doesn’t conform to Identifiable , use each case as its own identifier. The cases are always unique and the order is never going to change, so this can’t cause a problem. 3. Create a Button for each style. 4. Apply an action to each button that sets the styleSheet property. 5. Set the content of the Button to a Text view showing the rawValue of each style. You’ve created a menu, but you haven’t told your app to show it. Open MarkDownerApp.swift and add this modifier to the DocumentGroup : .commands { MenuCommands() }

This attaches MenuCommands to the DocumentGroup . Any menu or menu item that you add to the structure now appears in the main menu bar. Build and run to check out your new menu:

224

macOS by Tutorials

Chapter 11: Adding Menu Controls

Display menu

It looks good, and the neat thing is that it built itself using the cases in the StyleSheet enumeration, so if you add any new style sheets, they’ll appear in

the menu automatically. But so far, your menu doesn’t do anything. :[

Styling the HTML To get your WebView to use the selected style, open WebView.swift. Start by giving it access to the stored setting by adding this declaration at the top: @AppStorage("styleSheet") var styleSheet: StyleSheet = .raywenderlich

When you looked at the HTML code generated by the MarkdownKit package, you may have noticed that it had no section. Since that’s where an HTML file specifies its styles, you’ll have to create that section manually, before the WebView loads the HTML string.

Add this computed property to WebView : var formattedHtml: String { return """



\(html)

""" }

This uses Swift’s multi-line string syntax to wrap the html and styleSheet 225

macOS by Tutorials

Chapter 11: Adding Menu Controls

properties into a complete HTML document. Because you set the web view’s baseURL to the app’s Bundle.main.resourceURL , the file name, without any

folder path, is enough to locate the CSS file. Next, you have to tell the WebView to use this version of the HTML, so replace updateNSView with this version:

func updateNSView(_ nsView: WKWebView, context: Context) { nsView.loadHTMLString( formattedHtml, baseURL: Bundle.main.resourceURL) }

And now you can test it. Build and run the app, make sure you have some sample Markdown and test all the styles:

Changing styles

Adding Keyboard Shortcuts Back in Section 1, you added a menu that used a Picker . This gave you a checkmark beside the selected choice, but didn’t allow for keyboard shortcuts. Since this is an editor app, it’s more important that users can keep their hands on the keyboard, so having keyboard shortcuts is the priority. Earlier in this book, you added a keyboard shortcut using this syntax: .keyboardShortcut("t", modifiers: .command)

You probably assumed the first parameter was a String , but it’s not. It’s actually a KeyEquivalent , which you can initialize with a Character . This makes generating dynamic shortcuts more complicated than expected, but here’s how you can do it. 226

macOS by Tutorials

Chapter 11: Adding Menu Controls

In MenuCommands.swift, replace // keyboard shortcut goes here with: .keyboardShortcut(KeyEquivalent(style.rawValue.first!))

This line is doing a lot of work! Working from the inside out, style.rawValue.first gets the first character of the style’s rawValue . Since this has to exist to show the menu, force unwrapping is safe here although it’s not usually a good practice. Next, you initialize a KeyEquivalent using this character, and finally, you have the right type of object to pass to the keyboardShortcut modifier. The command key is the default modifier key for any shortcut, so it’s not strictly necessary to include it in the call. Build and run now to see your shortcuts in the menu. Test them all:

Style menu shortcuts

Even though you’ve decided to prioritize keyboard shortcuts over check marks, you can still use styling to indicate the current selection. You don’t want to add a check mark to the text manually. It appears in the wrong place and looks wrong. But you can modify the text in the menu item Button . Text in a menu item ignores a lot of style modifiers, but it can use different colors. Add this modifier to Text(style.rawValue) : .foregroundColor(style == styleSheet ? .accentColor : .primary)

Build and run now to see the active style sheet clearly indicated:

227

macOS by Tutorials

Chapter 11: Adding Menu Controls

Active style sheet marked

Because this uses semantic color names, it works in both light and dark modes. Interestingly, it’s not using the actual accent color, which is blue on this system, but is using a contrasting color that shows up even when you mouse over the menu item and color its background.

Inserting a Submenu Another useful feature would be the ability to change the editor’s font size. You’ll add this to the Display menu as a submenu, with items to increase, decrease and reset the font size. This is another app-wide setting, so start by defining another @AppStorage property at the top of MenuCommands : @AppStorage("editorFontSize") var editorFontSize: Double = 14

Next, replace // more menu items with: // 1 Divider() // 2 Menu("Font Size") { // 3 Button("Smaller") { if editorFontSize > 8 { editorFontSize -= 1 } } // 4 .keyboardShortcut("-") // 5 Button("Reset") { editorFontSize = 14 }.keyboardShortcut("0") Button("Larger") {

228

macOS by Tutorials

Chapter 11: Adding Menu Controls

editorFontSize += 1 }.keyboardShortcut("+") }

And what is this code doing? 1. Add a separator line to the menu to make it clear the next entry isn’t part of the style sheets group. 2. Create a submenu called Font Size. 3. Add a Button to reduce the font size with a lower limit. 4. Give it a keyboard shortcut. Command-Minus, Command-Plus and Command-Zero are commonly used for changing and resetting sizes. 5. Insert two more buttons to reset the font size back to the default and to increase the size. This gives you the interface to set the required font size, so now you must apply it. Open ContentView.swift and add the property declaration at the top of the structure: @AppStorage("editorFontSize") var editorFontSize: Double = 14

Apply this modifier to the HSplitView , just before the toolbar modifier: .font(.system(size: editorFontSize))

Build and run and check out your new submenu. Select items to change the font size and use the keyboard shortcuts too. Switch the preview to HTML code view, and you’ll see that the font size setting applies there too:

Font size submenu

229

macOS by Tutorials

Chapter 11: Adding Menu Controls

Using the Help Menu Back in Section 1, you removed the Help menu item as it didn’t do anything useful. In this app, you’ll make it show a new window that actually provides some useful information. But before you can do that, you need to upgrade your WebView so it can display a live web page as well as an HTML string. In the Project navigator, delete WebView.swift, moving it to trash. Next, open the assets folder for this chapter in the downloaded materials and drag the new version of WebView.swift from there into your Project navigator. The new feature is that you now initialize WebView with two optional parameters: either an HTML string or a web address. updateNSView(_:context:) uses one of them to populate the view.

With this in place, you can replace the Help menu item. In MenuCommands.swift, replace // more menus with: // 1 CommandGroup(replacing: .help) { // 2 NavigationLink( // 3 destination: WebView( html: nil, address: "https://bit.ly/3x55SNC") // 4 .frame(minWidth: 600, minHeight: 600) ) { // 5 Text("Markdown Help") } }

What’s going on here? 1. Use a CommandGroup to insert a menu item into one of the standard menus. Here, it replaces the help menu item. 2. Insert a NavigationLink as the menu item. 3. The NavigationLink navigates to a WebView with a URL that points to a Markdown cheat sheet. 4. Set the minimum frame for the new window. 230

macOS by Tutorials

Chapter 11: Adding Menu Controls

5. Use a Text view for the menu item title. Build and run the app, and test your new help menu item:

Opening a help window.

You’ve seen how menu items can be many different types of SwiftUI view. Most commonly you’ll use a Button , but Toggle s and Picker s are also useful, and you can use a Menu to add a submenu. Now, you’ve seen how a menu item can also be a NavigationLink . This is how you get a menu item to open a new window. The view inside the new window can be any SwiftUI view.

Focusing on a Window So far, all the menu items you’ve added apply their actions to the entire app. But this is a document-based app, so you’ll want to direct some menu items to the active window only. How can you tell which is the active window? Apple’s documentation suggests using @FocusedBinding . This works — sometimes. The problem seems to be that it only detects when the focus changes to a brand new window. Opening the app with windows already open fails to detect an active window, and sending the app to the back and then bringing it to the front also fails. Fortunately, there is a Swift package called KeyWindow and it works all the time. It uses AppKit observers to track when a window comes to the front and exposes this via a custom EnvironmentKey . The author of this package explains the process in this article on Reading from the Window in a SwiftUI lifecycle app. So now, you’re going to import the package and put it to use. 231

macOS by Tutorials

Chapter 11: Adding Menu Controls

As you did when importing the MarkdownKit package, select the project at the top of the Project navigator and click the project, not the target. Select Package Dependencies across the top and click the + button to add a second package to your project. Enter this URL into the search box: https://github.com/LostMoa/KeyWindow

You’ve already loaded one package, so this time, you’ll see two listed. Make sure to select the KeyWindow package before clicking Add Package:

Finding the KeyWindow package.

The Library is already checked in the next dialog, so click Add Package again to add it to your project. You now have two package dependencies listed:

Package dependencies

Next, there is a bit of setting up to do before you can start tracking the active window. 232

macOS by Tutorials

Chapter 11: Adding Menu Controls

Con guring the Library Start by opening MarkDownerDocument.swift and importing the new package by adding this line at the top of the file: import KeyWindow

Next, add this extension outside the existing structure: extension MarkDownerDocument: KeyWindowValueKey { public typealias Value = Binding }

This defines a binding to MarkDownerDocument as a valid type for a new KeyWindowValueKey . You’d use a very similar piece of code to define a custom EnvironmentKey , although in that case, you’d probably set a default value and

let Swift work out the type from that. Here the default is nil , so you have to specify the type. Now, open MarkDownerApp.swift and add the following with the other import statements:

import KeyWindow

Then change the contents of DocumentGroup to: ContentView(document: file.$document) .observeWindow()

You haven’t changed ContentView , but you’ve applied a new modifier to it. This allows KeyWindow to observe its window. Finally, open ContentView.swift. Add this modifier to HSplitView , just after the font modifier: .keyWindow( MarkDownerDocument.self, $document)

This keyWindow modifier publishes the key window information. You give it two parameters — a key-value pair. The key is the type of the object that you set 233

macOS by Tutorials

Chapter 11: Adding Menu Controls

up as a KeyWindowValueKey . The value is the object itself: A binding to your document. All the parts are in place now, so you can actually use them in your menus to target the front window.

Adding a Window-speci c Menu In MenuCommands.swift, add the following at the top to import the library: import KeyWindow

Next, add this property definition at the top of MenuCommands : @KeyWindowValueBinding(MarkDownerDocument.self) var document: MarkDownerDocument?

This uses a custom property wrapper to give you access to the key window’s document, if it exists. You can use this in your menu actions. The MenuCommand structure is getting long, but you can make it easier to navigate using code folding. If you don’t see the code folding ribbon, go to Xcode’s Preferences ▸ Text Editing ▸ Display and check Code folding ribbon. Now, click in the column between the line numbers and the code to collapse sections:

Code folding

Add some blank lines after the CommandGroup and insert this: // 1 CommandMenu("Markdown") { // 2 Button("Bold") { // 3 document?.text += "**BOLD**" } // 4 .keyboardShortcut("b")

234

macOS by Tutorials

Chapter 11: Adding Menu Controls

// 5 Button("Italic") { document?.text += "_Italic_" }.keyboardShortcut("i", modifiers: .command) Button("Link") { let linkText = "[Title](https://link_to_page)" document?.text += linkText } Button("Image") { let imageText = "![alt text](https://link_to_image)" document?.text += imageText } }

Taking this step by step, you: 1. Create a new menu titled Markdown. 2. Add a Bold menu item. 3. Append some text to the focused document, if it exists. 4. Apply a standard keyboard shortcut. 5. Make some more buttons in the same way. The Markdown syntax for images is very similar to the syntax for links so these are especially useful. Build and run the app. Open a new window so you have at least two windows open. Then, try out the new menu:

Markdown menu

Great work. There was a lot happening there, but you made it through, and now you have a menu that targets the frontmost window only.

Exporting the HTML 235

macOS by Tutorials

Chapter 11: Adding Menu Controls

You can type Markdown and preview this as HTML, but you may want to export the HTML code for use on a web site. Open MenuCommands.swift and collapse the new CommandMenu . Insert some blank lines after it and then add this: // 1 CommandGroup(after: .importExport) { // 2 Button("Export HTML…") { // exportHTML() } // 3 .disabled(document == nil) }

What does this do? 1. Create a new CommandGroup after the importExport group. This places it at the end of the File menu. 2. Add a Button to export the HTML. This will open a save dialog, and conventionally if a menu item or button is going to ask for further information, its title ends with an ellipsis. 3. Disable this menu item if there is no focused document. Build and run to check out your new menu item in the File menu:

Export menu item

Close all your windows and you’ll see that the Export HTML menu item is disabled. The menu item is in place, so now you need to make it work.

236

macOS by Tutorials

Chapter 11: Adding Menu Controls

Add this new method to the end of MenuCommands , outside the body : func exportHTML() { // 1 guard let document = document else { return } // 2 let savePanel = NSSavePanel() savePanel.title = "Save HTML" savePanel.nameFieldStringValue = "Export.html" // 3 savePanel.begin { response in // 4 if response == .OK, let url = savePanel.url { // 5 try? document.html.write( to: url, atomically: true, encoding: .utf8) } } }

So what’s happening here? 1. Check to see if there’s an active document. There should always be one, because the menu item is disabled if not, but it’s better to be sure. 2. Create an NSSavePanel , which is the standard system save dialog. Give it a title and set the default name for the file. 3. Display the save panel and wait for a response. 4. Check if the response was .OK , which is short for NSApplication.ModalResponse.OK , and that the user chose a URL.

5. Try to write the document’s HTML code to the URL. The last step is to use this method. Uncomment the // exportHTML() line in the button’s action so it can call the new method. Time to test it. Build and run the app. Open a document or create a new one with some Markdown. Then select Export HTML… from the File menu:

237

macOS by Tutorials

Chapter 11: Adding Menu Controls

Save panel

Once you’ve saved the file, double-click on it to open it in your default browser:

Exported HTML in Safari

You may be wondering why the sandbox doesn’t restrict you from saving this file in certain locations. By default, the sandbox allows Read/Write access to User Selected Files. This means that so long as you ask the user to choose a file location, you can save anywhere they want.

Note: If you include any local images in your Markdown, they don’t show up in the web preview, due to Apple’s security settings. But they appear as expected in the HTML export.

Coding for the Touch Bar A lot of MacBooks have a touch bar, and it’s frequently used to provide autocomplete suggestions or formatting options. So, it would be a neat touch to have some of your Markdown menu items available in the touch bar too.

238

macOS by Tutorials

Chapter 11: Adding Menu Controls

You may be wondering how you can test this if you don’t have a Mac with a touch bar, but Xcode has this covered. Open the Window menu and go to Touch Bar ▸ Show Touch Bar, or press Shift-Command-8 to open a full touch bar simulator that works with all the apps on your Mac:

Touch bar simulator

Adding commands to the touch bar is very similar to adding commands to a menu. But where you used text labels in the menu, you can use styled characters and icons in the more graphical touch bar. Open the assets folder in the downloaded materials for this chapter and drag TouchbarCommands.swift into your project. Looking at the file, you’ll see that unlike MenuCommands , this doesn’t have to conform to any special protocol. You can display any SwiftUI view in the touch bar, which opens up a lot of possibilities. The TouchbarCommands structure has access to the key window’s document, like MenuCommands does, and it has the same four abilities that the Markdown menu has but with different button labels. Now that you have this structure, open ContentView.swift and add this modifier to HSplitView : .touchBar { TouchbarCommands() }

That’s all you need to do to apply a touch bar to this view. This sets it to use the commands you just added, alongside the default touch bar commands. Build and run the app to check them out:

239

macOS by Tutorials

Chapter 11: Adding Menu Controls

Touch bar commands

There are a lot of MacBooks out there with touch bars and SwiftUI makes it easy to support them, so why not do it.

Challenges Challenge 1: Keyboard shortcuts When you added the Display menu, you created keyboard shortcuts for every menu item. Since then, you’ve added new menu items to the File and Help menus and they have no shortcuts. You also created a Markdown menu and only some of the items there have shortcuts. Work your way through MenuCommands and add keyboard shortcuts to as many items as you can.

Challenge 2: More Markdown snippets The Markdown menu has a few snippets, but it’d be good to have more. Add a Headers submenu that inserts the various header types. Header 1 starts with 1 # , Header 6 starts with 6 # s. You can use the header level number as the

shortcut. Adding a divider line can be tricky because the most common format is three dashes, but macOS tries to do clever things and converts this into an em-dash or en-dash. So add a menu item to enter a divider line, not forgetting to add line feeds before and after, using "\n" .

240

macOS by Tutorials

Chapter 11: Adding Menu Controls

Key Points The default macOS document app gets a standard suite of menus, but you can add to them in lots of useful ways. An enumeration can automate the creation of a menu. This can even extend to generating keyboard shortcuts. Include a Menu in a menu item to create a submenu. Using a NavigationLink as a menu item allows you to open a new window containing any SwiftUI view. Keeping track of the frontmost window isn’t an easy task and Apple’s mechanisms don’t always work. Once you know which window is the active one, you can target it directly from menu items. This allows you to have window-specific menu items. When saving a file, use an NSSavePanel to request the save path from the user. When you ask the user to select a file, your app can write to that file, even though it’s outside the app’s sandbox container. A lot of MacBooks have a touch bar and SwiftUI makes it easy to add to the default touch bar controls.

Where to Go From Here? Well done! You’ve reached the end of another section, and you’ve completed a new app. If you’ve been working through the book in order, you’ve now got three working apps in three very different styles. This section introduced you to a document-based app and used some new SwiftUI features to create a neat Markdown editor that will only get better as Apple improves and extends the SwiftUI components. In the next section, you’re going to build an app that allows you to do things you could never dream of doing in an iOS app. You’re going to run Terminal commands from within your app. This will enable you to provide a GUI for some obscure, but useful, commands.

241

macOS by Tutorials

12

Diving Deeper Into Your Mac Written by Sarah Reichelt

So far, you’ve created three different Mac apps: a windowed app, a menu bar app and a document-based app. In this section, you’re going to create another windowed app, but with a different purpose. You’ll dive deep into your Mac’s system and learn about the command line utilities that are part of macOS. You’ll learn more about using Terminal and how to invoke Terminal commands using Swift. In the app, you’ll use a Terminal command called sips , which stands for scriptable image processing system. This is a powerful tool, but it’s difficult to remember the syntax. Creating a graphical user interface will make it much easier to use.

Terminal Commands macOS, and its predecessor OS X, are built on top of Unix. Unix has an extensive set of commands you can run from the command line. These commands are mostly small, single-purpose utilities you can chain together, if needed. You’ve already used a sequence of commands like this in Chapter 1, “Designing the Data Model”, where you formatted the downloaded JSON to make it easier to read. These commands are executable files stored in secret folders, hidden deep in your Mac’s file system, but now you’re going to find them. Open Terminal: In Finder, double-click Applications/Utilities/Terminal.app, or press Command-Space to activate Spotlight, and start typing Terminal until the app becomes selectable. In the Terminal window, type these two commands, pressing Return after each one: cd /usr/bin ls

The cd command changes directory to one of the hidden folders. And ls lists the contents. This gives you a huge list of commands that you have access to. 242

macOS by Tutorials

Chapter 12: Diving Deeper Into Your Mac

Scroll back to see where you typed ls , then right-click it and select Open man Page:

Opening a manual page

Nearly every command has its own man page, or manual page, that you can read to find what the command does and what arguments it takes. Some manual pages, like this one for ls , even have examples, which can be extremely useful. Terminal displays these manual pages using the man command. Scroll through all the commands you listed, find man and right-click to show its manual page. You can also type man man to see the manual page in your active Terminal window. Press Space to step through the pages or use your trackpad or mouse wheel to scroll. Press q to exit, which makes the manual page disappear. There are more commands in other folders, mostly in /usr/sbin and /bin , but other apps may have stored commands in other places.

Note: You can make your system unusable with Terminal commands. Terminal assumes you know what you’re doing and will allow you to erase

243

macOS by Tutorials

Chapter 12: Diving Deeper Into Your Mac

every file off your drive or perform other catastrophic actions. Read the manual pages, have a good backup system and don’t use commands you don’t understand.

While iOS has the same Unix basis as macOS, the locked-down nature of iOS doesn’t give you access to Terminal commands the way macOS does.

Testing Some Commands Now that you know where macOS keeps these command files, it’s time for you to test some. Here are some interesting — and safe — ones you can run by typing them one at a time into Terminal: whoami uptime cal man splain

Testing some commands.

Press q to quit that last man command. A lot of these commands are old, but there are some new ones. macOS 12 added a new command to test your internet connection. Run this command and wait for it to complete, which takes about 30 seconds on my network: networkQuality -sv

The command name is networkQuality , but what’s this -sv ? Right-click the command to open its manual page:

244

macOS by Tutorials

Chapter 12: Diving Deeper Into Your Mac

networkQuality manual page

The SYNOPSIS lists several optional arguments. You can tell they’re optional because they appear inside square brackets. The -I argument allows you to specify the interface so, if your computer has more than one network connection, you can use it to select the one to test. The other options are four flags: -c , -h , -s and -v . Reading down the page, you can see what each of these does. Experiment with different combinations to see what you get. Command line arguments are always case-sensitive. The networkQuality command has -C and -c options that do completely different things. You can type arguments as a single word if the manual page shows them like that. For this example, you used networkQuality -sv , but networkQuality -s -v works exactly the same. Here’s another command that has even more arguments: ping -c 5 apple.com

Run the command, then open the manual page for ping to see what these arguments are. In this case, you’re not merely turning on a setting, you’re providing information. The -c argument, and the number following it, specify how many times to ping the server. The host, apple.com in this example, is not optional. So this command pings the Apple servers five times and reports how long it takes to get a response.

Terminal Shortcuts Here is a collection of tips to make working in Terminal easier, and more efficient. To clear the Terminal window at any time, press Command-K. Press the up or down arrow keys to cycle through recently used commands. Use Return when you see the one you want to use. When changing directory, type cd , followed by a space, and then drag a folder from Finder into the Terminal window to insert its path. 245

macOS by Tutorials

Chapter 12: Diving Deeper Into Your Mac

When entering a command or file path, start typing and then press Tab. Terminal tries to auto-complete the command or path for you, or it lists possible candidates.

Running Commands in a Playground Now that you know something about Terminal commands, it’s time to get back into Xcode and see how to run them from there. Open Xcode and select File ▸ New ▸ Playground…. Choose the macOS Blank template and name it Commands.playground:

New macOS playground

Apple provides a class called Process for running other programs like these Terminal commands.

Note: Process used to be called NSTask , and you’ll still see that name used a lot, including in some of Apple’s own documentation.

You set up a Process with a file path URL for the command it’s to run. When you run commands in Terminal, you type in the name of the command, e.g. whoami . That doesn’t work in a Process , so you need to discover exactly

where the command file is. Terminal gives us a way to get this information. Swap back to Terminal and use the which command to locate the executable command file:

246

macOS by Tutorials

Chapter 12: Diving Deeper Into Your Mac

which whoami

This command returns /usr/bin/whoami and that’s what you need to use in your Process :

Finding a command file.

Replace the contents of the playground with: // 1 import Cocoa // 2 let process = Process() // 3 process.executableURL = URL(fileURLWithPath: "/usr/bin/whoami") // arguments go here // standard output goes here // 4 try? process.run()

Stepping through these lines, you: 1. Import Cocoa so you can access Process . 2. Create a new Process . 3. Set the executableURL for the process to a file URL based on the path you discovered. 4. Try to run your Process . Run the playground now, and you’ll see your macOS user name appear in the console at the bottom:

247

macOS by Tutorials

Chapter 12: Diving Deeper Into Your Mac

Running your first process.

A command called whoami sounds like it should answer some deep, philosophical questions about life, but all it does is give you the name of the currently logged in user. :[ Your Process works and your playground can call a Terminal command, but where is the result? And how can you get that result into a string so you can use it?

Adding Some Pipes When you run any Terminal command, it opens three channels: stdin: standard input for receiving data stdout: standard output for sending data stderr: standard error for sending errors In Terminal, these all default to the Terminal itself. You provide input on the command line, and results or errors appear there too. In the playground, Process gets its input from the URL and from an arguments array you’ll see in a few minutes. It uses its console for output and errors. But if you want to do anything with the data, you have to set up your own standardOutput .

In your playground, replace // standard output goes here with: // 1 let outPipe = Pipe() // 2 let outFile = outPipe.fileHandleForReading // 3 process.standardOutput = outPipe

With this code, you: 248

macOS by Tutorials

Chapter 12: Diving Deeper Into Your Mac

1. Create a Pipe to provide communication between processes. One end of the Pipe connects to your process, and the other end connects to the Terminal

command. 2. Set up a FileHandle so you can read the data coming through the Pipe . 3. Assign this pipe as the standardOutput for your process. This gives you the mechanism you need to access the output of the command. Next you need to read from it. Replace the try? process.run() line with this: // 1 do { // 2 try process.run() process.waitUntilExit() // 3 if let data = try outFile.readToEnd(), let returnValue = String(data: data, encoding: .utf8) { print("Result: \(returnValue)") } } catch { // 4 print(error) }

What do your changes do? 1. Wrap the code in a do block, so you can catch any errors. 2. Run the process as before and wait until it’s finished. 3. Then, read all the data from the standardOutput ’s file handle, convert it to a string and print it. 4. If there was a problem, print the error. Run the playground again, and this time you’ll see that the returnValue variable now contains the expected result:

249

macOS by Tutorials

Chapter 12: Diving Deeper Into Your Mac

Getting returned data.

Supplying Arguments There’s only one more complication, and that’s when a command needs more input. Remember how you pinged Apple’s servers earlier from Terminal? Now, you’ll do the same from your playground. The first step is to locate the ping command, so type which ping in a Terminal window to get the full path: /sbin/ping . In the line that sets process.executableURL , replace /usr/bin/whoami with /sbin/ping :

process.executableURL = URL(fileURLWithPath: "/sbin/ping")

Next, you need to supply arguments as an array of strings. For each word that you’d type in Terminal, you add a separate string to the array. Replace // arguments go here with: process.arguments = ["-c", "5", "apple.com"]

In Terminal, you used ping -c 5 apple.com . The executableURL gets set to the full path to the ping command, and the other three words provide the three members of the arguments array. Even the numeric parameter must be a string. Run the playground and, after about 5 seconds, you’ll see the result in the console:

250

macOS by Tutorials

Chapter 12: Diving Deeper Into Your Mac

Running a command with arguments.

The ping command is obviously working, but it’s a bit boring waiting until the end to see any results. How about reading data as it arrives?

Reading Data Sequentially In the previous example, you waited until process finished and then used readToEnd() to get the complete output from the command in a single chunk.

Now, you’re going to use availableData to read the output as it arrives. Start by adding this function to your playground, below import Cocoa and above the let process declaration: // 1 func getAvailableData(from fileHandle: FileHandle) -> String { // 2 let newData = fileHandle.availableData // 3 if let string = String(data: newData, encoding: .utf8) { return string } // 4 return "" }

Taking this bit by bit: 1. Use this function to read data from a FileHandle . You already made a FileHandle to read from standardOutput .

2. Get all the data it can from the FileHandle . 3. Then, try to convert the incoming data into a string and return it. 4. If there’s a problem, or if there are no data, return an empty string.

251

macOS by Tutorials

Chapter 12: Diving Deeper Into Your Mac

To use this function, replace the contents of the do block with: // 1 try process.run() // 2 while process.isRunning { // 3 let newString = getAvailableData(from: outFile) print(newString.trimmingCharacters(in: .whitespacesAndNewlines)) } // 4 let newString = getAvailableData(from: outFile) print(newString.trimmingCharacters(in: .whitespacesAndNewlines))

And what’s happening here? 1. Start process running, exactly as you did before. 2. Then, set up a while loop to run as long as process is running. 3. Inside the loop, use the new function to read all the available output and print it. 4. When the loop has finished, do one last check to get the final chunk of data. Run the playground now, and you’ll see the same ping results coming in, but this time you can read each line as soon as it arrives, which gives a much better user experience.

Finding Commands You’ve probably spotted a flaw in this system. Using Terminal to find the path to each command is not a great solution. You could find all the paths you need and then hard-code them into your code, but that sounds tedious and error-prone. How about running the which command programmatically and using that? But where is which ? Run which which in Terminal. It feels like this might generate some sort of infinite loop, but it returns which: shell built-in command . Some commands are so important that they’re part of the shell and

don’t have a separate file path. So what’s the shell, and how can you access the which command programmatically? The shell is the command that creates the terminal prompt. Look at the title bar of your Terminal window and you’ll see some interesting information:

252

macOS by Tutorials

Chapter 12: Diving Deeper Into Your Mac

Shell window

This shows you: The current directory. The name of the shell. Your Terminal window size in characters across and rows down. Modern versions of macOS use zsh as the default shell, but you can also use bash , or you may have installed something completely different like fish .

However, you can rely on your Mac having zsh installed. Regardless of what shell you’re using, run this command in Terminal, to find zsh :

which zsh

This gives you /bin/zsh , and that’s the one file path you’re going to hard-code. Check the manual page for zsh . Scroll about half way down to find the section labeled INVOCATION. This tells you that you can use the -c flag to execute the next argument as if it was a regular command. Try this in Terminal first: zsh -c "which whoami"

And you’ll get /usr/bin/whoami exactly as you saw when you ran which whoami directly. The command you want zsh to run is inside quotes, so that zsh recognizes it as a single argument.

Now that you know how this works, go back to the playground and replace the lines that set the process executableURL and arguments with: process.executableURL = URL(fileURLWithPath: "/bin/zsh") process.arguments = ["-c", "which whoami"]

Run the playground to see /usr/bin/whoami in the console. So now you have a technique you can use to find the path to any executable command. And you know how to run built-in commands like which using zsh . 253

macOS by Tutorials

Chapter 12: Diving Deeper Into Your Mac

Wrapping it in Functions You now have everything you need to run Terminal commands from your playground, but before you use this in an app, it makes sense to wrap it into reusable functions. Start by adding these lines below func getAvailableData(from:) and immediately above where you declare process : func runCommand( _ command: String, with arguments: [String] = [] ) async -> String { // move all the process code below to here return "" }

This sets up a function that takes in the command path and an array of arguments. The arguments default to an empty array if not supplied. The function is async so it can run without blocking the main thread, and it returns a String . Next, select all the other lines of code below, starting with let process ... and ending with the catch closure. Then press Option-Command-[ enough times to move all this code into the body of runCommand(_:with:) , above the default return "" . Now, to use your function’s parameters, replace the process configuration lines with: process.executableURL = URL(fileURLWithPath: command) process.arguments = arguments

This sets the process to use the supplied command path and arguments instead of hard-coded values. Then, replace the contents of the do block with: try process.run() var returnValue = "" while process.isRunning { let newString = getAvailableData(from: outFile) returnValue += newString } let newString = getAvailableData(from: outFile) returnValue += newString

254

macOS by Tutorials

Chapter 12: Diving Deeper Into Your Mac

Instead of printing each line as it arrives, you merge it into a single returnValue string.

And now, still in the do block, add these lines to return this string: return returnValue .trimmingCharacters(in: .whitespacesAndNewlines)

Terminal commands always return strings with trailing line feeds, so you strip these out, along with any excess spaces, before returning returnValue . You’re no longer seeing the output as it arrives, but when you get to build this into an app, you’ll see how you can enable this again. The return "" outside the do-catch code returns an empty string if anything goes wrong. And now you’ve got a reusable function that can call Terminal commands. This is a general function to run any command, but it would be useful to have a more specialized function to find the path to any command’s executable file. Add this to the end of the playground: func pathTo(command: String) async -> String { await runCommand("/bin/zsh", with: ["-c", "which \(command)"]) }

This uses runCommand(_:with:) to run zsh and gets it to find the path to the supplied command using which . Now, to put it all together, add this code to your playground: // 1 Task { // 2 let commandPath = await pathTo(command: "cal") // 3 let cal = await runCommand(commandPath, with: ["-h"]) print(cal) }

This code: 1. Encloses the async function calls in a Task block so you can await their results. 255

macOS by Tutorials

Chapter 12: Diving Deeper Into Your Mac

2. Gets the path to the cal command. 3. Runs the command and prints the result. The -h flag turns off the bolding for today’s date, because that doesn’t display well in plain text. Run the playground to see a printout of the calendar for the current month.

Using the functions.

Once you’ve tested the command, comment out the Task block. You’re going to be running other commands, but you can leave this in place as a guide.

Manipulating Images You’re about to build an app called ImageSipper, and it’ll use the sips or scriptable image processing system command. Type sips in Terminal and press Return to see some help. Right-click the word and open its manual page for even more information. There is a lot of detail there, but sadly, no examples. This is a powerful utility, and you can edit single image files or batch process multiple images. But it’s not easy to use, and it’s definitely not easy to remember the syntax, so adding a user interface will make it much more usable. First, you’ll test some sips commands in the playground. Add this line at the end: let imagePath = ""

Now, you need to insert an image file path between the quotes on that line. In the downloaded materials for this chapter, the assets folder contains a 256

macOS by Tutorials

Chapter 12: Diving Deeper Into Your Mac

sample image called rosella.png. Right-click the file, hold down Option and select Copy “rosella.png” as Pathname:

Copy image path

Back in your playground, paste this copied file path between the quotes on the let imagePath line.

This gives you a file path to the image. In iOS and macOS apps, you’re used to working with URL s for files, but Terminal commands are all text-based, so you need the file path as a String . Next, you have to find the path to the sips command, so add this below: Task { let sipsPath = await runCommand("/bin/zsh", with: ["-c", "which sips"]) // sips commands here }

Finally, you’re ready to run your first sips command. Replace // sips commands here with:

// 1 let args = ["--getProperty", "all", imagePath] // 2 let imageData = await runCommand(sipsPath, with: args) print(imageData)

257

macOS by Tutorials

Chapter 12: Diving Deeper Into Your Mac

And what’s all this? 1. sips has an argument called --getProperty that reads data from an image file. You can follow it with the name of the specific property you want to get, but using all makes it return all the information sips can read. The third string in the array tells sips which image file to use. 2. Run the sips command with these arguments and print the result to the console. Run the playground and you’ll see a list of information about the image:

Image information

Shrinking the Image This is a large image, as you can see from the data you just read, so you’re going to use sips to make a smaller copy. So that you don’t overwrite the original, you’ll provide a new file path. Duplicate the line with the original file path. Change the variable name to imagePathSmall and the last part of the file name to rosella_small.png, so you

end up with something like this: let imagePath = "/path/to/folder/rosella.png" let imagePathSmall = "/path/to/folder/rosella_small.png"

You can use sips to change both the height and width of an image, but there are options that allow you to change only one dimension and have the other change automatically to maintain the same aspect ratio. You’ll reduce the width, and the height will adjust to match.

258

macOS by Tutorials

Chapter 12: Diving Deeper Into Your Mac

Below the last command inside the Task block, add these lines: let resizeArgs = [ // 1 "--resampleWidth", "800", // 2 imagePath, // 3 "--out", imagePathSmall ] // 4 let output = await runCommand(sipsPath, with: resizeArgs) print("Output: \(output)")

What does this code do? 1. Set up the array of arguments. The first arguments say to adjust the width of the image to 800 pixels. 2. The next element supplies the path to the original image file. 3. The third section of the arguments array tells sips to save the edited image to the new file path. If you left this out, the edited image would overwrite the original file. 4. Finally, run the command and print the output to the console. Run the playground now. The console shows the data for the original image again, and then the output of the resize operation, which is the original file path, followed by the new file path. In Finder, look at the two image files. Check the Finder preview, or press Command-I to Get Info about rosella_small.png, and you’ll see its dimensions are 800 x 600 pixels, down from 3796 x 2850:

259

macOS by Tutorials

Chapter 12: Diving Deeper Into Your Mac

Resized image

Calculating the aspect ratios, 2850 / 3796 = 0.751 while 600 / 800 = 0.75, so the ratio of height to width has remained virtually unchanged.

Formatting Arguments Go back to the manual page for sips . The first entry in the FUNCTIONS section is -g or --getProperty . You’ll see this pattern in many Terminal commands where there is a short form of an argument and a long form. Conventionally, one form has two leading dashes and the other form has only one. When you’re typing directly into Terminal, the short version makes much more sense, but that’s not the case when writing a utility app like this. Always use the longer form when calling commands in an app. You only have to type it once, and it makes your code much easier to read and understand when you come back to it later, or when anyone else has to read it.

Challenges Challenge 1: Use Another Terminal Command 260

macOS by Tutorials

Chapter 12: Diving Deeper Into Your Mac

Pick another Terminal command and run it in the playground. Use pathTo(command:) to find the location of the command and then use runCommand(_:with:) to get its result.

Don’t forget to wrap your function calls in a Task block so they can run asynchronously.

Challenge 2: Rotate or Flip the Sample Image You can use sips to flip or rotate an image. The syntax you’d use in Terminal is: sips --rotate 90 rosella.png --out rosella_rotated.png sips --flip vertical rosella.png --out rosella_flipped.png

Convert these commands to run in your playground. Test out different rotation angles and try flipping horizontally as well as vertically. Try to work this out for yourself, but if you get stuck, look in the playground in the challenge folder for this chapter.

Key Points macOS is built on top of Unix and contains a lot of utility commands you can access through Terminal. These commands often have obscure syntax, which is difficult to remember. You use Process to run these commands in Swift. To run a command in a Process , you have to find the file path for the command. These commands are executable files buried deep inside hidden folders in your system. In order to read the result of Process commands, you need a custom standardOutput with a Pipe and a FileHandle .

Where to Go From Here? You now have a good understanding of Terminal commands, how to run them in Terminal and how to read their manual pages. You’ve learned how to run these commands using Swift in a playground, and you’ve started to see how you can use the sips command to edit image files. In the next chapter, you’re going to take all this knowledge and use it to create a Mac app that will provide an easy interface to the power of the sips command. 261

macOS by Tutorials

13

Adding the Interface Written by Sarah Reichelt

In the previous chapter, you learned about Terminal commands, how to run them in Terminal and how to run them using Swift. Now, you’re going to take your knowledge and apply it to an app that provides a graphical user interface to some features of the sips command. Since you’re now an experienced macOS app developer, you don’t need to start from scratch. The starter project has all the UI, but you have to make it work. In this chapter, you’ll add multiple options for selecting files and folders, and you’ll apply the general functions you created in the last chapter to more specific commands. You’ll work on an app called ImageSipper that’ll give you controls for editing single images, as well as the ability to generate thumbnails for a complete folder of image files.

The Starter Project Go to the folder for this chapter in the downloaded materials and open the starter project. Build and run to see what you’re working with:

Starter project

The app window has a tab view with two tabs, each offering a different image 262

macOS by Tutorials

Chapter 13: Adding the Interface

editing feature. There’s a terminal output view at the side, so you can see what Terminal commands the app uses and what it gets back. Most of the controls are inactive, and since there’s no way to select an image file yet, you can’t do much. Head back to Xcode and look at the groups and files in the Project navigator:

Project files and groups

Going through the groups in this list: Views: ContentView is the main window view, containing a TabView and the TerminalView . The TabView contains ImageEditView and ThumbsView .

Components: These are subviews used by the main views. CustomImageView formats an Image view. The two Controls views provide the input fields and buttons at the bottom of each of the views in the TabView . You’ll use PathView and ScrollingPathView to show the location of the selected file

or folder. Models: Picture is a structure to hold the image data that you read using sips . PicFormat is an enumeration listing the supported image formats.

Utilities: CommandRunner is a class wrapped round the functions you wrote in the playground, along with a method for publishing the output. 263

macOS by Tutorials

Chapter 13: Adding the Interface

FileManager+Ext is an extension on FileManager for determining file

types and creating new file paths. Separating components and utilities like this makes them more reusable in other projects. Since now you have the app running, it’s time to make it functional.

Choosing Files and Folders The first step before you can edit any images is to allow your users to select an image file or a folder of images. In Chapter 11, “Adding Menu Controls”, you used NSSavePanel to allow the user to choose where to save a file. This time, you want the user to select an existing file, so you’ll use NSOpenPanel . These both inherit from NSPanel , so they share some properties. Open ImageEditView.swift. Find the empty method called selectImageFile() , and put this code inside it:

// 1 let openPanel = NSOpenPanel() openPanel.message = "Select an image file:" // 2 openPanel.canChooseDirectories = false openPanel.allowsMultipleSelection = false openPanel.allowedContentTypes = [.image] // 3 openPanel.begin { response in if response == .OK { // 4 imageURL = openPanel.url } }

So what does the method do now? 1. Create a new NSOpenPanel and give it a header. 2. Configure the panel so the user can only choose a single image file. 3. Show the panel and wait for the user to close it. 4. If the response is OK , set imageURL to the selected url. Build and run the app, click Select Image File and choose any image. Because CustomImageView already uses imageURL , your image appears:

264

macOS by Tutorials

Chapter 13: Adding the Interface

Selecting an image file.

Notice how the panel won’t let you select a folder, any non-image file type or multiple files.

Selecting Folders While you’re setting up panels, open ThumbsView.swift and fill selectImagesFolder() with:

let openPanel = NSOpenPanel() openPanel.message = "Select a folder of images:" // 1 openPanel.canChooseDirectories = true openPanel.canChooseFiles = false openPanel.allowsMultipleSelection = false openPanel.begin { response in if response == .OK { // 2 folderURL = openPanel.url } }

What’s different in this version? 1. The configuration only allows you to choose a single folder. 2. When the user selects a folder, you set the property that ThumbsView uses to populate a list of image names and thumbnails. Build and run, switch to the Make Thumbnails tab, click Select Folder of Images and try it out:

265

macOS by Tutorials

Chapter 13: Adding the Interface

Selecting a folder of images. ThumbsView uses AsyncImage for the images, so the app remains responsive

while they load. Now you have a way to provide the image or folder to each view, but isn’t there an easier way?

Dragging and Dropping How about allowing users to drag and drop image files or folders into the views? SwiftUI makes detecting drops easy, but working out the URLs from the dropped data is a bit obscure. Start by opening CustomImageView.swift and adding this new method to CustomImageView :

// 1 func loadURL(from data: Data?) { // 2 guard let data = data, let filePath = String(data: data, encoding: .utf8),

266

macOS by Tutorials

Chapter 13: Adding the Interface

let url = URL(string: filePath) else { return } // 3 imageURL = url }

Stepping through this: 1. The onDrop modifier will call this method whenever it detects a drop and pass in an optional Data parameter. 2. If there is any Data , try converting it to a String and using that String to create a URL . 3. If that works, you get a URL that you can use to set the imageURL property. This is a @Binding property, so its new value flows back to ImageEditView .

Note: You can’t use URL(fileURLWithPath: filePath) here. You must use URL(string: filePath) , or you get some strange file ID that won’t load.

You’re nearly ready to add an onDrop modifier to CustomImageView , but first, you need a Boolean property to hold the state of the drag and drop operation. Add this to the top of CustomImageView : @State private var dragOver = false

This is set to true whenever the drag enters the target and to false whenever the drag leaves.

Handling the Drop Finally, you can add this onDrop modifier to Image , replacing // onDrop here :

// 1 .onDrop( of: ["public.file-url"], isTargeted: $dragOver ) { providers in // 2 if let provider = providers.first { // 3 provider.loadDataRepresentation( forTypeIdentifier: "public.file-url") { data, _ in // 4 loadURL(from: data)

267

macOS by Tutorials

Chapter 13: Adding the Interface

} } // 5 return true }

There’s a lot happening in this block of code: 1. Add an onDrop modifier, stating that it can accept any file URL. Set it to use the dragOver property to store whether the view is currently targeted by the drag operation. The action for onDrop receives an array of NSItemProvider s.

2. This editor only handles one file at a time so look for the first NSItemProvider .

3. If the provider exists, query its data for the type that the onDrop accepts, which is a file URL. This gets two optionals: Data and Error . 4. Ignore any error. Pass the optional data to loadURL(from:) for processing. 5. Return true to show the method has handled the drop. To summarize, an onDrop modifier has to know what data types to accept and must have a binding to a Boolean property that it sets to true when the drop is over its view. The onDrop action receives NSItemProvider s that may contain data of the expected type. That was a dense section, but now you can build and run the app. Try dragging an image file into the first view:

268

macOS by Tutorials

Chapter 13: Adding the Interface

Dragging and dropping an image file.

So now you have two ways for users to import an image file.

Dropping Folders This gives you drag and drop for CustomImageView . Now, you can apply the same technique to ThumbsView . Start by adding the data processing method to ThumbsView.swift: func loadURL(from data: Data?) { guard let data = data, let filePath = String(data: data, encoding: .ascii), let url = URL(string: filePath) else { return } if FileManager.default.isFolder(url: url) { folderURL = url } }

This is similar to loadURL(from:) in CustomImageView , but it adds a check to make sure the URL points to a folder using a method from FileManager+Ext.swift.

269

macOS by Tutorials

Chapter 13: Adding the Interface

Next, define the dragOver property at the top of ThumbsView : @State private var dragOver = false

And finally, insert the identical onDrop modifier, again replacing // onDrop here :

.onDrop( of: ["public.file-url"], isTargeted: $dragOver ) { providers in if let provider = providers.first { provider.loadDataRepresentation( forTypeIdentifier: "public.file-url") { data, _ in loadURL(from: data) } } return true }

Build and run, switch to the Make Thumbnails tab and drag a folder into the view:

Dragging and dropping a folder.

Try dropping files into the Make Thumbnails tab or folders into the Edit Image tab. What happens if you drop a text file into Edit Image? Your code correctly 270

macOS by Tutorials

Chapter 13: Adding the Interface

handles the appropriate drops and ignores all the others. Good work!

Showing the File Path You now have multiple ways of getting images or folders into the app. But once they’re in, there’s nothing to show you where those files are. AppKit has a class called NSPathControl for this. Open PathView.swift in the Components group. It uses NSViewRepresentable to make the AppKit control available to SwiftUI. There’s only one problem with this. If you have a deeply nested file or folder, the path can get too long for the window. To get around this, you’re going to use a PathView embedded in a ScrollView . Check ScrollingPathView.swift to see

this, with some extra styling. You’ll use this new view in two places. First, open ImageEditView.swift and replace // path view here with: ScrollingPathView(url: $imageURL)

Next, go to ThumbsView.swift and this time replace // path view here with: ScrollingPathView(url: $folderURL)

Build and run. Import an image and a folder and look at the new path control:

Displaying the path.

271

macOS by Tutorials

Chapter 13: Adding the Interface

If the file path is too long to see, scroll sideways. This is neat, but wouldn’t it be nice to be able to double-click a file or folder in that path to show it? For this to work, you’ll add a Coordinator to PathView . A Coordinator is a class that allows NSViewRepresentable views to respond to events or delegate methods. To make a Coordinator, open PathView.swift and add this class. Since it’s only used by PathView , you can put it inside the PathView structure: // 1 class Coordinator { // 2 @objc func handleDoubleClick(sender: NSPathControl) { // 3 if let url = sender.clickedPathItem?.url { // 4 NSWorkspace.shared.selectFile( url.path, inFileViewerRootedAtPath: "") } } }

This is diving into AppKit again, but what’s it doing? 1. Declare a class that can act as the coordinator for PathView . 2. Add a method to respond to double-clicks in the NSPathControl . This must have the @objc marker so NSPathControl can recognize and call it. 3. Checking if a URL was double-clicked. 4. If so, displaying it in a Finder window using NSWorkspace . NSWorkspace gives access to in-built apps and services. You used it in Chapter

3, “Adding Menus & Toolbars”, to open a URL in the default browser, but you can also use it to open Finder windows. The Apple docs say that if you supply an empty string for the inFileViewerRootedAtPath parameter, it uses the current Finder window.

Giving it any string makes it open a new window. In macOS 12, they seem to have reversed this. With the Coordinator class in place, you can connect it. Add this new method to PathView : func makeCoordinator() -> Coordinator { return Coordinator() }

272

macOS by Tutorials

Chapter 13: Adding the Interface

This is a standard method of NSViewRepresentable for setting up a Coordinator, and now you can tell PathView to use it. Inside makeNSView(context:) , before the return line, add this: pathControl.target = context.coordinator pathControl.doubleAction = #selector(Coordinator.handleDoubleClick)

AppKit controls work using targets and actions. You specify a target object to receive events from the control, and you specify a selector as the action that the control calls for a specific event. You did this graphically in the Time-ato app using @IBAction . Here you set the coordinator as the target and its handleDoubleClick(sender:) method as the double-click action.

Build and run the app, import an image and double-click any item in the path control to open it in Finder:

Opening a Finder window.

273

macOS by Tutorials

Chapter 13: Adding the Interface

Using sips You now know a lot more about file dialogs, about dragging and dropping files and about file paths. But isn’t it time to start editing some images? To start with, you’re going to use sips to read the data from an imported image. You have a general-purpose class called CommandRunner . You could add all the sips commands there, but to keep it as reusable as possible, you’re going to

make a separate class to access sips . In the Utilities group, make a new Swift file called SipsRunner.swift. Replace its contents with: import SwiftUI // 1 class SipsRunner: ObservableObject { // 2 var commandRunner = CommandRunner() // 3 var sipsCommandPath: String? func checkSipsCommandPath() async -> String? { if sipsCommandPath == nil { sipsCommandPath = await commandRunner.pathTo(command: "sips") } return sipsCommandPath } // 4 func getImageData(for imageURL: URL) async -> String { // 5 guard let sipsCommandPath = await checkSipsCommandPath() else { return "" } // 6 let args = ["--getProperty", "all", imageURL.path] let imageData = await commandRunner .runCommand(sipsCommandPath, with: args) return imageData } }

What’s happening here? 1. Declare a new class and indicate it conforms to ObservableObject . 2. The SipsRunner class has its own instance of CommandRunner . 3. Every use of the sips command needs its full path, so you store it in a property. The method sets it, if needed, and returns the stored value. 4. getImageData(for:) runs the sips command to get all the data for an image. 274

macOS by Tutorials

Chapter 13: Adding the Interface

5. This method starts by checking that it has a path to the sips command. 6. Then, it uses the same syntax you used in the playground and returns the image information as a String . A lot of different parts of this app need access to SipsRunner , so you’re going to set it up as an @EnvironmentObject . This avoids the need to pass it through each view in the hierarchy. Open ImageSipperApp.swift and add this property declaration at the top of the structure: @StateObject var sipsRunner = SipsRunner()

Next, add this modifier after ContentView() : .environmentObject(sipsRunner)

With this in place, you’re ready to start using it.

Reading Image Information Open ImageEditView.swift and add this line at the top of the structure: @EnvironmentObject var sipsRunner: SipsRunner

This gives the view access to sipsRunner . But whenever you add an EnvironmentObject to a SwiftUI view, you break the preview. To enable it again,

add an instance of the EnvironmentObject as a modifier to the preview, like this: ImageEditView(selectedTab: .constant(.editImage)) .environmentObject(SipsRunner())

You’ll need to do this for every view that gets an EnvironmentObject before you can use its preview. Next, find the empty method called getImageData() and fill it in with: // 1 guard let imageURL = imageURL, FileManager.default.isImageFile(url: imageURL) else { return

275

macOS by Tutorials

Chapter 13: Adding the Interface

} // 2 let imageData = await sipsRunner.getImageData(for: imageURL) // 3 picture = Picture(url: imageURL, sipsData: imageData)

The view calls this method whenever imageURL changes, but what does it do? 1. Confirm there’s a URL and that it points to an image file. 2. Use sipsRunner to get the image file’s information. 3. Convert that information into a Picture . ImageEditControls has a binding to the picture , so it can now display the image properties and activate its controls. Build and run, select any image file and you’ll see its dimensions and format appear:

Showing image information.

Showing the Terminal Output So far, the terminal output view has remained stubbornly unchanged, so before adding any more commands, how about making it show what’s going on? Open CommandRunner.swift. It has a published property called output and a method to publish this. The methods that modify output are asynchronous, but you want output to change the UI. So publishOutput(_:) updates output on the MainActor to avoid errors caused by trying to update the

276

macOS by Tutorials

Chapter 13: Adding the Interface

interface from a background thread. This is equivalent to using DispatchQueue.main.async { } .

As each chunk of data becomes available, runCommand(_:with:) updates output . The sips commands all run quickly, but if you ran a slow command

like ping , you’d see each line as it arrived. The next task is to get the data from CommandRunner into TerminalView . Since SipsRunner owns CommandRunner , your first thought might be to set the Text view to show sipsRunner.commandRunner.output . That compiles

without error, but gets no data. You have to give TerminalRunner access to CommandRunner directly, which takes several steps.

Start in ContentView.swift and add this property to ContentView : @EnvironmentObject var sipsRunner: SipsRunner

Now that ContentView can access sipsRunner , it can pass its commandRunner to TerminalView . Change the TerminalView() line to this, ignoring the error: TerminalView(commandRunner: sipsRunner.commandRunner)

Now, open TerminalView.swift and replace the output property declaration with: @ObservedObject var commandRunner: CommandRunner

Chasing the errors, inside the ScrollView , replace Text(output) with: Text(commandRunner.output)

Next, replace the Clear Terminal button’s action with: commandRunner.clearOutput()

And now you’re left with a single error in the preview. You can fix it by changing the contents of previews to: TerminalView(commandRunner: CommandRunner())

277

macOS by Tutorials

Chapter 13: Adding the Interface

That was a convoluted chain of data passing, but finally, you can build and run the app. Drag an image into the view and read the terminal output at the side to see the same information you saw in the playground:

Terminal output

While not really necessary in this app, showing the terminal output might be important in other similar apps.

Resizing Images You’ve assembled a lot of the components of the app, so it’s finally time to resize some images. This requires a new SipsRunner method. Open SipsRunner.swift and add this: // 1 func resizeImage( picture: Picture, newWidth: String, newHeight: String, newFormat: PicFormat ) async -> URL? { // 2 guard let sipsCommandPath = await checkSipsCommandPath() else {

278

macOS by Tutorials

Chapter 13: Adding the Interface

return nil } // 3 let fileManager = FileManager.default let suffix = "-> \(newWidth) x \(newHeight)" var newURL = fileManager.addSuffix(of: suffix, to: picture.url) newURL = fileManager.changeFileExtension( of: newURL, to: newFormat.rawValue ) // 4 let args = [ "--resampleHeightWidth", newHeight, newWidth, "--setProperty", "format", newFormat.rawValue, picture.url.path, "--out", newURL.path ] // 5 _ = await commandRunner.runCommand(sipsCommandPath, with: args) // 6 return newURL }

It looks like a lot going on here, but taking it bit by bit: 1. You’ll call this method with a Picture and supply the edited parameters. 2. As before, the first task is to check for the sipsCommandPath . 3. Use the FileManager extension to generate a new file URL based on the image’s size and format. 4. This chunk assembles the arguments to pass to sips like you did in the playground. In this case, you’re changing the height, the width and the image format. You’re also providing the input and output file paths. 5. Run the sips command, ignoring the result. 6. Return the URL to the newly created file. With this in place, open ImageEditControls.swift. This is the subview that holds the editing facilities, so it’s where you assemble the data for sipsRunner . Start by giving the view access to sipsRunner by adding this at the top: @EnvironmentObject var sipsRunner: SipsRunner

Next, scroll down to find the empty resizeImage() method and fill it in with: // 1 guard let picture = picture else {

279

macOS by Tutorials

Chapter 13: Adding the Interface

return } // 2 imageURL = await sipsRunner.resizeImage( picture: picture, newWidth: picWidth, newHeight: picHeight, newFormat: picFormat)

And what does this do? 1. Check to see if there’s a picture to resize. 2. Call the method you just added to sipsRunner using the values from the edit fields and the format picker. This sets imageURL to the returned value so the newly edited image appears in the edit view.

The Mac Sandbox Again It looks like everything is in place, but wait just one moment… There’s a problem with the Mac sandbox. Terminal gets access to almost everything on your Mac. A Swift playground also has permission to read and write freely. But an app doesn’t, so if you run the app right now and try to resize an image, it’ll fail and the Xcode console will show Operation not permitted. When your app saves files, it can save them in its own container. That’s no good for this app as your users won’t be able to find them. There’s a sandbox option to allow access to user selected files, so you’d think that if you asked the user to choose a save location then it would work, but it won’t. You’re using a Process to save the new image file, and it bypasses all the usual mechanisms. The solution is to turn off the sandbox for this app. The downside of this is that you won’t be able to distribute the app through the Mac App Store. To turn off the sandbox, select the project at the top of the Project navigator. Choose the ImageSipper target and then Signing & Capabilities across the top. Click the X at the top right of the App Sandbox section to remove it:

280

macOS by Tutorials

Chapter 13: Adding the Interface

Turn off the sandbox.

And now, you’re finally ready to resize your images. Build and run the app. Import any image file, change some settings and then click Resize Image. You’ll see the commands in the terminal output and your new file will appear in the same folder as the original. The edit view will show your resized image:

Resizing an image.

Unless you calculated the new dimensions very carefully, your first impression is probably that this has squished and distorted your image. And you’d be right, so now it’s time to consider aspect ratios.

Locking the Aspect Ratio If you’ve used SwiftUI’s Image view, you’ll be familiar with aspect ratios. When displaying an image in SwiftUI, you set .aspectRatio(contentMode: .fit) or .aspectRatio(contentMode: .fill) to make it look right.

In this case, you want to give the user the option of locking the aspect ratio, to 281

macOS by Tutorials

Chapter 13: Adding the Interface

avoid distorting the image, or unlocking it, if they prefer. Preview has the same feature in its Adjust Size dialog where you can choose to scale proportionally or not:

Resizing in Preview

The app already has a button that looks like it locks and unlocks the aspect ratio, only it doesn’t do anything yet. But before you start coding, consider the problem. You can add an onChange modifier to detect changes to the width and, whenever it changes, alter the height to match. And you can add a similar modifier to detect the height that adjusts the width modifier. But each of these will trigger the other: You adjust the width, which changes the height, which changes the width, which changes the height and so on for ever. What you need is a way to determine the field the user is currently editing so you only adjust the other dimension programmatically. You’re going to use @FocusState to track this.

Focusing on Edit Fields Start by opening ImageEditControls.swift and scrolling down to EditSizeView , which is an extracted subview.

Add these two properties: @FocusState private var widthFieldHasFocus: Bool @FocusState private var heightFieldHasFocus: Bool

You’ll use these to keep track of the focused field, but the fields need to set these values. The property wrapper marks these properties as ones the views can modify as they get and lose focus. Replace the first VStack in body with this: 282

macOS by Tutorials VStack { HStack { Text("Width:").frame(width: 50) TextField("", text: $picWidth) .focused($widthFieldHasFocus) .frame(maxWidth: 60) } HStack { Text("Height:").frame(width: 50) TextField("", text: $picHeight) .focused($heightFieldHasFocus) .frame(maxWidth: 60) } }

Chapter 13: Adding the Interface

// NEW

// NEW

The two new lines are the focused modifiers. They bind the focused state of the fields to the two properties you added, so when the width field is active, widthFieldHasFocus is true , but when the cursor leaves that field, it resets to false .

And now you have the information you need to adjust the aspect ratios without getting into an infinite loop. :] Insert these two modifiers just before the end of EditSizeView ’s body , replacing // onChanges here : // 1 .onChange(of: picWidth) { newValue in // 2 if widthFieldHasFocus { // 3 adjustAspectRatio(newWidth: newValue, newHeight: nil) } } // 4 .onChange(of: picHeight) { newValue in if heightFieldHasFocus { adjustAspectRatio(newWidth: nil, newHeight: newValue) } }

What do they do? 1. Detect whenever the picWidth property changes. 2. Check if the width field has focus. 3. If it does, call adjustAspectRatio(newWidth:newHeight:) with the new width, leaving the newHeight parameter set to nil . 4. Do the same for the height field, but this time calling adjustAspectRatio(newWidth:newHeight:) with the new height, leaving the

283

macOS by Tutorials

Chapter 13: Adding the Interface

newWidth parameter set to nil .

Build and run the app, drag in a photo and adjust either dimension. Now, the other dimension adjusts to match. Unlock the lock button and you can set both sides independently again:

Locked aspect ratio.

That wraps up the image editing side of the app. Now, it’s time to process a folder of images.

Creating Thumbnails When you’re running sips from Terminal, it can batch process files using wild cards. Look at this command: sips --resampleHeight 600 *.png --out resized_images

This grabs every PNG file in the current directory, adjusts its height to 600 and saves the resized image into the resized_images folder. You can’t do this in a Swift Process , because Process requires exact file paths and doesn’t work with wild cards or file path abbreviations. But you know how to write loops in Swift, so you can list the files and process them one at a time. When you edit image files, you assign each one a new name. When creating thumbnails for a folder of files, you’ll keep the names the same but save the thumbnail files into a different folder. This requires another NSOpenPanel to 284

macOS by Tutorials

Chapter 13: Adding the Interface

allow choosing — and optionally creating — a destination folder. Open ThumbControls.swift; this is where the action takes place. Find the empty selectThumbsFolder() method and fill it with this: let openPanel = NSOpenPanel() openPanel.message = "Select the thumbnails folder:" // 1 openPanel.canCreateDirectories = true openPanel.canChooseDirectories = true openPanel.canChooseFiles = false openPanel.allowsMultipleSelection = false openPanel.begin { response in if response == .OK, let url = openPanel.url { // 2 Task { await createThumbs(in: url) } } }

This is like the panel you used to allow selection of a folder, but: 1. The main difference is canCreateDirectories . This is false by default but changing it to true allows the user to create a new folder from within the panel. 2. If the user selects a folder URL, use Task to call createThumbs(in:) asynchronously.

Adding a New sips Command With this in place, you now need to supply the code to create the thumbnails, so open SipsRunner.swift and add this: // 1 func createThumbs( in folder: URL, from imageURLs: [URL], maxDimension: String ) async { // 2 guard let sipsCommandPath = await checkSipsCommandPath() else { return } // 3 for imageURL in imageURLs { let args = [ "--resampleHeightWidthMax", maxDimension, imageURL.path,

285

macOS by Tutorials

Chapter 13: Adding the Interface

"--out", folder.path ] // 4 _ = await commandRunner.runCommand(sipsCommandPath, with: args) } }

What’s this method doing? 1. It receives a URL to the destination folder, an array of image file URLs and the maximum dimension for the thumbnails. 2. As with all the SipsRunner methods, it starts by checking for the sips executable file path. 3. Then, it loops through the image URLs, setting their maximum dimension to the maxDimension parameter. This means that images in landscape format have their width constrained to that dimension, and portrait images have their height limited. The --out parameter is a folder path, so sips uses the same file names, but in the new folder. 4. Wait for sips to save the thumbnail image. You’ve got the ability to ask for a folder, and you have the method to create the thumbnail files. Now, you need to join these together.

Calling the New Command Go back to ThumbControls.swift and start by adding the EnvironmentObject to give it access to sipsRunner : @EnvironmentObject var sipsRunner: SipsRunner

Next, fill in createThumbs(in:) with: // 1 await sipsRunner.createThumbs( in: folder, from: imageURLs, maxDimension: maxDimension) // 2 outputFolder = folder // 3 showAlert = true

What does this do? 286

macOS by Tutorials

Chapter 13: Adding the Interface

1. selectThumbsFolder() calls this method after the user chooses a destination folder. It then calls the SipsRunner method, passing the folder URL, the images and the maximum dimension. 2. When this returns, it sets a property to hold the URL of the destination folder for use in an alert. 3. Then, it turns on a flag to display an alert.

Showing an Alert When you edit an image, the new image appears in the edit view. This shows the user the edit has worked. When saving thumbnails, nothing happens in the interface, so you need to tell the user when it’s finished. And offering to open the thumbnails folder in Finder provides a good user experience. Still in ThumbControls.swift, find // alert goes here near the end of body , and replace it with:

// 1 .alert(Text("Thumbnails created"), isPresented: $showAlert) { // 2 if let outputFolder = outputFolder { // 3 Button("Show in Finder") { NSWorkspace.shared.selectFile( outputFolder.path, inFileViewerRootedAtPath: "") } } // 4 Button("OK") {} } message: { // 5 Text("\(imageURLs.count) thumbnails have been created.") }

And what’s going on here? 1. Add an alert modifier with a title, and set it to appear whenever showAlert is true. This property is already defined at the top of the view,

and you toggled it when you created the thumbnails. 2. Confirm that outputFolder is set. Only display the Show in Finder button if it is. 3. Add a button to the alert, and set its action to use NSWorkspace to open the outputFolder in Finder.

4. Add a standard OK button that won’t do anything except close the alert.

287

macOS by Tutorials

Chapter 13: Adding the Interface

5. Supply a message showing how many files this processed. With all this in place, it’s time to test it. Build and run the app. Switch to the Make Thumbnails tab and import a folder of images. Enter a maximum dimension, click the Save Thumbnails button, and follow the prompts to select a folder and show it in Finder:

Thumbnail image.

And that’s it. You started with some functions in a playground and an app with a user interface that did nothing. You’ve ended with an app that can edit image files and process a folder of images. Great job!

Challenge Challenge: Create your app icon using ImageSipper In the downloaded assets for this chapter, open the app icon folder. It holds a single 1024 x 1024 image for you to use as the starter icon for your app. Back in Xcode, open Assets.xcassets and click AppIcon. This shows you a box for every size of icon image you need. Some sizes have more than one box. Build and run ImageSipper, and import the starting icon file. Resize this sequentially to get all the sizes you need. Then drag them into the AppIcon boxes to create your icon. Remember to clean the build folder using Shift-Command-K to make Xcode incorporate the new icon into your build. 288

macOS by Tutorials

Chapter 13: Adding the Interface

Key Points You previously used NSSavePanel to open a file dialog for saving files. NSOpenPanel is similar, but it’s used to select files or folders.

Drag and drop works well in SwiftUI. Getting the dropped data depends on what your drop accepts. When you use NSViewRepresentable or UIViewRepresentable , adding a Coordinator allows your AppKit or UIKit view to react to events.

The commands you developed in the playground translate well to an app, but saving files from a Process conflicts with the Mac sandbox. You can track the active edit field using focused and @FocusState . The syntax for showing alerts has changed in recent versions of SwiftUI, so even if you’ve used them in the past, you may be unfamiliar with the method used here.

Where to Go From Here? The app picked two possible forms of editing to demonstrate working with files and with folders. You may have a completely different use case for the sips command. Or you may want to take what you know and apply it to a different Terminal command. You now have all the tools you need to do either of those. In the next chapter, you’re going to look into automation. You’ll add a service to ImageSipper that’ll appear in the standard Services menu. And you’ll publish a shortcut for use in the Shortcuts app.

289

macOS by Tutorials

14

Automation for Your App Written by Sarah Reichelt

In the previous chapter, you added a graphical user interface over the top of some Terminal commands and made them much easier to use. Now, you’re going to open some features of your app so other parts of macOS can access them and use them for automation. You’ll do this in two ways: by providing a service to the system-wide Services menu and by publishing a shortcut for use by the Shortcuts app.

What is Automation? For any app, there are two forms of automation. The first is when the app itself performs a task, especially when that task would have been long, tedious or finicky. ImageSipper already does this. Imagine how boring and timeconsuming it would be to generate a thumbnail for every image in a large folder. Now, you can do it with just a few clicks. The other way is to make various features of your app available to other apps or system services so your app can become part of an automated workflow. This is what you’re going to enable in this chapter. When looking at automation on macOS, there are several alternatives. One of the most common is the Services menu. This is a submenu that appears in every app’s own app menu. You can also find Services in contextual menus. Right-click any piece of text or any file to see what services you can use. What you see in the menu depends on what you’ve selected and what apps you’ve installed. Another possibility for automation is scripting. You can use shell scripts, AppleScripts and various other languages — even Swift! Scripting languages are outside the scope of this book, but they’re another facet of automation. The final option is through automation apps. Apple provides two such apps. Automator has been around for a while, but at WWDC 2021, Apple introduced Shortcuts for the Mac. Previously, this was available on iOS. Automator can be useful, and it comes with an extensive library of actions, as well as the ability to add custom actions using AppleScript or shell scripts. However, the Shortcuts app enables you to publish actions directly from your app. 290

macOS by Tutorials

Chapter 14: Automation for Your App

In this chapter, you’ll supply a service and publish a shortcut.

Adding a Service First, you’ll add a service. In Chapter 10, “Creating A Document-Based App”, you set up file types so the app could open Markdown files. ImageSipper isn’t a document-based app, so you can’t do this. Instead, you’re going to add an Open in ImageSipper menu item to the Services menu. This will open the selected image file or folder in the app, launching the app if necessary. Adding a service to your app takes two steps. First, you’ll edit the app’s Info.plist so its menu item appears in the Services menu when appropriate. Second, you’ll write the code to handle the Services menu selection.

Editing Info.plist Open your project from the last chapter or use the starter project from this chapter’s projects folder in the downloaded materials. In older versions of Xcode, you’d see the Info.plist file in the Project navigator, but in Xcode 13, it’s hidden until you change it in the target settings. Select the project at the top of the Project navigator, click the ImageSipper target and choose Info from the tabs across the top. Right-click the last line in the Custom macOS Application Target Properties section and select Add Row from the popup menu:

291

macOS by Tutorials

Chapter 14: Automation for Your App

Adding a row to Info.plist.

Start typing Services (with an upper case S) and press Return to select it when autocomplete finds it. Press Return once more to add the new row. This adds a new array to your Info.plist. Now, you can select Info.plist in the Project navigator. It’s easier to edit it from there as Xcode hides the default values. Option-click the disclosure arrow beside Services to expand it fully:

Editing Info.plist

Your new Services array has a single element, labeled Item 0. It’s a dictionary with two required elements. These are currently blank, waiting for values.

Filling in the Service Item Select the Menu item title row and then click in its Value column to start editing. This sets the title of the item in the Services menu. Enter Open in ImageSipper and press Return to move to the next field.

292

macOS by Tutorials

Chapter 14: Automation for Your App

This is the Instance method name and holds the name of the method that the service will call. You don’t have a method yet, but set the name to openFromService :

Editing settings

Now, you’ll add four more elements (rows) to the Item 0 dictionary: 1. Incoming service port name: String 2. Send Types: Array 3. NSSendFileTypes: Array 4. NSRequiredContext: Dictionary

Note: There are multiple ways of adding rows to Info.plist, and this section deliberately shows several different options. Once you know what’s possible, you can choose your favorite.

Right-click in the blank space below the entries and select Add Row. Choose Incoming service port name and set its value to ImageSipper. This tells the service the name of the app to call. Next, right-click the last row, select Add Row and choose Send Types from the menu. Press Return to add this array property. Expand Send Types and set the value for Item 0 to string . This means the service method receives a String when an application requests this service. The send type is an NSPasteboard.PasteboardType . Apple’s NSPasteboard.PasteboardType documentation lists the possible types. So far, the rows you’ve added have had names that the editor knows and could autocomplete. Next, you’ll add some unknown entries.

Note: You’re about to add two Services Properties from this list.

293

macOS by Tutorials

Chapter 14: Automation for Your App

Start by collapsing Send Types and clicking the small + button beside the name. If you did this with Send Types expanded, you’d be adding another element to its array. By collapsing first, you’re adding to the Services ▸ Item 0 dictionary. Set the name of the new row to NSSendFileTypes. Press Return and the type should be set to Array , but if it’s still String , click that and select Array from the contextual menu. Expand the new array and click its + button so you have two elements in the array. The service will allow you to select either image files or folders, and this is where you set that up. Set the value for NSSendFileTypes ▸ Item 0 to public.folder. Then, set the value for NSSendFileTypes ▸ Item 1 to public.image:

Send types settings

Apple provides a list of System-Declared Uniform Type Identifiers you can use to work out what to add here. You’ve now set up a service with a menu item title of Open in ImageSipper that provides a String to a method called openFromService in the ImageSipper app. It will only work with file types that are folders or image files.

Setting the Context You’re nearly finished with Info.plist. There’s just one more step, and it’s an important one. Lots of apps on your Mac have services, and you don’t want the Services menu showing them all every time. So you set a context for the service to tell the system when it’s appropriate to show your particular service. In this case, you only want to show it when the user selects an image file or a folder. Collapse NSSendFileTypes and click its + button. Type in the name for the new 294

macOS by Tutorials

Chapter 14: Automation for Your App

row: NSRequiredContext. Its default type is Array , so click that and change its type to Dictionary :

Changing the type.

Expand the new row. When you added elements to an array, Xcode assigned them names like Item 0, Item 1 and so on. With a dictionary, you get to set the name and the value. Change the name of the New item dictionary element to NSTextContent and the value to FilePath. Now, the Services menu knows only to show your menu item when you have a file path selected and when that file path points to an image or to a folder. That was a lot of setup work! To check the raw data, right-click Info.plist in the Project navigator and select Open As ▸ Source Code to see the XML:



NSServices

NSMenuItem

default Open in ImageSipper

NSMessage openFromService NSPortName ImageSipper

295

macOS by Tutorials

Chapter 14: Automation for Your App

NSSendTypes

string

NSSendFileTypes

public.folder public.image

NSRequiredContext

NSTextContent FilePath



Note: When adding entries using autocomplete, you get a friendly name like Incoming service port name. Behind the scenes, this has an AppKit title: NSPortName in this case. The XML file only shows the AppKit titles.

Click the back button to return to the property list view. That completes the first part of the setup. You’ve configured the Info.plist, and that’s enough to start testing.

Testing the Services Menu Build and run the app now. It looks unchanged, but behind the scenes, it’s registered your new service. Switch to Finder, select any image file and rightclick. Do you see a Services menu or an Open in ImageSipper item at the end of the contextual menu? What about if you right-click a folder?

Right-clicking a file.

If you don’t see this, don’t panic! Despite all your hard work, Open in 296

macOS by Tutorials

Chapter 14: Automation for Your App

ImageSipper may not show up. You haven’t done anything wrong, but macOS only scans for new services periodically, so yours may not appear immediately. To fix this, you’ll use the pbs Terminal command. Open Terminal, and enter man pbs to read what it does. When you’ve finished, press q to exit.

If you run the pbs command directly, Terminal returns zsh: command not found: pbs . This is because pbs is only intended for use while debugging

services, and so macOS has hidden it in a folder that Terminal doesn’t scan for commands. Still in Terminal, run these two commands that use the full path: /System/Library/CoreServices/pbs -flush /System/Library/CoreServices/pbs -update

You’ve forced macOS to reset and refresh the list of services, so now Open in ImageSipper appears. As well as right-clicking files and folders directly, test selecting an image file or folder and opening the Finder ▸ Services menu:

Services menu

Select a text file and confirm that Open in ImageSipper does not appear, in either the Finder ▸ Services menu or the right-click contextual menu. Your app now publishes a service that’s only available when appropriate. Great! If you try to use it right now, ImageSipper opens, but then Finder freezes for while, because you haven’t set up the method yet. So now, you need to handle the incoming service call.

Handling the Service Call Before your app can respond to a service call, it needs a servicesProvider . Open ImageSipperApp.swift and add this new class at the bottom: 297

macOS by Tutorials

Chapter 14: Automation for Your App

class ServiceProvider { }

You can use the class already, even though it’s empty. At the top of ImageSipperApp , declare a property to hold the ServiceProvider :

var serviceProvider = ServiceProvider()

And after setting the environmentObject on ContentView , add this: .onAppear { NSApp.servicesProvider = serviceProvider }

Now, when ContentView appears, it’ll set NSApp ’s servicesProvider to the new instance of your ServiceProvider class. NSApp is shorthand for NSApplication.shared , which is a reference to the running app.

Next, you’ll fill in the method. In Info.plist, you set the method name to openFromService , so add this to the ServiceProvider class:

// 1 @objc func openFromService( _ pboard: NSPasteboard, userData: String, error: NSErrorPointer ) { // 2 let fileType = NSPasteboard.PasteboardType.fileURL guard // 3 let filePath = pboard.pasteboardItems?.first? .string(forType: fileType), // 4 let url = URL(string: filePath) else { return } // 5 NSApp.activate(ignoringOtherApps: true) // handle url here }

What’s happening here? 1. Declare the method using the expected name. You must mark the method as 298

macOS by Tutorials

Chapter 14: Automation for Your App

@objc for the service to be able to access it. The arguments to the method

are the arguments that every service call sends. 2. The only argument you’re interested in is the NSPasteboard . Services pass data around using an internal pasteboard. The type of data you want to get from the pasteboard is a file URL, as you specified in Info.plist. 3. Check the first item in the pasteboard for a String of the correct type. 4. If you get a String , convert it to a URL . This is similar to how you extracted URLs from drop operations in the previous chapter. 5. Bring the app to the front, launching it if necessary, so it can process the URL.

Processing URLs Your app receives data and — hopefully — converts it into a URL. Now what? First, you have to work out whether the URL points to a folder or to an image file. Then, you must pass this data to one of the views. But how can ServiceProvider communicate with ImageEditView and ThumbsView ? By

using NotificationCenter ! Still in ImageSipperApp.swift, but outside any class or structure, add this extension: extension Notification.Name { static let serviceReceivedImage = Notification.Name("serviceReceivedImage") static let serviceReceivedFolder = Notification.Name("serviceReceivedFolder") }

This sets up names for the two different notifications you’ll use. Next, in openFromService(_:userData:error:) , replace // handle url here with: // 1 let fileManager = FileManager.default // 2 if fileManager.isFolder(url: url) { // 3 NotificationCenter.default.post( name: .serviceReceivedFolder, object: url) } else if fileManager.isImageFile(url: url) { // 4 NotificationCenter.default.post( name: .serviceReceivedImage,

299

macOS by Tutorials

Chapter 14: Automation for Your App

object: url) }

Stepping through this, you: 1. Get the default FileManager . 2. Use the FileManager extension to test if url points to a folder. 3. If it does, post the serviceReceivedFolder notification to NotificationCenter , passing url as the notification’s object .

4. If url points to an image file, post the serviceReceivedImage notification. Now, you’re detecting the service call, processing its data to get a URL and posting an appropriate notification. The next step is to have the views receive these notifications.

Receiving Noti cations Each of the main views will handle one of the notifications. Start with an image file URL. Open Views/ImageEditView.swift and add this declaration at the top of ImageEditView :

let serviceReceivedImageNotification = NotificationCenter.default .publisher(for: .serviceReceivedImage) .receive(on: RunLoop.main)

This sets up a NotificationCenter.Publisher to receive any notifications with the serviceReceivedImage name. As this will update the UI, you receive it on the main run loop. Next, add this at the end of body , after the onChange modifier: // 1 .onReceive(serviceReceivedImageNotification) { notification in // 2 if let url = notification.object as? URL { // 3 selectedTab = .editImage // 4 imageURL = url } }

What does this code do?

300

macOS by Tutorials

Chapter 14: Automation for Your App

1. Detect when the publisher receives a notification. 2. Check if the notification’s object is a URL . 3. Set selectedTab , which swaps to this view if needed. 4. Assign imageURL to import the image. The process for a folder URL is very similar. Open ThumbsView.swift and add this publisher: let serviceReceivedFolderNotification = NotificationCenter.default .publisher(for: .serviceReceivedFolder) .receive(on: RunLoop.main)

And below the onDrop modifier, add this: .onReceive(serviceReceivedFolderNotification) { notification in if let url = notification.object as? URL { selectedTab = .makeThumbs folderURL = url } }

This has been a lot of work, but now you’re ready to try it out.

Using the Service Quit the app if it’s already running. Press Command-B to compile the new code — there’s no need to run it. Switch to Finder, select an image file and choose Open in ImageSipper from the contextual menu or from the Services menu:

Opening an image.

Now test with a folder: 301

macOS by Tutorials

Chapter 14: Automation for Your App

Opening a folder.

Note: Depending on the number of services you have installed, you may see a Services submenu at the end of the contextual menu. If there are only a few options, Finder shows them directly.

That finishes the task of adding a service. Your service only appears when appropriate, and it communicates back to your app. Good job! And now, time to build the shortcut.

Adding a Shortcut Creating a service took a lot of steps, and you had to do many of them manually with no help from autocomplete. Adding a shortcut is slightly easier because Xcode provides a file template for you to fill in. Back in Xcode, right-click the ImageSipper folder that’s the second item in the Project navigator and select New File…. Search for intent and select the SiriKit Intent Definition File template. Click Next and save the file with the default name.

Intent file template

302

macOS by Tutorials

Chapter 14: Automation for Your App

An intent is what Apple calls a service that you publish for use by Siri or by the Shortcuts app. Make sure you’ve selected Intents.intentdefinition in the Project navigator. Click its + button and choose New Intent from the menu:

Adding a new intent.

Change the name of your new intent to PrepareForWeb and, optionally, fill in the description. Its task is to process an image so that it’s suitable for use on a web page.

Intent settings

In the Parameters section, click + to add a new parameter. 1. Change the name of the new parameter to url. 2. Set its Display Name to image file and its Type to File. 3. In the File Type popup, choose Image. 4. Finally, set the Siri Dialog prompt to Select an image file:

303

macOS by Tutorials

Chapter 14: Automation for Your App

Intent parameter

Now, your intent expects a single parameter called url — a file path URL pointing to an image file. Scroll down to the Shortcuts app section and set both Input Parameter and Key Parameter to url. In the Summary, start typing Prepare url for web. When you’ve typed url, select the url parameter from the autocomplete popup, then finish typing the summary.

Intent shortcuts

The preview shows Prepare image file for web:

304

macOS by Tutorials

Chapter 14: Automation for Your App

Coding the Intent Now that you’ve defined your intent, press Command-B to build the app. Switch to the Report navigator and look at the most recent build log:

Build log

As you’d expect, Xcode has processed your Intents.intentdefinition file, but in the Compile phase, Xcode compiled a file called PrepareForWebIntent.swift. You can’t see this file in your project, but it defines the classes and protocols your intent needs. And with those in place, you can start to use them. First, switch back to the Project navigator, select the project and click the ImageSipper target. Go to the General tab. In the Supported Intents section, click the + button. Start typing Prepare and, when you can, select PrepareForWebintent from the autocomplete menu.

Adding the supported intent.

Now that the project knows you want to use this intent, it’s time to start coding for it. 305

macOS by Tutorials

Chapter 14: Automation for Your App

Open ImageSipperApp.swift and add this at the top of the file, just after import SwiftUI :

import Intents

Next, scroll to the end of the file and define a new class: class PrepareForWebIntentHandler: NSObject, PrepareForWebIntentHandling { }

This sets up the class to handle the intent, and it conforms to one of the protocols that Xcode created for you.

Note: If you’d like to have a look at the file that Xcode generated, Commandclick PrepareForWebIntentHandling and select Jump to Definition.

Xcode will now complain that this class does not conform to the protocol. Click the red blob in the error marker, and use Fix to add the protocol stubs.

Adding the Intent Handlers The fix adds four method stubs and causes two more errors, because Xcode supplied two versions of each method. One uses a callback and the other uses async . You want the async methods, so delete the two that are not marked as async .

Now you’re left with two methods. One handles the intent and the other resolves the url parameter. If your intent had more parameters, you’d have more resolve methods — one for each parameter. The resolve methods are there to make sure the shortcut has supplied the parameters before you try to use them. resolveUrl(for:) returns an INFileResolutionResult to either indicate a

successful match or request further action from the user. Replace the code placeholder in resolveUrl(for:) with: // 1 guard let url = intent.url else { return .confirmationRequired(with: nil) } // 2

306

macOS by Tutorials

Chapter 14: Automation for Your App

return .success(with: url)

You’re creating two possible results: 1. If the intent has no url property, you return an INFileResolutionResult that asks the user to confirm the url . 2. If there is a url , you return a success result. This method could do more validation, and it could even return a different URL, but that’s not necessary here. Now you know you’ve got a url parameter, you can move on to handling the intent. Replace the code placeholder in handle(intent:) with: // 1 guard let fileURL = intent.url?.fileURL else { // 2 return PrepareForWebIntentResponse( code: .continueInApp, userActivity: nil) } // 3 // sips call here // 4 return PrepareForWebIntentResponse( code: .success, userActivity: nil)

What are you doing here? 1. You already checked that the intent has a url , but this guard confirms it has a fileURL . 2. If not, you return a PrepareForWebIntentResponse , telling the intent to open the app. This custom response class was automatically generated. 3. If there’s a valid file URL, you call sips to process it. 4. Finally, you send back a success response. The next stage is to write the code to use sips to prepare the file.

Writing the Action Open Utilities/SipsRunner.swift and add this method to SipsRunner :

307

macOS by Tutorials

Chapter 14: Automation for Your App

func prepareForWeb(_ url: URL) async { // 1 guard let sipsCommandPath = await checkSipsCommandPath() else { return } // 2 let args = [ "--resampleHeightWidthMax", "800", url.path ] // 3 _ = await commandRunner.runCommand(sipsCommandPath, with: args) }

What does this method do? 1. Check for the sips command path, as always. 2. Set up the changes to make to the image. This is a simple edit to make sure no dimension exceeds 800 pixels. As cameras and screens get bigger, many images on the internet get bigger too. And we all hate waiting for pages to load slowly on a bad network! 3. Run the sips command. To call this method, open ImageSipperApp.swift and replace // sips call here with:

await SipsRunner().prepareForWeb(fileURL)

There’s one more step. You have to set up an Application Delegate to receive the intent and pass it to the handler class.

Con guring the Application Delegate When using the SwiftUI architecture, you don’t get a custom application delegate by default, but you can set one up yourself. Still in ImageSipperApp.swift, add this new class at the bottom of the file: // 1 class AppDelegate: NSObject, NSApplicationDelegate { // 2 func application( _ application: NSApplication, handlerFor intent: INIntent ) -> Any? { // 3 if intent is PrepareForWebIntent { return PrepareForWebIntentHandler()

308

macOS by Tutorials

Chapter 14: Automation for Your App

} // 4 return nil } }

In this code, you: 1. Create a new class that conforms to the NSApplicationDelegate protocol. 2. Add the NSApplicationDelegate method the system calls when it receives an intent. 3. Ensure this is the expected intent, and if so, return an instance of the handler class. If your app had more than one intent, you’d check for each one here. 4. Return nil for an unknown intent. With the class in place, set it as your app’s delegate by adding this property at the top of ImageSipperApp : @NSApplicationDelegateAdaptor(AppDelegate.self) var appDel

This uses a SwiftUI property wrapper to allocate a custom application delegate. And that’s it. You’ve configured your app to publish an intent the Shortcuts app can use.

Using the Shortcut Press Command-B to build the app and incorporate this new code into the built product. Next, open the Shortcuts app. If you’ve used Shortcuts on an iOS device, this will look familiar. It comes with a gallery of shortcuts and brings over your iOS shortcuts, most of which are not relevant to macOS. Select My Shortcuts/Quick Actions in the sidebar and click the + button in the toolbar to create a new one:

309

macOS by Tutorials

Chapter 14: Automation for Your App

New shortcut

In the Receive section, click Any, then click Clear in the pop-up window’s bottom bar. This unchecks everything and changes Any to No.

Clear content list.

Then click No and check Images. Next, change what happens if there’s no input. Click Continue and select Ask For from the popup menu. It suggests asking for Photos, which sounds perfect, but isn’t what you want. That takes you to your Photos library when you actually want to choose an image file. Click Photos and select Files from the menu.

310

macOS by Tutorials

Chapter 14: Automation for Your App

Receive Images; Ask for Files.

This gives your shortcut its input; now you configure how to process it. In the column on the right, select the Apps tab and scroll down to find ImageSipper. Select it and you’ll see your Prepare for Web intent. Hover over the intent to see its Info button, then click it to see the description you entered and details of the expected input and output.

Show intent info.

Click Add to Shortcut or drag the Prepare for Web intent into your shortcut:

311

macOS by Tutorials

Chapter 14: Automation for Your App

Drag intent into shortcut

This sets the url placeholder to Shortcut Input, which is exactly what you want. One of the strengths of Shortcuts is its ability to chain actions. So now you’ve prepared your file for the web, how about revealing it in Finder? Click Finder in the Apps list and drag Reveal Files in Finder into your shortcut. You aren’t changing the URL, so it can use the Shortcut Input too. Now, give your shortcut a name, then click its icon to select a color and image too.

Shortcut

And now you’re ready to try it out.

Note: This ImageSipper shortcut writes over its input image, so duplicate the

312

macOS by Tutorials

Chapter 14: Automation for Your App

image you’re going to use to test the shortcut, then work with the copy.

Click the Play button, and because you haven’t supplied a URL, you’ll get a file dialog instead. Select a large image and click Open:

Shortcut input

Note: The first time you run the shortcut, you might see one or more privacy dialogs. Click Always Allow or OK in all, so you don’t have to answer them again.

Your shortcut runs and Finder displays your image. The Finder preview may not update immediately, so press Command-I to confirm the image has shrunk.

Accessing Your Shortcut You’ve now used your intent in a shortcut, triggered from within the Shortcuts app. This is a great place to build workflows, but there are several other ways to access this shortcut. Back in the Shortcuts app, in your Prepare Image for Web window, click the Shortcut Details button over the right toolbar:

313

macOS by Tutorials

Chapter 14: Automation for Your App

Shortcut details

Select the Privacy tab to see the permissions you granted. You can reset them here any time. The Details tab is where you select how the user can access the shortcut. Use as Quick Action and Services Menu are on by default. Check Finder to add it to the list. Now, go back to Finder and choose a large image file. You now have three different ways to trigger the shortcut: 1. Right-click the image file and select Quick Actions ▸ Prepare Image for Web. 2. Select the image file and in the Finder menu, choose Services ▸ Prepare Image for Web. 3. Make sure you’ve turned on Show Preview for your Finder window. Press Shift-Command-P to toggle it if not. Underneath the preview, click More… and select Prepare Image for Web.

314

macOS by Tutorials

Chapter 14: Automation for Your App

Triggering the shortcut

Trouble-shooting Shortcuts Shortcuts can be tricky to debug when you’re still working on the parent app. Here are some tips to help if you get stuck. If you can’t see your intent in the Shortcuts app, delete Xcode’s derived data and then rebuild. To do this, open Terminal and enter this command: rm -rf ~/Library/Developer/Xcode/DerivedData

Make sure there isn’t a bug in the command your intent calls. Repurpose an existing button to call it and make sure it works. The version of CommandRunner in the starter project has error reporting, which may help.

If your shortcut hangs, and neither of these other fixes work, restart your computer.

Key Points You can write an app to perform automation internally, but your app can also provide automations for macOS to use. Services are system-wide utilities. When setting up your app to publish a service, it’s important to make sure it only appears when appropriate. Apple’s Shortcuts app is an automation service that allows users to build workflows. Intents provide services from your app to a shortcut. 315

macOS by Tutorials

Chapter 14: Automation for Your App

Where to Go From Here? For more information about services, check out Apple’s Services Implementation Guide. It’s quite an old document, but still valid. To learn about creating shortcuts, watch Meet Shortcuts for macOS from WWDC 2021. Think about how you could add more services or intents to this app. Or maybe you’ve got another app that you’d like to automate? You have the tools now, so go out there and use them!

316

macOS by Tutorials

15

Using the Mac App Store Written by Sarah Reichelt

After working through all the sections in this book, you now have four Mac apps. Each one is a different style of app, and each has a different purpose. The next step in your journey to becoming a Mac developer is distributing your apps. The App Store is the only distribution option for iOS apps, but macOS apps can use both the Mac App Store and external distribution. In this section, you’ll look at both these possibilities. This chapter discusses the options and then covers using the Mac App Store for testing and release.

Distribution Options What are the advantages and disadvantages of using the App Store? First the advantages: 1. Apple handles everything. They serve the apps, they handle payments and refunds, they provide a review and rating mechanism, and they provide crash reports. 2. Users feel more secure about downloading apps from the App Store and are less reluctant to provide payment details. 3. You can open your apps for beta testing using TestFlight. Apple handles the distribution, feedback mechanisms and expiration dates automatically. 4. It’s easy to release updates to your app’s users. You upload a new version and the App Store app installs it. And now for some disadvantages: 1. You’re at the mercy of the app review system. The reviewers pass most apps without problem or with minor modifications. But this isn’t always the case. 2. Apple takes a cut of your sales: either 30% or 15%, depending on whether you’re in the small business program. 3. Your app must be sandboxed. For most apps this is not a problem, but for an app like ImageSipper, this rules out the App Store. 4. Apple doesn’t support update pricing or free trials. You can fake a trial by 317

macOS by Tutorials

Chapter 15: Using the Mac App Store

making the app free and having an in-app purchase to unlock it, but this is messy. Apple has no provision for upgrade pricing. If you release a major revision and you want your existing users to pay, you have to create an entirely new app in the App Store. And then, there’s no way to give those existing users a discount. You may wonder why I haven’t included the cost of the Apple Developer program as a disadvantage. As you’ll see in the next chapter, Apple is gradually making this a necessity regardless of your distribution method. One nice thing is that you don’t have to pick one method or the other. You’re free to distribute your app through the App Store and externally at the same time. But for the rest of this chapter, you’re going to walk through the process of testing and distributing via the Mac App Store. If you’re familiar with this from working with iOS apps, or if you’ve decided not to use the Mac App Store, then you can skip ahead to the next chapter.

Setting up your Developer Account Apple has two sites that you’ll use. Apple Developer is where you’ll manage your membership, download beta software, read the forums and so on. And App Store Connect is where you’ll configure your apps, sign agreements, handle payments and perform other tasks related to distributing and supporting your app. The first step is to make sure you have an Apple Developer account. You’ll need an Apple ID with two-factor authentication turned on. Start the enrollment process at the Apple Developer Program page. Next, you need to accept agreements by going to Agreements, Tax, and Banking at App Store Connect. You must accept the Free Apps agreement. If you want to charge for your apps, you’ll also need to accept the Paid Apps agreement and provide your banking and tax information. Once you have a Developer account, link it to Xcode. Open Xcode and go to Xcode ▸ Preferences ▸ Accounts. Click the + button and follow the steps to add your Apple ID account:

318

macOS by Tutorials

Chapter 15: Using the Mac App Store

Linking your Apple ID to Xcode.

This allows Xcode to work out all the code signing and to generate the required certificates. And with all this set up, you can open Xcode to proceed. For the purposes of this chapter, you’ll make a sample project and work through the stages of setting it up in App Store Connect and testing it through TestFlight.

Identifying Your App Each app must have a unique identifier in the App Store. You set this using the Bundle Identifier. It identifies your app forever and can never change once you’ve created the app in App Store Connect. Create a new app project in Xcode and fill in the Organization Identifier and the Product Name. Xcode uses those to generate a Bundle Identifier for the app. The usual scheme for bundle identifiers is to reverse your domain name and add the app name. For example, imagine you develop as Great Mac Apps and your domain name is greatmacapps.com. When creating a new project for your ReallyUsefulApp, you’ll use com.greatmacapps as the Organization Identifier. Xcode generates a bundle identifier of com.greatmacapps.ReallyUsefulApp:

319

macOS by Tutorials

Chapter 15: Using the Mac App Store

Setting up your project.

You can use my example Organization Identifier, but you’ll have to come up with your own app name. I’ve registered ReallyUsefulApp so it won’t work for you. :]

Note: If you’ve already linked your Apple Developer account to Xcode, you can set the Team here, but you’ll see another way to set it later in the chapter.

Once you’ve saved your new project, select the top entry in the Project navigator, click the target and go to the General tab. In the Identity section, you’ll see the App Category, Bundle Identifier, Version and Build:

App Identity

Select the primary category for your app here. You’ll have to set this again in the App Information section of App Store Connect, where you can optionally choose a secondary category. But if you don’t set it here, Xcode complains later. If you want to change the default Bundle Identifier, enter the new one here. Version shows the versioning for the app. You can use whatever scheme you prefer, but one option is the Major.Minor.Patch scheme, often referred to as Semantic Versioning. In this scheme, the version has three numbers. Major 320

macOS by Tutorials

Chapter 15: Using the Mac App Store

updates increase the first number, minor changes increase the second number and bug fixes increase the third number. Each version can have many builds. Open the About box for any app you’ve built. You’ll see the version followed by a number or string in parentheses. The part in parentheses is the build. You must change this every time you upload your app to Apple.

Build number for Xcode

When you’re testing your app, you’ll keep the version the same and change the build to identify new updates. When you update the released app, you’ll change the version and the build.

Code Signing Apple uses code signing as a way to verify your app. When you upload your app to Apple, their servers analyze it, match it to your developer identity and attach a digital signature. If any malware changes the app’s code, the signature won’t match and macOS won’t run it. Still in Xcode at the target settings, move to the next tab: Signing & Capabilities. You’ve looked at this tab quite a few times already, but only the App Sandbox section. Now, you’re interested in the Signing section. The Bundle Identifier came across from the General tab. The item to set here is the Team. Since you’ve linked your account to Xcode, you can select your team name from this popup. This changes the Signing Certificate selection from Sign to Run Locally to Development:

321

macOS by Tutorials

Chapter 15: Using the Mac App Store

Selecting the signing team.

Press Command-B to build your app. If it’s the first time you’ve used your account, you’ll get a dialog asking you to give codesign permission to access your Apple Developer account in your keychain. Enter your Mac’s user password and click Always Allow. If you click Allow, you’re going to get very sick of this dialog! Next, go to the Product menu and select Archive. All the app builds you’ve done so far have been using the Debug configuration. Archiving uses the Release configuration, which removes debugging features only needed during development. When Xcode has finished creating the archive, it’ll open the Organizer window and show it to you:

Archive in Organizer window

Uploading Your App Now, it’s time to make Xcode earn its keep. This next stage used to be very convoluted and tedious, but modern versions of Xcode do a lot of the work for you. In the Organizer window, make sure you’ve selected the latest archive of your app and click Validate App. This checks to see if you’ve already set up the app at 322

macOS by Tutorials

Chapter 15: Using the Mac App Store

App Store Connect, and if not, offers to register it for you. This step saves a lot of time and effort. It creates an app identifier at Apple Developer, sets up a new app in App Store Connect and links the two:

Registering your app

Xcode suggests the app name and bundle identifier from the project. The language is your default language and the SKU also uses the bundle identifier. The SKU (Stock Keeping Unit) is an identifier for your purposes only. You can make it whatever you like, but it must be unique within your own apps. When you’re happy with these settings, click Next. At this point, you’ll find out whether your app’s name is available. If you get an error because the app name is already in use, go back, change the name, create a new archive and try again. Before starting a new app, it’s a good idea to do a preliminary search in the App Store for your preferred name. But even if it isn’t appearing in the App Store, another developer may have claimed the name and not released yet, or it may be available in another country. So at this stage, it’s trial and error until you find a unique name. Once you get past that stage, there are more questions. Leave Upload your app’s symbols checked so you can get usable crash logs. You’ll see how to work with them later in the chapter. Let Xcode manage the versioning as it suggests, then click Next again:

323

macOS by Tutorials

Chapter 15: Using the Mac App Store

App options

The next dialog asks about signing your app. Select Automatically manage signing as it’ll save you a lot of grief. Click Next again. If you’re missing any developer certificates, Xcode now offers to generate them for you. Accept the offer; it’s another huge convenience as it means you don’t have to go to Apple Developer, generate all the certificates, then download and import them into your keychain manually.

Signing options

Xcode whirs away for a while, then it shows you a summary for review. When you’re ready to proceed, click Validate:

324

macOS by Tutorials

Chapter 15: Using the Mac App Store

App summary

Xcode gets busy again, and after a while, reports that your app has been successfully validated. Click Done to close the dialog. And now you can see and configure your app at App Store Connect.

Con guring the App Log in to App Store Connect and click My Apps. Find your new app, which should be the first one in the list, and click the icon or name to select it:

App in App Store Connect

You’re now at your new app’s page in the App Store section. And you’ll see a lot of boxes to fill in! The first entry in the sidebar is 1.0 Prepare for Submission, and that’s where you are now. The top section is for screen shots. Unlike for iOS apps, you only need to supply one size of screen shots. You can find the details under Screenshot specifications at App Store Connect Help. Scroll down to the Mac section to see the permitted image sizes. If you want to add a movie as an app preview, read App preview specifications. Again, you’ll need to scroll down to 325

macOS by Tutorials

Chapter 15: Using the Mac App Store

find the Mac details. Next, work your way down the app page filling in the blanks. Don’t worry about the Build section yet. That’s where you’ll select a build for app review later. In the App Review Information section, it’s important that you provide a username and password if your app has user accounts. If not, uncheck Sign-in required. Add any notes you think will help the reviewer decide in your favor and provide your contact details so they can get in touch if there’s a problem. In the App Sandbox Information section, give details of any sandbox exemptions you’ve requested. This isn’t necessary if you only use the standard App Sandbox check boxes, but if you use any temporary entitlements, click the + button, select each entitlement and explain why you need it. Scroll to the top and click Save when you’ve made your changes. Now, step through the General section of the sidebar. In App Information, you can give your app a subtitle if you like. Select one or two app categories — Xcode doesn’t currently transmit the category you selected in your project. Click Set up Content Rights Information, answer the question and click Done. To set the rating for your app, click Set Age Rating Across All Platforms. Make a selection on each line and click Next. Answer the questions about web access and gambling, then click Next again. You’ll see the age rating based on the information you provided, but there are two more checkboxes you can select if they’re appropriate for your app. Then, click Done to finish this section:

Age rating

Click Save on this page and move on to Pricing and Availability. Select the 326

macOS by Tutorials

Chapter 15: Using the Mac App Store

pricing for your app in your local currency, and the App Store converts this around the world. Click Save to record your choice.

App Privacy You may have noticed that apps in the App Stores now display their privacy settings and list how much of your data they gather. Select App Privacy in the sidebar and then click Get Started to fill this in. If your app collects any data, you’ll have to provide details. You also need to provide a URL to your privacy policy. If you don’t have a privacy page, you can generate one at this App Privacy Policy Generator. Click Publish when you’ve set that up. And now that you’ve configured your app in App Store Connect, it’s time to upload it to the Apple servers and put your testers to work.

TestFlight Apple has only recently opened TestFlight to macOS apps although it has been available for iOS apps for some years. TestFlight is a system that allows you to distribute pre-release versions of your app using the TestFlight app. You can have a closed test where you invite specific people to try your app, and you can have an open beta where up to 10,000 users can test it. To set this up, return to Xcode and the Organizer window, opening it from the Window menu if it isn’t already open. Choose your app in the popup at the top left and select the latest archive. This time, click Distribute App. Make sure App Store Connect is selected and click Next:

Upload to App Store Connect

327

macOS by Tutorials

Chapter 15: Using the Mac App Store

In the next dialog, confirm that Upload is selected and click Next again. Since you’ve already validated the app, this should go through without a hitch. You’ll get the same dialogs, so keep clicking Next to use the same options as before. When you get to the summary, click Upload and wait for Xcode to do its thing. When the upload is complete, you’ll get a new dialog and this one has a link to App Store Connect:

Upload complete

Get back to App Store Connect in your browser, click My Apps and select your app. This time, click TestFlight at the top. You may need to wait a few minutes before your app appears, but then it shows up in the Version 1.0 Build list, with its status set to Processing. The processing can take some time, so go get a cup of coffee and come back later.

Export Compliance After Apple has processed your app, you’ll see a warning symbol marked Missing Compliance. Because Apple distributes your app from the USA, it’s subject to US export laws, and you must report if your app uses any form of encryption. App Store Connect Help — Export compliance overview covers all the details. Read this to make sure you know what’s expected of you. Click the Manage link and answer the question. If you answer Yes, you’ll have to work through a few dialogs, but eventually, you’ll get to where you can click Start Internal Testing: 328

macOS by Tutorials

Chapter 15: Using the Mac App Store

Export Compliance

If your app does not use any encryption, you can add this entry to your Info.plist: ITSAppUsesNonExemptEncryption

This saves you having to go through this for every new build.

Internal Testing After you click Start Internal Testing, you’ll be back in the TestFlight tab for your app, but this time it’s listed as Ready to Submit. Before opening the app to external testers, Apple has to review it, but you can get it out to internal testers right away. Internal testers are Users in your App Store Connect account. Click Users and Access at the top of the page and use the blue + button to add new users. They need to have either Admin, App Manager, Marketing or Developer roles to access TestFlight builds. With your testers set up, go back to your app’s TestFlight settings. Click the + button beside Internal Testing and give your internal testers a group name. 329

macOS by Tutorials

Chapter 15: Using the Mac App Store

Leave Enable automatic distribution enabled and click Create:

Ready for internal testing

Your build now has a Ready to Test status, and you can use the blue + in the Testers section to add your test users. They’ll receive an email invitation to join your test program:

TestFlight invitation

If the user has TestFlight installed, the email link opens it and shows your app for the user to Accept and Install. Otherwise, the link opens a web page with instructions. TestFlight installs the app in the Applications folder. Users can run it like any other app. The TestFlight app allows them to submit feedback with comments and screenshots: 330

macOS by Tutorials

Chapter 15: Using the Mac App Store

Submitting feedback through the TestFlight app.

You can view feedback reports in the TestFlight section of your app’s page in App Store Connect, under Feedback ▸ Screenshots. This is where you read all responses, even if the tester didn’t attach a screenshot.

New Test Versions As you receive feedback, you’ll want to update the app and release new test versions. In Xcode, make your edits and then change the build but leave the version as it is. Archive the app and use the Organizer to Distribute the App as before. Your testers receive notifications about all updates. Keep iterating over this process until your app is ready for a bigger audience.

External Testing Internal testing is for people in your organization, and you can invite up to 100 testers. External testing lets you open your app to many more testers. You invite specific people by email and you can publish a link to allow anyone to join your test program. But, before you can release an app for external testing, you have to get it reviewed. You’ll see Test Information in the sidebar with a yellow warning badge. Click the link, fill in all the fields and click Save at the top.

Note: If this doesn’t get rid of the yellow triangle, check Sign-in required,

331

macOS by Tutorials

Chapter 15: Using the Mac App Store

enter fake credentials and save again. Then go back and uncheck that option.

Click the + beside External Testing and add a group name for your external testers. Select the build you want to test externally by clicking its build number. On the next page, enter what you want your testers to test and click Save. You’ll see your internal test group already listed in the Group section. Click the +, check your external group and click Next. Now, you can confirm the test instructions before clicking Submit for Review:

Submitting for TestFlight review.

Then wait. You should hear within 24 hours. Once you have a build that Apple has approved for testing, you’ll see a public link in the TestFlight page for your external group. Copy the link and make it available for anyone who wishes to test your app. You can also add testers by email address:

External testing

332

macOS by Tutorials

Chapter 15: Using the Mac App Store

Keep releasing new test versions as your users report in until you feel that your app is ready to meet the world.

Releasing Your App By going through the TestFlight process, you’ve done most of the work already. Log in to App Store Connect and open your app’s page. In the App Store tab, scroll down to Build, and click the blue + button. Select the build you want to release and click Done. Make sure you’ve filled in all the app information as you want it to appear in the App Store. Scroll to the bottom of the page and set the release timing options. Back at the top of the page, click Save and then Add for Review. Wait for around 24 hours, and you’ll receive the verdict from the app reviewer. If all goes well, your app passes, and it appears in the App Store over the next few hours or whenever you specified. But what if you get rejected? In most cases, the reviewers are quite clear about what needs to change. You can appeal, but unless it’s a change that would break your app, the most productive move is to comply with their requests. Respond to let them know you’ve done so, upload a new build, cross your fingers and try again. Apple has an App Review page with a section on Avoiding common app rejections, which has some useful advice.

Checking your Crash Logs macOS users, like iOS users, have the option of allowing their systems to share crash reports and diagnostics with developers. To see if your users have reported any crashes, open Xcode and go to Window ▸ Organizer. Select your app from the popup at the top left and then click Crashes in the sidebar. This includes crash reports from TestFlight and from App Store versions. You can adjust the filters across the top to see only the relevant reports. Sadly, ReallyUsefulApp has crashed. :[

333

macOS by Tutorials

Chapter 15: Using the Mac App Store

Crash report

Once you’ve found a crash report, select it to see the full crash log. In the right sidebar, click Open in Project…. Select your project and click Open. Xcode opens your app’s project and goes to the Debug navigator. Expand the thread showing the warning symbol to see the same details as in the crash report. The more prominent entries are more closely related to your own code and so are more informative. Click each one until you spot what caused the error. In this case it was quite obvious:

Finding the cause of the crash.

Who wrote this terrible code? ;] Now, you can fix the problem and submit an update to TestFlight or to the App Store.

Updating the App After your app has been available for a while, you’ll want to release a new version. Make your changes in Xcode, update the version and build details, create a new archive and upload to App Store Connect as before. 334

macOS by Tutorials

Chapter 15: Using the Mac App Store

In your browser, log in to App Store Connect. Go to My Apps and select your app. In the sidebar, where it says macOS App, you’ll see your current version marked as Ready for Sale. Click the blue + to add a new version and enter its number. Unless you’ve made major changes that need new informational text or new screenshots, you can leave most of the information as is. Fill in What’s New in This Version to let users know what you’ve changed. You can go through the TestFlight process again, but if it’s a minor bug fix, you can go directly to the App Store. Once the Apple servers have processed your build, select it in the Build section, save the details and submit it for review. As with a new release, you can specify whether you want the update released automatically or on a set date. Once the update is on the App Store, users get notifications through their App Store app.

Key Points The App Store provides a non-exclusive mechanism for distributing your macOS apps. You need an Apple ID with two-factor authentication and an Apple Developer account connected to Xcode. Xcode does a lot of the hard work of generating certificates, app identifiers and app records. TestFlight allows you to get feedback and bug reports for pre-release versions. Your app must pass app review before you can open it for external testing and again before you can release it on the App Store. You upload updates in the same way as new apps and Apple releases them to your users automatically.

Where to Go From Here? If you need further information on any aspect of distributing your app through Apple, App Store Connect Help is very comprehensive. Now you know how to test and distribute your apps through the Mac App Store. In the next chapter, you’ll look at ways to distribute your app outside the App Store.

335

macOS by Tutorials

16

Distributing Externally Written by Sarah Reichelt

In the last chapter, you looked at the advantages and disadvantages of distributing your app through the Mac App Store. Then, you worked through the process of testing, distributing and updating using the Apple system. Not all apps are eligible for the Mac App Store, and there are other reasons why you might want to distribute externally. In this chapter, you’ll look at what you need to do to distribute your app outside the App Store. If you only want to use the Mac App Store, you can skip this chapter. Come back to it if you change your mind.

Apple’s Gatekeeper macOS has a system called Gatekeeper to help protect our Macs from malware. Open System Preferences ▸ Security & Privacy. In the General tab, you’ll see Allow apps downloaded from:. This is where you configure Gatekeeper:

Configuring Gatekeeper

There used to be an option to allow apps from anywhere, but Apple has locked that down. Now, you only have two options: App Store or App Store and identified developers. To include your app in the identified developers category, it must be code signed and notarized. You upload the app archive to Apple for their servers to notarize it. In the previous chapter, you created an Apple Developer account and linked it to Xcode. If you skipped that section, go back and do it now, so that you can be one of these identified developers. :] You’ll need an Xcode project to work with. If you created a sample app for the 336

macOS by Tutorials

Chapter 16: Distributing Externally

last chapter, you can use that, or you can make a new empty project for this chapter. Open your project in Xcode and make sure you’ve selected your Team in the project’s target settings under Signing & Capabilities. While you’re there, make sure that Hardened Runtime is enabled. This does even more work to lock down your app and protect your users from malicious code. Apple will not notarize an app without this. If you can’t see a Hardened Runtime section in Signing & Capabilities, click + Capability and double-click Hardened Runtime in the palette to add it.

Enabling hardened runtime

By default, Xcode leaves all the options unchecked, but if your app uses any of the listed features, you’ll have to check them. Change the Build in the target settings General tab. If you’re using numbers, you can increment this. Otherwise, update it using whatever scheme you prefer. It’s a good idea to get into the habit of changing the build before starting any new distribution step. Even if the app is unchanged, it’s best to have different builds for App Store distribution and external distribution because they get processed differently. Now, use Product ▸ Archive to create a new archive in the Organizer window.

Exporting the App In the Organizer ▸ Archives window, click Distribute App to see the four 337

macOS by Tutorials

Chapter 16: Distributing Externally

possibilities:

Distribution options

You used the App Store Connect option in the last chapter. Now, consider the others. Copy App creates a folder containing the built app. Because you’ve selected your developer team, it’s already code signed. You can run it on your own computer, but if you give it to someone else, they’ll see this:

Can't run app

The key phrase here is that Apple cannot check it for malicious software. In the next section, you’ll get Apple to perform this check. People can overrule Gatekeeper and run the app by right-clicking it and selecting Open from the popup menu. They’ll get another warning dialog and then the app runs. Interestingly, macOS quarantines it to a temporary AppTranslocation folder. 338

macOS by Tutorials

Chapter 16: Distributing Externally

I asked ReallyUsefulApp to show where it was running from and got this, even though I’d run the app from the Applications folder:

App Translocation

I think that’s the first really useful thing the app has ever done. :] Development is similar to Copy App, but if you’re part of a developer team, you can send this version to other members of the team who’ll be able to run it in the same way you can. These are useful methods for testing, but no good for wider distribution.

Notarizing the App The only option you haven’t looked at yet is Developer ID. That’s the one which allows you to send your app off to the Apple notary service so they can confirm it’s clean. This isn’t the same as app review. Apple only checks that your app does not contain any harmful code. Select Developer ID and click Next. Now, there are a few more questions for you to step through, clicking Next after each one: Select Upload, which is the option that sends the app off to the notary service. Confirm your development team, if you’re asked. Go with Automatically manage signing. If you don’t have the developer certificates installed yet, Xcode offers to generate them for you, but if you worked through the previous chapter, they’re already in place. Finally, you’ll get to the summary, where you click Upload:

339

macOS by Tutorials

Chapter 16: Distributing Externally

upload summary

It may take a few minutes, but you’ll eventually see a dialog saying your app has been successfully notarized. There’s an Export… button on this dialog, but click Close instead and go back to the Organizer window.

Note: The Upload dialog might say your app has been uploaded, and you’ll receive a notification when it’s ready for distribution. Simply close this window and wait for more information to appear in the Organizer window.

Apple’s notary service has checked your app, confirmed it isn’t doing anything malicious and stored this information on their servers. In the Organizer window, you’ll see more information at the bottom of the right sidebar. You may need to expand the window to see it all:

Organizer window

Note: If your app failed the notarization process, click Show Status Log to see what went wrong.

340

macOS by Tutorials

Chapter 16: Distributing Externally

The Identifier is the unique ID assigned by the notary service.

Note: There might be a short delay between your app’s status changing to Ready to distribute and the appearance of the Identifier and Export Notarized App button.

Now, click Export Notarized App to export a fully code-signed and notarized version of your app. Congratulations!

Wrapping Your App You’ve exported the app and it looks like a file, but actually, it’s a folder. See what’s inside by right-clicking the app and selecting Show Package Contents:

App package contents

This means your app is not in a good state for emailing, or for upload and download. Before releasing it, you need to wrap it up somehow. There are two main ways to do this. The simplest is to create a zip file. Rightclick the app and select Compress “” from the contextual menu:

Compressing

This creates a single file you can email or make available for download. Apple distributes Xcode betas like this, although they use XIP files, which are digitally signed zip files. 341

macOS by Tutorials

Chapter 16: Distributing Externally

The main disadvantage of this method is that the app is likely to end up in the user’s Downloads folder and not in their Applications folder where it should be. The better alternative is to create a disk image. This takes a bit more work, but it encourages users to install your app in their Applications folder.

Creating a Disk Image Setting up a disk image, or DMG, is a three-step process: 1. Create a new blank disk image and add your files. 2. Configure the window display. 3. Create a locked copy of the disk image for release. To start, open Disk Utility from Applications ▸ Utilities. Go to the File menu and choose New Image ▸ Blank Image…. Fill in the file name and image name. Make sure the size is larger than your app and leave all the other default values:

Creating a disk image

Click Save and Disk Utility makes your image. Open it to mount the image, and you’ll see an empty Finder window. Now, you can put files and folders into it. You’ll add two items — your app and an alias to the Applications folder. This way, when the user opens the image, they can drag the app directly into the correct location without having to open another Finder window. To add your app, drag it from wherever you exported it, into the disk image 342

macOS by Tutorials

Chapter 16: Distributing Externally

Finder window. You’ll see the green plus sign as you drag, showing that you’re making a copy. The Applications window is a bit trickier. You don’t want to copy your Applications folder, you want to add an alias that points to the Applications folder on the user’s system. Open your Applications folder in a new Finder window. Right-click the word Applications in the title bar or toolbar to pop down a menu showing the folder’s parents. Select the next one down, which is probably Macintosh HD:

Applications

With that open, select Applications and Command-Option-drag it into your disk image window. You’ll see a curved black arrow on the icon as you drag, indicating that you’re creating an alias.

Con guring the Display Now, your image has two visible items, but the next thing you’ll add will be invisible. Disk images frequently have a background image with instructions or logos. You can use whatever image you like, but if you don’t have one, open the assets folder in the downloaded materials for this chapter and locate background.png. Drag background.png into your disk image window and then press ShiftCommand-. to show invisible files. Next, rename the image by adding a period at the front of the name, so it’s now .background.png. Finder may warn you that this will make the file invisible, but that’s what you want.

343

macOS by Tutorials

Chapter 16: Distributing Externally

Note: Finder doesn’t allow you to rename a file with a leading period unless you’re already showing invisible files.

The next task is to configure the window so it opens looking the way you want your customers to see it. In your disk image window, press Command-1 to View ▸ as Icons, then press Command-J to open the View Options. Set them as follows: Check Always open in icon view: This also checks Browse in icon view. Select None for Group By and Sort By. Set the icon size to 80 x 80 or whatever suits your background image. Drag the grid spacing to its maximum setting.

View options

Next, you’ll set the background image. Select Picture in View Options ▸ Background and drag your background image into the image well:

344

macOS by Tutorials

Chapter 16: Distributing Externally

Setting the background picture.

With that in place, close the View Options window, then press ShiftCommand-. to hide the invisible files. To make your disk image window look less cluttered, go to Finder ▸ View and select — toggle to Show — all the Hide options you see: Hide Sidebar, Hide Preview, Hide Toolbar, Hide Tab Bar, Hide Path Bar and Hide Status Bar. You may have hidden some of these views already, but hide all the others:

Hiding Finder options

When all these menu items start with Show instead of Hide, you’ve done it. Now, move your two icons into the appropriate locations on your background image and resize the window so that your background image fills it:

345

macOS by Tutorials

Chapter 16: Distributing Externally

Disk image window

Locking it Down The final step is to lock your disk image by creating a read-only copy of it. In another Finder window, eject your new disk image by selecting it and pressing Command-E. This unmounts it, but you still have the DMG file. Open Disk Utility again and go to Images ▸ Convert…. Select your DMG file, give it a different name and set the image format to read-only:

Converting the disk image.

Click Convert and wait while Disk Utility creates your new image. This also resizes it to suit your app. Open your new image, and you’ll see it appear exactly as you set it.

Note: If your new disk image doesn’t show the background picture, eject and delete the newly created read-only image. Open the writable image again and make sure it’s set up correctly. Move or resize the window to force Finder to write its configuration to the .DS_Store file, and then try again.

346

macOS by Tutorials

Chapter 16: Distributing Externally

Further Notarizing Some sources suggest you should also notarize the disk image or zip file. I have not found this to be necessary, but if you want to do it, there are detailed instructions in Chapter 14 of Catalyst by Tutorials.

Selling Your App You’ve prepared the app for distribution. Apple’s notary service has approved it and you’ve packaged it ready for downloads. Now what? How are you going to deliver your app to your customers? Are you going to charge for it? You’ll need a web site for hosting your app and information about it. And if you’re charging for the app, you’ll need a payment system. If the app is free, host it on your web site and start telling people about it. There are lots of web site builders that can get you started quickly, if you don’t already have a site. Squarespace is one possibility, and it also offers payment facilities if you’re charging for your app. Another option is to pass the responsibility on to a third-party reseller. In this case, Paddle is a good option to consider. They charge a fee but take a lot of the pressure off you.

Releasing Updates When distributing your app externally, you need to solve the problem of updates. In the App Store, updates get pushed out to users automatically, but when you’re distributing yourself, you have to handle this. The simplest option is to have a file on your web site with the latest version information. Your app can check this periodically and, if it’s newer, direct the users to the download site to get the update. Your app needs a way to get its version and build. This method does that: func versionAndBuild() -> String { if let bundleInfo = Bundle.main.infoDictionary, let version = bundleInfo["CFBundleShortVersionString"] as? String, let build = bundleInfo["CFBundleVersion"] as? String { return "Version: \(version) (\(build))" } return "Version: unknown" }

347

macOS by Tutorials

Chapter 16: Distributing Externally

This extracts two entries from Info.plist, checks they’re strings and assembles them into a single string. A more automatic way is to use a framework like Sparkle. This is a very popular open source library for adding update facilities to Mac apps. Whenever you update, you have to create a new Xcode archive and get the new version notarized before release.

Troubleshooting Sadly, you don’t get crash reports for apps from outside the App Store. People can still make reports, but these disappear into the Apple servers, never to be seen again. Because of this, it’s important to give your users a way to contact you directly to tell you about any problems. You can do that by including a button or menu item that calls this method: func emailDeveloper() { // 1 let subject = "Really Useful App \(versionAndBuild())" // 2 .addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)! // 3 let link = "mailto:[email protected]?subject=\(subject)" // 4 if let url = URL(string: link) { NSWorkspace.shared.open(url) } }

What does this do? 1. Use the previous method to get the version information and appends it to the app name. 2. Encode this to work as a query in a URL. You know what’s going to be here, so force-unwrapping is safe in this case. 3. Create a mailto link containing the recipient and the encoded subject. 4. Convert the link into a URL and use NSWorkspace to open it in the default email app. Adding a link to your social media accounts is also a good idea, especially for 348

macOS by Tutorials

Chapter 16: Distributing Externally

people who only use web-based email clients. Use NSWorkspace.shared.open() to open these in the browser.

Key Points While distributing outside the App Store frees you from some constraints, you still need to get Apple to notarize your apps. An app is actually a folder, so you need to wrap it for distribution. Handling payments for the app is now your responsibility, although there are resellers that can help. You’ll have to implement a process for getting updates out to your app’s users.

349

macOS by Tutorials

17

Conclusion Written by Sarah Reichelt

What a fun journey we’ve been on together! As you can see, the opportunities for building cool and exciting macOS apps are endless. We hope you’re eager to get started; we’re certainly excited to see what you build. By completing this book, you’ve gained the knowledge and tools you need to build beautiful macOS apps. Set your imagination free and couple your creativity with your newfound knowledge to create some impressive apps of your own. If you have any questions or comments as you work through this book, please stop by our forums at https://forums.raywenderlich.com and look for the particular forum category for this book. Thank you again for purchasing this book. Your continued support is what makes the books, tutorials, videos and other things we do at raywenderlich.com possible. We truly appreciate it! – Sarah, Audrey, Ehab and Richard The macOS by Tutorials team

350