SwiftUI is fantastic, but not perfect. Some of these imperfections are expected from a nascent 4-year-old framework, but just one creates the roadblock to wider adoption.
Today, I’m going to tell the story of my SwiftUI journey at 3 startups; and explain my solution to the critical flaw preventing you from using SwiftUI full-time.
Subscribe to Jacob’s Tech Tavern for free to get ludicrously in-depth articles on iOS, Swift, tech, & indie projects in your inbox every two weeks.
Paid subscribers unlock Quick Hacks, my advanced tips series, and enjoy exclusive early access to my long-form articles.
SwiftUI through the years
I’ve been lucky enough to be primarily using SwiftUI since its inception.
When the beta dropped in 2019, I was working on a side-project-slash-start-up, Patcher - think Uber for car repairs. Like all naïve engineers on our first rodeo we decided to build everything before getting our first customers.
This app had everything. A map, onboarding, profiles, job requests, payments, a repair-in-progress flow, and a second companion app for mechanics. It’s safe to say it was a pretty complex beast, and it’s safe to say SwiftUI was not ready for this in late 2019 and early 2020.
Early UIKit inter-op was janky and it was difficult to pass state about. This was a problem when two major features used the Google Maps SDK (MapView didn’t exist!) and the camera. Worst of all, Navigation was entirely broken in SwiftUI 1.0. We eventually ended up using modals for the vast majority of in-app navigation.
I co-founded Carbn in late 2020; a startup aimed at helping you develop greener habits and reduce your carbon footprint. By now, iOS 14 had come out and SwiftUI was ready for showtime. We got @StateObject, lazy stacks, ScrollViewReader, and my favourite - matchedGeometryEffect - but most importantly, people started to work out how to use SwiftUI to structure apps properly.
Today, I’m mobile engineering lead at Gener8, where we’re helping you to control and get value from your own data. We’re fortunate enough to target iOS 15+, giving us access to material effects, refreshable, the task modifier, markdown rendering, and more!
It’s safe to say SwiftUI is chugging along on all cylinders now; and at some point in the last 4 years it went from an experimental toy to a fully-fledged UI framework on which you can build a serious business.
…but is it production-ready?
Look, if you use it in production, then it’s production-ready!
Before Swift got ABI stability, there was a sizeable minority of Objective-C folks who denied that Swift was ready for the big leagues. We’re likely to hear the same characters evangelising UIKit in 2030 (I haven’t met anyone who still does this with Storyboards, at least).
I’ll briefly look at what SwiftUI does well, what it does badly, and what might be stopping engineers from committing to SwiftUI fully.
SwiftUI
…the good
You don’t need me to evangelise SwiftUI, so I’ll be brief. It’s fast, it’s declarative, all that good stuff. It’s reactive; giving you the power of unidirectional data flow out of the box. It’s the future.
…the bad
SwiftUI isn’t perfect of course. Navigation has always been janky. There’s limited support for old-school UIKit-based frameworks like AV, Camera, and Maps (at least, prior to iOS 17). Since it’s not yet a mature framework, there’s often things missing that require dipping into UIKit. Finally, the rendering engine uses a clever mix of CoreAnimation, UIKit, and Metal under the hood, which is a little bit more ‘magic’ than with imperative frameworks - this makes down-to-the-bit performance optimisation more difficult.
…the ugly
Frankly, Apple is in denial that anything other than single-view or master-detail apps exist. As with all young frameworks, the OS version is a moving target, meaning an uncomfortable amount of if #available
throughout your code when you want to use the latest kit. Finally, it can be tough to get SwiftUI playing well with a large amount of existing UIKit code - since state management behaves so differently.
SwiftUI is fantastic, but not perfect. Some of these imperfections are expected from a nascent 4-year-old framework, but just one creates the roadblock to wider adoption:
Let’s focus on navigation
Navigation is the #1 issue stopping people from fully committing to SwiftUI. There simply aren’t natural options available for large, complex apps. Allowing the state of your app’s data to define navigation works great for toy apps, but scaling this up is exhausting. You shouldn’t need to hold your app’s entire state in your brain to reason about which screen needs to be presented.
Really, the underlying issue is…
…Apple has resurrected the massive view controller problem.
Consider the navigation paradigms available in SwiftUI, upgraded by Apple in iOS 16:
NavigationStack
, NavigationLink
, .navigationDestination
, .sheet
, and NavigationSplitView
.
All of these are built into your SwiftUI views, and allow the view to determine which interaction can lead to which child views based on user interaction or app state.
This forces you to tightly couple your views with your navigation logic. This is anathema to testability, goes against engineering best practice, and (frankly) a habit that iOS engineers as a breed have only relatively recently recovered from. The horror of string
ly-typed segues, URLSession calls inlined in viewDidLoad()
, and whole-app Main.storyboard
files feels like a bad trip from which we’ve only recently sobered up.
If you’re newer to iOS, and want to know more about ‘massive view controller’ syndrome, this tutorial by Paul Hudson is a great primer on both the problems caused by poor separation of concerns; and UIKit best practices in general.
Abstracting our navigation logic
So we’ve spotted the main problem. SwiftUI doesn’t let you abstract away your navigation logic, which makes it tough for a mature engineering team to work together to produce a complex product.
But there’s a solution! It’s been under your nose the whole time.
The coordinator pattern
You might also call this the Router
pattern, or the Navigator
pattern. But the core idea is the same: Encapsulate your navigation logic.
The way I like to implement this is with an AppCoordinator that runs the show (set up in your AppDelegate/SceneDelegate). This owns a few child coordinators for the code flows in your app, and these in turn can have their own children for sub-flows.
The protocol for the coordinator is pretty straightforward:
Here, Route
is often just an enum to represent the possible screens in a coordinator’s navigation flow, and all your app’s navigation logic is isolated inside the navigate(to:)
method of your coordinators.
This is a common approach you might be familiar with from UIKit, but I’m going to explain how you can modify this to enable full inter-op with SwiftUI. We just need 2 more components:
UIHostingController
This wraps a SwiftUI context inside a UIViewController, acting as a bridging mechanism between the two paradigms.
Navigation protocols
We’re going to introduce 2 protocols to abstract over the functionality of a UINavigationController and UITabBarController; the imperative building blocks of a complex UIKit app. The resulting navigation structure of your app should be extremely familiar to anyone who’s used UIKit before:
NavigationContext
This protocol is our wishlist, defining the navigation power we’re looking for: showing and hiding our SwiftUI views, with the presentation logic coded separately from the views.
These presentation functions take a generic view, and we implement all the main players of navigation: push and pop; present and dismiss; and of course creating an initial view at the root of a feature.
The concrete implementation of the protocol works through a subclass of UINavigationController:
Here, you can see that implementation is very similar to the standard UIKit navigation methods - with one more extra step, of wrapping our SwiftUI views in a UIHostingController.
Now, we can easily fill out our first coordinator’s navigate(to:)
method:
NavigationRoot
This protocol abstracts over the functionality of a UITabBarController we want to use. Usually, you’ll just have one of these owned by your AppCoordinator.
There’s no SwiftUI here - it just owns a few navigation contexts and allows you to switch between them through the tabs.
There’s also one method to present a context modally on top of the tab bar controller - this is a very common approach when dealing with authentication.
Unlocking more power
Now we’ve put all the main building blocks together, and have the ability to develop a SwiftUI app using the bulletproof backbone of 16-year-old Cocoa Touch APIs. But we can squeeze out more power from these by subclassing UIHostingController. We can do things like:
Enable more customised styling on a per-view basis without invoking UIAppearance APIs
Utilise UIViewController lifecycle events to pass state between views in separate NavigationContexts.
Here’s a basic implementation:
This leads us to create a more complex implementation of our navigate(to:)
method:
Here, we are utilising our existing presentation code, implementing view lifecycle events to show and hide an overlay on the parent view. Finally, we demonstrate the seamless UIKit inter-op that is possible when using a standard coordinator pattern by presenting an SFSafariViewController.
Conclusion
Coordinators aren’t the only way to structure a complex SwiftUI app. You can brave the natural state-based paradigms; if targeting iOS 16 you could get by with the newer navigation APIs; and you might even try out The Composable Architecture.
The commonality with all these alternatives is that the state-of-the-art is a moving target. Using coordinators, you take the best of both worlds:
Reactive and blazingly fast UI creation from SwiftUI
The mature navigation and powerful customisation of UIKit
You also gain inter-op out-of-the-box, and barely need to think about state. If you’ve not jumped fully into SwiftUI yet, grasp these 16-year-old paradigms that will never break and get started today.
I built a large app using this pattern and found two downsides about it: first of all @Environment objects don’t percolate down when there’s a UIKit component between them, and secondly: VoiceOver navigation gets very confused when it’s in for example a UIKit wrapped bottomsheet and won’t let users leave that context.