On the heels of rewriting most of our app in SwiftUI, here are our reflections and learnings. It's not about just new APIs, but embracing entirely new paradigms of programming that have helped us unlock new levels of productivity and quality.
The journey since August 2022
We started writing Remotion for macOS in May 2019, when Swift was reaching maturity but before anybody outside of Apple knew about SwiftUI. Remotion was therefore implemented in the long-standing Cocoa framework. NSViews, NSViewControllers, callback blocks, delegates, and Grand Central Dispatch were at the core of the application.
It took a while — August 2022 — before we felt that we were targeting a new enough version of macOS that we could feasibly start using SwiftUI in our code base. Since then we haven’t looked back: Development is faster, the app is more stable, and new teammates are ramping up faster thanks to the simpler code base.
We now think: If you are a macOS or iOS developer who hasn't yet taken the plunge yet, now is a great time to start writing a new app using almost exclusively SwiftUI, and use its friends Combine and concurrency for the data flow. If you have an existing app written with Cocoa or Cocoa Touch, but you are planning on adding some new features — even new views in existing Cocoa-based windows — it's also a great time to build that new section of your app in these modern frameworks.
Prerequisite: Picking a Version of macOS and SwiftUI
First off, you should decide which version of SwiftUI to use. Apple isn't clear about versions, and each update corresponds to a particular version of iOS and macOS.
We can’t recommend supporting the first (2019) version of SwiftUI. It may be enough for some simple tasks, but from our experience, it wasn’t complete or stable enough for anything realistic.
If possible, we also recommend avoiding support for version 2 on macOS 11. Although it is quite complete, we’ve encountered a problematic number of bugs that only reproduce on macOS 11. For us at Remotion, with 5% of our active userbase on macOS 11, we’re debating whether this support is worth it. Our current approach has been to support basic calls on macOS 11, but require newer OSes to get access to our deeper collaborative features. We’re approaching the cusp of dropping support entirely, and look forward to reduced maintenance and testing burden when we do.
So set your minimum OS requirement to the latest version you can justify. If you do target an older OS version, you may find yourself needing to create some work-arounds using NSView/UIView substitutes in a few cases. And be sure to test with all OS versions you support, since there seem to be OS-specific issues for all versions.
Factor in support for Combine and Swift Concurrency when choosing which OS to support
No matter which OS you target, you should consider making use of the Swift concurrency features as you are building with SwiftUI. Introduced in 2021 as a part of Swift 5.5 (Xcode 13), with native support in iOS 15 and macOS 12, it will back deploy to all OS versions which support SwiftUI and Combine. As we blogged earlier, the new concurrency language features are wonderful, but they don’t replace Combine by any means. You will likely want to make use of both tools.
Thinking in SwiftUI
SwiftUI is fundamentally different from Cocoa in some ways that force you to shift how you think about programming.
The biggest difference in SwiftUI is the paradigm that the framework manages the view hierarchy and automatically updates views based on changes in state or data. In AppKit or UIKit, you are used to displaying, removing, populating, and updating views from your procedural code. SwiftUI, instead, reacts to the changes in your data models and updates the display for you. It’s a big change in thinking!
Layout & design
Always consider that SwiftUI views are lightweight. Compare this simplicity to an NSView/UIView, which has dozens of properties for just the base class. By being lightweight, it’s encouraged to design interfaces in terms of simple components. And with each
View being a
struct rather than a
class — a value type rather than a reference type — there are fewer opportunities for bugs due to shared mutable state. To adjust SwiftUI views, we wrap them in modifiers (which insert them into new views) rather than setting properties on Cocoa views.
Sizing and layout in SwiftUI require a new way of thinking. You need to stop thinking in terms of layout constraints. Instead, SwiftUI lays out its views according to an algorithm in which the container view proposes a size for its children, and the children choose a size based on their contents and this available space. Some view types such as text and images have an intrinsic size which they would like to use; others such as shapes or colors have no intrinsic size and will fill the entire space being offered.
You’ll need to get used to a new mental model of how views are created and managed. SwiftUI uses a declarative syntax where the state of the application is specified and SwiftUI automatically updates the views as the state changes. This is in contrast to the imperative syntax used in Application Kit where the views are updated manually.
Beginners at SwiftUI may expect to use any arbitrary Swift code in a view declaration, but may be frustrated by apparent limitations. That’s because SwiftUI Views (and explicit
@ViewBuilder marked code), while looking like vanilla Swift code, are actually declarations with a Swift-like syntax. The code is a Domain-Specific Language (DSL) that allows for certain swift-syntax abilities (variable and constant declarations, conditional branches, switch statements) but is highly constrained. Don’t worry, you’ll get accustomed to it quickly.
Identity, lifetime, and dependencies
It’s helpful when getting used to SwiftUI development to wrap your head around the concepts of identity, lifetime, and dependencies.
Identity refers to the idea that every view in SwiftUI has a unique identifier that allows the framework to track it over time. This identifier is used to determine whether a view needs to be updated when its state changes. Views are identified through explicit identity, e.g. an
id parameter in a
ForEach, or an explicit
.id(…) modifier for a view, or structural identity, where a view is identified by its placement in a view hierarchy given the current state that may affect which elements exist.
Lifetime refers to the fact that SwiftUI views are created and destroyed dynamically as needed. This keeps SwiftUI zippy! Hint: if you need to define an explicit
init(…) method for a view, keep it as lightweight as possible; SwiftUI creates and destroys
View objects frequently as part of its update cycle.
Dependencies refer to the idea that SwiftUI views should only update when their underlying state changes. This means that views should not update unnecessarily, as it can be a waste of system resources. In SwiftUI, dependencies are managed automatically by the framework, which tracks all the properties that a view depends on. When a property changes, the framework automatically updates the view and any other views that depend on that property.
For more information, please see the “Demystify SwiftUI” video from WWDC 2021. https://developer.apple.com/videos/play/wwdc2021/10022/
While SwiftUI is all you’ll need for a basic Mac or iOS application, there are still quite a few gaps that will require you to partially make use of classic Cocoa views. In our code base, for example, we need some access to NSEvents, text input, and tweaking the first responder that just aren’t possible with pure SwiftUI. Fortunately Apple has provided bridges — the
NSHostingController for embedding SwiftUI in a Cocoa view, and
NSViewControllerRepresentable for embedding a Cocoa view in a SwiftUI view. A really powerful technique we can’t neglect to mention is combining these two together, as described in this SwiftUI Lab article.
Moving from callbacks to concurrency and from delegates to publishers
SwiftUI is for your application’s user interface, but you shouldn’t neglect to modernize your data flow infrastructure as well. Many Cocoa APIs now contain new alternatives that return a Combine publisher and/or offer an
async variation that makes use of the new concurrency language feature.
For example, you might have fetched data from a
dataTask(with:completionHandler:). Now you have the ability to use Combine and create a
dataTaskPublisher(for:) to handle your incoming data. Or, you could
await the results of the
data(from:delegate:). Common APIs with modern variations include those for the notification center, user defaults, timers, and key-value observation.
Though your old code will continue to work just fine, there are advantages to starting to use modern methods for any new code you may write. You are less likely to introduce certain kinds of bugs, such as forgetting to invoke a callback method on an error branch, or when a callback is nested in another callback. You’ll have a better time integrating inputs with your SwiftUI code, since SwiftUI’s data flow is built on top of Combine. You have much better tools for handling multiple asynchronous operations. And you’ll be able to have more robust error-handling.
If you have some legacy code that you’d like to convert to being
async, or if you find a Cocoa API that doesn’t offer an asynchronous version, fret not — there are ways to wrap these. Paul Hudson has a good overview of the process here. Similarly, you could convert a legacy delegate-based callback to send new values to a
PassthroughSubject if you want a Combine publisher for your existing code.
A wonderful benefit of moving to either Combine or asynchronous functions that you will need to deal with
@objc wrappers and Objective-C selectors less and less!
If and when you are ready to start writing SwiftUI, Combine, and concurrency code, remember that you can take small steps here. There’s no need to rewrite code that works and doesn’t need a big refresh. You can instead focus on either entirely new features, such as those taking up an entire window, or even just a new view or control type within an existing window.
Thanks to a plethora of adaptors, it’s possible to have both front-end and back-end code with both the legacy and modern variants right next to each other in your source code. Then as you migrate toward the modern solutions, maybe you can pull out the old code once it’s no longer needed.
As far as whether to move more towards Combine or asynchronous code, it really depends. Messages that are going to end up being published for your user interface can really shine as Combine. So don’t rule out Combine just because the concurrency Swift features were introduced more recently. Concurrency is also great — we use it everywhere now — so if you find yourself needing some asynchronous back-end work then you should probably consider it first.