Advanced cross-platform apps using local Swift packages and UIKit/AppKit
Before the release and hype of SwiftUI we had to use plain UIKit for iOS and AppKit for the macOS interfaces… even if the core application was exactly the same. Naturally your cross-platform applications keep growing over time, and eventually you get to the point of refactoring the code into modules.
This tutorial shows you, how to harness the impressive power of the Swift Package Manager (SPM) to create a clean, extensible and especially shared UI structure for your large-scale apps.
Note: This is a follow-up tutorial to Modularize Xcode Project using local Swift Packages and builds up on the topics mentioned there. In case you are already an advanced iOS/macOS/SPM user, go ahead, but if you are fairly new to the topic, I highly recommend you read my other article first.
Backstory on UI building in Swift & Xcode
At the time of writing, four main options of building user interfaces exist:
- Plain code: You have full control over the UI elements, the Auto Layout engine and the connected logic… without any hidden magic. On the other hand it is a lot more verbose and harder to iterate, especially without a live preview.
- Single view interface builder (XIB/NIB): Visual building with full support for Auto Layout, IB Outlets and IB Designables to connect the UI with the code. It takes away a lot of pain and can be wired up with the application quite nicely. Important side note: macOS and iOS XIB are NOT the same!
- Multi view interface builder (Storyboard): Same features as the interface builders and additionally allows to link multiple views together using navigation segues. Important side note: macOS and iOS storyboards are NOT the same!
- SwiftUI DSL (we won’t cover it in this article, as it is not quite production-ready for large-scale apps with backwards-compatibility requirements)
From my personal experience, storyboards are great for the initial development. Especially due to the simple view navigation using segues you can get a prototype running rather quickly. Over time more views get added and eventually Xcode starts struggling with rendering/processing/compiling the file. Slowly it starts to become more painful to work with (don’t get me started talking about dealing with merge-conflicts with those multi-thousands lines of XML configuration).
It gets even worse, when you just want to iterate a single view, but the large storyboard keeps changing multiple ones (e.g. due to layout updates). Or when it takes forever updating every single view again and again, when working with IBDesignables.
Oh, you don’t feel like navigating to a very nested sub view every. single. time? You want to use only that single view from the storyboard in a different app target? Of course you can always dequeue it, but make sure to include all necessary assets (even those you aren’t using right now), or otherwise it might fail to compile/run. 🙃
So… how can we improve our workflow? We go back to simpler UI building!
Creating our Clock apps
In this tutorial we are going to build a simple clock app with the following requirements:
- iOS & macOS app
- No SwiftUI (use UIKit/AppKit)
- Modular, extensible architecture
- Show the current time in a single view
- Time updates manually when user interacts with the app
As you can see this is a (stupidly) simple app, but the main focus is using the Swift Package Manager for building UI components, so it will suffice.
As the first step, create an empty Xcode project as our starting point and name it Clock:
Now you have a clean starting point and inside the project settings you need to add the iOS and the macOS app targets by clicking the little plus symbol in the bottom left corner:
For the iOS target, use the default App template with the UIKit App Delegate Lifecycle and name it Clock_iOS
.
For the bundle identifier, use your own reverse-domain, e.g. for me it’s com.techprimate because of my mobile app agency’s domain techprimate.com
Similarly for the macOS app, use the default App template with the AppKit App Delegate Lifecycle and name it Clock_macOS. As before, use your own organization identifier:
In the final project setup step, disable automatic code signing (we don’t need it right now, and usually it messes up your Apple Developer Account by creating unwanted provisioning profiles and app identifiers). You should still be able to run the the macOS application, with the setting Sign To Run Locally, and the iOS app in the simulator.
Congratulations on creating your cross-platform app Clock! Run both applications at least once to make sure they work fine. No worries, both screens will be a white void, as there is no UI to show yet, but we are going to fix that next.
Creating the shared UI library
As the clock logic must be shared by both applications, we create a local Swift package library ClockPackage and drag it into our Xcode project. Detailed step-by-step instructions can be found in my previous article.
$ cd root/of/my/project
$ mkdir ClockPackage
$ cd ClockPackage
$ swift package init --type library
Afterwards your project should look like this:
For simplicity, rename the ClockPackage
target to ClockUI
by changing the folder name and the declaration in the package manifest Package.swift. Also, we won’t use tests in this tutorial, so go ahead and delete the folder Tests and the test targets.
Adding XIB resources (the wrong way)
Now that our UI package is set up, you will create the XIB interface builder files. Both applications should offer the user a button to update the current time and display it in a label.
While using this SPM concept in one of the iOS/macOS projects at WolfVision, I realized that XIB interface files for macOS and iOS are not interchangeable. To visualize my experiences to you, we will now do it the wrong way and fix it afterwards.
Since Swift 5.3 it is possible to add resources to a Swift package. Apple created a pretty detailed documentation on how to handle them, but for the sake of the tutorial I will give you the quick summary:
-
Create a folder Resources inside
Sources/ClockUI
Folder structure after adding the resources folder
-
Create an iOS View
ClockViewController_iOS.xib
-
Create a macOS View
ClockViewController_macOS.xib
-
Add both files as resources to the package manifest in the targets section:
... targets: [ .target(name: "ClockUI", resources: [ .process("Resources/ClockViewController_iOS.xib"), .process("Resources/ClockViewController_macOS.xib"), ]) ] ...
As you can see here, we use process(path: String)
to compile the XIB files at build time into NIB files, which are then used at runtime for loading the UI.
To test the compilation of the package, select ClockUI as the run scheme in the top toolbar in Xcode. Try to build it once for Any Mac and once for Any iOS Device. It will fail both times.
Inside the *Report navigator *you can take a closer look at the two failed build logs. You will see that the macOS build failed due to the iOS XIB file, and vice versa.
As Swift packages do not support conditional resource compilation, we can’t use this exact project structure any further and have to change our libraries.
Adding XIB resources (the right way)
Create two additional libraries ClockUI_iOS
and ClockUI_macOS
with a folder Resource inside each one of them. Afterwards move the *.xib
files into their respective one and change the Package.swift manifest to reflect our new structure:
let package = Package(
name: "ClockPackage",
products: [
.library(name: "ClockUI", targets: ["ClockUI"]),
.library(name: "ClockUI_iOS", targets: ["ClockUI_iOS"]),
.library(name: "ClockUI_macOS", targets: ["ClockUI_macOS"]),
],
targets: [
.target(name: "ClockUI"),
.target(name: "ClockUI_iOS", resources: [
.process("Resources/ClockViewController_iOS.xib"),
]),
.target(name: "ClockUI_macOS", resources: [
.process("Resources/ClockViewController_macOS.xib"),
])
]
)
Now you can build the scheme ClockUI_iOS
for Any iOS Device, ClockUI_macOS
for Any Mac and ClockUI
for both of them successfully 🎉
Creating the interfaces
Hope you don’t mind that I am skipping the detailed explanation of the creation of the UI interfaces inside our iOS/macOS XIB files (there so many UIKit/AppKit tutorials out there) and instead focus on the build architecture. Of course you can always checkout the full code in the GitHub repository.
iOS Interface
First create a class ClockViewController
in a new file at ClockUI_iOS/ClockViewController.swift
with an @IBOutlet
for accessing the time label and an IBAction for as the target action for the button.
import UIKit
public class ClockViewController: UIViewController {
// MARK: - IB Outlets
@IBOutlet weak var timeLabel: UILabel!
// MARK: - IB Actions
@IBAction func didTapFetchButtonAction() {
print("did tap fetch")
}
}
Afterwards you need to connect the File’s Owner to the view controller. Make sure the module ClockUI_iOS is selected, or otherwise it won’t be able to resolve the class later. This also allows to connect the IBOutlet/IBAction to the code (don’t forget to set the view outlet!!)
Before continuing with the macOS equivalent, let’s present this interface in our iOS app. To do so, we first need to add the ClockUI_iOS
module as a framework to our iOS target:
If you followed along nicely, you can now import ClockUI_iOS
in the Clock_iOS/ViewController.swift
file and create an instance of the ClockViewController
:
import UIKit
import ClockUI_iOS
class ViewController: UIViewController {
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
let clockVC = ClockViewController()
self.addChild(clockVC)
// set a debug background color so we can see the view
clockVC.view.backgroundColor = .orange
clockVC.view.autoresizingMask = [
.flexibleWidth, .flexibleHeight
]
self.view.addSubview(clockVC.view)
}
}
As you can see, the whole view is now orange but still empty. Instead of creating an instance of ClockViewController
we need to load it from the XIB file by changing the following line
...
let clockVC = ClockViewController()
// becomes
let clockVC = ClockViewController.loadFromNib()
...
And add the loadFromNib()
method to the ClockViewController
:
// MARK: - Nib Loading
public static func loadFromNib() -> ClockViewController {
// Loads the compiled XIB = NIB file from the module
// resources bundle. Bundle.module includes all resources
// declared in the Package.swift manifest file
ClockViewController(nibName: "ClockViewController_iOS",
bundle: Bundle.module)
}
Run the app again and your button and label will show up 🎉
macOS Interface
Lucky for us, the macOS implementation works exactly the same, but with the respective macOS equivalents of classes and modules:
Create the ClockPackage/ClockUI_macOS/ClockViewController.swift
public class ClockViewController: NSViewController {
// MARK: - IB Outlets
@IBOutlet weak var timeLabel: NSTextField!
// MARK: - IB Action
@IBAction func didClickFetchButtonAction(_ sender: Any) {
print("did click fetch")
}
// MARK: - Nib Loading
public static func loadFromNib() -> ClockViewController {
ClockViewController(nibName: "ClockViewController_macOS",
bundle: Bundle.module)
}
}
Once again, don’t forget to connect the view outlet, or it fails to instantiate the NIB.
If Xcode is not showing you the option to link view, add it as an
@IBOutlet weak var view: NSView!
in the view controller Swift class and then it should show up in the Xcode Interface Builder. After linking, you can simply delete line and it should still work fine, as NSViewController already owns a property with that name.
- Connect the macOS XIB with the class
- Add the
ClockUI_macOS
framework to theClock_macOS
app target - Add import
ClockUI_macOS
inClock_macOS/ViewController.swift
- Copy the
loadFromNib
from the iOS package into the macOS package -
Add the view controller to the view hierarchy:
import Cocoa import ClockUI_macOS class ViewController: NSViewController { override func viewDidAppear() { super.viewDidAppear() let clockVC = ClockViewController.loadFromNib() self.addChild(clockVC) clockVC.view.autoresizingMask = [.width, .height] self.view.addSubview(clockVC.view) } }
Great job! You have now a running iOS and macOS application, using interface resources from Swift packages 🚀
Shared UI Logic
As a final step, we want to create a service which is shared between the ClockUI_iOS and ClockUI_macOS packages. As we started off with a ClockUI package, it fits our needs perfectly, therefore rename the file ClockUI/UI.swift
to ClockUI/ClockService.swift
and create a class with the same name inside:
import Foundation
import Combine
public class ClockService {
// subject to subscribe for updates
public var currentTime = PassthroughSubject<String, Never>()
public init() {}
public func updateTime() {
let formatter = DateFormatter()
formatter.timeStyle = .full
currentTime.send(formatter.string(from: Date()))
}
}
As explained in the previous tutorial, add ClockUI as a dependency to the ClockUI_macOS
and ClockUI_iOS
libraries in the package manifest.
Quick summary on the implementation logic:
- our service can be tasked to “update” the time
- we use Combine as it is a modern reactive framework for subscribing time changes and update our UI
To use the service in our view controllers, create a local instance and subscribe to the currentTime publisher. As an example, here the final iOS view controller:
import UIKit
import ClockUI
import Combine
public class ClockViewController: UIViewController {
// MARK: - Services
private let service = ClockService()
private var timeCancellable: AnyCancellable!
// MARK: - IB Outlets
@IBOutlet weak var timeLabel: UILabel!
// MARK: - IB Actions
@IBAction func didTapFetchButtonAction() {
service.fetchTime()
}
// MARK: - View Life Cycle
public override func viewDidLoad() {
super.viewDidLoad()
timeCancellable = service.currentTime
.sink(receiveValue: { self.timeLabel.text = $0 })
}
// MARK: - Nib Loading
public static func loadFromNib() -> ClockViewController {
// Load the compiled XIB = NIB file from the
// module resources bundle
ClockViewController(nibName: "ClockViewController_iOS",
bundle: Bundle.module)
}
}
Run both applications, and voilà… you can update the time label when pressing the button in each one, with them behaving in the same way!
You can find the full code in the GitHub repository.
Conclusion
We did it. We created two applications using non-compatible interfaces builders with the same UI logic.
In case you are still wondering, why one should go through the hassle of splitting the project into such fragmented packages, let me explain further:
- Imagine your application reaches a large scale with 20, 50, 100 or even more modules. Using this highly modular structure, you can easily create another app target (similar to how we did it for macOS & iOS) and simply import the specific feature you are working on.
- The build process should become more performant, as unchanged packages are cached (unfortunately I don’t have any statistics available at the moment to prove this)
- Due to the built-in isolation of Swift packages, we have a strong Separation of Concerns and can work with small subsets of our code individually (e.g. create shared utilities with their own unit tests)
- It becomes easier to create a clean architecture, such as VIPER, where the UI rendering (View) and logic (Presenter) are completely abstracted using protocols/interfaces.
If you would like to know more, checkout my other articles, follow me on Twitter and feel free to drop me a DM. You have a specific topic you want me to cover? Let me know! 😃