22.
Converting an iOS App to macOS
Written by Sarah Reichelt
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.
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.
- All the .swift files in the MountainAirport folder.
- AwardsView folder.
- FlightDetails folder.
- FlightStatusBoard folder.
- Misc folder.
- Models folder.
- SearchFlights folder.
- Timeline folder.
After you move the files, delete MountainAirport.swift from your project. This is an iOS specific file that is not needed for your new Mac app.
By the end of that process, your Project navigator will look like this:
You now have a lot of the working code from the iOS app in your macOS app. You can assume that the model classes and structures are mostly working fine and don’t need you to change anything. Your main task is going to be to change the SwiftUI code to make the user interface work on a Mac. But you’ve already saved yourself a heap of time and trouble by importing all this code. Next, you’ll import assets.
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.
First, go to Assets.xcassets in your Xcode Project navigator and then open the Assets.xcassets folder in the iOS project’s Finder window. (If you are not showing file extensions, these may appear as Assets without the .xcassets extension.) Now, drag every folder inside this folder into your list of assets.
This adds all the assets but an iOS project configures its assets differently to a macOS project, so now you’ve got some house-keeping to do, starting with the app icon.
In the assets list, you have AppIcon that is part of the app template and AppIcon-1 that you just imported. Unfortunately, iOS and macOS have very different image size requirements for their app icons. The best solution is to take the largest of the images from AppIcon-1 and use an icon creator utility to make all the right image sizes, but for now, you’re going to cheat and take the easy way out.
In AppIcon-1, select the icon at the bottom: App Store iOS 1024pt and press Command-C to copy it. Go to AppIcon, select App Store - 2x and press Command-V to paste in the copied image. Now you can delete AppIcon-1 and your Mac app will use the imported icon.
Note: For an iOS app, you supply square icons and iOS rounds the corners for you. Modern Mac app icons have rounded corners with transparent padding, which you have to apply. If you were going to release a Mac version of an iOS app, you would need to re-design the icon, but for now, the square icon will do.
Delete the launch-assets group as Mac apps don’t have a launch view.
Select the ascending-airplane asset, click on the image and make sure that you can see the Attributes inspector on the right.
In the Devices section, Universal is checked, meaning that this image will work on any Apple device. But the image is in the 3x box for the very high-resolution iPhones and iPads and 3x images don’t work in a Mac app. Drag the image from the 3x box to the 2x box to make it Mac-compatible.
Repeat this process for all the images that are 3x, not forgetting the ones in the award-images folder.
Now it’s time to build!
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.
Press Command-B to build the app, but don’t panic when you get a string of errors appearing. You have to expect this when you import code that was written for a different platform.
Open the Issue navigator to see all the issues. There are a lot of warnings and errors, but fixing the errors will fix the warnings, so hide the warnings for now, to make the display less cluttered.
Click the X button at the right of the filter section at the bottom of the Issue navigator, so that it turns blue:
Now you’ll be down to sixteen errors you need to fix. Most of these are due to the iOS app using features that are not available in macOS.
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:
-
StackNavigationViewStyleis unavailable in macOS — WelcomeView.swift:
For this app the default style will be fine, so delete the navigationViewStyle modifier.
Press Command-B again to build the app after this and every other fix.
-
StackNavigationViewStyleis unavailable in macOS — AwardsView.swift:
The preview is wrapped in a NavigationView. This can be really useful on iOS for seeing how views will look with a navigation bar, but this isn’t necessary for macOS. Replace previews with this:
static var previews: some View {
AwardsView()
.environmentObject(AppEnvironment())
}
-
navigationBarItems(trailing:)is unavailable in macOS — FlightStatusBoard.swift:
Instead of a navigation bar item for this Toggle, you’re going to use a Mac toolbar. Replace the navigationBarItems modifier with this toolbar modifier:
.toolbar {
Toggle("Hide Past", isOn: $hidePast)
}
This uses the same Toggle control but wrapped in a toolbar instead of in navigationBar.
-
InsetGroupedListStyleis unavailable in macOS — SearchFlights.swift:
Search the Developer Documentation for the ListStyle protocol and check the available list styles. You can click through each and check the availability for macOS. Once you’ve looked at the options, change this to .listStyle(.inset).
-
navigationBarTitleis unavailable in macOS — SearchFlights.swift:
The macOS equivalent is navigationTitle so replace the line showing the error with:
.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.
- Cannot find type
UIColorin scope — FlightInformation.swift:
UIColor is a UIKit color object. The equivalent in AppKit is NSColor, but you’re not going to use this property in the Mac version, so delete the timelineColor computed property to get rid of this error.
- Cannot find type
UIColorin scope — FlightMapView.swift:
This time, you are going to replace the three uses of UIColor with NSColor.
- The last set of problems all originate with FlightMapView.swift which uses a
UIViewRepresentableto display a UIKit view inside a SwiftUI view. Just like withUIColor, you’ve got to change theUItypes toNSinstead.
In FlightMapView.swift, search for each of these, and replace them with their NS equivalents:
-
change
UIViewRepresentabletoNSViewRepresentable. -
change
makeUIViewtomakeNSView. -
change
updateUIViewtoupdateNSView. -
change
UIEdgeInsetstoNSEdgeInsets
Press Command-B again and this time, the app builds with no errors. And if you turn off Show only errors, you’ll see that all the warnings have disappeared too.
Well done! You now have a Mac app project populated with a lot of code and assets from an iOS app and with no build issues!
Before you try running the app, go to ContentView.swift and replace the standard Text("Hello, world!") with WelcomeView(). Now build and run.
Expand the window so you can see both columns. You can see the tops of the four navigation buttons on the left, and the animated plane zooming across the top. Click on the top left button and data will appear on the right. It isn’t pretty but it’s working! In the next sections, you’re going to make it look much better.
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.
To fit the Mac window better, the navigation buttons are going to be in a column of squarish buttons, not a grid of tall buttons.
First, go to WelcomeButtonView.swift and change the first frame — the Image modifier — to:
.frame(width: 20, height: 20)
Change the last frame that is modifying the VStack to:
.frame(width: 155, height: 140, alignment: .leading)
This makes the buttons shorter so they can all fit in a single column. All the navigation buttons in the sidebar use this button view.
Back in WelcomeView.swift, replace body with this:
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)
)
}
OK, that’s a lot of code, but actually fewer lines than were there before. All the button views are still there but wrapped differently.
- This view will be inside the main
NavigationViewso doesn’t need another one here. TheZStack,NavigationLinks,ScrollViewandLazyVGridare all gone. -
WelcomeAnimationis what shows the plane moving across the top. - Instead of
NavigationLinks,Buttons that set adisplayStatevariable contain each of the different button views. - The button style is set to
.plainto remove the standard macOS rounded rectangle button appearance and allow the view to set the size of the button. - The
VStackview has aframemodifier that sets the minimum, ideal and maximum width and height. - A
backgroundmodifier applies the image as a background that will fill the view.
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
}
And what’s happening here?
- In earlier chapters, you read about
@AppStoragethat provides a property wrapper forUserDefaults.@SceneStorageis similar to@AppStoragebut stores settings for each window and not for the entire app. Since you may want to have multiple windows open showing different views, it makes sense to use@SceneStoragehere.displayStatekeeps a record of what button you clicked, and that dictates what other view to display.lastViewedFlightIDstores an optionalIntwith the ID of the flight that you looked at last. -
@SceneStorageand@AppStoragecan only contain primitive types likeString,Int,Double,Bool, or enums that conform to these types. So you’re storing the ID of the last viewed flight and using this computed property to get an optionalFlightInformationobject from it.
To fix the remaining errors, add this enum to the end of MountainAirportMacApp.swift outside the struct:
enum DisplayState: Int {
case none
case flightBoard
case searchFlights
case awards
case timeline
case lastFlight
}
Now build and run the app to see your completed Mac sidebar.
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.
First, go to ContentView.swift and replace the body contents with this:
// 1
NavigationView {
// 2
WelcomeView()
Text("Flight info goes here")
}
// 3
.navigationTitle("Mountain Airport")
Going through this code:
- The outermost view is now a
NavigationView. - Inside the
NavigationVieware two views that will appear side-by-side with one of them being a placeholder for now. - The
NavigationViewhas a title which will appear as the window title.
Build and run and you can see how the window is starting to come together. You’ll need to make the window wider to see the second view.
You can resize the sidebar by dragging on the divider, but if you collapse it completely, you won’t be able to get it back, except by closing the window and opening a new one. To get around this bug, you’ll add a pre-configured menu item to your app.
Go to MountainAirportMacApp.swift and add this modifier to the WindowGroup:
// 1
.commands {
// 2
SidebarCommands()
}
And what do these few lines do?
- A
commandsmodifier is how you add menus to your app as you saw in the previous chapter. -
SidebarCommands()is a pre-definedCommandGroupthat adds a menu item and keyboard shortcut to the View menu, for toggling the sidebar.
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:
- none: EmptyView
- flightBoard: FlightStatusBoard + FlightDetails
- searchFlights: SearchFlights
- awards: AwardsView
- timeline: FlightTimelineView
- lastFlight: FlightDetails (for last viewed flight)
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
}
And what are all these?
- The main data model for the list of flights at the airport is in
flightInfowhich you initialize as a@StateObject.ContentViewthen owns this data object and can pass it to other views. - As in WelcomeView.swift,
@SceneStorageholds the window specific settings.selectedFlightIDis the only new one here. - These two computed properties use the
@SceneStorageproperties to get flight information from the main model.
Remove the @StateObject var flightInfo property from WelcomeView.swift and replace it with this:
var flightInfo: FlightData
You also need to edit the preview to this:
WelcomeView(flightInfo: FlightData())
.previewLayout(.fixed(width: 190, height: 800))
This gives the preview some data and sets its width and height to a column layout that will be more like how it appears in the app itself.
Next, ContentView must supply flightInfo to WindowView, so jump over to ContentView.swift and change WelcomeView() to:
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)
}
Here is what this code does:
- Select which view to display in the main part of the window by switching over the possible states for
displayState. - If no
displayStatehas been set, use anEmptyViewso that theNavigationViewstill has the two views it needs to dictate its structure. - The flight board displays an
HStackwith two internal views. - The other options display the appropriate views as discussed earlier. The parameters for these views are exactly the same as those used by the
NavigationLinks in the iOS version.
Now that you’ve added all that, Xcode is showing errors. This is because you’re passing optional values to the FlightDetails view, and it’s expecting non-optionals. Expand the FlightDetails group, open FlightDetails.swift and make these changes:
Replace the two properties at the top with this:
var flight: FlightInformation?
@SceneStorage("lastViewedFlightID") var lastViewedFlightID: Int?
This sets the flight to an optional and tells this view to use the @SceneStorage setting for the last viewed flight.
Command-click on the VStack and select Make Conditional. Type in let flight = flight in place of the true placeholder.
Note: Sometimes you’ll Command-click on a view or open the Library and not see all the expected options. In this case, check that the canvas preview is open. It doesn’t have to be active, but it has to be open to show all the options.
Move the onAppear modifier up to just under the line that sets the navigationTitle so that it’s inside the if let and change its action to:
lastViewedFlightID = flight.id
which makes it set the @SceneStorage variable.
And finally, add these two frame modifiers to the ZStack:
.frame(minWidth: 350)
.frame(minHeight: 350)
They’ll ensure that this view never gets too small to display everything it needs to.
You may feel like there has been a lot of work to get this far, but there is a mass of code that you haven’t touched that is just working.
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.
But before you start on that, open a new window in your app and click Flight Status there. You can select different flights in each window and you can have different settings for Hide Past, but when you change the tabs in one window, you change all the open windows.
Expand the FlightStatusBoard group and open FlightStatusBoard.swift. At the top of the struct, you’ll see an @AppStorage property that stores the selected tab.
Change @AppStorage to @SceneStorage to make selectedTab a window setting instead of an app setting.
Build and run again and test out two different windows. Now you can select a different tab in each window.
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.
Looking in FlightStatusBoard.swift, you can see that the body contains a TabView and each tab uses a FlightList view to display the relevant data. So that tells you that FlightList is the view you need to edit to change the list behavior.
Open FlightList.swift from the FlightStatusBoard group and add this to the top of the struct:
@SceneStorage("selectedFlightID") var selectedFlightID: Int?
This gives FlightList access to selectedFlightID so that it can store the selection for this window whenever you click on a flight.
Move down the file until you see the NavigationLink inside the List. Delete the NavigationLink and its two modifiers, and replace it with this:
// 1
Button(action: {
selectedFlightID = flight.id
}, label: {
// 2
FlightRow(flight: flight)
})
// 3
.buttonStyle(.plain)
// 4
So what’s happening here?
- You’ve replaced a
NavigationLinkwith aButtonthat sets theselectedFlightID. - The content of the
Buttonis exactly the same as the content of theNavigationLink. - The button’s style is set to
.plainto remove the standard button appearance. - You’ve deleted the
listRowBackgroundandswipeActionsmodifiers which are not appropriate for a Mac app.
Attach a frame modifier to the ScrollViewReader to set a minimum width:
.frame(minWidth: 350)
You may have seen some weird scrolling as you changed tabs. The flight list scrolls to the next scheduled flight but sometimes this leaves blank spaces at the top of the list. This is because the scrollTo method sets the anchor point to .center and this doesn’t work so well in a Mac app. Change the scrollTo anchor to .top and your Mac will handle the scrolls much better.
Build and run the app again and test out the Flight Status. Click a flight to see its details.
The details appear, but what’s that white bar? Click it and an animated terminal map will appear or disappear. The iOS version uses a custom transition to animate the button and that appears to be working, but the button is not styled to suit this display.
Open FlightInfoPanel.swift from the FlightDetails group, and about half-way down the code, you’ll see a Button. Double-click on the opening bracket after the word Button to select the entire button code, which tells you where the button ends. After that closing bracket, add this:
.buttonStyle(.plain)
Now try again and the buttons will look just right. You can now see the button animating as well as the terminal map. And you haven’t written a single line of animation code!
Great job! That was a big section, but now the app is really starting to come together.
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.
The data is all there, the segmented picker at the top works and the search field in the toolbar even allows you to select from a list of cities. But the display needs work and clicking on a flight crashes the app.
Fixing the display is going to be an easy one. Expand the SearchFlights group and open SearchResultRow.swift. This uses a Button to contain the data view and as you’ve done with all the Button views so far, you need to set the style of this button.
Underneath the Button and just before the .sheet line, add this modifier:
.buttonStyle(.plain)
Build and run the app again to see an immediate improvement.
However clicking on a flight still crashes the app and if you look at the crash report, the error is in FlightSearchDetails.swift, where onAppear is setting lastFlightInfo.
Scroll to the top of this struct and you will see it has an @EnvironmentObject property. You’re now using@SceneStorage for window settings, so replace the @EnvironmentObject property with this:
@SceneStorage("lastViewedFlightID") var lastViewedFlightID: Int?
And change the onAppear action to this:
lastViewedFlightID = flight.id
Build and run the app again, go to Search Flights and click on any flight.
A sheet pops up with the flight details which is great. Not so great is that the buttons on the sheets are using white text on a white background.
Click the one at the top-right of the sheet to dismiss it and go back to FlightSearchDetails.swift. Near the end of the struct, you’ll see a foregroundColor modifier that is setting the text color to white. The location of this modifier means that the setting is being applied to every subview in this view, including the buttons.
Don’t delete it completely as you still want the flight details text to be white. Cut the modifier out from where it is, and paste it in immediately after two other views: FlightInfoPanel near the end of the struct and FlightDetailHeader near the top.
Build and run the app and test out the Search Flights:
Now, select a flight and click any available buttons. On-Time History uses some custom drawing and animation for a great infographic display but it all just works, even though that is iOS code!
But now that FlightTimeHistory has appeared, showing the on-time history, you’ve got a problem. How do you get rid of it? On iOS, you’d swipe down, but that doesn’t work on macOS, so you’re going to have to find another solution.
In FlightSearchDetails.swift, find the On-Time History button. This button toggles a Boolean called showFlightHistory and that variable controls the display of FlightTimeHistory in a sheet.
Change this Button to show a popover, like this:
Button("On-Time History") {
showFlightHistory.toggle()
}
.popover(isPresented: $showFlightHistory) {
FlightTimeHistory(flight: flight)
}
Now try again and you’ll be able to click anywhere outside the view to dismiss it:
If you can find a canceled flight, you can click Rebook Flight, which uses a standard system alert. The Check In for Flight button uses a confirmationDialog. And now, this view is now totally functional.
The styling of the controls at the top of the flights list isn’t great and the sheet would be better with a set frame, but I will leave that as a challenge for you.
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.
You’re probably expecting a long list of changes needed to get this working but guess what? You’ve already done them all. Click on it and try it out.
So this is a nice short section. On to the awards…
Awards view
When you click Your Awards, the app will crash, reporting that it cannot find the AppEnvironment ObservableObject.
In the Models group, take a look at AppEnvironment.swift and you’ll see that most of this class is setting up the awards data structure. The data is all there, but you need to pass it to the awards UI.
Open AwardsView.swift from the AwardsView group and find the AwardsView struct. It’s expecting to get an AppEnvironment object passed to it as an @EnvironmentObject. But now that you’re using @SceneStorage for the other properties, this is the only view that needs to access AppEnvironment, so why not let it own that data itself?
Replace the @EnvironmentObject line with this:
@State var flightNavigation = AppEnvironment()
So now AwardsView has its own data model that it can display.
Build and run the app and click on Your Awards. No crashes anymore but the UI needs work.
Open AwardGrid.swift to see the struct which lays out each section of the view. Each AwardCardView is inside a NavigationLink, but you’re going to get rid of this. Replace the entire contents of the ForEach with:
AwardCardView(award: award)
.foregroundColor(.black)
.aspectRatio(0.67, contentMode: .fit)
The ForEach now contains only the AwardCardView and its two modifiers. When you build and run the app, you can see all the awards in two grids, but they’re not clickable.
Note: If you are not seeing all the images, make sure that you dragged them all from the 3x box to the 2x box in Assets ▸ award-images.
One other problem is that the section headers are using white text. Delete the .foregroundColor(.white) modifier from the Section header. This will make it use the default text color, in both light and dark modes.
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.
First, add this property:
@State private var isPresented = false
isPresented will dictate whether the sheet is visible or not.
Command-click on the VStack and select Embed…. This is an easy way to wrap a view making sure you get all the components and that the indentation is correct.
Now, replace the Container placeholder with this:
// 1
Button(action: {
isPresented.toggle()
}, label:
You’ll see an error at the end of the struct, but add this code just above the line with the error to make it go away:
// 2
)
// 3
.buttonStyle(.plain)
// 4
.sheet(
isPresented: $isPresented,
content: {
AwardDetails(award: award)
}
)
So these two chunks of code do these things:
- Create a button that toggles the
isPresentedvariable to display the sheet. - Close off the
Buttonview, wrapping theVStack. - Set the plain button style as usual.
- Use a
sheetto display theAwardDetailsfor the selected award ifisPresentedis true.
Don’t run the app yet. There is one more important feature to add to this sheet — a way to dismiss it. On iOS, you can swipe a sheet down to get rid of it, but as you’ve already seen, that doesn’t work on macOS. Every sheet must have a dismiss option.
Dismissing the sheet
Open AwardDetails.swift and add this property:
@Environment(\.dismiss) var dismiss
This gives the view access to an environment property that you can use to dismiss the sheet.
Next, add this inside the VStack before the first Image:
// 1
HStack {
Spacer()
Button(action: {
// 2
dismiss()
}, label: {
// 3
Image(systemName: "xmark.circle")
.font(.largeTitle)
})
// 4
.buttonStyle(.plain)
}
So what’s going on here?
- You’re adding an
HStackwith aSpaceras the first view to push theButtonto the right. - The button’s action uses the
dismissenvironment property to close the sheet. - The UI of the button is an
Imageusing an icon from SF Symbols with afontmodifier to set its size. - And I bet you didn’t see this coming… the button style is set to
.plain.
Build and run again and now you can view the awards, click on an award to display its details, and click the X to close the sheet.
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.
Well guess what? Those changes you made were all that this view needed! Click the Flight Timeline button and you’ll see the flight maps appear, just like they did in the iOS version.
And that’s it! You’ve done it. The app now has all the features of its iOS counterpart.
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.
If you need some hints, check out SearchFlights.swift and FlightSearchDetails.swift in the challenge folder.
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.
Select another interesting iOS project, maybe one of your own projects, one of the other raywenderlich.com apps or perhaps something open source, and see if you can use these techniques to convert it to a Mac app.