Chapters

Hide chapters

SwiftUI by Tutorials

Fourth Edition · iOS 15, macOS 12 · Swift 5.5 · Xcode 13.1

Before You Begin

Section 0: 4 chapters
Show chapters Hide chapters

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

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

If you’ve worked through the early chapters of this book, you’ve built several iOS apps. And in the previous chapter, you made a document-based Mac app. But in this chapter, you’re going to make a macOS app from an iOS app. You’ll 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’re 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 screens
iOS app screens

The iOS version uses a very common navigational pattern where the initial screen offers a selection of choices . Each choice uses a NavigationLink 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’re going to have the 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’ll 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 for the Interface and Swift for the Language. Call the app MountainAirportMac and save it.

Importing code files

To start, switch to Finder and open the MountainAirport folder inside the starter project folder. Then select the following folders and files, 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 after imports
Mcosucn hizitufom ucdug abhoyng

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.

Imported assets
Adzurqek elxafl

Image assets
Osece asmewd

Fixing the build errors

You’ve 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.

Show only errors
Jdud elww uphoby

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. You may see these listed in a different order but match up the error name and file name with the fixes below:

static var previews: some View {
  AwardsView()
    .environmentObject(AppEnvironment())
}
.toolbar {
  Toggle("Hide Past", isOn: $hidePast)
}
.navigationTitle("Search Flights")

Clearing remaining errors

You have only made five changes, but some of them were causing multiple errors. Press Command-B to build the app again and you’ll have ten remaining issues to get rid of.

First run
Xivqs gev

Styling the sidebar

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

.frame(width: 20, height: 20)
.frame(width: 155, height: 140, alignment: .leading)
var body: some View {
  // 1
  VStack {
    // 2
    WelcomeAnimation()
      .foregroundColor(.white)
      .frame(height: 40)
      .padding()
    // 3
    Button(action: { displayState = .flightBoard }, label: {
      FlightStatusButton()
    })
    // 4
      .buttonStyle(.plain)

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

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

    Button(action: { displayState = .timeline }, label: {
      TimelineButton()
    }).buttonStyle(.plain)

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

Sidebar properties

You’ll be seeing some errors now because body is accessing properties that don’t exist yet, 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 timeline
  case lastFlight
}
Sidebar
Zevolix

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
Yokudoseik zeix

// 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: 800))
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 .timeline:
  FlightTimelineView(
    flights: flightInfo.flights.filter {
      Calendar.current.isDate(
        $0.localTime,
        inSameDayAs: Date()
      )
    })
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’re going to have to fix.

Flight Status
Nvefvb Ngilin

Showing the selected flight

You’ve already set up the FlightDetails view to show the selected flight 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 you click on any flight.

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

.buttonStyle(.plain)

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
Zeetyd

.buttonStyle(.plain)
@SceneStorage("lastViewedFlightID") var lastViewedFlightID: Int?
lastViewedFlightID = flight.id
Search result
Zuittw sobakh

Search details
Giekxm kawoawb

Button("On-Time History") {
  showFlightHistory.toggle()
}
.popover(isPresented: $showFlightHistory) {
  FlightTimeHistory(flight: flight)
}
On-time popover
Ir-joba minerag

Last viewed flight

Before you jump into fixing the awards view, notice how the Last Viewed Flight button appears after you’ve selected a flight in the Search Flights section.

Awards view

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

@State var flightNavigation = AppEnvironment()
Awards UI needs work
Orejyg IO ciost hemf

AwardCardView(award: award)
  .foregroundColor(.black)
  .aspectRatio(0.67, contentMode: .fit)
Awards
Ogomwm

Showing the selected award

To make the awards clickable, open AwardCardView.swift where you’re going to add a sheet modifier to display the AwardDetails.

@State private var isPresented = false
// 1
Button(action: {
  isPresented.toggle()
}, label:
// 2
)
// 3
.buttonStyle(.plain)
// 4
.sheet(
  isPresented: $isPresented,
  content: {
    AwardDetails(award: award)
  }
)

Dismissing the sheet

Open AwardDetails.swift and add this property:

@Environment(\.dismiss) var dismiss
// 1
HStack {
  Spacer()
  Button(action: {
    // 2
    dismiss()
  }, label: {
    // 3
    Image(systemName: "xmark.circle")
      .font(.largeTitle)
  })
  // 4
  .buttonStyle(.plain)
}
Awards Details
Ojajqz Qinuugw

Flight Timeline

There’s one more view to look at. Remember how you had to change a lot of UIs to NSs in FlightMapView.swift? This was so that the timeline view could embed maps, using MapKit.

Flight timeline showing map.
Lvuvlt licemuzo gtitihy qon.

Challenge

Challenge: Styling

The tab bar at the top of the Search Flights display needs some styling to make it look good and its popup is too tall. Don’t forget to check how things look in both light and dark modes.

Key points

  • There’s 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 you have to change.
  • You imported 43 Swift files into your app. 29 of them required no editing and only 5 of the 14 changed files had significant numbers of changes! That has saved an enormous amount of time and effort.

Where to go from here?

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.

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 reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now