Learn how you can build apps for multiple Apple platforms using Xcode 14. We'll show you how to streamline app targets, maintain a common codebase, and share settings by default. We'll also explore how you can customize your app for each platform through conditionalizing your settings and code.
- Howdy. I'm Jake, a designer on the Xcode team. Multiplatform app development is being taken to the next level in Xcode 14. A single app target can now support even more destinations across multiple platforms, all while maintaining a single common codebase, sharing settings by default, and allowing new ways to conditionalize where needed. First, we'll cover what a multiplatform app target is, and in which cases it works best.
Next, we'll modify our project to support multiple destinations and platforms, then we'll update our project to get it building and running on the new platform.
We'll ensure our app looks great on each supported platform...
and finally we'll integrate Xcode Cloud with our project changes.
First, let's understand which technique we want to use to allow our app to support multiple platforms. Before Xcode 14, if you wanted your app to support iOS and macOS, you would need two separate targets. This is great if your project needs significantly different codebases, shares very few of its settings between its different platforms, or if each app target relies heavily on a different underlying technologies.
If that's still the case with your project today, your best bet would be to continue to use separate targets for each platform. In Xcode 14, a single app target can declare support for many destinations like iPhone, iPad, Mac, and Apple TV. This is great for an app that uses a common codebase and shares most of its settings across all its destinations while still allowing for customization when needed. Let's take a look at how multiplatform apps work in Xcode 14. If we're starting from scratch, a great way to begin would be to use the new, improved multiplatform app template when making a new project in Xcode.
The multiplatform app template uses SwiftUI for its lifecycle and interface, which starts us out with a target configured by default to support iPhone, iPad, and Mac. This is a fantastic configuration for new projects. Because we're using SwiftUI, we have access to the full feature-set of each platform's SDK, allowing the creation of amazing new apps that take advantage of what each platform has to offer. Existing projects can also declare support for multiple destinations in their app target and use SwiftUI to get access to the full power of each platform's SDK. Let's take a look at how to add a Mac destination to an existing iOS app. I've been building a Food Truck app, and it works great on iPhone and iPad. I'm pretty happy with this iOS app, and now I want to bring it to the Mac and embrace the platform and its features. Let's take a look at what our project looks like in Xcode.
If we take a look at my app target, we can see a list of all the destinations my app supports.
You can see I have a Mac destination already--Designed for iPad. This allows Mac computers with Apple silicon to run my unmodified iOS app. This is a great way to get started supporting the Mac, but I want to take my Mac support to the next level. Let's add a "Designed for Mac" experience, so to speak.
We can easily edit our list of supported destinations and add a Mac destination to our app. There are a couple options for Mac destinations: Mac, Mac Catalyst, and Designed for iPad, the last of which is grayed out, because my app already supports it.
Choosing between Mac and Mac Catalyst mainly comes down to which technology we're most interested in using. If our app made heavy use of UIKit or Storyboards at the core of our app, Mac Catalyst would be a great way to convert our existing iPad app into a compatible Mac app. However, our app uses SwiftUI, which makes the "Mac option" the best choice to craft our, well, Mac app. We'll get the amazing Mac look and feel right out of the box, with the full power of the macOS SDK without any limits. Meaning, we'll have the freedom to use UIKit in our iOS app, and AppKit in our macOS app, if we want that flexibility. With all that in mind, let's choose Mac, the best option for working with SwiftUI. Once I make my choice, Xcode will alert me to some changes necessary to prepare my project for Mac support. In this case, Xcode will update my target to only include dependencies and frameworks that are supported on Mac. It's important to note Xcode won't make changes to my code, so if I'm calling API that isn't available on Mac, I'll need to resolve those issues myself. Once I pick my Mac option, it's added to my list of supported destinations. It's totally valid to have more than one Mac destination when I'm developing in Xcode. This is especially useful if I'm transitioning from "Mac Catalyst" or "Designed for iPad" to a full Mac app.
This means I can continue testing out each of my Mac products within Xcode. And I'm not necessarily restricted to a single choice when developing my app. However, if I were to publish my native Mac app to the App Store, my Designed for iPad app wouldn't be available anymore to my customers, so Xcode offers a quick way to remove this destination. But I'll consider removing this destination once I'm happy with my native Mac experience. Whether I'm starting from scratch or adding a new destination to an existing app, using a single target in Xcode lets me share code and build settings by default. There may be cases where I want to customize an individual setting, like my app's display name or a minimum deployment version. Let's take a look at how to do that in the improved target editor in Xcode 14. Many app target settings now include a way to conditionalize its value. On supported settings, I can reveal an editor that lets me set the default value for each build configuration in my project. I have a custom Beta configuration that I added, as well as the standard Debug and Release configurations that come with new Xcode projects. I want to give my app a different display name when built with a beta configuration, so I can just edit the name right here. As I type, we'll see our app's display name in Xcode has been replaced with a readout of all possible values the display name can now have. If needed, I could also add a condition, letting me specify a value based on which SDK is being used. This allows me to say set a specific name for the beta configuration when building for Mac.
Okay, I think we're done with the edits we want to make in the General tab. Let's take a look at the Signing and Capabilities tab for any other changes we need to make.
The good news is, with Automatic Signing turned on, there are no extra steps to take. When I added my Mac destination, the necessary Signing Certificate and Provisioning Profile for the Mac was generated on my behalf. Both my iOS and macOS app products use the same bundle identifier by default, which is awesome, because that means when I publish them to the App Store, they will be made available for Universal Purchase. So folks who buy my iOS app will also get my Mac app automatically. My app also makes use of capabilities like push notifications. Any capabilities I've been using for my iOS app that are applicable to my macOS app get applied with no extra work from me. They even get combined into a single entitlements file. Now that we've added support for multiple destinations to our app, our next goal is to get it to build. It's normal to run into issues building an app for a new destination, especially if a new SDK is involved, like our new Mac support. So let's take a look at some of these common issues. Some frameworks are not available to all platforms. We'll need to make sure we're not importing or linking any unavailable frameworks. Remember that Xcode won't change our code when adding support for a new destination, so we'll need to conditionalize our code based on SDK, similar to how we conditionalized our app's settings. This is also true for API. Some features are marked as unavailable based on which SDK we're building with. Swift offers a way to conditionalize portions of our code to only include features available for the SDKs we're building with. Xcode also lets us specify if an individual file should be compiled when building for some SDKs. If I build my project right now... I don't see any issues. That's because I still have a destination selected in my toolbar that uses the iOS SDK. I'll need to pick "My Mac" from the list to build against the macOS SDK.
Building now reveals some new issues, and as we expected, they're mainly related to availability. In one of my files, I'm importing ARKit, which is not available on Mac. I could wrap this import statement in #if canImport to conditionalize it out.
This is useful if I don't want to manage a list of known platforms a framework is available for and simply say if it's not available, don't include it. However, I'm still using ARKit throughout this file, so sometimes it makes more sense to conditionalize out an entire file for an SDK. If we navigate back to our target and go to the Build Phases tab, I can search for my file...
And specify it should only be compiled for iOS.
After building, once I've made those changes, Xcode reports a new issue-- a framework that is available on Mac, SwiftUI, has a feature that's been marked as unavailable. Specifically, I'm using EditMode on iOS to allow users to make edits and select content in Tables and Lists, but on macOS EditMode doesn't exist! Users can already freely select and edit rows of content on Mac, so let's make sure this code is only runs on iOS. I can condition out my environment property and any place I was using EditMode below.
Now, I need to make sure any places I was using this property are also conditioned out, like this onChange modifier. I can wrap the entire modifier in an "if os" condition.
And finally, I'm using an EditButton view in the toolbar, which is also iOS-only.
Okay, let's try running our app.
Ah! It lives! Our app now builds and runs on Mac! Just because our app now builds and runs on our new platform doesn't mean our job is done. There will be cases where you want to refine your app experience for what users on your new platform will expect.
Also, trimming out our iOS-only features isn't the end of our journey. We now have all the features of the macOS SDK to play with. Now that I see my app running on Mac, I'm noticing a quirk about my app that doesn't feel natural in its new context. These donuts in this grid view seem much too large! That's because our grid items were designed for touch. Situations like this arise when you declare a point size for a UI element or otherwise customize a control with only a single platform in mind. On the Mac, we don't need to make our buttons or thumbnails so large, since we have a much more precise pointing device. This is a great case to conditionalize a constant in our project to vary based on which SDK we're building for. When we bring our app to other platforms it's important to reconsider many of these choices with our new platform's expectations. Let's take a look at specifying a different value based on which SDK we're building for. One technique I often use is making a constant a computed property, and using "#if os" to conditionalize what is returned. Let's convert this to a computed property and return what previously was a constant... but only return that value on iOS.
Ah, 80 feels much more naturally sized.
Now, as for making use of the macOS SDK, there's a cool new feature in SwiftUI that allows us to add our own UI element to the Menu Bar. I have a summary view for my app that I'd love to let my users have quick and easy access to. Let's go to my App declaration, and here, I can add a new Scene for my Menu Bar Extra. Note, though, because this is a macOS-only feature, I do need to conditionalize it for the macOS SDK.
Let's build and run and take a look.
Ah cool, my truck icon now shows up in the menu bar. Awesome, now my Mac users can see a quick glance at today's information right from their menu bar. When we use SwiftUI, we get access to the full SDK of each platform and can utilize its awesome features. It's important to note, when we bring our app to other platforms, we'll often need to reconsider many past choices when working in the context of our new platform. SwiftUI bakes platform expectations directly into the API. Many interface elements will gain an automatic appearance that looks great on each platform. Conversely, that means we can lose that automatic styling when we heavily customize our controls and other pieces of our UI, so we should always double-check our UI looks great everywhere. All that said, as we construct our cool app, we should ensure we're following the best practices laid out by the human interface guidelines. Now that we're happy with our local changes to our app, it's time to archive our app products and upload them to App Store Connect, which we can do from Xcode or automate it with Xcode Cloud. Once we're ready, we can then share the app with internal and external testers on TestFlight and release it to the App Store. We'll need to archive our products to upload them to App Store Connect. Just because we have a single target doesn't mean we only have a single product. We'll need to archive for each platform and upload those individually. If you're building and archiving locally, you'll need to select a destination that has the SDK you want to create an archive for. If I want to produce my macOS app, I'll need to select "My Mac" from the list of destinations, otherwise I'd select an iOS device to produce my iOS app.
Once I have a destination selected, I can choose "Product Archive" to create the archive.
Once my archives are complete, I can use the Organizer window in Xcode to upload them to App Store Connect.
If I'm using Xcode Cloud, I can add actions to my workflow to build, test, and archive my products. In my list of actions in my workflow, I can create new items to build, test, analyze, and archive each of my products. In this case, I have an iOS app and a macOS app. I can take it one step further and include a deployment preparation to automate uploading my app to App Store Connect, and I can even send those builds to my internal TestFlight team right away and start getting feedback on the changes hot off the presses. To summarize, Xcode 14 takes multiplatform app development to the next level with streamlined app targets which can now support even more destinations across multiple platforms. With a single app target, you can maintain a common codebase and shared settings by default. As demonstrated, we can conditionalize our settings and code based on our needs, letting us customize our app to best match platform expectations. The rest is up to you. To learn more about new features and improvements in Xcode this year, check out "What's new in Xcode." I can't wait to see what incredible ideas you bring to life with the power of Xcode and SwiftUI.