Some SwiftUI fundamentals

[PDF version unfortunately with some spacing and page splitting problems]

Guy Lapalme, Université de Montréal, October 2023

This document was originally written to help me remember what I consider to be the SwiftUI fundamentals. Finally, it became the tutorial that I would have like to read before working with SwiftUI. I hope it is as useful to others as it has been for me writing it.

Many documents on the web, notably Apple Tutorials, usually demonstrate SwiftUI features by means of examples without never taking time to explain the underlying ideas. If it can be relatively easy, but not always, to build spectacular demos by following the steps of a tutorial, it is not so straightforward to apply these ideas later in one's own application. There are also many commercial or ad-full web sites and videos that explain some ideas, but there are usually limited to a few minutes, even seconds, reading focused on aspects of the language and system1.

A notable exception to this superficial approach was the first edition of Thinking in Swift, which aimed to explain the rationale behind SwiftUI, but even there I found it a bit hard to follow. So I was looking forward for their second edition. Unfortunately, this new edition is even less explicit on the fundamentals than their first one. The authors go to great length to illustrate some aspects, but they skip interesting explanations about the flow of information in SwiftUI that appeared in the first edition. They seem to take for granted that the reader is a seasoned Swift programmer at ease with more advanced features of the language or that they have followed their on-line courses. The main advantage of the second edition is that it explains important changes in the state management that appeared in IOS 17.

Swift is a script-like language combining great insights from years of programming language design. I see it as a Python-like script language with static strong typing à la Haskell. Its designers have also introduced some uncommon features, such as result builders or property wrappers that are heavily used in SwiftUI2. This is why I will spend some time explaining these in this document.

I take it for granted that the reader has already some basic notions of the Swift language and some experience working with Xcode. Link to the Xcode project of the examples in this document. At the beginning of some sections of this document, links to the complete source code for that section are also given.

Main principle: linking states and views

Interactive applications must ensure that the display always reflects the states, sources of truth in the Apple of terminology, that define the system. When the user or an external event modifies states, the display must be updated to reflect these changes. Many frameworks have been developed over the years to help build systems for synchronizing the states and display. The most well known being the Model-View-Controller approach in which the controller updates the view when notified of model changes, usually by means of callback functions.

Recently, a new paradigm has appeared which bypasses the controller, so that the system is defined in terms of a view which is updated automatically when the model changes. One well-known example being React. See this article, for a comparison between SwiftUI and React.

According to this Apple introductory document, you create a lightweight description of your user interface by declaring views in a hierarchy that mirrors the desired layout of your interface. SwiftUI then manages drawing and updating these views in response to events like user input or state changes.

According to Bart Jacobs, SwiftUI provides developers with an API to declare or describe what the user interface should look like. SwiftUI inspects the declaration or description of the user interface and converts it to your application's user interface. A view is no longer the result of a sequence of events. A view is a function of state. In other words, the user interface is a derivative of the application's state. If the application's state changes, the user interface automatically updates, eliminating a whole category of bugs.

To explain how to achieve these laudable goals, I will start by explaining how to build a view from a hierarchy of other views. When developing in SwiftUI with Xcode, views are defined by Swift code which can be executed on the fly in a preview window, so that it is easy to see how it is laid out. Xcode allows building a SwiftUI view by drag-and-drop of code snippets and patterns, but finally it seems simpler to me to type the code directly. Once the views can be defined, I will describe how to synchronize them with system states.

View

View is a predefined Swift type, a protocol, the equivalent to an interface in Java. Remember that SwiftUI View instances, being struct, are immutable, so they must be recreated when some of its content changes. SwiftUI provides a whole gamut of predefined views that can be combined to form new custom views.

Predefined views

Custom views

A new view is defined with a struct declaration that must conform to the View protocol which means that there must be a body property of the type some View of the following form.

Show two dice

[source code]

We begin with an application that displays views without any interaction. The left part of following figure is an example of custom view (ImageNameView) that is used twice within the scaffolding code provided by Xcode with the corresponding preview. It illustrates the following interesting features:

ImageNameView

 

The syntax used for defining the value of the body of a View is defined by a specialized result builder which is a predefined property wrapper. These concepts are interesting in themselves, so I spend some time explaining them independently of SwiftUI. Views also use trailing closures, a notation which might be unfamiliar to programmers in other languages than Swift. I will first present this notation used throughout Swift programs.

Trailing closure

Swift uses closure to designate an anonymous function, often called lambda in other programming languages. A closure is a list of expressions and instructions within curly brackets. When parameters are specified, they are indicated at the beginning followed by the keyword in such as in the following example for computing the sum of squares of two integers and returning an integer. A closure containing a single expression does not need a return keyword in front.

In most contexts, type annotation of parameters and result can be inferred by the Swift compiler, such as in the following expression returning 25

A closure is most often used as a functional parameter for functions like map that transforms all elements of an array to create a new one in with elements of the original list modified by the function. For example, the next expression creates a list of corresponding squares of two lists of integers. It uses zip to build a list of pairs from corresponding elements of two lists.

As the closure is the last (and only) functional parameter of map, the call can also be written as:

Here I use several lines to show how it would appear if the content of the function would be more elaborated. The fact that it appears without the enclosing parentheses is called a trailing closure.

There are also some other goodies for simplifying closures (not only trailing ones) especially in the case of single expressions. Parameters can be referenced implicitly by position with a dollar sign. The above expression could thus be written as:

And even better (or worse !), a closure of the form {$0 op $1}, where op is a binary operator, can be simplified as op, but then it must be used as a parameter not a trailing closure. For example,

Property wrapper

[source code]

A property wrapper is a notation that encapsulates read and write access to a value and adds additional behavior to it. A Swift property is the name for accessing a value in an object, in SwiftUI most often a struct. A property can be either stored (as in most programming languages) or computed which means that the value is recomputed at each time its value is needed with a getter function or changed with a setter function.

I start with a very simple example of a struct for a die whose values must be between 1 and 6. Internally, an instance of Die uses a value between 0 and 5 but this fact is hidden from the user. The concrete value used for computation is the private integer number and the private function limit converts the value to the acceptable range. Die wraps an integer to limit its values between 1 and 6 by taking the value minus one modulo 6 and projects its current value as a string. The value is accessed and modified by the get and set function of the computed property wrappedValue. TheprojectedValue has only a get.

This seemingly convoluted terminology for such a simple application will prove to be useful later. Note that any action could be added to the setter code here, such as accessing a database, validating a data or refreshing a view!

Given this definition, the creation of an instance of this struct would be var d = Die(wrappedValue:10)

Accessing the constrained values would be d.wrappedValue, in this case 4, or d.projectedValue, in this case "four" .

This seems a bit cumbersome, but once this struct is prefixed with @propertyWrapper, the Swift compiler greatly simplifies the access and manipulation because the wrapped integer can now be used like any other integer. So the structure definition would be

The declaration and initialization of an integer of this type become @Die var d = 10

The ampersand before a property wrapper creates an attribute in the Swift terminology, in a way like annotations in other programming languages. Now d , whose value is 4, can be used like any integer in an expression such as d*3+d, the range constraint being applied transparently . The projected value is obtained with $d which returns "four".

So that the compiler knows which variable are wrapped and projected, the variable names wrappedValue and projectedValue must be used in the definition of the struct. As an added bonus, _v gives access to the struct instance itself, although this is seldom needed.

Result builder

[source code]

A result builder is a user-defined type attribute that adds syntax for creating nested data, like a list or tree, in a natural, declarative way. The code that uses the result builder can include ordinary Swift syntax, like if and for, to handle conditional or repeated pieces of data. This notation allows the creation of Domain Specific Languages (DSL), see some spectacular examples. It explains how the body of a SwiftUI view can be considered as a single expression without spurious parentheses, brackets and commas.

I now give an example of a result builder, independently of SwiftUI. It is a command-line application featuring a Blocks world with the following behavior:

Using the result builder explained later this intricate chain of embedded calls can be simplified with the following call that is more intuitive and reminiscent of a SwiftUI view.

A resultBuilder is a predefined property wrapper for building at compile time elaborated data structures or lists of calls. It can be considered as a kind of structured macro system that separates the various components and combines them into calls to the appropriate data structure.

The first thing to define is a resultBuilder structure to deal with a list of statements, conditionals and loops. In our case, all functions of the result builder create a list of Blocks of various forms: variadic parameters in the first case and list of list of blocks in the second and last cases. buildEither calls deal with conditional statements and buildExpression is applied automatically by the resultBuilder interpreter when a single Block is encountered. Definitions for buildEither (lines 13-18) and buildArray (lines 19-21) are optional, but when they are not defined then no conditional statement, nor loop can be used in the calls.

The resulting property wrapper BlocksBuilder can then be used to define the following two functions to create an appropriate list of calls to Blocks. The last parameter is a Swift trailing closure that is called to create the list of blocks that are stacked either to the right for Horiz or to the bottom for Vert. The first parameter is the optional border that is applied.

With these can now write functions to print blocks of numbers, here Ckn in a pyramid such as this

to create a Pascal's triangle

or to print a checkerboard

whose output starts with

Back to SwiftUI

The resultBuilder for creating views in SwiftUI is (appropriately) called ViewBuilder which is seldom called directly. It was defined by SwiftUI designers for declaring functions that create the views to perform layout e.g. VStack, HStack, List , etc. The trailing closure explains the peculiar syntax which also relies on the fact that a function definition containing a single expression does not need to add the return keyword.

Equipped with this specialized result builder, static views can be built, but the key point is how to link this view with an application model. SwiftUI uses again property wrappers which proved to be well adapted for transparently linking view updates with object modifications. Swift being statically typed, SwiftUI designers managed to use types to limit the number of views that need to be recreated when certain variables and objects are changed.

State management system

In SwiftUI, a view is bound to some data (or state) as a source of truth, and automatically updates whenever the state changes. Every view has a state, which can be changed during execution and each time the state is changed, the view is recreated, remember that a view is immutable.

As shown above, annotating a variable as a propertyWrapper allows executing code at each access and or modification. SwiftUI uses this feature to ensure that when a state variable is modified, the view is recreated accordingly to reflect the new value. The code that Swift executes on getter and setter of the state property wrapper is hidden from the user.

SwiftUI provides 17 property wrappers, but I present only the five ones that I consider to be fundamental: @State, @Binding, @StateObject, @ObservedObject, @Environment.

I first describe the first two attributes that are used for local modifications of simple values and use them in a small application. The other three will be used in the next application.

In iOS 17 and macOS 14, the state management API of SwiftUI has changed while still staying compatible with previous versions. As the basic principles stay the same (specifically @State and @Binding), we will first describe the previous API and explain later how the same application can be somewhat simplified with the new API.

Roll two dice

[source code]

The following example, a variation on our first example, shows typical use cases for these two property wrappers. It displays two dice that can be rolled by tapping (or clicking) on their view. The global view displays the total of the two dice as shown in the next figure. The left part shows the initial display and the right part a typical display once the user has tapped (or clicked) at least once on each die.

A DieView defines number as a @State corresponding to the number of dots on the last draw. Conceptually, this could be a simple integer value, but as Swift views are immutable, number must be annotated as a @State which performs the appropriate delegation and linking with the view so that when number is changed, the view is recreated appropriately. number is initially set to 0 and modified in the code associated with the view modifier .onTapGesture in which it is set to a random integer between 1 and 6. Updating the total is done by subtracting the current value to get the value of the other die before adding the new value. The Image and the Text displayed in the view depend on the value of number, so each time a new number is regenerated, the Image and the Text are recreated,

The ContentView stacks two DieViews side by side over a text that displays the total of the two dice when it is not 0. total is a local @State to the ContentView, but it needs to be passed to the two DieViews to be updated. For this, the Binding associated with the State must be retrieved and sent to each DieView. As the projectedValue of a State is a Binding (how convenient!), the binding $total is given to each call to DieView and any modification done to total in a DieView will be reflected in the ContentView to recreate its Text with the new value.

So conceptually a variable with attribute @State is used for storing a local value that is watched by the system to ensure that the display is updated when it is modified. A variable with attribute @Binding has similar properties but it is linked to a @State variable in another context. The Swift implementation ensure that the new view (re)creation process is transparent to the user and efficient. This how the controller of the Model-View-Controller is bypassed, thus simplifying the flow of information, because when the state is changed, the view is automatically changed to reflect the new state.

Solitaire Yahtzee

[source code]

To show use cases of other property wrappers available in SwiftUI, I now present an application for playing a solitaire version of the game of Yahtzee. The objective of the game is to score points by rolling five dice to make certain combinations. The dice are rolled three times in a turn to try to make various scoring combinations. On the second or third turn, the player can fix some dice and roll only the others. After the third roll, the player chooses a scoring category to add to the total score. Once a category has been selected in the game, it cannot be used again. The scoring categories have varying point values, some of which are fixed values and others for which the score depends on the value of the dice. The goal is to score the most points over the 13 turns.

Description of the Yahtzee interface

Yathzee-0 Yathzee-0 Yathzee-0 Yathzee-0

As shown in this figure showing the first turn, the top part is a display of five dice, followed by buttons Roll and C. Below are shown two sections of possible dice combinations, called categories, with subtotal scores. When tapped, the Roll button assign new values to the dice.

The second part on the left of the figure shows the situation once the user has rolled the dice three times. As the three dice in the middle have been fixed by clicking on them, thus changing their color, only the values of the first and last dice have changed. The system displays all possible scores: a Small straight (a list of 4 consecutive values here 1,2,3 and 4) worth 30 points or 2 Threes worth 6 points, an Ace, a Two or Chance which is the sum of all dice values.

In the third part of the figure, the user has chosen the Small straight by tapping on it and its total is added to Lower section total and Grand total. Once a category has been selected, it cannot be selected again. The game continues until all 13 categories have been selected. If at a turn no paying category occurs, one must be chosen anyway which results in a 0 score for this turn.

The last part of the figure shows what happens when the user clicks on the C button for changing the color of the selection. This change of color would also affect fixed dice.

I do not claim that this user interface is the best one or the most intuitive for this game, but it illustrates the use of SwitfUI complex object attributes in a restricted setting. It involves class instances, and not only structs, with methods and properties, these instances being shared between many views.

I first define the roles of three attributes, although the nuances might seem a bit cryptic at this point, I hope that they will become clearer once they are seen in action.

Organization of the application

The main view (ContentView in the usual SwiftUI terminology) is organized as follows from top to bottom

This is coded as follows in the body of the main ContentView. The other Views will be explained later.

The internal states (sources of truth) for this application are kept in instances of two classes:

[source code]

[source code]

As properties of instances of these classes will be shared among many views, they cannot be annotated as @State with the corresponding @Binding which can only be used for simple values. So these classes inherit from the ObservableObject protocol. This implies that the programmer must ensure that the objectWillChange method is called on any modification of the object. But thanks to the @Published attribute, this happens automatically when references are made to this object. So the Dice class publishes its dice and fixed properties while the Section class publishes its scores and selected properties that need to be used externally. Note the names of the section are not needed outside the Section, so this list is not marked as an attribute, but as an ordinary local variable to the class instance.

Instances of these classes will be used in specialized views. Here is the DiceView which itself uses a view for a single die. [source code]

The DiceView gets a Dice object as a parameter and creates a list of DieViews. As the Dice instance is an ObservableObject, it must be marked with the attribute @ObservedObject. In DieView, value and fixed define the state of a die, but it must be used outside, so they are annotated with @Binding. In DiceView, all DieViews are created by passing the binding for each Die thus the dollar sign in front. The property selectColor will be dealt with later.

Viewing a section composed of categories is as follows [source code]

A CategoryView displays its kind (a String) with the corresponding score (a dash if is negative). Its value is not modified inside this view, but it can be modified by the parent view (see function update_totals, lines 36-46 of next listing), so it is annotated as @Binding. SectionView gets a Section instance, an ObservableObject, as a parameter so its declaration must be annotated with @ObservedObject. Once the selection has been done, the scores must be updated in the main view by the caller. These actions are wrapped in a closure parameter3. The body of the SectionView creates a CategoryView for each name of its section. When it is tapped, the category is marked as selected and its background color is changed.

With these views, the application is built as follows [source code] [source code of yahtzeeScores ].

The status of the system is kept in the following simple variables (of value type in Swift terminology) annotated as @State :

There are three @StateObjects, ignoring selectColor for the moment:

The body of the View is vertical stacking of 8 views, the first one being a line with the dice and two buttons. The action associated with the first button tracks the number of rolls and displays the scores at the third. The second button, used to change the color of the selection, will be presented later.

Then are added the upper and lower sections followed by two CategoryViews for their partial scores. When all categories are fixed, then a Text is added at the bottom.

The code for the show_scores and update_totals is straightforward is not discussed here as I want to focus on the SwiftUI interface aspects.

Accessing the global environment

The Environment is a mechanism used by SwiftUI to propagate values from a View to its descendants. It is used implicitly for handling some methods that are available on all view types. When these types of function are called their effect is to change a value that is available not only in the current view but also in all embedded views of this one. There are about 50 predefined EnvironmentValues dealing with global objects (e.g. locale, timeZone), characteristics of the display (e.g. colorScheme), text styles (e.g. font, lineSpacing), view attributes (e.g. backgroundStyle).

But it is also possible to define custom environment values to transfer information from one view to another without passing them as parameters as this can become cumbersome in complex applications.

In my simple application, this is probably an overkill, but I want to give an example of the use of an environment object. I define a color used for a fixed die or for a selected category (see the last part of the last figure). This color can be changed by clicking the C button. There are five choices of color that are selected in rotation. The environment is an instance of an ObservableObject with @Published properties and kept in the ContentView as a @StateObject. The object tracking the current color is follows [source code]

For setting the environment, an instance of this class is given as a parameter to the global view modifier environmentObject (see line 24 of ContentView). When the user taps on the C button, the nextColor method of this object is called to change the value of the @Published variable color. This new color is used in the current view for setting the color of the text for the end of game, but also in the DieView and SectionView which refer to this environment object with the following attribute.

In the code of the views, the color itself is referred to selectColor.color. So when the value in the environment object is changed, this value is propagated to all views in the hierarchy. As for global variables, this is useful for making changes to the whole application, but it should be used parsimoniously at it implies recreating many views.

Changes for iOS 17

[source code] file names are the same as the previous ones, but suffixed by _17

Starting with iOS 17 and macOS 14, SwiftUI provides support for Observation, a Swift-specific implementation of the observer design pattern implemented by means of macros that modify the abstract syntax tree of the program. The compiler can then track changes in objects and in the environment. This migration guide illustrates the changes on a simple example.

In our case, the following changes are made for IOS 17:

The logic of the application is not changed, the main advantage is that any state is annotated the same independently of the fact that it is a single variable or an object. It is the job of the compiler to detect changes in the object and redraw only the necessary views

Conclusion

This document has presented small SwiftUI applications featuring some aspects of SwiftUI. After presenting how SwiftUI differs from the usual approach to developing interactive applications, some unusual features of Swift such as trailing closure, property wrapper and result builder, were first described as they are used extensively to define views in SwiftUI. State management and the associated bindings being at the core of SwiftUI, they were first illustrated with a single view application. Then more complex state management attributes were presented and put in action in an application using multiple objects and views.

This document does not deal with many aspects of SwiftUI such as scene management, animation or navigation lists, but I feel it gives the necessary tools to tackle them, because state management is at the core of SwiftUI.

References

Appendix: Useful tricks

Here is a list of personal non-obvious techniques that I found useful during my SwiftUI development.

The function is used like this to print out the view’s type when the body gets executed:

var body: some View { VStack { */\*... \*/* }.debug()}

Insert print in a View body while ignoring the Void return type...

let _ = print("Executing <MyView> body") // print when the body is re-executed let _ = Self._printChanges() // print why the body is re-executed

 


1 My text editor claims that this document is less than a 20-minute read, but it surely does not consider making sense of the computer code...
2 I often wonder if some of these features were not introduced primarily for the sake of SwiftUI.
3 In principle, updating scores could have been done with a SwiftUI simultaneous gesture, but in SwiftUI the gesture of the caller is executed before the one of the callee, a dubious choice... This is not what is needed in my case because the selection must be considered for computing the totals before clearing them. I would have needed to bubble in JavaScript parlance, this explains the callback trick.