Chapters

Hide chapters

SwiftUI by Tutorials

Third Edition · iOS 14 · Swift 5.3 · Xcode 12

Before You Begin

Section 0: 3 chapters
Show chapters Hide chapters

21. Converting an iOS App to macOS
Written by Sarah Reichelt

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

If you have worked through the early chapters of this book, you have built several iOS apps. You have used Catalyst to run an iOS app on your Mac, and you have created a multi-platform app that runs on both iOS and macOS, and in the last chapter, you made a document-based Mac app. But in this chapter, you are going to make a macOS app from an iOS app. You will use the code, views and assets from an iOS project to make your macOS app.

The vast majority of Swift and SwiftUI tutorials and examples on the internet are for iOS, mostly specifically for iPhones. So learning how to re-use the code in an iOS project to create a real Mac app, will be a very valuable skill.

Getting started

Download the starter project, which is the iOS app that you are going to convert. You may have already built this app in earlier chapters, but even if you have, please use this starter project.

Build and run the app in an iPhone simulator and click through all the options to see how it works.

iOS App
iOS App

The iOS version uses a very common navigational pattern where the initial screen offers a selection of choices which then use NavigationLinks to display other views. These secondary views sometimes have even more options, which can be full navigation views, sheets or dialogs.

For the Mac version, where you can assume much wider screens, you are going to have the main navigation in a sidebar on the left. The main portion of the window on the right will display different views depending on the navigation selections.

As you work through this chapter, there will be a lot of editing which can be hard to explain and even harder to follow, but if you get lost, download the final project and check out the code there.

Setting up the Mac app

In Xcode, create a new project, using the macOS App template and selecting SwiftUI, SwiftUI App and Swift for the Interface, Life Cycle and Language. Call the app MountainAirportMac and save it.

Importing code files

To start, switch to Finder and open the MountainAirport folder inside the starter projects folder. Then select all these folders and files, except for the folder named Assets.xcassets, and drag them into the Project navigator for your new Mac project — be sure to select Copy items if needed and Create groups for each one. Confirm that the MountainAirportMac target is checked.

Project navigator
Xhoyokc sutewazeh

Importing assets

As well as the .swift files, you can import the assets used by the iOS app, primarily the app icon and any images used in the app’s UI.

Assets
Upcuzh

Image assets
Ozepe alzurw

Fixing the build errors

You have imported all the code files, imported the assets, set up your app’s icon and configured the other images for the Mac. The big task now is to get the app to build.

Replacing unavailable features

For each of the errors, find the matching error in the Issue navigator. Click on the line with the red X to jump to the line of code with the error and then follow these instructions to fix it.

static var previews: some View {
  AwardsView()
    .environmentObject(AppEnvironment())
}

Clearing remaining errors

Now you have two or three remaining issues to get rid of, depending on how Xcode is behaving on the day.

.toolbar {
  Toggle("Hide Past", isOn: $hidePast)
}
Run 1
Win 8

Styling the sidebar

The sidebar in the app is going to show the main navigation links to the other parts of the app. Open up WelcomeView.swift and take a look at what it is doing right now. The main action is in a NavigationView that contains a grid of NavigationLinks. This is not a scheme that performs well on macOS, so you are going to replace it with a set of buttons that set a variable to dictate what is shown in the main part of the window.

.frame(width: 155, height: 155, alignment: .leading)
var body: some View {
  // 1
  VStack {
    // 2
    Button(action: { displayState = .flightBoard }, label: {
      FlightStatusButton()
    })
    // 3
    .buttonStyle(PlainButtonStyle())

    Button(action: { displayState = .searchFlights }, label: {
      SearchFlightsButton()
    }).buttonStyle(PlainButtonStyle())

    Button(action: { displayState = .awards }, label: {
      AwardsButton()
    }).buttonStyle(PlainButtonStyle())

    if let lastFlight = lastViewedFlight {
      Button(action: {
        displayState = .lastFlight
        showNextFlight = true
      }, label: {
        LastViewedButton(name: lastFlight.flightName)
      }).buttonStyle(PlainButtonStyle())
    }
    Spacer()
  }
  .padding()
  // 4
  .frame(minWidth: 190, idealWidth: 190, maxWidth: 190,
         minHeight: 630, idealHeight: 630, maxHeight: .infinity)
  // 5
  .background(
    Image("welcome-background")
      .resizable()
      .aspectRatio(contentMode: .fill)
  )
}

Sidebar properties

You will be seeing some errors now because body is accessing properties that do not exist, so scroll to the top of the WelcomeView struct and add this:

// 1
@SceneStorage("displayState") var displayState: DisplayState = .none
@SceneStorage("lastViewedFlightID") var lastViewedFlightID: Int?

// 2
var lastViewedFlight: FlightInformation? {
  if let id = lastViewedFlightID {
    return flightInfo.getFlightById(id)
  }
  return nil
}
enum DisplayState: Int {
  case none
  case flightBoard
  case searchFlights
  case awards
  case lastFlight
}
Sidebar
Nezawol

NavigationViews in macOS

In an iPhone app, a NavigationLink inside a NavigationView slides the current view out and a new one in, while providing a way to go back. With a macOS app, this works differently. Because the views appear side-by-side, the NavigationView has to specify all of its views at the start. These views can change as the model data changes, but there must be a view in place when the NavigationView first appears, for each pane you want to display.

// 1
NavigationView {
  // 2
  WelcomeView()
  Text("Flight info goes here")
}
// 3
.navigationTitle("Mountain Airport")
Navigation View
Wukuyahail Xiec

// 1
.commands {
  // 2
  SidebarCommands()
}

Displaying the data views

Right now, the second pane of the NavigationView is displaying a placeholder Text view, but in this app, it will have to choose what to display based on the setting of displayState:

Setting up properties

Before you can set this up, ContentView is going to need the data to pass to these other views, so add these properties to the top of the ContentView struct:

// 1
@StateObject var flightInfo = FlightData()

// 2
@SceneStorage("displayState") var displayState: DisplayState = .none
@SceneStorage("lastViewedFlightID") var lastViewedFlightID: Int?
@SceneStorage("selectedFlightID") var selectedFlightID: Int?

// 3
var selectedFlight: FlightInformation? {
  if let id = selectedFlightID {
    return flightInfo.getFlightById(id)
  }
  return nil
}

var lastViewedFlight: FlightInformation? {
  if let id = lastViewedFlightID {
    return flightInfo.getFlightById(id)
  }
  return nil
}
var flightInfo: FlightData
WelcomeView(flightInfo: FlightData())
  .previewLayout(.fixed(width: 190, height: 630))
WelcomeView(flightInfo: flightInfo)

Choosing the view

Now that the data is ready for use, replace the Text placeholder view in ContentView.swift with this:

// 1
switch displayState {
  case .none:
    // 2
    EmptyView()
  case .flightBoard:
    // 3
    HStack {
      FlightStatusBoard(flights: flightInfo.getDaysFlights(Date()))
      FlightDetails(flight: selectedFlight)
    }
  // 4
  case .searchFlights:
    SearchFlights(flightData: flightInfo.flights)
  case .awards:
    AwardsView()
  case .lastFlight:
    FlightDetails(flight: lastViewedFlight)
}
var flight: FlightInformation?
@SceneStorage("lastViewedFlightID") var lastViewedFlightID: Int?
lastViewedFlightID = flight.id
.frame(minWidth: 350)
.frame(minHeight: 350)

Flight Status

Build and run the app. Click on Flight Status and test out the tabs and the Hide Past toggle. Clicking on a flight shows a popover or maybe even two so that is something you are going to have to fix.

Flight Status
Xqerxx Hkasij

Showing the selected flight

You already set up the FlightDetails view to show the flight that has been selected but to join this up to the list of flights, you need to change the list that displays all the flights so that it sets selectedFlightID when a flight is clicked.

@SceneStorage("selectedFlightID") var selectedFlightID: Int?
// 1
Button(action: {
  selectedFlightID = flight.id
}, label: {
  // 2
  FlightRow(flight: flight)
})
// 3
.buttonStyle(PlainButtonStyle())
.frame(minWidth: 350)
Selected Flight
Remozkih Bxigst

.buttonStyle(PlainButtonStyle())

Searching for flights

The first section of the app is now complete, so click the Search Flights button in the side bar to have a look at the next section.

Search
Ziachh

.buttonStyle(PlainButtonStyle())
@SceneStorage("lastViewedFlightID") var lastViewedFlightID: Int?
lastViewedFlightID = flight.id
Search 2
Peevly 4

Last viewed flight

Before you jump into fixing the awards view, notice how the Last Viewed Flight button appears after you have selected a flight in either the Flight Status or Search Flights sections.

Awards view

If you click Your Awards, the app will crash reporting that it cannot find the AppEnvironment ObservableObject.

@State var flightNavigation = AppEnvironment()
AwardCardView(award: award)
  .foregroundColor(.black)
  .aspectRatio(0.67, contentMode: .fit)
Awards
Ehowzs

@State private var isPresented = false
// 1
Button(action: {
  isPresented.toggle()
}, label: {
// 2
)
// 3
.buttonStyle(PlainButtonStyle())
// 4
.sheet(
  isPresented: $isPresented,
  content: {
    AwardDetails(award: award)
  }
)
@Environment(\.presentationMode) var presentationMode
// 1
HStack {
  Spacer()
  Button(action: {
    // 2
    presentationMode.wrappedValue.dismiss()
  }, label: {
    // 3
    Image(systemName: "xmark.circle")
      .font(.largeTitle)
  })
  // 4
  .buttonStyle(PlainButtonStyle())
}
Awards 2
Otagbw 5

Challenges

Challenge 1: Styling

What about adding some conditional styling to the sidebar buttons to show which of the main views is selected? And the Search Flights display and its popups could do with some modifications to the look and feel and to the sizes. Don’t forget to check how things look in both light and dark modes.

Challenge 2: Check-in alert

Remember how you commented out the actionSheet in FlightSearchDetails.swift? See if you can work out how to replace this with an alert.

Challenge 3: Converting other apps to Mac

Congratulations! You made it. You started with an iOS app and you re-used code and assets to make a Mac app. You have learned how to fix the bugs caused by importing iOS code and how to set up images to work on a Mac.

Key points

  • There is a lot of iOS code around and you can use a great deal of it in your macOS apps with little or no changes.
  • macOS apps can have multiple windows open at once, so you need to make sure that your settings apply correctly. Do they need to be app-wide or per window?
  • iOS apps have fixed-sized views, but on the Mac, you must be aware of different possible window sizes.
  • When faced with a conversion task, take it bit by bit. Get the app-building without error first, even if this means commenting out some functionality. Then go through the interface one section at a time, checking to see what works and what has to be changed.
  • You imported 34 Swift files into your app. Twenty-one of them required no editing and only four of the 13 changed files had significant numbers of changes! That has saved an enormous amount of time and effort.
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.
© 2024 Kodeco Inc.

You’re accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now