Transitioning to SwiftUI

How Thumbtack Rebuilt Its UI Component Library in SwiftUI

Daniel Roth
Thumbtack Engineering
13 min readAug 16, 2023

--

Photo by Kid Circus on Unsplash

When Apple announced its new UI framework “SwiftUI” in 2019, the iOS engineers at Thumbtack were excited to get their hands dirty and start using it. Working with any new framework is exciting and fun, to be sure, but there were practical reasons for our eagerness to adopt the new framework: Developing in SwiftUI promises to be faster, more productive, and safer than developing with UIKit.

SwiftUI represents a massive shift in the way UI code is written for Apple devices. Unlike UIKit code, which is written imperatively, SwiftUI code is written declaratively. Both programming paradigms communicate the desired output to the computer, but they do so differently: imperative code describes the manner in which some output should be generated, whereas declarative code describes that same output directly. Generally speaking, since SwiftUI strips away the boilerplate view assembly mechanics required by UIKit, it takes less code (a lot less code) to build a view with SwiftUI than it does to do the same with UIKit. This means SwiftUI developers can spend more time perfecting the look and feel of their views, and less time tweaking logic to get it to work correctly.

Along with code semantics improvements, SwiftUI introduces a powerful feature to Xcode called Previews, which further improves developer productivity. Previews show a real time rendering of the view under development alongside the SwiftUI code that produces it. As the underlying code changes, the preview updates, without requiring that the app be recompiled.

This marks a substantial improvement over the UIKit development experience. To see the effect of code change in UIKit, a developer must first recompile the app, wait for the simulator to load, and navigate to the updated view in the simulator. Each incremental build of the Thumbtack apps can take 20–30 seconds to complete, and launching the app on the simulator another 10; preview updates are nearly instantaneous. While the time savings may only be on the order of minutes per day, these savings compound over time.

Finally, SwiftUI is blazingly fast compared to UIKit. Code written with SwiftUI is lightweight, compiles faster, and is demonstrably more performant. Even though Thumbtack is still early in the transition from UIKit to SwiftUI, we already feel that it is leading to a more productive and generally more enjoyable developer experience, and we are confident that with faster, more responsive, bug-free views, we will be able to deliver a more enjoyable user experience as well.

SwiftUI Adoption Hurdles

Despite our excitement, there were two major hurdles to overcome before we could adopt the framework in earnest.

First, we had to be confident that the framework was mature and stable enough that development against it would be a net win. If early versions of the framework were too limited, or if subsequent versions would require changes to code we had already written, adopting the framework might not result in any improvement to our productivity or code quality. Ultimately, it was decided that iOS 15 (and the SwiftUI feature set that it supports) would be a good minimum deployment target. Thumbtack has a long tail of OS versions that it needs to support, and we couldn’t drop support for iOS 14 until there were few enough users using it that we wouldn’t too negatively impact revenue. We hit that mark in April and were able to make iOS 15 our minimum deployment target, clearing the way for SwiftUI adoption on our iOS apps.

Second, we needed a way to bridge the gap between our existing UIKit based view component library, and the new SwiftUI code that would need to use it. The bulk of our view code is built against an open source design system we call “Thumbprint”. Using this design system ensures consistency within and among all of our apps. It also significantly speeds up the development of new features by allowing us to compose them from pre-built, configurable components. But the iOS Thumbprint component library was built with UIKit, and to move forward with SwiftUI, we would either have to wrap all of our existing UIKit components in SwiftUI code (Apple provides a protocol for this called UIViewRepresentable), or rebuild the framework from the ground up in SwiftUI.

Ultimately, we decided to rebuild the framework in SwiftUI. SwiftUI’s coding paradigm is so fundamentally different from UIKit’s, that keeping the existing components’ configuration semantics would keep us from fully enjoying the benefits that SwiftUI has to offer. While the individual SwiftUI components would technically work as they did before, the way in which the underlying UIKit components are configured is still fundamentally imperative. We simply couldn’t continue to develop on top of these wrapped UIKit components, without constraining the surrounding SwiftUI code from fully realizing the power, speed, and convenience that it has to offer.

Design goals for the new component library

Having decided that we would rebuild the component library from scratch in SwiftUI, we then set out to establish a set of design goals that would align our code development towards a cohesive library written with a singular voice.

Our primary goal was that, as much as possible, our view components should behave like standard Apple components. Ideally, our components would respond to the same viewModifiers that Apple’s own components use, and wouldn’t require any specific knowledge of their implementation. Similarly, we wanted to follow the same configuration patterns that Apple uses in their own components, preferring configuration via modifiers and the environment over passing configuration information into initializers. This results in more readable code, and offers more flexibility to developers as these environment modifiers can be applied at any level of the view hierarchy (with the effect cascading down to child views).

A slightly less significant goal, though still an important one, was to address points of frustration and/or confusion with the old design framework. As an example, the UIKit Thumbprint library defined number of spacing constants in a ‘Space’ namespace. These spaces are invoked by referencing the name (e.g. Space1 and Space2), as per the Thumbprint documentation. Unlike standard colors and font styles, which are called out by name in the Figma design mocks, spacing values have to be inferred by analyzing the layout. This means that when a developer sees that two elements are 16px apart, they have to mentally translate this to Space.three, only for the person reviewing their code to have to translate it back to 16px. A better approach, and the one that we used in the second generation of the design system, is to put the actual value in the name of the constant, and better yet, to put that constant in an extension of CGFloat. Now we can put .space16 anywhere we can put a float value, and it’s immediately obvious that it is both a Thumbprint constant and has an value of 16 pts. We took a similar approach with the other token constants, turning Color.black300 into .tpBlack300 (where UIColor is inferrable and can be omitted).

And finally, we wanted to be sure that we were going into this process in tight alignment with the design team. Rebuilding the design framework from the ground up gave us the opportunity to not just clean up the framework we already had, but also improve upon it. To take full advantage of this opportunity, and to make our components as accessible to our users as possible, we would need to work closely with the designers responsible for it.

Challenge One: Think Differently

The first challenge we had to overcome was to stop thinking about view code imperatively and instead start thinking about it declaratively. To reiterate the distinction, imperative code describes a process — in our case, how a view should be constructed; whereas declarative code describes the intended result — how the view should look and behave. There is an admittedly fine line between the two, and it’s tempting to write the distinction off as a semantic exercise, so let me give a clear example.

Thumbprint includes an infinitely scrollable calendar component. Like all UIKit code, the original component was implemented imperatively. It was written using a common pattern for infinitely scrolling views, as a three page scroll view with pages representing the previous, current, and next content.

Initially, the current page is visible on the screen. The previous and next pages are masked by view clipping.

Initial view layout. Only the current page is visible; the previous and next pages are clipped.

When the user moves to the next page, the previously current page is no longer in the middle, invalidating our previous setup. To prepare for the next interaction, we have to reload all three pages, and reset the scroll view’s offset so that the middle page is once again visible.

After the user has moved to the next page, the view needs to be reset to prepare for the next transition.

If this is done quickly and while the user isn’t trying to interact with the view, the behind the scenes swap will be undetected and the illusion of infinite scrollability preserved.

After the view is reloaded, the current page is once again in the middle, and a transition in either direction is once again possible.

This is an excellent example of an imperatively implemented view component. Rather than describe what we want (when the user taps the next button, show the next view), we had to describe how to create said experience (use a paged scrollview, put the current page content on the second page, set the contentOffset such that the second page is visible, etc.).

When I first set out to reimplement this component in SwiftUI, I began to doubt that it would even be possible to do. I was so used to having to figure out how to create the view, and I struggled to find an existing SwiftUI component that would give me the fine-grained control I thought I needed in order to build it.

The problem was made vastly simpler when I realized that I didn’t have to figure out how the view would work; I just had to describe how it would function (and be a bit clever about the swift code behind it). Describing the views functionality only required a state variable named visibleMonth to store the grid data for the current month, a state variable named monthChangeDirection which is updated when the next or previous buttons are tapped, and transition on the calendar grid to animate the old and new grid views based on the monthChangeDirection.

@State private var visibleMonth: TPCalendarMonth
@State private var monthChangeDirection: MonthChangeDirection = .next

private var monthChangeTransition: AnyTransition {
if #available(iOS 16, *) {
return .push(from: monthChangeDirection == .next ? .trailing : .leading)
} else {
return .asymmetric(
insertion: .move(edge: monthChangeDirection == .next ? .trailing : .leading),
removal: .move(edge: monthChangeDirection == .next ? .leading : .trailing)
)
}
}

When the previous or next month buttons get tapped, they first update the monthChangeDirection, and then update the visible month inside of a withAnimationBlock. The transition is asymmetric, defining different insertion and removal transitions. Both parts of the asymmetric transition are .move transitions, they are just bound to either the leading or trailing edges depending on the monthChangeDirection. And, that’s it!

// In the header view implementation
TPIconButton(icon: .navigationCaretLeftSmall) {
monthChangeDirection = .previous
withAnimation(.linear(duration: 0.3)) {
visibleMonth = visibleMonth.previousCalendarMonth()
}
}
TPIconButton(icon: .navigationCaretRightSmall) {
monthChangeDirection = .next
withAnimation(.linear(duration: 0.3)) {
visibleMonth = visibleMonth.nextCalendarMonth()
}
}

// In the body implementation
public var body: some View {
// ...
gridView().transition(monthChangeTransition)
}

You may be confused at this point — I set out to explain how I implemented an infinite scroll view in SwiftUI, and yet there isn’t any scroll view at all. Calling this design an infinite scroll view is an inherently imperative way to think about the problem; it frames the problem in terms of the implementation rather than the desired outcome. Looking at the problem through a declarative lens, the way that the view is constructed is irrelevant; it simply needs to push the next view onto the screen while animating the old view out in the same direction.

TPCalendar interaction demo

It’s also worth calling out that, since the new implementation doesn’t depend on any view swapping sleight of hand, the SwiftUI based calendar view is much faster and more responsive than its predecessor. The UIKit calendar view had seemingly arbitrary limitations due to the way that it was implemented. For example, since the previous and next views had to be preloaded, it could only ever advance one view at a time, and only at the pace of one complete scroll animation. The next and previous month controls had to be disabled during the transition animation because an additional tap during an animation would invalidate underlying index invariants. This simply isn’t an issue in the SwiftUI version — you can tap the buttons as many times as you want as fast as you want, and the view updates as you would expect.

Challenge 2: The View Hierarchy Catch-22

Unfortunately, our goal of having all component views respond to Apple’s built in view modifiers turned out to be largely unattainable (at least in the current version of SwiftUI) due to the way in which the environment has been implemented. The SwiftUI environment is a bundle of values that gets passed from each parent view to its children. Each environment variable declares a default value, which gets used if no other value is set. This can be useful when applying the same styling to an entire set of elements, for example; just set the environment value on the parent, and each child element will receive the updated value.

The model works well when composing a new view out of existing components, but not so well when building a component library whose defaults differ from Apple’s. Any styling applied within your component definition is going to be at the deepest level of the view hierarchy. But because that styling is applied at the deepest level of the view hierarchy, no code that uses the component would be able to override it. As a trivial example, consider a Link view which contains some underlined text and a blue default styling. If the underlying text had its .foregroundStyle() set to .blue within the Link implementation, there is literally nothing that a developer can do outside of that implementation to change it. Any application of style outside the Link implementation will necessarily occur at a higher level of the view hierarchy and be overridden by the inner style application.

The workaround is to create a new environment variable specific to the component, with the a default value. This works, but could significantly increase the amount of framework specific knowledge a person would need to develop against it. Fortunately for us, most of our components are configured with predefined themes encompassing multiple state specific attributes, like font and color. Adding new environment variables types for these themes was a natural and elegant solution to the problem.

Another possible solution to the problem would be to allow for environment defaults, not just the environment value, to be set and passed down at each level of the view hierarchy. I proposed that change in Apple Radar FB12339918.

Challenge 3: Layout Catch-22

SwiftUI has the most versatile layout model of any Apple has previously attempted (including hardcoded frame values, springs and struts, and autolayout). The layout model has three steps:

  1. The parent proposes a size for its child.
  2. The child determines its own size and returns it to the parent.
  3. The parent has to respect the size returned by the child.

The “sizes” that are being passed back and forth are not just height and width pairings like you might expect, but are actually sets of height and width pairings, with values for minimum height and width, maximum height and width, and ideal height and width.

This model differs from the UIKit model that preceded it. In the UIKit model the parent was entirely responsible for determining both the size and position of its child. The parent may ask the child for its preferred size, but it was under no obligation to respect it.

This creates another catch-22: We can either make our component provide a size that fits its own contents, or we can have it provide a size that fills the available space, but it can’t do both. Where before a feature developer could choose whether to use the components preferred size, in SwiftUI this choice is forced on the component developer.

We ran into this problem early on when porting our Button component over to the new framework. In some contexts, we want our button to be as small still fitting its contents. In others, we want our button to fill as much of the horizontal space around it as possible. Again, this is easy to do in UIKit, because the parent view determines the child view’s size, but it’s non-trivial in SwiftUI.

While there is no perfect solution for this problem, it can be solved in the same way that the view hierarchy catch-22 was: with the addition of another environment variable. The environment variable can be set at the level of the parent view, but applied at the level of the child view, which is where the sizing discrepancy needs to be resolved.

For the Thumbprint button, we defined an environment variable name tpButtonWidth which takes an enum value of either .intrinsic or .full. The TPButton body logic creates a Button wrapped in a frame whose maxWidth is either .infinity or nil, depending on the tpButtonWidth environment value. With that environment variable in place, the button width style can be applied anywhere in the view hierarchy, but it still gets applied directly to the underlying Button instance. An additional benefit, a row of equal width buttons can be created by applying the .full buttonWidth to an hStack of Thumbprint buttons.

VStack(alignment: .leading) {
Text("TPButton").tpFontStyle(.title4)
HStack {
LoadingButton(title: "Primary")
.tpButtonTheme(.primary)
LoadingButton(title: "Secondary")
.tpButtonTheme(.secondary)
}
}
.tpButtonWidth(.full)
.padding(.space16)

Conclusion

We’re excited to have completed this major milestone. We have already begun implementing new features in SwiftUI and we look forward to sharing ThumbprintUI as open source code after it has had more real world testing.

We are already seeing real benefits from adopting SwiftUI in our apps. We are finding writing code in SwiftUI to be a more productive, pleasant, and streamlined development process. It’s hard to overstate the value of being able to see the results of code changes in near real time as each saved compilation (of which there are many) saves minutes of developer time every day.

We are also finding that our SwiftUI code is simpler and less error prone. SwiftUI views automatically update when their bound variables change. So long as the view components are hooked up to their models correctly, it is impossible for the view to get out of sync with the underlying state. The same cannot be said for UIKit views, which are updated by code that responds to system events (e.g. server response received, navigation complete, user interaction, etc.). SwiftUI’s state driven model reduces or outright eliminates the potential for a number of different types of bugs, including race conditions and out of sync updates.

At Thumbtack, we are sold on the benefits of using SwiftUI to develop our product, and look forward to building new functionality for our users on top of this powerful framework.

--

--