Bringing the Best of SwiftUI to Our Team’s UIKit Code

Bringing the Best of SwiftUI to Our Team’s UIKit Code

Like pretty much all our colleagues in the field, iOS developers at Grammarly are excited about SwiftUI. Released at the 2019 Apple Worldwide Developers Conference (WWDC), it represents a major step forward in Apple’s support for building great user experiences. But as much as we want to use SwiftUI for everything, we can’t. For one thing, the libraries are still new and will probably take a few years to completely stabilize. Plus, SwiftUI is only bundled in iOS 13+, and we need to continue to support Grammarly for older versions of iOS. And finally, our existing UIKit code represents a huge, years-long investment for our team—we don’t want to just throw it out. 

So we asked ourselves: How can we take the improvements in SwiftUI that we’re so excited about and essentially reverse engineer them for our UIKit code? This post will explain how we’ve looked at what SwiftUI has to offer and used what we’ve learned in order to improve our UI code and development processes. We’ll also talk about some pitfalls we ran into when integrating SwiftUI’s live previews.

Building UIs for Apple platforms

Before getting into actual code, we’ll give a brief overview of the tradeoffs between the three major approaches that Apple supports for building UIs: Interface Builder, UIKit + Auto Layout in code, and now SwiftUI.  

Interface Builder 🛠

Interface Builder is the default editor that comes bundled with Xcode, Apple’s developer toolset. It’s a WYSIWYG editor and provides a lot of drag-and-drop elements, so it’s easy to get started and build a very simple app. In addition to the provided UI elements, Interface Builder also supports custom properties and custom view rendering.

But the more customizations you add, the more likely it is that you’ll run into instabilities and crashes. Fun fact: Interface Builder predates OSX. It first appeared in 1986, the same year I was born. You can imagine that approaches to building layouts have evolved a bit since then.

In IB, the layout isn’t organized in any way, and you can’t embed one file inside another, so as your app gets more complex, navigating the editor can get out of hand very fast. Here’s a peek into organization in IB: 

There are other challenges to collaborating on a complex project in IB. Code reviews are hard: You typically don’t edit IB files in text form, and source control systems don’t render human-readable diffs. It can be a lot of work to disentangle merge conflicts, because the XML is machine-generated and the order can change significantly even after minor edits. The lifecycle of UIKit objects loaded from IB files is nontrivial, meaning you need to wade through methods like init(coder:) and awakeFromNib() to figure out what’s going on. And for local testing, any time you make changes, you have to open your device and navigate to the right screen.

UIKit + Auto Layout in code 🧑‍💻

Another option is to build the UI entirely in code using UIKit + Auto Layout. UIKit is built around the MVC model that’s used for most modern UI logic. It’s easy to reuse views, and you can build UI components with unlimited flexibility.

However, there are many challenges to building UI from code. There’s no live preview of your interface, so you need to build and run just to see any changes. With Auto Layout, it can be difficult to reason through what will happen just by looking at the code. Ultimately, you’re writing free-form UI code—and after a few years of support and maintenance on a complex app, any free-form code is bound to get messy.

Swift UI 🚀

At WWDC 2019, Apple released SwiftUI as the next evolution of building UIs, and the reception has been overwhelmingly positive. We even created a version of a Futurama meme to joke about how universal the excitement seemed: 

SwiftUI is declarative, so views are a function of a state. With live previews, as you edit code on the left-hand side, you immediately see the changes reflected in the simulator on the right-hand side. SwiftUI code is usually written in a way that outputs concise diffs in source control. And the code hierarchy matches the UI layout’s hierarchy, helping with readability and maintainability. 

UI code in transition

There is a lot to love about SwiftUI, but because it’s so new, its APIs will likely take some time to mature and become fully stable. Like many iOS engineering teams, we also have a lot of great UIKit code that we’re in no hurry to throw away. So we’ve been investigating ways to refactor our UIKit code to incorporate many of the things everyone loves about SwiftUI. 

If you’ve built UIs in code for a complex app, you’re probably familiar with a lot of the problems and anti-patterns our team has encountered. First of all, the subviews are all jumbled together in the same list, so it’s unclear what the hierarchy is for our UI elements. Furthermore, Auto Layout is interleaved with configuration. The viewDidLoad() method jumbles the layout with a lot of non-UI logic: opening network endpoints, setting up data sources, subscribing to system event notifications, and so on. 

How can we make our UIKit code look more clean and manageable, like SwiftUI? One way to avoid free-form code is to use conventions and wrappers. UIKit was designed for Objective-C, long before Swift, and has changed a lot during its lifetime. Looking at SwiftUI, we’ve found inspiration for refactoring our UIKIt code to have better defaults and richer interfaces. Some APIs can be improved using Swift generics, while others can be altered to avoid legacy defaults that one might occasionally forget to unset (like removing translatesAutoresizingMaskIntoConstraints).

Here are some examples of what our team’s been working on.

Shape the way millions of people communicate!
Open Roles

Extracting view code

We can use custom view classes and override the loadView() method in a view controller to load specific views. With a generic view controller as our base, we reduce boilerplate code.

open class ContentViewController: UIViewController where ContentView: UIView {
public final lazy var contentView = makeContentView()

public final override func loadView() {
view = contentView
}

open func makeContentView() -> ContentView {
return ContentView(frame: .zero)
}

Condensing content updates

We can define a fill(with:) method and use it as a single point where the view is populated with data. This makes the code easier to read and less prone to errors.

Creating cleaner Auto Layout constraints

We defined LayoutPins—which, as a concept, falls somewhere between Auto Layout constraints and older auto-resizing masks. It’s used to generate constraints when adding a subview (which works with about 90% of the layout code in our project). 

public enum LayoutPins {
case fixed(
attribute: NSLayoutConstraint.Attribute,
relatedBy: NSLayoutConstraint.Relation = .equal,
constant: CGFloat
)

case relativeToParent(
attribute: NSLayoutConstraint.Attribute,
relatedBy: NSLayoutConstraint.Relation = .equal,
to: NSLayoutConstraint.Attribute,
multiplier: CGFloat = 1,
constant: CGFloat = 0
)

case aspectRatio(
multiplier: CGFloat,
constant: CGFloat = 0
)

indirect case aggregate([LayoutPins])
}

extension LayoutPins: ExpressibleByArrayLiteral {
public init(arrayLiteral elements: LayoutPins...) {
self = .aggregate(elements)
}
}

extension LayoutPins {
public func constraints(for view: UIView) -> [NSLayoutConstraint] {
// ...
}
}

This simple enum is then extended with helper functions to produce compound layout rules like “center in parent” or “fill width.”

extension LayoutPins {
public static func height(_ value: CGFloat) -> LayoutPins {
return .fixed(attribute: .height, constant: value)
}

public static let fillWidth: LayoutPins = [
.relative(attribute: .centerX, to: .centerX),
.relative(attribute: .width, to: .width)
]

//…
}

Hierarchical wrappers

A nice thing about SwiftUI is that code indentation matches view hierarchy. To achieve a similar effect with UIKit, we introduced generic addSubview and addArrangedSubview wrappers that accept configuration closures to make view construction code look hierarchical.

extension UIView {
@discardableResult
public func addSubview(_ subview: ViewType, pin: LayoutPins = [], configure: (ViewType) -> Void) ->ViewType where ViewType: UIView {
subview.translatesAutoresizingMaskIntoConstraints = false
addSubview(subview)
NSLayoutConstraint.activate(pin.constraints(for: subview))
configure(subview)
return subview
}
}

Combining these wrappers with LayoutPins lets us build views from code like this:

func setupView() {
addSubview(UIStackView(), pin: [.fillWidth, .height(50), .centerVertically]) {
$0.axis = .vertical
$0.alignment = .fill
$0.distribution = .fill

title = $0.addArrangedSubview(UILabel()) {
$0.text = "Title"
}

subtitle = $0.addArrangedSubview(UILabel()) {
$0.text = "Subtitle"
}
}
}

Adding SwiftUI previews for existing UI code

One of the best things about SwiftUI is its live preview feature, which automatically shows the effects of your code changes on a simulated device in Xcode. It fills views with real data, and you can have multiple previews at once. Among other advantages, live previews make it easy to find and document edge cases, with the added benefit of automatically sharing this documentation with your team, since the preview configuration is part of the source code.

There are many articles about using SwiftUI previews with UIKit. The idea is simple: Just wrap your UIKit view in a SwiftUI container, and you’re done. As explained at WWDC, integration is straightforward and can be easily achieved by implementing the UIViewRepresentable protocol. However, for us, the integration wasn’t that simple. We’ll explain some of the hurdles in case it might help your team debug similar issues.

Extension target → framework target

Grammarly’s primary UI component in iOS is a keyboard extension, and unfortunately, app extension targets do not support SwiftUI previews—only application and framework targets are supported. (Also worth noting: You can’t write unit tests for app extension targets, either.)

To get the previews, we needed to first move all the UI code to a new framework target. The extension target just links to the framework and references the main view controller in the Info.plist file:

xml
NSExtensionPrincipalClass
KeyboardExtensionContent.KeyboardViewController

Static target → dynamic target

Next we ran into another problem: Live previews aren’t supported for static framework targets.   

Startup time is crucial for keyboard UX, so to minimize dynamic linkage, we use the  MACH_O_TYPE = staticlib build setting. To get live previews working, we added a build setting to use dynamic linkage when debugging with the simulator:  MACH_O_TYPE[sdk=iphonesimulator*] = mh_dylib

Not-so-live previews

So far so good: we got the live previews running. But often, after we made changes to our views, Xcode would start rebuilding the entire project to apply those changes, which took a long time, resulting in a slow debugging process with the previews. What’s happening under the hood? To reload the live preview when a file changes, Xcode generates a “patch” file that updates methods on the fly. (Here’s a useful blog post with details about implementation.)

swift
extension CustomView {
@_dynamicReplacement(for: setupView())
private func __preview__setupView() {
// ...
}
}

A new dynamic library is created from this patch file and loaded into the preview renderer process. But the hitch is that only instance methods can be replaced in this way. Any changes to init methods or property initializers triggers a full rebuild. 

While working with UIKit, you can decide to set up everything in the init(frame:) method, which means Xcode will need to rebuild for each minor edit. To use live previews more smoothly, the workaround is to move everything into setupView(), call it from the initializer, and never touch init again.

Duplicate symbols in dependencies

While working with the live previews, some of our code was behaving incorrectly. First, we noticed that our as and is operators were failing. Why?

Recall that each time we modify code, a new dynamic library is loaded into the renderer to patch modified methods. But this library is linked to all our dependencies—and if the dependencies are static, we are effectively creating multiple copies of code and types every time we change something. Because of the duplicates that were now in the mix, we ended up with unpredictable results when we applied checks to components. The solution to this problem was easy: Just make all dependencies also dynamic.

Framework dependencies not found

At least, it seemed easy—but after making all dependencies dynamic, the extension wouldn’t start at all. Instead, it printed to console errors like this: 

dyld: Library not loaded: @rpath/Utils.framework/Utils
Reason: image not found

To understand what’s happening here, let’s inspect the default value of the LD_RUNPATH_SEARCH_PATHS build setting, which defines the set of locations used by the dynamic linker to locate frameworks:

LD_RUNPATH_SEARCH_PATHS =
$(inherited)
@executable_path/Frameworks
@executable_path/../../Frameworks
@loader_path/Frameworks

To explain:

  • @executable_path/Frameworks are embedded frameworks from the application bundle. 
  • @executable_path/../../Frameworks are embedded frameworks from the host/container bundle (relevant for app extensions).
  • @loader_path/Frameworks are embedded frameworks from the current bundle (this is mostly important for Mac apps where frameworks are allowed to embed other frameworks).

These search paths work when our framework is embedded inside an application or extension, but in this case our framework is loaded in some internal Xcode machinery, so our dependencies aren’t being embedded. Luckily, we only need to run our simulator debug configuration on a local machine. So for that configuration, we can add absolute paths to LD_RUNPATH_SEARCH_PATHS to let it know where all of our dependencies live. 

We use Carthage to fetch and build all external dependencies, so we added $(PROJECT_DIR)/Carthage/Build/iOS. As for our internal dependencies, Xcode will build all of them automatically, and they can be found in $(BUILT_PRODUCTS_DIR). The final search path for our live preview debugging configuration looks like this: 

LD_RUNPATH_SEARCH_PATHS[sdk=iphonesimulator*] =
$(inherited)
$(PROJECT_DIR)/Carthage/Build/iOS
$(BUILT_PRODUCTS_DIR)
@executable_path/Frameworks
@executable_path/../../Frameworks
@loader_path/Frameworks

Conclusion

Despite some of the trickiness we encountered, we remain really excited about SwiftUI itself—and about how we can bring some of its most beneficial features to our existing code. We hope you learned something that you can apply to your own UIKit code! 

Grammarly is actively hiring for iOS developers. Learn more about our tech stack and check out open roles. If you’re interested in writing great UI code that helps millions of people write across all their devices, we would love to hear from you!

Your writing, at its best.
Get Grammarly for free
Works on all your favorite websites
Related Articles
Shape the way millions of people communicate every day!
Explore open roles