Chapters

Hide chapters

SwiftUI by Tutorials

Second Edition · iOS 13 · Swift 5.2 · Xcode 11

Before You Begin

Section 0: 3 chapters
Show chapters Hide chapters

5. The Apple Ecosystem
Written by Audrey Tam

Apple says SwiftUI is the shortest path to building great apps on every device. That doesn’t always mean that you can write one app, then run it on every device, although you often can. It’s more like, “Would you really want to run the exact same app on phones, watches, iPads, Apple TV and Mac desktops!?” Each platform is good for some things, but not so good for others.

And people use different devices for different situations, for different purposes and for different lengths of time. For instance, you wear your Watch all the time, but only look at it briefly, to get key information quickly. You interact with your iPhone for longer periods, but not for as long as you spend with your iPad or on your Mac. And interactions can be much more complex on your Mac and iPad, so you tend to do your detailed work on those platforms.

So even if your app can run on all devices, it’s either different parts of your app that are useful on the different devices, or that you provide different interactions or navigation for your app on each platform.

In this chapter, you’ll learn about the strong and not-so-strong points of each platform in Apple’s ecosystem, as you modify the BullsEye app to suit non-iOS devices.

Getting started

Open the BullsEyePlus starter project from the chapter materials. Open ContentView.swift in the iOS target, check that the scheme is an iOS device, and Preview the iOS app:

BullsEyePlus iOS app
BullsEyePlus iOS app

This app displays a random target value between 1 and 100. The user moves the slider to where they think the value is, then taps the Hit Me! button to see their score. Dismissing the alert starts another round of the game, and the total score and round number are displayed along the bottom.

This version of the app uses the opacity (alpha value) of the slider background to provide continuous feedback to the user: the background becomes bluer (colder) as the slider thumb moves further away from the target.

Now open ContentView.swift in the WatchKit Extension. Change the scheme to WatchKit App, pick one of the Apple Watch sizes, then Live-Preview the Watch app:

BullsEyePlus Watch app.
BullsEyePlus Watch app.

Note: If you change the bundle ID of the WatchKit targets, you must also change it in the WatchKit Info.plist files: Search for com.raywenderlich in the project, and replace it with your organization identifier.

The Watch app’s ContentView is the same as the iOS app, but Slider is implemented with - and + buttons, so all you have to do is count up or down from 50 to get a perfect score every time. That is, if you could see what the target value is! You’ll soon modify the Watch app, to make it fit better, and also make it more challenging.

The two apps share a BullsEyeGame model class, using target membership.

Target membership of BullsEyeGame.swift.
Target membership of BullsEyeGame.swift.

Creating a Swift package

Target membership is adequate when the targets share only one or two files, but it gets cumbersome as you add features. Then it’s better to organize the shared files in a package. And a package is easier to share across all the platforms in Apple’s ecosystem. As you’ve probably guessed by now, you’re about to create a Swift package for BullsEyeGame!

First, create a new Swift package: File ▸ New ▸ Swift Package… or Shift-Control-Command-N. Name the package Game, and add it to the project and to its root group. Create a new folder named packages, then click Create:

New Swift package dialogue.
New Swift package dialogue.

The new package already has Readme and Package.swift files, plus Sources and Tests groups.

Structure of new package.
Structure of new package.

And here it is in its own folder in Finder:

New package in Finder.
New package in Finder.

Note: Game is a local package, which you can add to any project by dragging it into the project navigator. A more common use of Swift Package Manager is to link remote libraries via their repository URL. Check out WWDC 2019 sessions 408 Adopting Swift Packages in Xcode apple.co/2luxuLJ and 410 Creating Swift Packages apple.co/2mVg4YX. Or follow our tutorial An Introduction to Swift Package Manager bit.ly/2JXL3yD or, if you’re a subscriber, our screencast Creating a Swift Package bit.ly/2mu3srC

Customizing your Game package

Your Game package just needs the BullsEyeGame model class. Drag BullsEyeGame.swift into Sources/Game/.

Move Game.swift into the package.
Move Game.swift into the package.

Open BullsEyeGame.swift. Now that it’s not in the BullsEye group, everything in it needs to be public, so add that everywhere:

public class BullsEyeGame: ObservableObject {
  public var round = 0
  public var startValue = 50
  public var targetValue = 50
  public var scoreRound = 0
  public var scoreTotal = 0

  public init() {
    startNewGame()
  }

  public func startNewGame() {
    ...
  }

  public func startNewRound() {
    ...
  }

  public func checkGuess(_ guess: Int) {
    ...
  }
}

Versioning your Game package

Next, open the manifest file Package.swift; it describes how to build the package. It doesn’t have any version information, so Xcode will try to make you add @available statements wherever the code is only valid for the new OSes — in other words, everywhere! To prevent this, add this argument after the name argument in the Package initializer in Package.swift:

platforms: [.iOS(.v13), .macOS(.v10_15), .watchOS(.v6), .tvOS(.v13)],

Linking your Game package library

The products argument defines the library that you can link with your app:

products: [
  .library(
    name: "Game",
    targets: ["Game"]),
],

So, to link this library with your app, go to the iOS app target’s Frameworks, Libraries and Embedded Content section: Click +, then select Workspace/Game/Game from the list:

Add BullsEyeGame library to iOS app target.
Add BullsEyeGame library to iOS app target.

And do the same for the WatchKit Extension target:

Add BullsEyeGame library to Watch app target.
Add BullsEyeGame library to Watch app target.

Importing your Game package module

Finally, you must import the Game package module into your app, wherever you use it. Add this line to ContentView.swift in both the iOS app and the Watch app:

import Game

Xcode probably pops up the error message No such module “Game”.

The first step is to try building the project with Command-B. If Xcode is stubborn, try Product ▸ Clean (Shift-Command-K), Product ▸ Clean Build Folder… (Shift-Option-Command-K), and delete the project’s Derived Data folder (Xcode Preferences ▸ Locations).

Refresh the preview (Option-Command-P), then turn on Live Preview, and check that both apps still work:

Apps running with Game package.
Apps running with Game package.

Both apps work with the Game package just as they did with the shared BullsEyeGame.swift file. Creating and linking the package was really easy, and Xcode automatically built the package product for each app. You didn’t have to configure anything explicitly about platforms, because packages are platform-independent.

Creating a GameView package

Now reinforce your packaging skills by creating a GameView package with BullsEyeGame.swift and ContentView.swift, to use in the macOS app you’ll create later in this chapter.

To start, create a new package (Shift-Control-Command-N), name it GameView, save it in your packages folder, and don’t add it to any project:

New GameView package.
New GameView package.

Dragging files into the project navigator of a package doesn’t give you the Copy items if needed option, so you’ll copy files manually, in Finder. Locate your Game package in Finder, open your new GameView folder in another Finder window, then copy BullsEyeGame.swift from Game/Sources/Game into GameView/Sources/GameView. Also copy ContentView.swift from BullsEyePlus/BullsEyePlus into GameView/Sources/GameView:

Copy files to GameView.
Copy files to GameView.

Now open GameView/Sources/GameView in its project navigator. Everything in BullsEyeGame.swift is already public, but you need to edit ContentView.swift to make ContentView and body public.

Modify ContentView.swift as follows:

public struct ContentView: View {
  ...
  public var body: some View {
    ...
  }
  ...
}

You don’t need to import Game anymore, so delete this line.

Then add this empty init method:

public init() { }

And finally, add version information after the name argument in the Package initializer in Package.swift:

platforms: [.iOS(.v13), .macOS(.v10_15), .watchOS(.v6), .tvOS(.v13)],

This package is all set for you to use later in this chapter. Go ahead and close it in Xcode.

Designing for the strengths of each platform

SwiftUI provides you with powerful tools for developing apps that run on multiple platforms, such as generic views. Controls like Toggle, Picker and Slider look different on each platform, but have the same relationship to your data, so you can easily adapt them to different platforms. And it has a common layout system as well. You use the same container views to layout your UI.

Each platform has its own strengths, so instead of “write once, run everywhere”, it’s more like “learn once, apply anywhere”.

watchOS

The Watch is the best device for quickly getting the right information at the right time. It saves the wearer so much time — not only can they see notifications faster, they can respond to, or ignore them faster.

The screen is very small, so you should show only the most important and relevant information. And navigation should be streamlined, so the user can get any important information within two or three taps.

Remember, it’s very tiring to hold your arm up for too long! WatchKit has added more ways to use the digital crown, and you’ll soon put that into practice.

macOS/iPadOS

People tend to use their Macs and iPads for longer periods, and for more detailed tasks, such as taking notes, searching, and sorting and filtering.

The Mac has a large screen and full keyboard, so many people enjoy saving time by using keyboard shortcuts.

Mac users are accustomed to opening multiple windows. When it’s time to declutter your screen, the default Window menu includes Merge All Windows.

Mac apps often have preferences and inspector windows. And you can also layout touchbar items, using the standard SwiftUI layout system.

tvOS

Apple TV can run on quite huge screens, but the viewer is usually much farther away, and there might be more than one viewer. Like all TV viewing, sessions can be quite long.

Apple TV is best for full-screen experiences of images and video, but not very good for reading or writing a lot of text. It’s not mobile, so you can leave out any geofencing features or location-based notifications.

Navigation needs to be streamlined because the interaction is via the Siri remote, with swipe-to-browse-and-focus mechanisms to use controls. SwiftUI gives you access to the play, pause and on-exit buttons.

TabView is an example of how you would design differently for tvOS than iOS. In an iOS app, TabView would be the top level, to keep the tabs visible when the user navigates down the view hierarchy. But you’d embed TabView in a NavigationView for a tvOS app, so the tabs would disappear when the user drills down so they would get a full-screen experience.

Improving the watchOS app

You saw earlier in this chapter that the iOS ContentView works for the Watch app, but the smaller screen causes a few problems.

First, some of the text labels are too long, so you’ll shorten them. Go into ContentView.swift in the watchOS extension and make the following changes:

  • Change “Put the Bull’s Eye as close as you can to:” to “Aim for:”
  • Change “Total score:” to “Total:”

Also, the top label goes off the top edge of the screen, so you need to reduce some of the spacing between the UI objects.

Delete the padding() on the button (just above the HStack with “Total” and “Round”). This is the quickest fix for the 44mm size. For the 40mm size, you can squeeze the UI objects together with a negative value of spacing on the top-level VStack:

VStack(spacing: -0.01)

Next, make the Slider more challenging by removing the step parameter:

Slider(value: $currentValue, in: 1.0...100.0)

And finally, take advantage of the Watch’s digital crown to change the Slider value:

Slider(value: $currentValue, in: 1.0...100.0)
  .digitalCrownRotation($currentValue, from: 1.0, through: 100.0)

Note: The digital crown actually works without adding this modifier but, after the first game, you must tap the slider to give it focus. With the modifier, the digital crown just works every time.

Set the scheme to one of the Watch sizes, then build and run. If you get a blank screen, build and run a second time.

Improved watchOS app.
Improved watchOS app.

To move the simulator’s digital crown, use your usual scrolling action — for me, it’s a two-finger drag on the trackpad.

So that’s an example of how you could adapt your app to a smaller screen. Now it’s time to move on to bigger things: Mac and Apple TV!

Extending the Mac Catalyst app

It’s easy to run the iOS app on your Mac as a Mac Catalyst app; you simply need to take care of some administrative details first.

To start, you need to sign the iOS target, in the Signing and Capabilities tab. First, personalize the Bundle Identifier’s organization identifier to something different from com.raywenderlich, then select a Team — it doesn’t need to be a paid developer account.

Change bundle ID and select a team.
Change bundle ID and select a team.

Next, go back to the General tab, and check the Deployment Info ▸ Mac checkbox:

Check the Mac checkbox.
Check the Mac checkbox.

Xcode pops up a dialog:

Enable Mac support for this iOS app.
Enable Mac support for this iOS app.

Click Enable.

Now check that Xcode has switched to the scheme for your Mac, then build and run. This might take a while, even after the build succeeds.

BullsEye running on my Mac.
BullsEye running on my Mac.

Wow, you just checked a checkbox, and it works! You get an interesting feature with this Mac Catalyst app: Clicking on the slider moves its thumb to that location.

Next, you’ll try out another Mac Catalyst freebie.

iOS Settings == macOS Preferences

Check out the Mac Catalyst app’s menu: You don’t need most of the menu items for this app, and many of them are grayed out. The BullsEyePlus menu doesn’t have a Preferences menu item:

No Preferences menu item.
No Preferences menu item.

But if you add Settings to the iOS app, you’ll get macOS app Preferences for free! You’re going to add a setting to let the user turn the slider opacity hint on or off.

Stop the Mac Catalyst app, then add a Settings Bundle to your app: Command-N, then select iOS ▸ Resource ▸ Settings Bundle:

Add Settings Bundle.
Add Settings Bundle.

Leave the name as Settings, and make sure the BullsEyePlus target is checked:

Save Settings Bundle.
Save Settings Bundle.

And here it is in the project navigator:

Settings Bundle structure.
Settings Bundle structure.

Now open Root.plist, then delete every item in the Preference Items dictionary, except Toggle Switch — Enabled:

Toggle Switch Preference Item.
Toggle Switch Preference Item.

Edit the toggle switch dictionary values: Set Title to Show Hint, Identifier to show_hint, and leave Default Value set to YES:

Toggle switch dictionary values.
Toggle switch dictionary values.

So that sets up a new show_hint key in UserDefaults. Next, you’ll modify your app to use it.

Open SceneDelegate.swift, and add this property:

let defaults = UserDefaults.standard

Next, pass this to ContentView when you create window.rootViewController:

window.rootViewController = UIHostingController(
  rootView: ContentView()
    .environmentObject(defaults))

Note that you’re modifying the ContentView() argument, not the UIHostingController.

An error message appears: “Instance method ‘environmentObject’ requires that ‘UserDefaults’ conform to ‘ObservableObject’”. So add this line outside the SceneDelegate class:

extension UserDefaults: ObservableObject { }

You’re setting up defaults to be an @EnvironmentObject: All that’s needed is for UserDefaults to conform to ObservableObject!

Now move on to ContentView.swift. Add your new @EnvironmentObject property:

@EnvironmentObject var defaults: UserDefaults

Next, you’ll replace the Slider view with an if-else statement. To do this, you’ll leverage some new contextual menus in Xcode.

First, ensure the canvas is open — press Option-Command-Enter if it isn’t. Command-click on the Slider, then select Make Conditional from the menu:

Inserting if-else statement via make conditional.
Inserting if-else statement via make conditional.

Note: If your canvas is closed, you won’t see the Make Conditional option. If Command-click jumps to the definition of Slider, use Control-Command-click instead.

Update the conditional statements to match the following:

if defaults.bool(forKey: "show_hint") {
  Slider(value: $currentValue, in: 0.0...100.0, step: 1.0)
    .background(Color.blue)
    .opacity(abs((Double(self.game.targetValue) - self.currentValue)/100.0))
} else {
  Slider(value: $currentValue, in: 0.0...100.0, step: 1.0)
}

If show_hint is true, you show the opacity hint; otherwise, you don’t.

Now check that your scheme is still Mac, then build and run. Press Command-, to open Preferences, and there it is:

Show Hint preference window.
Show Hint preference window.

Note: If the app doesn’t respond to the show_hint setting, build and run again.

Play around with toggling Show Hint on and off: Your preference takes effect as soon as you move the slider!

Creating a MacOS BullsEye app

The Mac Catalyst framework is really convenient for iOS developers — you can continue to use the familiar UI-specific API, and you get a Mac app for free! If you’re a macOS developer, you’ll be working with macOS knowledge. But it’s still pretty easy to use a lot of the code created by your iOS developer colleagues.

In this section, you’ll use the GameView package you created earlier. It contains the UI as well as the model class. As you’ll see, there’s not much more work to do!

To start, create a new macOS project: Select macOS ▸ App, name it MacBullsEye, and check that it’s set to SwiftUI User Interface:

New macOS app.
New macOS app.

Actually, you won’t need ContentView.swift, so delete it in the project navigator.

Now find the GameView package you created earlier, and drag it from Finder into the project navigator of your new macOS app, above the MacBullsEye group:

Add GameView package to MacBullsEye.
Add GameView package to MacBullsEye.

And now to link it: In your app target’s Frameworks, Libraries and Embedded Content section, click +, then select Workspace/GameView/GameView from the list:

Add GameView library to MacBullsEye.
Add GameView library to MacBullsEye.

Then import the module: Add this line to the other imports, at the top of AppDelegate.swift:

import GameView

Now, press Command-B to build your new package, then build and run your app:

MacBullsEye running from GameView package.
MacBullsEye running from GameView package.

Everything works! How easy was that?

Creating a tvOS BullsEye app

And finally, tvOS. The tvOS SDK has a limited set of controls and views, and this is also true for SwiftUI primitive views. The BullsEye app runs into trouble right away — there’s no Slider! Not even a Stepper. What to do?

Well, the BullsEyeGame class has a party trick up its sleeve. It’s just a random target value, coupled with a way to compute a score from the user’s guess. The actual presentation of the game can be flexible. In this case, you can present the target value as a mark on a 1-to-100 line, and ask the user to enter their guess in a text field.

Create a new tvOS Single View App with SwiftUI User Interface, and name it TVBullsEye:

New tvOS app.
New tvOS app.

Then drag the Game package from Finder into the project navigator, and link the library on the target page:

Add Game library to TVBullsEye.
Add Game library to TVBullsEye.

Now import the module in ContentView.swift:

import Game

Build the library (Command-B) to get rid of the No such module “Game” error, but to be honest, it will probably appear again, whenever you change anything in the project.

Now replace the ContentView struct with the following code. I’ll break it up so it doesn’t span across pages.

First, the data properties:

struct ContentView: View {
  @ObservedObject var game = BullsEyeGame()

  @State var currentValue = 50.0
  @State var valueString: String = ""
  @State var showAlert = false
}

These are the same as for the regular BullsEye app, but you also need a String variable, because of the TextField.

Next, start building the body; this first part is very different from the regular app:

var body: some View {
  VStack {
    Text("Guess the number:")
    TextField("1-100", text: $valueString, onEditingChanged: { 
      _ in self.currentValue = Double(self.valueString) ?? 50.0
    })
      .frame(width: 150.0)
    HStack {
      Text("0")
      GeometryReader { geometry in
        ZStack {
          Rectangle()
            .frame(height: 8.0)
          Rectangle()
            .frame(width: 8.0, height: 30.0)
            .offset(x: geometry.size.width *
              (CGFloat(self.game.targetValue)/100.0 - 0.5), 
                y: 0.0)
        }
      }
      Text("100")
    }
    .padding(.horizontal)
  }
}

In this game, you ask the user to guess the number that’s marked on a horizontal line, and provide a TextField to enter their guess as valueString. You convert this String to a Double to get currentValue, assigning 50.0 if the String doesn’t represent a number.

You present the target value as a marker superimposed on a horizontal line — actually, a skinny Rectangle on a flat wide Rectangle, with the location of the target marker calculated as a fraction of the line’s width. The marker’s default location is in the center of the line, so a positive offset value moves it above 50, and you need a negative offset value to display a target value that’s less than 50.

Note: Learn more about GeometryReader in Chapter 13: “Drawing and Custom Graphics”.

Now fill in the rest of the body, which is almost the same as the regular app:

var body: some View {
  VStack {
    ...
    Button(action: {
      self.showAlert = true
      self.game.checkGuess(Int(self.currentValue))
    }) {
      Text("Hit Me!")
    }
    .alert(isPresented: $showAlert) {
      Alert(title: Text("Your Score"), 
        message: Text(String(game.scoreRound)),
        dismissButton: .default(Text("OK"), action: {
          self.game.startNewRound()
          self.valueString = ""
        }))
    }
    .padding()
    HStack {
      Text("Total Score: \(game.scoreTotal)")
      Text("Round: \(game.round)")
    }
  }
}

The Hit Me! button and Text labels for Total Score and Round are the same as the iOS game, except to reset the TextField text to the empty string.

Using the tvOS simulator

Now build and run: This takes quite a while, the first time. And the simulator can be difficult to use.

When the app starts, you’ll see a Keyboard Connected message.

Running TVBullsEye app.
Running TVBullsEye app.

Your Mac keyboard’s arrow keys move the focus: Press Down and Up to see the focus move from the text field to the button, then back to the text field. With the focus on the text field, press Return to show the software keyboard:

TVBullsEye software keyboard.
TVBullsEye software keyboard.

The arrow keys work here, too: Use them to focus on a digit, then press Return. Huh? You’re back on the game screen, with nothing in the text field! Well, there’s another tool you can use.

Open the Window menu, and select Show Apple TV Remote (Shift-Command-R):

Apple TV Remote simulator.
Apple TV Remote simulator.

With focus on the text field, select it by pressing Return or by tapping in the center of the remote’s touch surface, above the MENU and Home buttons. Again, use the arrow keys to focus on a digit, or hold the Option key to scroll on the touch surface, then tap the center of the remote’s panel. Success! Your selected digit appears in the text field, and you’re still on the text entry screen.

Enter another digit to complete your guess value, then press Down to move focus to the done button.

Press Return or tap the remote’s panel to select it. And you’re back in the game:

Successful TextField entry.
Successful TextField entry.

Move the focus to the Hit Me! button, then press Return. And there’s your score:

Score!
Score!

Press Return to dismiss the alert.

New round
New round

Just like the regular BullsEye game, there are new target, total score and round values. And the text field has reset to the empty string.

So that’s one way to port BullsEye to tvOS. With a little more work, you could port RGBullsEye, by creating your own sort-of slider, supplemented by a TextField where the user can enter a value between 0 and 255.

Key points

  • SwiftUI provides generic views and a common layout system, so you can learn once, and apply anywhere.
  • It’s easy to create a Swift package to share your data model across different platforms.
  • Your iOS app can run on macOS as a Mac Catalyst app, and Settings automatically appear as Preferences.
  • You can also share SwiftUI views between iOS and macOS apps.
  • Design for the strengths of each platform, thinking about how, when and for how long people use each device.
  • Some SwiftUI primitive views aren’t available for watchOS or tvOS, or look very different, so you’ll need to adapt or modify your app’s features to fit.
Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.