#dreiLab –SwiftUI with a UIKit Bias

In this post, we discuss how SwiftUI handles layouting and state. We explain, from an iOS perspective, how this helps us avoid some of the most common mistakes made when implementing UIs while also generating easily understandable and maintainable code.

Laila Becker
dreipol

--

Visit www.dreipol.ch to learn more about us.

This post is the second in a two-part mini-series and highlights some of our experiences with SwiftUI. You can read the first part about App Clips here.

With iOS 14 and iOS 15, Apple introduced a series of improvements to SwiftUI. If you are, like us, excited to try out these new features, this post is designed to help you overcome some of the challenges you might encounter if you’re using SwiftUI for the first time.

Recently, we had the chance to gain some experience with SwiftUI and try out its new features on an internal project: the Buchstabensalat (typoglycemia) is a puzzle where users prove their knowledge of the Bernese German dialect by finding words in a grid of letters.

To try out the app scan or tap on the QR code below:

Layout

SwiftUI is a declarative paradigm for defining user interfaces. To create a view we describe it top-down, i.e. starting with the largest structures. Take, for example, a look at the following screen from Buchstabensalat:

A screenshot of the final app is shown. At letters are shown in a grid and the word “Schnousäreiä” is highlighted. Below the grid a list is shown “Schnousäreiä” is revealed and highlighted, the remaining words are redacted. Below the list there is a switch which can be used to reveal the redacted words. Next to the screenshot there is a diagram showing how we describe the UI starting from the largest structures and zooming into ever smaller UI elements.
The diagram on the left shows how we zoom in on ever smaller structures when describing our UI in SwiftUI. On the right the final result is shown.

On the top level, you might describe it as consisting of a top part (grid) and a bottom part (word list). So we describe this in SwiftUI as

We then zoom in on the word list. It in turn consists of the “Show words” toggle and the word list itself. The list consists of two columns, each column of multiple rows, and each row of a number, the word, and, optionally, an info button. The background color of a word is determined by whether

  • it has been found, yet
  • words that have not been found should currently be hidden, i.e. whether the “Show words” toggle is currently on or not

Efficiency and level of abstraction

At first glance, this might seem like a lot of wrappers just to get the layout right. So at this point, it is worth noting that SwiftUI is somewhat more abstract than UIKit: in UIKit views are more or less directly responsible for drawing their contents. In contrast, a view in SwiftUI is simply a description of the kind of view we need. At runtime SwiftUI then decides how to build the specified view. In some cases, it may do this by instantiating the corresponding UIKit class whereas others are implemented by SwiftUI and render directly into the framebuffer. Others, yet, are simply translated into properties of other views (e.g. HStacks and VStacks simply position their child views correctly without the need to instantiate a UIStackView).

View sizing: basics

Once we have figured out the arrangement of our subviews relative to one another we, then, need to understand how SwiftUI calculates the size of each view. We start with some amount of available space (usually determined by the size of the screen / scene) at the top level and divide it up between the direct children of the main view according to a set of simple rules. Each child, then, recursively divides up the space it has been assigned between its children until we reach the leaves of the view hierarchy. While information about the amount of space we have available flows down the view hierarchy from the root we also need a way to propagate information about the amount of space needed to present our content back up the view hierarchy. In SwiftUI we do this by defining a minWidth / minHeight and maxWidth / maxHeight. Each leaf knows its size based on its content (e.g. an Image view sets both its minimum and maximum size to the size of the image it is displaying unless you manually make it resizable and provide a size range) and each intermediary node calculates its minimum and maximum size based on the minimum and maximum sizes of its children (e.g. by adding padding).

View sizing: stacks

We frequently use HStacks and VStacks to combine multiple views so it is worth taking a closer look at these. In the case of an HStack the min / max width is calculated as the sum of the min / max widths of its children plus any spacing between them. The min / max height is determined to always fit the tallest subview at each extreme (any views that cannot be made tall enough are laid out according to the configuration of the HStack e.g. by centering them vertically). Analogously, a VStack calculates its height as the sum of its subviews and its width as the minimum / maximum width of its subviews. So far this is pretty straightforward. Next, let’s look at how the space assigned to a stack is divided up between its children. The following pseudo-code shows how space is allocated along the stack’s axis (for simplicity we omit the spacing between the subviews):

A stack always assigns each view at least the minimum amount of space it requires. If, after that, there is still some space left it starts filling up the sizes of its subviews with the highest layoutPriority, trying to size them equally, if possible.

How it works in practice

Let’s look at a few examples to get a feel for how this works in practice. We start with a simple example:

3 rectangles (green, black, purple) are placed next to each other horizontally. All rectangles have the same width.

By default, a rectangle has a minimum width of 0 and a maximum width of .infinity so the HStack can also take on any size and simply divides its space up equally between each rectangle.

3 rectangles (green, black, purple) are placed next to each other horizontally. The first 2 rectangles take up a quarter of the width each, while the remaining rectangle takes up the remaining half of the available space.

Now the outer HStack has two subviews so it gives half its space to the inner HStack and the other half to the purple rectangle.

3 rectangles (green, black, purple)  are placed next to each other horizontally. The first rectangle takes up only a small portion of the available space while the remaining space is divided up equally between the other 2 rectangles.

Reducing the layoutPriority (the default priority is 0) results in the green rectangle only being assigned its minimum width while the other rectangles grow to cover the remaining space. In fact, this is how Spacers work: they have a layoutPriority of -.infinity and a maximum width and height of .infinity thus ensuring that all other views take on their maximum size first and then filling up the remaining space.

The minimum and maximum width / height form the backbone of SwiftUI’s layout system. Additionally, we can manually specify the size we want our view to have using the .frame view modifier. This wraps our view in another view with the specified minimum and maximum size (recall that this does not necessarily mean a UIView is instantiated at runtime). The wrapped view tries to match the container’s size if possible. If the container is smaller or larger than the wrapped view’s minimum / maximum size then the specified alignment is used to place the wrapped view within the container, either extending beyond the container’s bounds or leaving some empty space in the container. Moreover, we can set a fixed width and height which can be interpreted as setting both the minimum and maximum width and height to the specified value. For example, if we change our example from above as follows:

3 rectangles (green, black, purple) are placed next to each other horizontally. They take up only half of the available space. Each rectangle has the same width.

The maxWidth of the HStack is calculated as 300 and it cannot fill the frame we set for it. Accordingly, it is placed within the container according to the .leading alignment we specified.

Lastly, we can set an ideal size. This allows a child to specify a size that can be used by its parent: using the .fixedSize()view modifier is equivalent to using .frame() with the specified ideal size.

State

“A view can never change without also changing the state.”

An important problem in software architecture is how to keep the states of multiple views in sync with each other and the app state. Various solutions to this problem have been proposed and most of them have in common that they utilize some form of “single source of truth”. SwiftUI embraces this concept. There is a single “copy” of each relevant value a view can either be passed that value in its initializer or receive a reference allowing it to also modify that value. Whenever we define a View we essentially define how to map our state to a view. Thus a view can never change without also changing the state. This mapping is reevaluated every time (the relevant parts of) our state changes and this is where the level of abstraction of a SwiftUI view really shows its strength: a SwiftUI View struct is very lightweight and can be easily reconstructed. The actual UIKit view classes that are created by SwiftUI are not recreated but rather SwiftUI is able to update them based on our view structs.

Direct correlation between view and state

In contrast to views in UIKit that can be modified at any point, making it impossible to easily see all the steps involved in obtaining a certain view state, SwiftUI reduces the mental workload by condensing all the necessary information in one place. Consider for example a single row of the word list at the bottom of the Buchstabensalat app: if a word has been found in the grid it is highlighted in yellow and a button appears allowing the user to view a definition of that word. Words that have not been found, yet, are redacted but can be revealed temporarily via a global setting. In SwiftUI this view looks as follows:

Now imagine having to manage these states using UIKit. It would look something like this:

The length of this class alone should be enough to make you want to use SwiftUI but if you now try to figure out what the view looks like after a word has been found and you reveal the all words you easily see the advantage of SwiftUI. In SwiftUI you immediately see that you are in the else branch. You set the background color to yellow and the button is displayed.

In UIKit you first look at the initializer to figure out the basic layout then you need to jump to updateWordFound() the line button.isHidden = !wordFound is a double negative and it always takes me a minute to wrap my head around it. Next, we have to shift our attention to updateRedacted() which is somewhat clunky because of the guard statement and look at the if-construct. Now we notice that this also modifies the label’s background color so we have to go back to updateWordFound() to check whether it makes any difference in which order these methods are called. Later, if we want to change the logic or design for indicating that a word has already been found we have to make sure that these methods remain compatible with each other.

“SwiftUI’s layouting system is much more predictable: what happens to a view as you scale it up or down is well defined and understandable.”

Admittedly, we could use Redux and refactor the state updates into a single method to make it more manageable but we would still not be able to match the brevity and clarity of the SwiftUI implementation.

Conclusion

Much like Swift’s language features prevent you from making some of the mistakes that are common in Objective-C while also improving the readability and structure of your code, SwiftUI takes on some common challenges in UI architecture. While SwiftUI’s layout system takes some getting used to, especially if you are used to the freedom afforded to you by constraint-based layout, it is much more predictable: what happens to a view as you scale it up or down is well defined and understandable. With constraint-based layout it is unclear (and often inconsistent between iOS versions) what happens if you scale your view to a size at which the constraints can no longer be satisfied and you often run into problems even before that point since constraints are often not intuitively understandable.

The second problem we looked at is that of state synchronization. As we have seen, SwiftUI makes the connection between state and view very direct, making sure that state changes affect our view in a consistent manner. This makes our code more easily readable.

Lastly, SwiftUI is more flexible when it comes to how views are represented in the end. Since this blog post looks at SwiftUI from an iOS perspective we haven’t explored how the same view can be presented differently on iOS, macOS, tvOS, and watchOS. However, this may also be relevant purely from an iOS perspective since it gives Apple the freedom to progressively get rid of some of UIKit’s accumulated technical debt or to replace UIKit altogether without breaking existing applications.

. . .

Feel free to like this post, share it, follow me or dreipol on Social Media:

twitter.com/dreipol
medium.com/@nils.becker_40934
www.dreipol.ch

--

--