Streaming is available in most browsers,
and in the Developer app.
-
Qualities of a great Mac Catalyst app
Discover best practices, tools, and techniques to help craft the best possible Mac Catalyst app. We'll take you through key considerations when you bring your iPad app to macOS, explore detailed code examples for refining your interface and experience, and show you how to distribute your Mac app to everyone. To get the most out of this session, we recommend a basic familiarity with Mac Catalyst. Watch “What's new in Mac Catalyst” from WWDC21 to get an overview of the latest features for bringing your iPad app to Mac. And for more on improving your macOS experience, watch “Optimize the interface of your Mac Catalyst app” from WWDC20.
Resources
- Accessibility design for Mac Catalyst
- Adding Menus and Shortcuts to the Menu Bar and User Interface
- Bring an iPad App to the Mac with Mac Catalyst
- Building and improving your app with Mac Catalyst
- Human Interface Guidelines: Mac Catalyst
- Mac Catalyst
Related Videos
WWDC21
- Focus on iPad keyboard navigation
- Meet the UIKit button system
- Qualities of great iPad and iPhone apps on Macs with M1
- Take your iPad apps to the next level
- What's new in Mac Catalyst
- What's new in UIKit
WWDC20
-
Download
♪ Bass music playing ♪ ♪ Owen Monsma: Hello, and welcome to "Qualities of a great Mac Catalyst app." My name is Owen Monsma, and I'm a Cocoa engineer.
And I'll be joined later by my colleague Dave Rahardja from UIKit.
Today we're going to go over three important considerations for making an amazing Catalyst app.
First, we'll cover some high-level changes that happen when you migrate to a Mac Catalyst app.
Next, we'll dive into some specific code modifications you can make to improve your app experience on Mac.
And we'll end with information about app distribution.
So let's begin with migrating your app to Mac Catalyst.
The first step toward a great Catalyst app is having a great iPad app, and your app already runs with no additional changes on Macs with M1.
If you have an M1 Mac, you can try this right away with the Designed for iPad run destination in Xcode.
By adopting these features on iPad, your Mac app will have a great head start.
If your app supports multitasking on iPad, you can automatically get multiple window support on Mac.
And if you use UIMenuBuilder, your menus are automatically picked up in your app's menu bar, and by contextual menus with a secondary click on your views.
We also automatically bridge system behaviors like copy/paste and drag and drop.
To learn more about how your iPad app runs as-is on M1 Macs, check out our video, "Qualities of great iPad and iPhone apps on Macs with M1." But you're here because you want to take things further.
By checking the Mac checkbox, you gain the ability to distribute to all Macs, and get access to additional APIs to further refine your app.
So let's do it with our app, Trip Planner! In our Xcode project settings, we check the Mac option under Deployment Info.
Notice to the right, an additional pop-up appears, letting us choose between a scaled iPad interface and a Mac optimized interface.
We will examine this choice more in a little bit.
For now, let's click build and run in the Xcode toolbar.
And our app builds and runs! Now, if your app failed to build, there are a few things to investigate.
Certain deprecated frameworks and classes are unavailable for Mac Catalyst, so now is the time to modernize.
Not only will this get your app running on Mac, but it will improve your iOS app, too.
Moving from OpenGLES to Apple's own Metal framework unlocks the full power of the GPU.
The Contacts framework replaced the deprecated AddressBook and is a forward-looking, thread-safe way to handle contacts.
And UIWebView is deprecated and has been superseded by WKWebView.
Also, make sure to check your third-party dependencies.
If those frameworks are distributed as an XCFramework bundle, make sure that they provide a Mac binary to link against.
As you begin work on your Mac app, watch for compiler warnings when building your project, and monitor the console log for runtime messages.
These warnings tell you how to fix your code so it runs well as a Mac Catalyst process.
And remember to only use supported API so your app continues to run on future macOS releases.
It's also important to be aware of the lifecycle events that your app will receive when running on a Mac.
If your app currently relies on lifecycle events called on your app delegate, you should instead monitor scene lifecycles so your app can respond to events specific to the content of each window on the desktop.
Remember, a Mac Catalyst app will not receive the sceneDidEnterBackground event as often as an iPad app.
Scenes enter the background state when a desktop window is minimized or closed.
If your app uses sceneDidEnterBackground to perform some routine work like autosaving a document, using a timer instead will ensure this action is taken regularly.
Finally, remember that your Catalyst app may have zero scenes but continue to run in the foreground.
This state happens when all your app's windows have been closed, but your app's name remains visible in the menu bar.
Now let's decide whether to optimize our interface for Mac.
This is one of the most important decisions to make when you first begin bringing your app over.
Using Mac idiom is recommended to make your app feel the most at home on Mac, but it does require some additional work.
In the Mac idiom, your app will run at 100 percent scale, giving you pixel-perfect text and images, and native AppKit controls.
If you want, you can add new Mac-specific assets in your asset catalog to take advantage of this additional detail.
It's good practice to provide both 1x and 2x assets to support all monitor resolutions.
Be aware, the size metrics of many of your controls will change, so it is important to adjust your app's layout to accommodate.
For custom controls in your app, you have an additional choice.
Automatically, you get the Mac style of control.
But now you can opt out your buttons and sliders from this conversion to use customization APIs that are unavailable on Mac controls.
If you use any custom assets -- like setting the thumb on a UISlider -- they will appear larger than expected by default, so you may need to scale them or provide new assets.
Also keep in mind that Mac users expect AppKit-style controls, so custom controls should be used sparingly.
For more detail on the Mac idiom, check out our video, "Optimize the interface of your Mac Catalyst app." Because a Catalyst app in the Mac idiom takes on AppKit control styles, both the appearance and behavior of some of your controls will change.
In our video, "What's new in Mac Catalyst," we introduced the new pop-up button style which rounds out our suite of Mac button types.
Let's dive into what makes these controls different and how the system picks which one to use.
Understanding these controls and where they are commonly found will help you make informed choices about their use in your app.
The default UIButton type is UIButton type .system.
With this button type, the button automatically takes on the expected appearance for its context.
In the Mac idiom, this means it becomes a bordered push button.
Pull-down buttons are a Mac-native control used to provide a list of possible actions, and are drawn with a single-arrow indicator.
A good example is the PDF pull-down in the print dialog, which presents actions such as Save as PDF or Send in Mail.
To get a pull-down button, make sure that you have assigned a UI menu to your button via its menu property, and additionally set showsMenuAsPrimaryAction to true.
Your button will take on the pull-down look and present the menu on a click.
And new to Catalyst with macOS Monterey are pop-up buttons.
Pop-up buttons look similar to pull-down buttons but have a double-arrow indicator, and they do something slightly different.
Where the pull-down triggers an action, a pop-up button is used to select one of a set of mutually exclusive options.
For example, selecting the day of the week.
The title in the button then updates to reflect the selection.
This is a good, Mac-friendly choice to replace a UIPickerView in your app.
Getting this control is similar to a pull-down button, but the property changesSelectionAsPrimaryAction must also be true.
Finally, checkboxes are used to represent a non-exclusive binary toggle, and are a more mouse-friendly alternative to a switch.
And as it turns out, you get the checkbox with no additional work! Just make sure that the switch has a title set, and keep in mind the title property is only supported in the Mac idiom.
By default, the switch has a preferredStyle of automatic, and you can verify at runtime whether it is a switch or a checkbox using the read-only style property.
Now, to dive into some specific code changes, let's hand things off to my colleague, Dave.
Dave Rahardja: Hi everyone, my name is Dave, and I'm an engineer on the UIKit team.
Let's talk about some specific things you can do to make your Mac Catalyst app feel more at home.
A Mac Catalyst app may have access to a lot more screen real estate.
Your app's windows can be resized much larger on a Mac than on an iPad, and can be shown full screen.
Take a moment to resize your app's windows and pay attention to its layout.
Make sure you're using the additional space to show more content and controls to make your apps easier to use.
Live resizing will put your app's layout performance to the test.
Your app should do the least amount of work possible during layout to keep your app's windows responsive during resizing.
Take special note of interactions in your app that rely on modal presentations and popovers.
With a larger display area, you can make these interactions always available by showing them as child views.
Now, let's talk about pointer input devices.
Remember that not all Macs have a trackpad, and some Macs are connected to input devices that don't support scrolling.
If your views rely on a pinch or rotate gesture to work, make sure that all of its capabilities are accessible using a mouse without scroll input.
Add additional buttons or other controls to your Mac Catalyst app's view to make sure all of its functionality is accessible.
Additionally, detecting keyboard modifiers on tap or pan gesture recognizers can sometimes provide faster access to your view's functionality.
For example, allowing Shift-pan to zoom.
Let's talk about keyboard shortcuts and the main menu.
The main menu of a Mac app is a great place to discover all the actions available in your app, as well as their associated keyboard shortcuts.
If your app already supports keyboard shortcuts by returning key commands from its responders, add these commands to the main menu using the menu builder API instead.
Moving all your keyboard shortcuts to the main menu makes them discoverable even when they are not currently enabled.
What's more, using the MenuBuilder API to organize your shortcuts on Mac Catalyst also organizes them on the iPad shortcuts overlay.
As you build out your main menu, be sure to add all the actions needed to interact with your app.
Actions performed with gestures on an iPad should also be accessible by selecting items from the main menu.
Adding keyboard shortcuts to your menu items will provide even quicker access to these actions.
Because menu bar and key command actions are routed starting from the first responder, make sure that the views that would be the target of those actions can become first responder and can accept focus.
You can do this by having your views return true for the canBecomeFirstResponder and canBecomeFocused properties.
Since a Mac app must rely less on direct manipulation of views, and more on the user selecting a view and then selecting an action from the main menu, the ability for more of your app's views to become first responder and focused becomes more important on Mac Catalyst.
For more information about focus and first responders, check out the video, "Focus on iPad keyboard navigation." While we're on the subject of responders, be sure to leave the responder chain unmodified in your app.
In other words, don't override nextResponder.
Leaving the responder chain unmodified ensures that Mac Catalyst can route your actions to the appropriate targets.
If your app has to handle certain actions using objects that are not in its responder chain, use the target(for Action:, withSender:) function to delegate these actions to the appropriate object instead.
Let's go over the code.
In this example, our view delegates the setAsFavorite action to a model object, while allowing other actions to continue to propagate up the responder chain.
Now let's talk about scenes and how they work in a Mac Catalyst app.
A Mac app may have many desktop windows open at the same time.
In a Mac Catalyst app, each of these windows is paired with a UIWindowScene.
Your app may offer windows that have different functions.
For example, it may have a document window, a detail viewer window, a message composer window, and so on.
The best way to organize these different scene functions is by defining a scene configuration for each type of window.
To define scene configurations, add them to your Info.plist under the Application Scene Manifest entry.
Under the Application Session Role array, create one configuration for each type of scene your app supports.
Give each of these configurations a name and choose the scene class, delegate class, and storyboard that will be instantiated when the scene is created.
Now that we've defined our scene configurations, let's discuss how we can use them to create a new scene of a particular configuration.
In this example, we want to create a new detail viewer scene when a view is double-clicked.
The first thing we do is define a new user activity type for requesting a detail viewer scene.
We'll call it viewDetailActivityType.
When we create that new user activity, we want to pass along an identifier for the item that we want to show in detail.
To do that, we define an itemIDKey that will hold that information in the user info dictionary.
Then, in our double-click event handler, we create a new NSUserActivity object of the appropriate type, and set its userInfo property to a dictionary holding the itemID that we want to show.
Finally, we call the UIApplication requestSceneSessionActivation function, passing in the user activity we just created.
This will cause the system to create our new scene.
So now we know how to request a new scene for a particular user activity type.
Now let's talk about how to use that information to load the appropriate scene configuration.
We respond to scene creation requests by implementing the application configurationForConnecting function in the application delegate.
In our implementation, we examine if the incoming scene request contains any user activities.
The request can contain multiple user activities, but for this code example, we'll just examine the first one.
If there is an activity we need to handle, we then check its activityType.
Here, we test if it's equal to the viewDetailActivityType.
If so, we return the scene configuration named DetailViewer.
This will cause the system to check in our Info.plist for a configuration of that name, and load the appropriate scene and scene delegate classes, and display the specified storyboard in a new desktop window.
If no specific scene configuration should be loaded, we fall back to returning the default configuration.
There is one more thing to do.
Remember that we saved the itemID for the item to be shown? We still need to set that value on the view controller of the scene that we just created.
We do that in our SceneDelegate class.
The scene willConnectTo session function is called just before the scene is about to be shown on the desktop.
The user activity that was passed into our application delegate is also passed into this function in the scene delegate.
We can now extract the itemID from its userInfo dictionary and set it on our new view controller.
Using NSUserActivity to configure new scenes also makes it easier for your app to support state restoration.
If your scene delegate responds to the stateRestorationActivity (for Scene:) callback, the returned user activity will be saved by the system when your app exits.
If state restoration is enabled in System Preferences, the next time your app is launched, the system will recreate your scenes and pass each scene's user activity object to your app delegate's application configurationForConnecting SceneSession function.
This is the same function that is called when your app creates new scenes, as covered earlier.
By using a consistent set of activity types, you can use the same code to select the appropriate scene configuration when your app creates new desktop windows and during state restoration.
There is one thing you need to add to your scene delegate so your app can handle both new scene requests and state restoration with the same code, and that is to modify your scene willConnect session function in your scene delegate so that it falls back to the stateRestorationActivity if the activity in the scene connection options is nil.
Now your app is ready to handle new scene requests and state restoration.
For more information on state restoration, check out the "Introducing Multiple Windows on iPad" video.
Next, let's talk about your app's toolbar.
A great Mac app uses its windows' toolbars to present frequently used actions and other navigation options for quick access.
Unlike toolbars on iOS, the toolbar on a Mac Catalyst app's desktop window does not change as view controllers appear and disappear in the Split View controller or a navigation controller.
Because toolbars are strongly associated with scenes, the best place to configure your toolbar is in your scene delegate subclass.
One important item usually found on the toolbar is the sharing button.
Adding an NSSharingServicePicker ToolbarItem to your toolbar allows your app to share the main content shown in your scene using the Mac's standard sharing menu.
In macOS Monterey, we have added the ability for the button to automatically use the activity items configuration shared by your scene.
Notice that this is the same configuration that the new Share This function of Siri uses on iOS.
A good way to provide a sharing configuration for your scene is to return an object from your RootViewController's activityItemsConfiguration property.
On Mac Catalyst, an NSSharingServicePicker ToolbarItem in your app's toolbar automatically uses this property.
On iOS, Siri uses the same property to share data using Share This.
Of course, the toolbar is not the only place your app can offer items to share.
Often, you'd want to allow sharing of images or other items through a context menu.
To do this, return an activityItemsConfiguration object from your view then add a contextMenuInteraction.
Here are the results, on both Mac Catalyst and iPad.
On Mac Catalyst, note that a Copy action and a Share menu are automatically added.
And when your app runs on iPad, a Copy and Share action are added.
Tapping the Share action automatically presents the share sheet.
Using the Activity Items Configuration API allows your app to declare what its views can share so the system can display the appropriate UI on each platform.
Now that we've talked about how your app can share its data, let's talk about how your app can import images from an iPhone or iPad using Continuity Camera.
If your app uses a UITextView to display rich text, Continuity Camera support is automatically enabled in macOS Monterey.
A right-click on a text view will show a context menu with an option to take a photo on your iPhone or iPad and automatically add it as an attachment.
To add support for Continuity Camera to any view, simply return a UIPasteConfiguration object that accepts images from your view's pasteConfiguration property, then add a UI contextMenuInteraction.
Then implement the paste(itemProviders:) function to load and paste the incoming objects -- in this case, images.
As a bonus, returning a paste configuration from your view not only enables Continuity Camera when the configuration accepts images, it also automatically enables a Paste action in the context menu and allows your view to accept incoming drags, both on Mac Catalyst and iPad.
So those are some specific things you can do to help make your app a great Mac Catalyst app.
Now let's go back to Owen to talk about distribution.
Owen: Thanks, Dave.
When it comes to releasing your app, the big thing to remember is that Mac Catalyst apps are Mac apps, and can be distributed through all the same means as any other Mac app.
You can publish your app on the Mac App Store, with the option for Universal Purchase so your existing iOS customers automatically get your Mac app.
You have access to TestFlight for beta releases of your app and to get early feedback on new builds.
You can also use App Notarization and distribute it yourself.
And if you develop a framework, use XCFrameworks to distribute cross-platform, bundling together binaries for all platforms.
Today, we've covered the process of building your iOS app for Mac with Mac Catalyst, and highlighted some important decisions and changes to make along the way.
Now it's time to consider your own projects.
It's easy to get your app running on Mac, and with just a little bit of work, you can make your app feel right at home and make it available to a whole new set of excited customers.
Thank you! ♪
-
-
6:50 - System button
let button = UIButton(type: .system) button.setTitle("Button", for: .normal)
-
7:06 - Pull-down button
button.menu = UIMenu(...) button.showsMenuAsPrimaryAction = true
-
7:44 - Pop-up button
button.menu = UIMenu(...) button.showsMenuAsPrimaryAction = true button.changesSelectionAsPrimaryAction = true
-
8:24 - Checkbox
let checkbox = UISwitch() if checkbox.style == .checkbox { checkbox.title = "Checkbox" }
-
13:20 - Delegating actions
final class MyView: UIView { override func target(forAction action: Selector, withSender sender: Any?) -> Any? { if action == #selector(Model.setAsFavorite(_:)) { return myModel } else { return super.target(forAction: action, withSender: sender) } } }
-
14:43 - Requesting a new scene
let viewDetailActivityType = "viewDetail" let itemIDKey = "itemID" final class MyView: UIView { @objc func viewDoubleClicked(_ sender: Any?) { let userActivity = NSUserActivity(activityType: viewDetailActivityType) userActivity.userInfo = [itemIDKey: selectedItem.itemID] UIApplication.shared.requestSceneSessionActivation(nil, userActivity: userActivity, options: nil, errorHandler: { error in //... }) } //... }
-
15:57 - Responding to a new scene request
let viewDetailActivityType = "viewDetail" final class AppDelegate: UIApplicationDelegate { func application(_ application: UIApplication, configurationForConnecting session: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { if let activity = options.userActivities.first { if activity.activityType == viewDetailActivityType { return UISceneConfiguration(name: "DetailViewer", sessionRole:session.role) } } return UISceneConfiguration(name: "Default Configuration", sessionRole: session.role) } //... }
-
17:13 - Setting item ID on new scene's root view controller
let itemIDKey = "itemID" final class SceneDelegate: UIWindowSceneDelegate { func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) { if let userActivity = connectionOptions.userActivities.first { if let itemId = userActivity.userInfo?[itemIDKey] as? ItemIDType { // Set item ID on new view controller } } //... } //...
-
17:47 - Saving state for later restoration
final class SceneDelegate: UIWindowSceneDelegate { func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? { //... } }
-
17:57 - State restoration
final class AppDelegate: UIApplicationDelegate { func application(_ application: UIApplication, configurationForConnecting session: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { //... } }
-
18:42 - Handle both new scene requests and state restoration
let itemIDKey = "itemID" final class SceneDelegate: UIWindowSceneDelegate { func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { if let userActivity = connectionOptions.userActivities.first ?? session.stateRestorationActivity { if let itemId = userActivity.userInfo?[itemIDKey] as? ItemIDType { // Set item ID on new view controller } } } }
-
20:20 - Provide sharing configuration for the scene
final class RootViewController: UIViewController { override var activityItemsConfiguration: UIActivityItemsConfigurationReading? { get { UIActivityItemsConfiguration(objects: [image]) } //... } }
-
20:56 - Support sharing through context menu
final class MyView: UIView { override var activityItemsConfiguration: UIActivityItemsConfigurationReading? { get { UIActivityItemsConfiguration(objects: images) } //... } func viewDidLoad() { let contextMenuInteraction = UIContextMenuInteraction(delegate: self) addInteraction(contextMenuInteraction) } }
-
22:08 - Supporting continuity camera
final class MyView: UIView { override var pasteConfiguration: UIPasteConfiguration? { get { UIPasteConfiguration(forAcceptingClass: UIImage.self) } //... } func willMove(toWindow: UIWindow) { addInteraction(contextMenuInteraction) } override func paste(itemProviders: [NSItemProvider]) { for itemProvider in itemProviders { if itemProvider.canLoadObject(ofClass: UIImage.self) { if let image = try? await itemProvider.loadObject(ofClass:UIImage.self) { insertImage(image) } //...
-
-
Looking for something specific? Enter a topic above and jump straight to the good stuff.
An error occurred when submitting your query. Please check your Internet connection and try again.