If you’re developing apps for iOS, you already have a particular set of skills that you can use to write apps for another platform – macOS!
If you’re like most developers, you don’t want to have to write your app twice just to ship your app on a new platform, as this can take too much time and money. But with a little effort, you can learn how to port iOS apps to macOS, reusing a good portion of your existing iOS app, and only rewriting the portions that are platform-specific.
In this tutorial, you’ll learn how to create an Xcode project that is home to both iOS and macOS, how to refactor your code for reuse on both platforms, and when it is appropriate to write platform specific code.
To get the most out of this tutorial you should be familiar with NSTableView. If you need to refresh your knowledge we have an introduction for you.
Getting Started
For this tutorial, you’ll need to download the starter project here.
The sample project is a version of the BeerTracker app used in previous tutorials. It allows you to keep a record of beers you’ve tried, along with notes, ratings, and images of the beers. Build and run the app to get a feel for how it works.
Since the app is only available on iOS, the first step to porting the app for macOS is to create a new target. A target simply is a set of instructions telling Xcode how to build your application. Currently, you only have an iOS target, which contains all the information needed to build your app for an iPhone.
Select the BeerTracker project at the top of the Project Navigator. At the bottom of the Project and Targets list, click the + button.
This will present a window for you to add a new target to your project. At the top of the window, you’ll see tabs representing the different categories of platforms supported. Select macOS, then scroll down to Application and choose Cocoa App. Name the new target BeerTracker-mac.
Adding the Assets
In the starter app you downloaded, you’ll find a folder named BeerTracker Mac Icons. You’ll need to add the App Icons to AppIcon in Assets.xcassets found under the BeerTracker-mac group. Also add beerMug.pdf to Assets.xcassets. Select beerMug, open the Attributes Inspector and change the Scales to Single Scale. This ensures you don’t need to use different scaled images for this asset.
When you’re done, your assets should look like this:
In the top left of the Xcode window, select the BeerTracker-mac scheme in the scheme pop-up. Build and run, and you’ll see an empty window. Before you can start adding the user interface, you’ll need to make sure your code doesn’t have any conflicts between UIKit, the framework used on iOS, and AppKit, the framework used by macOS.
Separation of Powers
The Foundation framework allows your app to share quite a bit of code, as it is universal to both platforms. However, your UI cannot be universal. In fact, Apple recommends that multi-platform applications should not attempt to share UI code, as your secondary platform will begin to take on the appearance of your initial application’s UI.
iOS has some fairly strict Human Interface Guidelines that ensure your users are able to read and select elements on their touchscreen devices. However, macOS has different requirements. Laptops and desktops have a mouse pointer to click and select, allowing elements on the screen to be much smaller than would be possible on a phone.
Having identified the UI as needing to be different on both platforms, it is also important to understand what other components of your code can be reused, and which ones need to be rewritten. Keep in mind that there isn’t necessarily a definitive right or wrong answer in most of these cases, and you will need to decide what works best for your app. Always remember that the more code shared, the less code you need to test and debug.
Generally, you’ll be able to share models and model controllers. Open Beer.swift, and open the Utilities drawer in Xcode, and select the File Inspector. Since both targets will use this model, under Target Membership, check BeerTracker-mac leaving BeerTracker still checked. Do the same thing for BeerManager.swift, and SharedAssets.xcassets under the Utilities group.
If you try to build and run, you will get a build error. This is because Beer.swift is importing UIKit. The model is using some platform specific logic to load and save images of beers.
Replace the import line at the top of the file with the following:
import Foundation
If you try to build and run, you’ll see the app no longer compiles due to UIImage being part of the now removed UIKit. While the model portion of this file is shareable between both targets, the platform specific logic will need to be separated out. In Beer.swift, delete the entire extension marked Image Saving. After the import
statement, add the following protocol:
protocol BeerImage {
associatedtype Image
func beerImage() -> Image?
func saveImage(_ image: Image)
}
Since each target will still need access to the beer’s image, and to be able to save images, this protocol provides a contract that can be used across the two targets to accomplish this.
Models
Create a new file by going to File/New/File…, select Swift File, and name it Beer_iOS.swift. Ensure that only the BeerTracker target is checked. After that, create another new file named Beer_mac.swift, this time selecting BeerTracker-mac as the target.
Open Beer_iOS.swift, delete the file’s contents, and add the following:
import UIKit
// MARK: - Image Saving
extension Beer: BeerImage {
// 1.
typealias Image = UIImage
// 2.
func beerImage() -> Image? {
guard let imagePath = imagePath,
let path = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first else {
return #imageLiteral(resourceName: "beerMugPlaceholder")
}
// 3.
let pathName = (path as NSString).appendingPathComponent("BeerTracker/\(imagePath)")
guard let image = Image(contentsOfFile: pathName) else { return #imageLiteral(resourceName: "beerMugPlaceholder") }
return image
}
// 4.
func saveImage(_ image: Image) {
guard let imgData = UIImageJPEGRepresentation(image, 0.5),
let path = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first else {
return
}
let appPath = (path as NSString).appendingPathComponent("/BeerTracker")
let fileName = "\(UUID().uuidString).jpg"
let pathName = (appPath as NSString).appendingPathComponent(fileName)
var isDirectory: ObjCBool = false
if !FileManager.default.fileExists(atPath: appPath, isDirectory: &isDirectory) {
do {
try FileManager.default.createDirectory(atPath: pathName, withIntermediateDirectories: true, attributes: nil)
} catch {
print("Failed to create directory: \(error)")
}
}
if (try? imgData.write(to: URL(fileURLWithPath: pathName), options: [.atomic])) != nil {
imagePath = fileName
}
}
}
Here’s what’s happening:
- The BeerImage protocol requires the implementing class to define an associated type. Think of this as a placeholder name for the type of object you really want to use, based on your object’s needs. Since this file is for iOS, you’re using UIImage.
- Implement the first protocol method. Here, the Image type represents UIImage.
- Another example of how the type alias can be used when initializing an image.
- Implement the second protocol method to save an image.
Switch your scheme to BeerTracker, then build and run. The application should behave as before.
Now that your iOS target is working, you’re ready to add macOS-specific code. Open Beer_mac.swift, delete all the contents, and add the following code:
import AppKit
// MARK: - Image Saving
extension Beer: BeerImage {
// 1.
typealias Image = NSImage
func beerImage() -> Image? {
// 2.
guard let imagePath = imagePath,
let path = NSSearchPathForDirectoriesInDomains(.applicationSupportDirectory, .userDomainMask, true).first else {
return #imageLiteral(resourceName: "beerMugPlaceholder")
}
let pathName = (path as NSString).appendingPathComponent(imagePath)
guard let image = Image(contentsOfFile: pathName) else { return #imageLiteral(resourceName: "beerMugPlaceholder") }
return image
}
func saveImage(_ image: Image) {
// 3.
guard let imgData = image.tiffRepresentation,
let path = NSSearchPathForDirectoriesInDomains(.applicationSupportDirectory, .userDomainMask, true).first else {
return
}
let fileName = "/BeerTracker/\(UUID().uuidString).jpg"
let pathName = (path as NSString).appendingPathComponent(fileName)
if (try? imgData.write(to: URL(fileURLWithPath: pathName), options: [.atomic])) != nil {
imagePath = fileName
}
}
}
The above code is nearly identical to the previous code, with just a few changes:
- Here, instead of using UIImage, you’re using the AppKit specific class NSImage.
- On iOS, it’s common to save files in the Documents directory. You usually don’t have to worry about cluttering up this directory, since it is specific to the app and hidden from the user. On macOS, however, you won’t want to not mess up the user’s Documents, so you save the app’s files to the Application Support directory.
- Since NSImage doesn’t have the same method for getting image data as UIImage, you’re using the supported
tiffRepresentation
.
Switch your target to BeerTracker_mac, then build and run. Your app now compiles for both platforms, while maintaining a standard set of functionality from your model.
Creating the User Interface
Your empty view Mac app isn’t very useful, so it’s time to build the UI. From the BeerTracker-mac group, open Main.storyboard. Start by dragging a Table View into your empty view. Now select the Table View in the Document Outline.
macOS storyboards sometimes require you to dig down a bit deeper into the view hierarchy. This is a change from iOS, where you’re used to seeing all template views at the top level.
Configuring the Table View
With the Table View selected, make the following changes in the Attributes Inspector:
- Set Columns to 1
- Uncheck Reordering
- Uncheck Resizing
Select the Table Column in the Document Outline and set its Title to Beer Name.
In the Document Outline, select the Bordered Scroll View (which houses the Table View), and in the Size Inspector find the View section and set the View dimensions to the following:
- x: 0
- y: 17
- width: 185
- height: 253
Setting the coordinates is going to be slightly different here, as well. In macOS, the origin of the UI is not in the top left, but the lower left. Here, you’ve set the y coordinate to 17, which means 17 points up from the bottom.
Adding a Delegate and Data Source
Next you’ll need to connect your delegate, data source and properties for the Table View. Again, you’ll need to select the Table View from the Document Outline to do this. With it selected, you can Control-drag to the View Controller item in the Document Outline and click delegate. Repeat this for the dataSource.
Open ViewController.swift in the Assistant Editor, Control-drag from the Table View and create a new outlet named tableView
.
Before you finish with the Table View, there’s one last thing you need to set. Back in the Document Outline, find the item named Table Cell View. With that selected, open the Identity Inspector, and set the Identifier to NameCell.
Images and Text
With the Table View setup, next comes the “form” section of the UI.
First, you’ll add an Image Well to the right of the table. Set the frame to the following:
- x: 190
- y: 188
- width: 75
- height: 75
An Image Well is a convenient object that displays an image, but also allows a user to drag and drop a picture onto it. To accomplish this, the Image Well has the ability to connect an action to your code!
Open the BeerTracker-mac ViewController.swift in the Assistant Editor and create an outlet for the Image Well named imageView
. Also create an action for the Image View, and name it imageChanged
. Ensure that you change Type to NSImageView, as shown:
While drag and drop is great, sometimes users want to be able to view an Open Dialog and search for the file themselves. Set this up by dropping a Click Gesture Recognizer on the Image Well. In the Document Outline, connect an action from the Click Gesture Recognizer to ViewController.swift named selectImage
.
Add a Text Field to the right of the Image Well. In the Attributes Inspector, change the Placeholder to Enter Name. Set the frame to the following:
- x: 270
- y: 223
- width: 190
- height: 22
Create an outlet in ViewController.swift for the Text Field named nameField
.
Rating a Beer
Next, add a Level Indicator below the name field. This will control setting the rating of your beers. In the Attributes Inspector, set the following:
- Style: Rating
- State: Editable
- Minimum: 0
- Maximum: 5
- Warning: 0
- Critical: 0
- Current: 5
- Image: beerMug
Set the frame to the following:
- x: 270
- y: 176
- width: 115
Create an outlet for the Level Indicator named ratingIndicator
.
Add a Text View below the rating indicator. Set the frame to:
- x: 193
- y: 37
- width: 267
- height: 134
To create an outlet for the Text View, you’ll need to make sure you select Text View inside the Document Outline, like you did with the Table View. Name the outlet noteView
. You’ll also need to set the Text View‘s delegate to the ViewController.
Below the note view, drop in a Push Button. Change the title to Update, and set the frame to:
- x: 284
- y: 3
- width: 85
Connect an action from the button to ViewController named updateBeer
.
Adding and Removing Beers
With that, you have all the necessary controls to edit and view your beer information. However, there’s no way to add or remove beers. This will make the app difficult to use, even if your users haven’t had anything to drink. :]
Add a Gradient Button to the bottom left of the screen. In the Attributes Inspector, change Image to NSAddTemplate if it is not already set.
In the Size Inspector, set the frame to:
- x: 0
- y: -1
- width: 24
- height: 20
Add an action from the new button named addBeer
.
One great thing about macOS is that you get access to template images like the + sign. This can make your life a lot simpler when you have any standard action buttons, but don’t have the time or ability to create your own artwork.
Next, you’ll need to add the remove button. Add another Gradient Button directly to the right of the previous button, and change the Image to NSRemoveTemplate. Set the frame to:
- x: 23
- y: -1
- width: 24
- height: 20
And finally, add an action from this button named removeBeer
.
Finishing The UI
You’re almost finished building the UI! You just need to add a few labels to help polish it off.
Add the following labels:
- Above the name field, titled Name.
- Above the rating indicator titled Rating.
- Above the notes view titled Notes.
- Beneath the table view titled Beer Count:.
- To the right of the beer count label, titled 0.
For each of these labels, in the Attributes Inspector, set the font to Other – Label, and the size to 10.
For the last label, connect an outlet to ViewController.swift named beerCountField
.
Make sure your labels all line like so:
Click the Resolve Auto Layout Issues button and in the All Views in View Controller section click Reset to Suggested Constraints.
Adding the Code
Whew! Now you’re ready to code. Open ViewController.swift and delete the property named representedObject
. Add the following methods below viewDidLoad()
:
private func setFieldsEnabled(enabled: Bool) {
imageView.isEditable = enabled
nameField.isEnabled = enabled
ratingIndicator.isEnabled = enabled
noteView.isEditable = enabled
}
private func updateBeerCountLabel() {
beerCountField.stringValue = "\(BeerManager.sharedInstance.beers.count)"
}
There are two methods that will help you control your UI:
setFieldsEnabled(_:)
will allow you to easily turn off and on the ability to use the form controls.updateBeerCountLabel()
simply sets the count of beers in thebeerCountField
.
Beneath all of your outlets, add the following property:
var selectedBeer: Beer? {
didSet {
guard let selectedBeer = selectedBeer else {
setFieldsEnabled(enabled: false)
imageView.image = nil
nameField.stringValue = ""
ratingIndicator.integerValue = 0
noteView.string = ""
return
}
setFieldsEnabled(enabled: true)
imageView.image = selectedBeer.beerImage()
nameField.stringValue = selectedBeer.name
ratingIndicator.integerValue = selectedBeer.rating
noteView.string = selectedBeer.note!
}
}
This property will keep track of the beer selected from the table view. If no beer is currently selected, the setter takes care of clearing the values from all the fields, and disabling the UI components that shouldn’t be used.
Replace viewDidLoad()
with the following code:
override func viewDidLoad() {
super.viewDidLoad()
if BeerManager.sharedInstance.beers.count == 0 {
setFieldsEnabled(enabled: false)
} else {
tableView.selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false)
}
updateBeerCountLabel()
}
Just like in iOS, you want our app to do something the moment it starts up. In the macOS version, however, you’ll need to immediately fill out the form for the user to see their data.
Adding Data to the Table View
Right now, the table view isn’t actually able to display any data, but selectRowIndexes(_:byExtendingSelection:)
will select the first beer in the list. The delegate code will handle the rest for you.
In order to get the table view showing you your list of beers, add the following code to the end of ViewController.swift, outside of the ViewController
class:
extension ViewController: NSTableViewDataSource {
func numberOfRows(in tableView: NSTableView) -> Int {
return BeerManager.sharedInstance.beers.count
}
}
extension ViewController: NSTableViewDelegate {
// MARK: - CellIdentifiers
fileprivate enum CellIdentifier {
static let NameCell = "NameCell"
}
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
let beer = BeerManager.sharedInstance.beers[row]
if let cell = tableView.makeView(withIdentifier: NSUserInterfaceItemIdentifier(rawValue: CellIdentifier.NameCell), owner: nil) as? NSTableCellView {
cell.textField?.stringValue = beer.name
if beer.name.characters.count == 0 {
cell.textField?.stringValue = "New Beer"
}
return cell
}
return nil
}
func tableViewSelectionDidChange(_ notification: Notification) {
if tableView.selectedRow >= 0 {
selectedBeer = BeerManager.sharedInstance.beers[tableView.selectedRow]
}
}
}
This code takes care of populating the table view’s rows from the data source.
Look at it closely, and you’ll see it’s not too different from the iOS counterpart found in BeersTableViewController.swift. One notable difference is that when the table view selection changes, it sends a Notification to the NSTableViewDelegate.
Remember that your new macOS app has multiple input sources — not just a finger. Using a mouse or keyboard can change the selection of the table view, and that makes handling the change just a little different to iOS.
Now to add a beer. Change addBeer()
to:
@IBAction func addBeer(_ sender: Any) {
// 1.
let beer = Beer()
beer.name = ""
beer.rating = 1
beer.note = ""
selectedBeer = beer
// 2.
BeerManager.sharedInstance.beers.insert(beer, at: 0)
BeerManager.sharedInstance.saveBeers()
// 3.
let indexSet = IndexSet(integer: 0)
tableView.beginUpdates()
tableView.insertRows(at: indexSet, withAnimation: .slideDown)
tableView.endUpdates()
updateBeerCountLabel()
// 4.
tableView.selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false)
}
Nothing too crazy here. You’re simply doing the following:
- Creating a new beer.
- Inserting the beer into the model.
- Inserting a new row into the table.
- Selecting the row of the new beer.
You might have even noticed that, like in iOS, you need to call beginUpdates()
and endUpdates()
before inserting the new row. See, you really do know a lot about macOS already!
Removing Entries
To remove a beer, add the below code for removeBeer(_:)
:
@IBAction func removeBeer(_ sender: Any) {
guard let beer = selectedBeer,
let index = BeerManager.sharedInstance.beers.index(of: beer) else {
return
}
// 1.
BeerManager.sharedInstance.beers.remove(at: index)
BeerManager.sharedInstance.saveBeers()
// 2
tableView.reloadData()
updateBeerCountLabel()
tableView.selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false)
if BeerManager.sharedInstance.beers.count == 0 {
selectedBeer = nil
}
}
Once again, very straightforward code:
- If a beer is selected, you remove it from the model.
- Reload the table view, and select the first available beer.
Handling Images
Remember how Image Wells have the ability to accept an image dropped on them? Change imageChanged(_:)
to:
@IBAction func imageChanged(_ sender: NSImageView) {
guard let image = sender.image else { return }
selectedBeer?.saveImage(image)
}
And you thought it was going to be hard! Apple has taken care of all the heavy lifting for you, and provides you with the image dropped.
On the flip side to that, you’ll need to do a bit more work to handle user’s picking the image from within your app. Replace selectImage()
with:
@IBAction func selectImage(_ sender: Any) {
guard let window = view.window else { return }
// 1.
let openPanel = NSOpenPanel()
openPanel.allowsMultipleSelection = false
openPanel.canChooseDirectories = false
openPanel.canCreateDirectories = false
openPanel.canChooseFiles = true
// 2.
openPanel.allowedFileTypes = ["jpg", "png", "tiff"]
// 3.
openPanel.beginSheetModal(for: window) { (result) in
if result == NSApplication.ModalResponse.OK {
// 4.
if let panelURL = openPanel.url,
let beerImage = NSImage(contentsOf: panelURL) {
self.selectedBeer?.saveImage(beerImage)
self.imageView.image = beerImage
}
}
}
}
The above code is how you use NSOpenPanel
to select a file. Here’s what’s happening:
- You create an
NSOpenPanel
, and configure its settings. - In order to allow the user to choose only pictures, you set the allowed file types to your preferred image formats.
- Present the sheet to the user.
- Save the image if the user selected one.
Finally, add the code that will save the data model in updateBeer(_:)
:
@IBAction func updateBeer(_ sender: Any) {
// 1.
guard let beer = selectedBeer,
let index = BeerManager.sharedInstance.beers.index(of: beer) else { return }
beer.name = nameField.stringValue
beer.rating = ratingIndicator.integerValue
beer.note = noteView.string
// 2.
let indexSet = IndexSet(integer: index)
tableView.beginUpdates()
tableView.reloadData(forRowIndexes: indexSet, columnIndexes: IndexSet(integer: 0))
tableView.endUpdates()
// 3.
BeerManager.sharedInstance.saveBeers()
}
Here’s what you added:
- You ensure the beer exists, and update its properties.
- Update the table view to reflect any names changes in the table.
- Save the data to the disk.
You’re all set! Build and run the app, and start adding beers. Remember, you’ll need to select Update to save your data.
Final Touches
You’ve learned a lot about the similarities and differences between iOS and macOS development. There’s another concept that you should familiarize yourself with: Settings/Preferences. In iOS, you should be comfortable with the concept of going into Settings, finding your desired app, and changing any settings available to you. In macOS, this can be accomplished inside your app through Preferences.
Build and run the BeerTracker target, and in the simulator, navigate to the BeerTracker settings in the Settings app. There you’ll find a setting allowing your users to limit the length of their notes, just in case they get a little chatty after having a few.
In order to get the same feature in your mac app, you’ll create a Preferences window for the user. In BeerTracker-mac, open Main.storyboard, and drop in a new Window Controller. Select the Window, open the Size Inspector, and change the following:
- Set Content Size width to 380, and height to 55.
- Check Minimum Content Size, and set width to 380, and height to 55.
- Check Maximum Content Size, and set width to 380, and height to 55.
- Check Full Screen Minimum Content Size, and set width to 380, and height to 55.
- Check Full Screen Maximum Content Size, and set width to 380, and height to 55.
- Under Initial Position, select Center Horizontally and Center Vertically.
Next, select the View of the empty View Controller, and change the size to match the above settings, 380 x 55.
Doing these things will ensure your Preferences window is always the same size, and opens in a logical place to the user. When you’re finished, your new window should look like this in the storyboard:
At this point, there is no way for a user to open your new window. Since it should be tied to the Preferences menu item, find the menu bar scene in storyboard. It will be easier if you drag it close to the Preferences window for this next part. Once it is close enough, do the following:
- In the menu bar in storyboard, click BeerTracker-mac to open the menu.
- Control-drag from the Preferences menu item to the new Window Controller
- Select Show from the dialog
Find a Check Box Button, and add it to the empty View Controller. Change the text to be Restrict Note Length to 1,024 Characters.
With the Checkbox Button selected, open the Bindings Inspector, and do the following:
- Expand Value, and check Bind to.
- Select Shared User Defaults Controller from the drop down.
- In Model Key Path, put BT_Restrict_Note_Length.
Create a new Swift file in the Utilities group named StringValidator.swift. Make sure to check both targets for this file.
Open StringValidator.swift, and replace the contents with the following code:
import Foundation
extension String {
private static let noteLimit = 1024
func isValidLength() -> Bool {
let limitLength = UserDefaults.standard.bool(forKey: "BT_Restrict_Note_Length")
if limitLength {
return self.characters.count <= String.noteLimit
}
return true
}
}
This class will provide both targets with the ability to check if a string is a valid length, but only if the user default BT_Restrict_Note_Length is true.
In ViewController.swift add the following code at the bottom:
extension ViewController: NSTextViewDelegate {
func textView(_ textView: NSTextView, shouldChangeTextIn affectedCharRange: NSRange, replacementString: String?) -> Bool {
guard let replacementString = replacementString else { return true }
let currentText = textView.string
let proposed = (currentText as NSString).replacingCharacters(in: affectedCharRange, with: replacementString)
return proposed.isValidLength()
}
}
Finally, change the names of each Window in Main.storyboard to match their purpose, and give the user more clarity. Select the initial Window Controller, and in the Attributes Inspector change the title to BeerTracker. Select the Window Controller for the Preferences window, and change the title to Preferences.
Build and run your app. If you select the Preferences menu item, you should now see your new Preferences window with your preferences item. Select the checkbox, and find some large amount of text to paste in. If this would make the note more 1024 characters, the Text View will not accept it, just like the iOS app.
Where to Go From Here?
You can download the finished project here.
In this tutorial you learned:
- How to utilize a current iOS project to host a macOS app.
- When to separate code based on your platform's needs.
- How to reuse existing code between both projects.
- The differences between some of Xcode's behaviors between the two platforms.
For more information about porting your apps to macOS, check out Apple's Migrating from Cocoa Touch Overview.
If you have any questions or comments, please join in the forum discussion below!
The post Porting Your iOS App to macOS appeared first on Ray Wenderlich.