SwiftUI Observable and Environment Objects – A Tutorial

The chapter entitled “SwiftUI State Properties, Observable, State and Environment Objects” introduced the concept of observable and environment objects and explained how these are used to implement a data driven approach to app development in SwiftUI.

This chapter will build on the knowledge from the earlier chapter by creating a simple example project that makes use of both observable and environment objects.

About the ObservableDemo Project

Observable objects are particularly powerful when used to wrap dynamic data (in other words, data values that change repeatedly). To simulate data of this type, an observable data object will be created which makes use of the Foundation framework Timer object configured to update a counter once every second. This counter will be published so that it can be observed by views within the app project.

Initially, the data will be treated as an observable object and passed from one view to another. Later in the chapter, the data will be converted to an environment object so that it can be accessed by multiple views without being passed between views.

Creating the Project

Launch Xcode and select the option to create a new Multiplatform App project named ObservableDemo.

Adding the Observable Object

The first step after creating the new project is to add a data class implementing the ObservableObject protocol. Within Xcode, select the File -> New -> File… menu option and, in the resulting template dialog, select the Swift File option. Click the Next button and name the file TimerData before clicking the Create button.

With the TimerData.swift file loaded into the code editor, implement the TimerData class as follows:

import Foundation
import Combine
 
class TimerData : ObservableObject {
    
    @Published var timeCount = 0
    var timer : Timer?
    
    init() {    
        timer = Timer.scheduledTimer(timeInterval: 1.0, target: self, selector: #selector(timerDidFire), userInfo: nil, repeats: true)
    }
    
    @objc func timerDidFire() {
        timeCount += 1
    }
    
    func resetCount() {
        timeCount = 0
    }
}

The class is declared as implementing the ObservableObject protocol and contains an initializer which configures a Timer instance to call a function named timerDidFire() once every second. The timerDidFire() function, in turn, increments the value assigned to the timeCount variable. The timeCount variable is declared using the @Published property wrapper so that it can be observed from within views elsewhere in the project. The class also includes a method named resetCount() to reset the counter to zero.

Designing the ContentView Layout

The user interface for the app will consist of two screens, the first of which will be represented by the ContentView. Swift file. Select this file to load it into the code editor and modify it so that it reads as follows:

import SwiftUI
 
struct ContentView: View {
    
    @StateObject var timerData: TimerData = TimerData()
    
    var body: some View {
        
        NavigationView {
            VStack {
                Text("Timer count = \(timerData.timeCount)")
                
                    .font(.largeTitle)
                    .fontWeight(.bold)
                    .padding()
                
                Button(action: resetCount) {
                    Text("Reset Counter")
                }
            }
        }
    }
 
    func resetCount() {
        timerData.resetCount()
    }
}
 
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

With the changes made, use the Live Preview button to test the view. Once the Live Preview starts, the counter should begin incrementing:

Figure 25-1

Next, click on the Reset Counter button and verify that the counter restarts counting from zero. Now that the initial implementation is working, the next step is to add a second view which will also need access to the same observable object.

Adding the Second View

Select the File -> New -> File… menu option, this time choosing the SwiftUI View template option and naming the view SecondView. Edit the SecondView.swift file so that it reads as follows:

import SwiftUI
 
struct SecondView: View {
    
    @StateObject var timerData: TimerData
    
    var body: some View {
        
        VStack {
            Text("Second View")
                .font(.largeTitle)
            Text("Timer Count = \(timerData.timeCount)")
                .font(.headline)
        }
        .padding()
    }
}
 
struct SecondView_Previews: PreviewProvider {
    static var previews: some View {
        SecondView(timerData: TimerData())
    }
}

Use Live Preview to test that the layout matches Figure 25-2 and that the timer begins counting.

In the Live Preview, the view has its own instance of TimerData which was configured in the SecondView_ Previews declaration. To make sure that both ContentView and SecondView are using the same TimerData instance, the observed object needs to be passed to the SecondView when the user navigates to the second screen.

Figure 25-2

Adding Navigation

A navigation link now needs to be added to ContentView and configured to navigate to the second view. Open the ContentView.swift file in the code editor and add this link as follows:

var body: some View {
    
    NavigationView {
        VStack {
            Text("Timer count = \(timerData.timeCount)")
            
                .font(.largeTitle)
                .fontWeight(.bold)
                .padding()
            
            Button(action: resetCount) {
                Text("Reset Counter")
            }
            
            NavigationLink(destination: 
                       SecondView(timerData: timerData)) {
                Text("Next Screen")
            }
            .padding()
        }
    }
}

Once again using Live Preview, test the ContentView and check that the counter increments. Taking note of the current counter value, click on the Next Screen link to display the second view and verify that counting continues from the same number. This confirms that both views are subscribed to the same observable object instance.

Using an Environment Object

The final step in this tutorial is to convert the observable object to an environment object. This will allow both views to access the same TimerData object without the need for a reference to be passed from one view to the other.

This change does not require any modifications to the TimerData.swift class declaration and only minor changes are needed within the two SwiftUI view files. Starting with the ContentView.swift file, modify the navigation link destination so that timerData is no longer passed through to SecondView. Also add a call to the environmentObject() modifier to insert the timerData instance into the view hierarchy environment:

import SwiftUI
 
struct ContentView: View {
    
    @StateObject var timerData: TimerData = TimerData()
        
    var body: some View {
        
        NavigationView {
.
.
                NavigationLink(destination: SecondView(timerData: timerData)) {
                    Text("Next Screen")
                }
                .padding()
            }
        }
        .environmentObject(timerData)
    }
.
.
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

Next, modify the SecondView.swift file so that it reads as follows:

import SwiftUI
 
struct SecondView: View {
    
    @EnvironmentObject var timerData: TimerData
    
    var body: some View {
        
        VStack {
            Text("Second View")
                .font(.largeTitle)
            Text("Timer Count = \(timerData.timeCount)")
                .font(.headline)
        }.padding()
    }
}
 
struct SecondView_Previews: PreviewProvider {
    static var previews: some View {
        SecondView().environmentObject(TimerData())
    }
}

Test the project one last time, either using Live Preview or by running on a physical device or simulator and check that both screens are accessing the same counter data via the environment.

Summary

This chapter has worked through a tutorial that demonstrates the use of observed and environment objects to bind dynamic data to the views in a user interface, including implementing an observable object, publishing a property, subscribing to an observable object and the use of environment objects.

SwiftUI Concurrency and Lifecycle Event Modifiers

One of the key strengths of SwiftUI is that, through the use of features such as views, state properties, and observable objects, much of the work required in making sure an app handles lifecycle changes correctly is performed automatically.

It is still often necessary, however, to perform additional actions when certain lifecycle events occur. An app might, for example, need to perform a sequence of actions at the point that a view appears or disappears within a layout. Similarly, an app may need to execute some code each time a value changes or to detect when a view becomes active or inactive. It will also be a common requirement to launch one or more asynchronous tasks at the beginning of a view lifecycle.

All of these requirements and more can be met by making use of a set of event modifiers provided by SwiftUI.

Since event modifiers are best understood when seen in action, this chapter will create a project which makes use of the four most commonly used modifiers.

Creating the LifecycleDemo Project

Launch Xcode and select the option to create a new Multiplatform App project named LifecycleDemo.

Designing the App

Begin by editing the ContentView.swift file and modifying the body declaration so that it reads as follows:

import SwiftUI
 
struct ContentView: View {
    
    var body: some View {
        TabView {
            TabView {
                FirstTabView()
                    .tabItem {
                        Image(systemName: "01.circle")
                        Text("First")
                    }
 
                SecondTabView()
                    .tabItem {
                        Image(systemName: "02.circle")
                        Text("Second")
                    }
            }
        }
    }
}

Select the Xcode File -> New -> File… menu option and in the resulting template panel, select the SwiftUI View option from the User Interface section as shown in Figure 26-1 below:

Figure 26-1

Click the Next button, name the file FirstTabView.swift, and select the Shared folder as the save location before clicking on the Create button. With the new file loaded into the editor, change the Text view to read “View One”.

Repeat the above steps to create a second SwiftUI view file named SecondTabView.swift with the Text view set to “View Two”

The onAppear and onDisappear Modifiers

The most basic and frequently used modifiers are onAppear() and onDisappear(). When applied to a view, these modifiers allow actions to be performed at the point that the view appears or disappears. Within the FirstTabView.swift file, add both modifiers to the Text view as follows:

import SwiftUI
 
struct FirstTabView: View {
   
    var body: some View {
 
        Text("View One")
            .onAppear(perform: {
                print("onAppear triggered")
            })
            .onDisappear(perform: {
                print("onDisappeared triggered")
            })
    }
}

Using Live Preview in debug mode, test the app and note that the diagnostic output appears in the console panel when the app first appears (if the output does not appear, try running the app on a device or simulator). Click on the second tab to display SecondTabView at which point the onDisappear modifier will be triggered. Display the first tab once again and verify that the onAppear diagnostic is output to the console.

The onChange Modifier

In basic terms, the onChange() modifier should be used when an action needs to be performed each time a state changes within an app. This, for example, allows actions to be triggered each time the value of a state property changes. As we will explore later in the chapter, this modifier is also particularly useful when used in conjunction with the ScenePhase environment property.

To experience the onChange() modifier in action, begin by editing the SecondTabView.swift file so that it reads as follows:

import SwiftUI
 
struct SecondTabView: View {
    
    @State private var text: String = ""
    
    var body: some View {
        TextEditor(text: $text)
            .padding()
            .onChange(of: text, perform: { value in
                print("onChange triggered")
            })
    }
}
 
struct SecondTabView_Previews: PreviewProvider {
    static var previews: some View {
        SecondTabView()
    }
}

Test the app again and note that the event is triggered for each keystroke within the TextEditor view.

ScenePhase and the onChange Modifier

ScenePhase is an @Environment property that is used by SwiftUI to store the state of the current scene. When changes to ScenePhase are monitored by the onChange() modifier, an app can take action, for example, when the scene moves between the foreground and background or when it becomes active or inactive. This technique can be used on any view or scene but is also useful when applied to the App declaration. For example, edit the LifecycleDemoApp.swift file and modify it so that it reads as follows:

import SwiftUI
 
@main
struct LifecycleDemoApp: App {
    
    @Environment(\.scenePhase) private var scenePhase
    
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .onChange(of: scenePhase, perform: { phase in
                switch phase {
                    case .active:
                        print("Active")
                    case .inactive:
                        print("Inactive")
                    case .background:
                        print("Background")
                    default:
                        print("Unknown scenephase")
                }
            })
    }
}

When applied to the window group in this way, the scene phase will be based on the state of all scenes within the app. In other words, the phase will be set to active if any scene is currently active and will only be set to inactive when all scenes are inactive.

When applied to an individual view, on the other hand, the phase state will reflect only that of the scene in which the view is located. The modifier could, for example, have been applied to the content view instead of the window group as follows:

.
.
var body: some Scene {
    WindowGroup {
        ContentView()
            .onChange(of: scenePhase, perform: { phase in
.
.
 
    }
.
.

Run the app on a device or simulator and place the app into the background. The console should show that the scene phase changed to inactive state followed by the background phase. On returning the app to the foreground the active phase will be entered. The three scene phases can be summarized as follows:

  • active – The scene is in the foreground, visible, and responsive to user interaction.
  • inactive –The scene is in the foreground and visible to the user but not interactive.
  • background – The scene is not visible to the user.

Launching Concurrent Tasks

The chapter entitled “An Overview of Swift Structured Concurrency” covered the topic of structured concurrency in Swift but did not explain how asynchronous tasks can be launched in the context of a SwiftUI view. In practice, all of the techniques described in that earlier chapter still apply when working with SwiftUI. All that is required is a call to the task() modifier on a view together with a closure containing the code to be executed. This code will be executed within a new concurrent task at the point that the view is created. We can, for example, modify the FirstTabView to display a different string on the Text view using an asynchronous task:

import SwiftUI
 
struct FirstTabView: View {
    
    @State var title = "View One"
    
    var body: some View {
        Text(title)
            .onAppear(perform: {
                print("onAppear triggered")
            })
            .onDisappear(perform: {
                print("onDisappeared triggered")
            })
            .task(priority: .background) {
                title = await changeTitle()
            }
 
    }
    
    func changeTitle() async -> String {
        Thread.sleep(forTimeInterval: 5)
        return "Async task complete"
    }
}
 
struct FirstTabView_Previews: PreviewProvider {
    static var previews: some View {
        FirstTabView()
    }
}

When the view is created, a task is launched with an optional priority setting. The task calls a function named changeTitle() and then waits for the code to execute asynchronously.

The changeTitle() function puts the thread to sleep for 5 seconds to simulate a long-running task before returning a new title string. This string is then assigned to the title state variable where it will appear on Text view. Build and run the app and verify that the tabs remain responsive during the 5-second delay and that the new title appears on the first tab.

Summary

SwiftUI provides a collection of modifiers designed to allow actions to be taken in the event of lifecycle changes occurring in a running app. The onAppear() and onDisappear() modifiers can be used to perform actions when a view appears or disappears from view within a user interface layout. The onChange() modifier, on the other hand, is useful for performing tasks each time the value assigned to a property changes.

The ScenePhase environment property, when used with the onChange() modifier, allows an app to identify when the state of a scene changes. This is of particular use when an app needs to know when it moves between foreground and background modes. Asynchronous tasks can be launched when a view is created using the task() modifier.

An Introduction to Swift Actors

Structured concurrency in Swift provides a powerful platform for performing multiple tasks at the same time, greatly increasing app performance and responsiveness. One of the downsides of concurrency is that it can lead to problems when multiple tasks access the same data concurrently, and that access includes a mix of reading and writing operations. This type of problem is referred to as a data race and can lead to intermittent crashes and unpredictable app behavior.

In the previous chapter, we looked at a solution to this problem that involved sequentially processing the results from multiple concurrent tasks. Unfortunately, that solution only really works when the tasks involved all belong to the same task group. A more flexible solution and one that works regardless of where the concurrent tasks are launched involves the use of Swift actors.

An Overview of Actors

Actors are a Swift type that controls asynchronous access to internal mutable state so that only one task at a time can access data. Actors are much like classes in that they are a reference type and contain properties, initializers, and methods. Like classes, actors can also conform to protocols and be enhanced using extensions. The primary difference when declaring an actor is that the word “actor” is used in place of “class”.

Declaring an Actor

A simple Swift class that contains a property and a method might be declared as follows:

class BuildMessage {
    
    var message: String = ""
    let greeting = "Hello"
    
    func setName(name: String) {
        self.message = "\(greeting) \(name)"
    }
}

To make the class into an actor we just need to change the type declaration from “class” to “actor”:

actor BuildMessage {
    
    var message: String = ""
    let greeting = "Hello"
    
    func setName(name: String) {
        self.message = "\(greeting) \(name)"
    }
}

Once declared, actor instances are created in the same way as classes, for example:

let hello = BuildMessage()

A key difference between classes and actors, however, is that actors can only be created and accessed from within an asynchronous context, such as within an async function or Task closure. Also, when calling an actor method or accessing a property, the await keyword must be used, for example:

func someFunction() async {
    let builder = BuildMessage()
    await builder.setName(name: "Jane Smith")
    let message = await builder.message
    print(message)
}

Understanding Data Isolation

The data contained in an actor instance is said to be isolated from the rest of the code in the app. This isolation is imposed in part by ensuring that when a method that changes the instance data (in this case, changing the name variable) is called, the method is executed to completion before the method can be called from anywhere else in the code. This prevents multiple tasks from concurrently attempting to change the data. This, of course, means that method calls and property accesses may have to wait until a previous task has been handled, hence the need for the await statement.

Isolation also prevents code from directly changing the mutable internal properties of an actor. Consider, for example, the following code to directly assign a new value to the message property of our BuildMessage instance.

builder.message = "hello"

Though valid when working with class instances, the above code will generate the following error when attempted on an actor instance:

Actor-isolated property ‘message’ can not be mutated from a non-isolated context

By default, all methods and mutable properties within an actor are considered to be isolated and, as such, can only be called using the await keyword. Actor methods that do not access mutable properties may be excluded from isolation using the nonisolated keyword. Once declared in this way, the method can be called without the await keyword, and also called from synchronous code. We can, for example, add a nonisolated method to our BuildMessage actor that returns the greeting string:

actor BuildMessage {
    
    var message: String = ""
    let greeting = "Hello"
    
    func setName(name: String) {
        self.message = "\(greeting) \(name)"
    }
    
    nonisolated func getGreeting() -> String {
        return greeting
    }
}

This new method can be called without the await keyword from both synchronous and asynchronous contexts:

var builder = BuildMessage()
 
func asyncFunction() async {
    let greeting = builder.getGreeting()
    print(greeting)
}
 
func syncFunction() {
    let greeting = builder.getGreeting()
    print(greeting)
}

It is only possible to declare getGreeting() as nonisolated because the method only accesses the immutable greeting property. If the method attempted to access the mutable message property, the following error would be reported:

Actor-isolated property 'message' can not be referenced from a non-isolated context

Note that although immutable properties are excluded from isolation by default, the nonisolated keyword may still be declared for clarity:

nonisolated let greeting = "Hello"

A Swift Actor Example

In the previous chapter, we looked at data races and explored how the compiler will prevent us from writing code that could cause one with the following error message:

Mutation of captured var ‘timeStamps’ in concurrently-executing code

We encountered this error when attempting to write entries to a dictionary object using the following asynchronous code:

func doSomething() async {
 
    var timeStamps: [Int: Date] = [:]
    
    await withTaskGroup(of: Void.self) { group in
        for i in 1...5 {
            group.addTask {
                timeStamps[i] = await takesTooLong()
            }
        }
    }
}

One option to avoid this problem, and the one implemented in the previous chapter, was to process the results from the asynchronous tasks sequentially using a for-await loop. As we have seen in this chapter, however, the problem could also be resolved by making use of an actor.

With the ConcurrencyDemo project loaded into Xcode, edit the ContentView.swift file to add the following actor declaration is used to encapsulate the timeStamps dictionary and to provide a method via which data can be added:

import SwiftUI
 
actor TimeStore {
    
    var timeStamps: [Int: Date] = [:]
    
    func addStamp(task: Int, date: Date) {
        timeStamps[task] = date
    }
}
.
.

Having declared the actor, we can now modify the doSomething() method to add new timestamps via the addStamp() method:

func doSomething() async {
 
    let store = TimeStore()
    
    await withTaskGroup(of: Void.self) { group in
        for i in 1...5 {
            group.addTask {
                await store.addStamp(task: i, date: await takesTooLong())
            }
        }
    }
 
    for (task, date) in await store.timeStamps {
            print("Task = \(task), Date = \(date)")
    }
}
 
func takesTooLong() async -> Date {
    Thread.sleep(forTimeInterval: 5)
    return Date()
}

With these changes made, the code should now compile and run without error.

Introducing the MainActor

In the chapter entitled An Overview of Swift Structured Concurrency, we talked about the main thread (or main queue) and explained how it is responsible both for handling the rendering of the UI and also responding to user events. We also demonstrated the risks of performing thread blocking tasks on the main thread and how doing so can cause the running program to freeze. As we have also seen, Swift provides a simple and powerful mechanism for running tasks on separate threads from the main thread. What we have not mentioned yet is that it is also essential that updates to the UI are only performed on the main thread. Performing UI updates on any separate thread other than the main thread is likely to cause instability and unpredictable app behavior that can be difficult to debug.

Within Swift, the main thread is represented by the main actor. This is referred to as a global actor because it is An Introduction to Swift Actors accessible throughout your program code when you need code to execute on the main thread.

When developing your app, situations may arise where you have code that you want to run on the main actor, particularly if that code updates the UI in some way. In this situation, the code can be marked using the @ MainActor attribute. This attribute may be used on types, methods, instances, functions, and closures to indicate that the associated operation must be performed on the main actor. We could, for example, configure a class so that it operates only on the main thread:

@MainActor
class TimeStore {
    
    var timeStamps: [Int: Date] = [:]
    
    func addStamp(task: Int, date: Date) {
        timeStamps[task] = date
    }
}

Alternatively, a single value or property can be marked as being main thread dependent:

class TimeStore {
    
    @MainActor var timeStamps: [Int: Date] = [:]
 
    func addStamp(task: Int, date: Date) {
        timeStamps[task] = date
    }
}

Of course, now that the timeStamps dictionary is assigned to the main actor, it cannot be accessed on any other thread. The attempt to add a new date to the dictionary in the above addStamp() method will generate the following error:

Property 'timeStamps' isolated to global actor ‘MainActor’ can not be mutated from this context

To resolve this issue, the addStamp() method must also be marked using the @MainActor attribute:

@MainActor func addStamp(task: Int, date: Date) {
    timeStamps[task] = date
}

The run method of the MainActor can also be called directly from within asynchronous code to perform tasks on the main thread as follows:

func runExample() async {
 
    await MainActor.run {
        // Perform tasks on main thread
    }
}

Summary

A key part of writing asynchronous code is avoiding data races. A data race occurs when two or more tasks access the same data and at least one of those tasks performs a write operation. This can cause data inconsistencies where the concurrent tasks see and act on different versions of the same data.

A useful tool for avoiding data races is the Swift Actor type. Actors are syntactically and behaviorally similar to Swift classes but differ in that the data they encapsulate is said to be isolated from the rest of the code in the app. If a method within an actor that changes instance data is called, that method is executed to completion before the method can be called from anywhere else in the code. This prevents multiple tasks from concurrently attempting to change the data. Actor method calls and property accesses must be called using the await keyword.

The main actor is a special actor that provides access to the main thread from within asynchronous code. The @ MainActor attribute can be used to mark types, methods, instances, functions, and closures to indicate that the associated task must be performed on the main thread.

An Overview of Swift Structured Concurrency

Concurrency can be defined as the ability of software to perform multiple tasks in parallel. Many app development projects will need to make use of concurrent processing at some point and concurrency is essential for providing a good user experience. Concurrency, for example, is what allows the user interface of an app to remain responsive while performing background tasks such as downloading images or processing data.

In this chapter, we will explore the structured concurrency features of the Swift programming language and explain how these can be used to add multi-tasking support to your app projects.

An Overview of Threads

Threads are a feature of modern CPUs and provide the foundation of concurrency in any multitasking operating system. Although modern CPUs can run large numbers of threads, the actual number of threads that can be run in parallel at any one time is limited by the number of CPU cores (depending on the CPU model, this will typically be between 4 and 16 cores). When more threads are required than there are CPU cores, the operating system performs thread scheduling to decide how the execution of these threads is to be shared between the available cores.

Threads can be thought of as mini-processes running within a main process, the purpose of which is to enable at least the appearance of parallel execution paths within application code. The good news is that although structured concurrency uses threads behind the scenes, it handles all of the complexity for you and you should never need to interact with them directly.

The Application Main Thread

When an app is first started, the runtime system will typically create a single thread in which the app will run by default. This thread is generally referred to as the main thread. The primary role of the main thread is to handle the user interface in terms of UI layout rendering, event handling, and user interaction with views in the user interface.

Any additional code within an app that performs a time-consuming task using the main thread will cause the entire application to appear to lock up until the task is completed. This can be avoided by launching the tasks to be performed in separate threads, allowing the main thread to continue unhindered with other tasks.

Completion Handlers

As outlined in the chapter entitled “Swift Functions, Methods and Closures”, Swift previously used completion handlers to implement asynchronous code execution. In this scenario, an asynchronous task would be started and a completion handler assigned to be called when the task finishes. In the meantime, the main app code would continue to run while the asynchronous task is performed in the background. On completion of the asynchronous task, the completion handler would be called and passed any results. The body of the completion handler would then execute and process those results.

Unfortunately, completion handlers tend to result in complex and error-prone code constructs that are difficult to write and understand. Completion handlers are also unsuited to handling errors thrown by the asynchronous tasks and generally result in large and confusing nested code structures.

Structured Concurrency

Structured concurrency was introduced into the Swift language with Swift version 5.5 to make it easier for app developers to implement concurrent execution safely, and in a way that is logical and easy to both write and understand. In other words, structured concurrency code can be read from top to bottom without having to jump back to completion handler code to understand the logic flow. Structured concurrency also makes it easier to handle errors thrown by asynchronous functions.

Swift provides several options for implementing structured concurrency, each of which will be introduced in this chapter.

Preparing the Project

Launch Xcode and select the option to create a new Multiplatform App project named ConcurrencyDemo. Once created, edit the ContentView.swift file so that it reads as follows:

import SwiftUI
 
struct ContentView: View {
    var body: some View {
        Button(action: {
            doSomething()
        }) {
            Text("Do Something")
        }
    }
    
    func doSomething() {
    }
 
    func takesTooLong() {
    }    
}
 
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

The above changes create a Button view configured to call the doSomething() function when clicked. In the remainder of this chapter, we will make changes to this template code to demonstrate structured concurrency in Swift.

Non-Concurrent Code

Before exploring concurrency, we will first look at an example of non-concurrent code (also referred to as synchronous code) execution. Begin by adding the following code to the two stub functions. The changes to the doSomething() function print out the current date and time before calling the takesTooLong() function. Finally, the date and time are output once again before the doSomething() function exits.

The takesTooLong() function uses the sleep() method of the Thread object to simulate the effect of performing a time consuming task that blocks the main thread until it is complete before printing out another timestamp:

func doSomething() {
    print("Start \(Date())")
    takesTooLong()
    print("End \(Date())")
}
 
func takesTooLong() {
    Thread.sleep(forTimeInterval: 5)
    print("Async task completed at \(Date())")
}

Run the app on a device or simulator and click on the “Do Something” button. Output similar to the following should appear in the Xcode console panel:

Start 2022-03-29 17:55:16 +0000
Async task completed at 2022-03-29 17:55:21 +0000
End 2022-03-29 17:55:21 +0000

The key point to note in the above timestamps is that the end time is 5 seconds after the start time. This tells us not only that the call to takesTooLong() lasted 5 seconds as expected, but that any code after the call was made within the doSomething() function was not able to execute until after the call returned. During that 5 seconds, the app would appear to the user to be frozen. The answer to this problem is to implement a Swift async/await concurrency structure.

Introducing async/await Concurrency

The foundation of structured concurrency is the async/await pair. The async keyword is used when declaring a function to indicate that it is to be executed asynchronously relative to the thread from which it was called. We need, therefore, to declare both of our example functions as follows (any errors that appear will be addressed later):

func doSomething() async {
    print("Start \(Date())")
    takesTooLong()
    print("End \(Date())")
}
 
func takesTooLong() async {
    Thread.sleep(forTimeInterval: 5)
    print("Async task completed at \(Date())")
}

Marking a function as async achieves several objectives. First, it indicates that the code in the function needs to be executed on a different thread to the one from which it was called. It also notifies the system that the function itself can be suspended during execution to allow the system to run other tasks. As we will see later, these suspend points within an async function are specified using the await keyword.

Another point to note about async functions is that they can generally only be called from within the scope of other async functions though, as we will see later in the chapter, the Task object can be used to provide a bridge between synchronous and asynchronous code. Finally, if an async function calls other async functions, the parent function cannot exit until all child tasks have also completed.

Most importantly, once a function has been declared as being asynchronous, it can only be called using the await keyword. Before looking at the await keyword, we need to understand how to call async functions from synchronous code.

Asynchronous Calls from Synchronous Functions

The rules of structured concurrency state that an async function can only be called from within an asynchronous context. If the entry point into your program is a synchronous function, this raises the question of how any async functions can ever get called. The answer is to use the Task object from within the synchronous function to launch the async function. Suppose we have a synchronous function named main() from which we need to call one of our async functions and attempt to do so as follows:

func main() {
    doSomething()
}

The above code will result in the following error notification in the code editor:

'async' call in a function that does not support concurrency

The only options we have are to make main() an async function or to launch the function in an unstructured task. Assuming that declaring main() as an async function is not a viable option in this case, the code will need to be changed as follows:

func main() {
    Task {
        await doSomething()
    }
}

The await Keyword

As we have previously discussed, the await keyword is required when making a call to an async function and can only usually be used within the scope of another async function. Attempting to call an async function without the await keyword will result in the following syntax error:

Expression is 'async' but is not marked with 'await'

To call the takesTooLong() function, therefore, we need to make the following change to the doSomething() function:

func doSomething() async {
    print("Start \(Date())")
    await takesTooLong()
    print("End \(Date())")
}

One more change is now required because we are attempting to call the async doSomething() function from a synchronous context (in this case the action closure of the Button view). To resolve this, we need to use the Task object to launch the doSomething() function:

var body: some View {
    Button(action: {
        Task {
            await doSomething()
        }
    }) {
        Text("Do Something")
    }
}

When tested now, the console output should be similar to the following:

Start 2022-03-30 13:27:42 +0000
Async task completed at 2022-03-30 13:27:47 +0000
End 2022-03-30 13:27:47 +0000

This is where the await keyword can be a little confusing. As you have probably noticed, the doSomething() function still had to wait for the takesTooLong() function to return before continuing, giving the impression that the task was still blocking the thread from which it was called. In fact, the task was performed on a different thread, but the await keyword told the system to wait until it completed. The reason for this is that, as previously mentioned, a parent async function cannot complete until all of its sub-functions have also completed. The call, therefore, has no choice but to wait for the async takesTooLong() function to return before executing the next line of code. In the next section, we will explain how to defer the wait until later in the parent function using the async-let binding expression. Before doing so, however, we need to look at another effect of using the await keyword in this context.

In addition to allowing us to make the async call, the await keyword has also defined a suspend point within the doSomething() function. When this point is reached during execution, it tells the system that the doSomething() function can be temporarily suspended and the thread on which it is running used for other purposes. This allows the system to allocate resources to any higher priority tasks and will eventually return control to the doSomething() function so that execution can continue. By marking suspend points, the doSomething() function is essentially being forced to be a good citizen by allowing the system to briefly allocate processing resources to other tasks. Given the speed of the system, it is unlikely that a suspension will last more than fractions of a second and will not be noticeable to the user while benefiting the overall performance of the app.

Using async-let Bindings

In our example code, we have identified that the default behavior of the await keyword is to wait for the called function to return before resuming execution. A more common requirement, however, is to continue executing code within the calling function while the async function is executing in the background. This can be achieved by deferring the wait until later in the code using an async-let binding. To demonstrate this, we first need to modify our takesTooLong() function to return a result (in this case our task completion timestamp):

func takesTooLong() async -> Date {
    Thread.sleep(forTimeInterval: 5)
    return Date()
}

Next we need to change the call within doSomething() to assign the returned result to a variable using a let expression but also marked with the async keyword:

func doSomething() async {
    print("Start \(Date())")
    async let result = takesTooLong()
    print("End \(Date())")
}

Now all we need to do is specify where within the doSomething() function we want to wait for the result value to be returned. We do this by accessing the result variable using the await keyword. For example:

func doSomething() async {
    print("Start \(Date())")
    async let result = takesTooLong()
    print("After async-let \(Date())") 
    // Additional code to run concurrently with async function goes here
    print ("result = \(await result)")
    print("End \(Date())")
}

When printing the result value, we are using await to let the system know that execution cannot continue until the async takesTooLong() function returns with the result value. At this point, execution will stop until the result is available. Any code between the async-let and the await, however, will execute concurrently with the takesTooLong() function.

Execution of the above code will generate output similar to the following:

Start 2022-03-30 14:18:40 +0000
After async-let 2022-03-30 14:18:40 +0000
result = 2022-03-30 14:18:45 +0000
End 2022-03-30 14:18:45 +0000

Note that the “After async-let” message has a timestamp that is 5 seconds earlier than the “result =” call return stamp confirming that the code was executed while takesTooLong() was also running.

Handling Errors

Error handling in structured concurrency makes use of the throw/do/try/catch mechanism previously covered in the chapter entitled “Understanding Error Handling in Swift 5”. The following example modifies our original async takesTooLong() function to accept a sleep duration parameter and to throw an error if the delay is outside of a specific range:

enum DurationError: Error {
    case tooLong
    case tooShort
}
.
.
func takesTooLong(delay: TimeInterval) async throws {
    
    if delay < 5 {
        throw DurationError.tooShort
    } else if delay > 20 {
        throw DurationError.tooLong
    }
    
    Thread.sleep(forTimeInterval: delay)
    print("Async task completed at \(Date())")
}

Now when the function is called, we can use a do/try/catch construct to handle any errors that get thrown:

func doSomething() async {
    print("Start \(Date())")
    do {
        try await takesTooLong(delay: 25)
    } catch DurationError.tooShort {
        print("Error: Duration too short")
    } catch DurationError.tooLong {
        print("Error: Duration too long")
    } catch {
        print("Unknown error")
    }
    print("End \(Date())")
}

When executed, the resulting output will resemble the following:

Start 2022-03-30 19:29:43 +0000
Error: Duration too long
End 2022-03-30 19:29:43 +0000

Understanding Tasks

Any work that executes asynchronously is running within an instance of the Swift Task class. An app can run multiple tasks simultaneously and structures these tasks hierarchically. When launched, the async version of our doSomething() function will run within a Task instance. When the takesTooLong() function is called, the system creates a sub-task within which the function code will execute. In terms of the task hierarchy tree, this sub-task is a child of the doSomething() parent task. Any calls to async functions from within the sub-task will become children of that task, and so on.

This task hierarchy forms the basis on which structured concurrency is built. For example, child tasks inherit attributes such as priority from their parents, and the hierarchy ensures that a parent task does not exit until all descendant tasks have completed.

As we will see later in the chapter, tasks can be grouped to enable the dynamic launching of multiple asynchronous tasks.

Unstructured Concurrency

Individual tasks can be created manually using the Task object, a concept referred to as unstructured concurrency. As we have already seen, a common use for unstructured tasks is to call async functions from within synchronous functions.

Unstructured tasks also provide more flexibility because they can be externally canceled at any time during execution. This is particularly useful if you need to provide the user with a way to cancel a background activity, such as tapping on a button to stop a background download task. This flexibility comes with some extra cost in terms of having to do a little more work to create and manage tasks.

Unstructured tasks are created and launched by making a call to the Task initializer and providing a closure containing the code to be performed. For example:

Task {
    await doSomething()
}

These tasks also inherit the configuration of the parent from which they are called, such as the actor context (a topic we will explore in the chapter entitled An Introduction to Swift Actors), priority, and task local variables. Tasks can also be assigned a new priority when they are created, for example:

Task(priority: .high) {
    await doSomething()
}

This provides a hint to the system about how the task should be scheduled relative to other tasks. Available priorities ranked from highest to lowest are as follows:

  • .high / .userInitiated
  • .medium
  • .low / .utility
  • .background

When a task is manually created, it returns a reference to the Task instance. This can be used to cancel the task, or to check whether the task has already been canceled from outside the task scope:

let task = Task(priority: .high) {
    await doSomething()
}
.
.          
if (!task.isCancelled) {
    task.cancel()
}

Detached Tasks

Detached tasks are another form of unstructured concurrency, but differ in that they do not inherit any properties from the calling parent. Detached tasks are created by calling the Task.detached() method as follows:

Task.detached {
    await doSomething()
}

Detached tasks may also be passed a priority value, and checked for cancellation using the same techniques as outlined above:

let detachedTask = Task(priority: .medium) {
    await doSomething()
}
.
.          
if (!detachedTask.isCancelled) {
    detachedTask.cancel()
}

Task Management

Regardless of whether you are using structured or unstructured tasks, the Task class provides a set of static methods and properties that can be used to manage the task from within the task scope.

A task may, for example, use the currentPriority property to identify the priority assigned when it was created:

let detachedTask = Task(priority: .medium) {
    await doSomething()
}
.
.          
if (!detachedTask.isCancelled) {
    detachedTask.cancel()
}

Unfortunately, this is a read-only property so cannot be used to change the priority of the running task. It is also possible for a task to check if it has been canceled by accessing the isCancelled property:

if Task.isCancelled {
    // perform task cleanup
}

Another option for detecting cancellation is to call the checkCancellation() method which will throw a CancellationError error if the task has been canceled:

do {
    try Task.checkCancellation()
} catch {
   // Perform task cleanup
}

A task may cancel itself at any time by calling the cancel() Task method:

Task.cancel()

Finally, if there are locations within the task code where execution could safely be suspended, these can be declared to the system via the yield() method:

Task.yield()

Working with Task Groups

So far in this chapter, all of our examples have involved creating one or two tasks (a parent and a child). In each case, we knew in advance of writing the code how many tasks were required. Situations often arise, however, where several tasks need to be created and run concurrently based on dynamic criteria. We might, for example, need to launch a separate task for each item in an array, or within the body of a for loop. Swift addresses this by providing task groups.

Task groups allow a dynamic number of tasks to be created and are implemented using either the withThrowingTaskGroup() or withTaskGroup() functions (depending on whether or not the async functions in the group throw errors). The looping construct to create the tasks is then defined within the corresponding closure, calling the group addTask() function to add each new task.

Modify the two functions as follows to create a task group consisting of five tasks, each running an instance of the takesTooLong() function:

func doSomething() async {
    await withTaskGroup(of: Void.self) { group in
        for i in 1...5 {
            group.addTask {
                let result = await takesTooLong()
                print("Completed Task \(i) = \(result)")
            }
        }
    }
}
 
func takesTooLong() async -> Date {
    Thread.sleep(forTimeInterval: 5)
    return Date()
}

When executed, there will be a 5 second delay while the tasks run before output similar to the following appears:

Completed Task 1 = 2022-03-31 17:36:32 +0000
Completed Task 2 = 2022-03-31 17:36:32 +0000
Completed Task 5 = 2022-03-31 17:36:32 +0000
Completed Task 3 = 2022-03-31 17:36:32 +0000
Completed Task 4 = 2022-03-31 17:36:32 +0000

Note that the tasks all show the same completion timestamp indicating that they executed concurrently. It is also interesting to notice that the tasks did not complete in the order in which they were launched. When working with concurrency, it is important to keep in mind that there is no guarantee that tasks will complete in the order that they were created.

In addition to the addTask() function, several other methods and properties are accessible from within the task group including the following:

  • cancelAll() – Method call to cancel all tasks in the group
  • isCancelled – Boolean property indicating whether the task group has already been canceled. •
  • isEmpty – Boolean property indicating whether any tasks remain within the task group.

Avoiding Data Races

In the above task group example, the group did not store the results of the tasks. In other words, the results did not leave the scope of the task group and were not retained when the tasks ended. As an example, let’s assume that we want to store the task number and result timestamp for each task within a Swift dictionary object (with the task number as the key and the timestamp as the value). When working with synchronous code, we might consider a solution that reads as follows:

func doSomething() async {
 
    var timeStamps: [Int: Date] = [:]
    
    await withTaskGroup(of: Void.self) { group in
        for i in 1...5 {
            group.addTask {
                timeStamps[i] = await takesTooLong()
            }
        }
    }
}

Unfortunately, the above code will report the following error on the line where the result from the takesTooLong() function is added to the dictionary:

Mutation of captured var 'timeStamps' in concurrently-executing code

The problem here is that we have multiple tasks accessing the data concurrently and risk encountering a data race condition. A data race occurs when multiple tasks attempt to access the same data concurrently, and one or more of these tasks is performing a write operation. This generally results in data corruption problems that can be hard to diagnose.

One option is to create an actor in which to store the data. Actors, and how they might be used to solve this particular problem, will be covered in the chapter entitled An Introduction to Swift Actors.

Another solution is to adapt our task group to return the task results sequentially and add them to the dictionary. We originally declared the task group as returning no results by passing Void.self as the return type to the withTaskGroup() function as follows:

await withTaskGroup(of: Void.self) { group in
.
.

The first step is to design the task group so that each task returns a tuple containing the task number (Int) and timestamp (Date) as follows. We also need a dictionary in which to store the results:

func doSomething() async {
            
    var timeStamps: [Int: Date] = [:]
 
    await withTaskGroup(of: (Int, Date).self) { group in       
        for i in 1...5 {
            group.addTask {
                return(i, await takesTooLong())
            }
        }
    }
}

Next, we need to declare a second loop to handle the results as they are returned from the group. Because the results are being returned individually from async functions, we cannot simply write a loop to process them all at once. Instead, we need to wait until each result is returned. For this situation, Swift provides the for-await loop.

The for-await Loop

The for-await expression allows us to step through sequences of values that are being returned asynchronously and await the receipt of values as they are returned by concurrent tasks. The only requirement for using forawait is that the sequential data conforms to the AsyncSequence protocol (which should always be the case when working with task groups).

In our example, we need to add a for-await loop within the task group scope, but after the addTask loop as follows:

func doSomething() async {
    
    var timeStamps: [Int: Date] = [:]
    
    await withTaskGroup(of: (Int, Date).self) { group in
        
        for i in 1...5 {
            group.addTask {
                return(i, await takesTooLong())
            }
        }
        
        for await (task, date) in group {
            timeStamps[task] = date
        }
    }
}

As each task returns, the for-await loop will receive the resulting tuple and store it in the timeStamps dictionary. To verify this, we can add some code to print the dictionary entries after the task group exits:

func doSomething() async {
.
.
        for await (task, date) in group {
            timeStamps[task] = date
        }
    }
    
    for (task, date) in timeStamps {
        print("Task = \(task), Date = \(date)")
    }
}

When executed, the output from the completed example should be similar to the following:

Task = 1, Date = 2022-03-31 17:48:20 +0000
Task = 5, Date = 2022-03-31 17:48:20 +0000
Task = 2, Date = 2022-03-31 17:48:20 +0000
Task = 4, Date = 2022-03-31 17:48:20 +0000
Task = 3, Date = 2022-03-31 17:48:20 +0000

Asynchronous Properties

In addition to async functions, Swift also supports async properties within class and struct types. Asynchronous properties are created by explicitly declaring a getter and marking it as async as demonstrated in the following example. Currently, only read-only properties can be asynchronous.

struct MyStruct {
    var myResult: Date {
        get async {
            return await self.getTime()
        }
    }
    func getTime() async -> Date {
        Thread.sleep(forTimeInterval: 5)
        return Date()
    }
}
.
.
func doSomething() async {
 
    let myStruct = MyStruct()
 
    Task {
        let date = await myStruct.myResult
        print(date)
    }
}

Summary

Modern CPUs and operating systems are designed to execute code concurrently allowing multiple tasks to be performed at the same time. This is achieved by running tasks on different threads with the main thread being primarily responsible for rendering the user interface and responding to user events. By default, most code in an app is also executed on the main thread unless specifically configured to run on a different thread. If that code performs tasks that occupy the main thread for too long the app will appear to freeze until the task completes. To avoid this, Swift provides the structured concurrency API. When using structured concurrency, code that would block the main thread is instead placed in an asynchronous function (async properties are also supported) so that it is performed on a separate thread. The calling code can be configured to wait for the async code to complete before continuing using the await keyword, or to continue executing until the result is needed using async-let.

Tasks can be run individually or as groups of multiple tasks. The for-await loop provides a useful way to asynchronously process the results of asynchronous task groups. When working with concurrency, it is important to avoid data races, a problem that can usually be resolved using Swift Actors, a topic we will cover in the next chapter entitled An Introduction to Swift Actors.

A SwiftUI Example Tutorial

Now that some of the fundamentals of SwiftUI development have been covered, this chapter will begin to put this theory into practice through the design and implementation of an example SwiftUI-based project.

The objective of this chapter is to demonstrate the use of Xcode to design a simple interactive user interface, making use of views, modifiers, state variables, and some basic animation techniques. Throughout the course of this tutorial, a variety of different techniques will be used to add and modify views. While this may appear to be inconsistent, the objective is to gain familiarity with the different options available.

Creating the Example Project

Start Xcode and select the option to create a new project. On the template selection screen, make sure Multiplatform is selected and choose the App option as shown in Figure 23-1 before proceeding to the next screen:

Figure 23-1

On the project options screen, name the project SwiftUIDemo before clicking Next to proceed to the final screen. Choose a suitable filesystem location for the project and click on the Create button.

Reviewing the Project

Once the project has been created it will contain the SwiftUIDemoApp.swift file along with a SwiftUI View file named ContentView.swift which should have loaded into the editor and preview canvas ready for modification (if it has not loaded, simply select it in the project navigator panel). From the target device menu (Figure 23-2) select an iPhone 13 simulator:

Figure 23-2

If the preview canvas is in the paused state, click on the Resume button to build the project and display the preview:

Figure 23-3

Adding a VStack to the Layout

The view body currently consists of a single Text view of which we will make use in the completed project. A container view now needs to be added so that other views can be included in the layout. For the purposes of this example, the layout will be stacked vertically so a VStack needs to be added to the layout.

Within the code editor, select the Text view entry, hold down the Command key on the keyboard and perform a left-click. From the resulting menu, select the Embed in VStack option:

Figure 23-4

Once the Text view has been embedded into the VStack the declaration will read as follows:

struct ContentView: View {
    var body: some View {
        VStack {
            Text("Hello, world!")
                .padding()
        }
    }
}

Before modifying the view, remove the padding() modifier from the Text view:

struct ContentView: View {
    var body: some View {
        VStack {
            Text("Hello, world!")

        }
    }
}

Adding a Slider View to the Stack

The next item to be added to the layout is a Slider view. Within Xcode, display the Library panel by clicking on the ‘+’ button highlighted in Figure 23-5, locate the Slider in the View list and drag it over the top of the existing Text view within the preview canvas. Make sure the notification panel (also highlighted in Figure 23-5) indicates that the view is going to be inserted into the existing stack (as opposed to being placed in a new vertical stack) before dropping the view into place.

Figure 23-5

Once the slider has been dropped into place, the view implementation should read as follows:

struct ContentView: View {
    var body: some View {
        VStack {
            VStack {
                Text("Hello, world!")
                Slider(value: Value)
            }   
        }
    }
}

Adding a State Property

The slider is going to be used to control the degree to which the Text view is to be rotated. As such, a binding needs to be established between the Slider view and a state property into which the current rotation angle will be stored. Within the code editor, declare this property and configure the Slider to use a range between 0 and 360 in increments of 0.1:

struct ContentView: View {
    
    @State private var rotation: Double = 0
    
    var body: some View {
        VStack {
            VStack {
                Text("Hello, world!")
                Slider(value: $rotation, in: 0 ... 360, step: 0.1)
            }     
        }
    }
}

Note that since we are declaring a binding between the Slider view and the rotation state property it is prefixed by a ‘$’ character.

Adding Modifiers to the Text View

The next step is to add some modifiers to the Text view to change the font and to adopt the rotation value stored by the Slider view. Begin by displaying the Library panel, switch to the modifier list and drag and drop a font modifier onto the Text view entry in the code editor:

Figure 23-6

Select the modifier line in the editor, refer to the Attributes inspector panel and change the font property from Title to Large Title as shown in Figure 23-7:

Figure 23-7

Note that the modifier added above does not change the font weight. Since modifiers may also be added to a view from within the Attributes inspector, take this opportunity to change the setting of the Weight menu from Inherited to Heavy.

On completion of these steps, the View body should read as follows:

var body: some View {
    VStack {
        VStack {
            Text("Hello, world!")
                .font(.largeTitle)
                .fontWeight(.heavy)
                Slider(value: $rotation, in: 0 ... 360, step: 0.1)
        }
    }
}

Adding Rotation and Animation

The next step is to add the rotation and animation effects to the Text view using the value stored by the Slider (animation is covered in greater detail in the “SwiftUI Animation and Transitions” chapter). This can be implemented using a modifier as follows:

.
.
Text("Hello, world!")
    .font(.largeTitle)
    .fontWeight(.heavy)
    .rotationEffect(.degrees(rotation))
.
.

Note that since we are simply reading the value assigned to the rotation state property, as opposed to establishing a binding, the property name is not prefixed with the ‘$’ sign notation.

Click on the Live Preview button (indicated by the arrow in Figure 23-8), wait for the code to compile, then use the slider to rotate the Text view:

Figure 23-8

Next, add an animation modifier to the Text view to animate the rotation over 5 seconds using the Ease In Out effect:

Text("Hello, world!")
    .font(.largeTitle)
    .fontWeight(.heavy)
    .rotationEffect(.degrees(rotation))
    .animation(.easeInOut(duration: 5), value: rotation)

Use the slider once again to rotate the text and note that rotation is now smoothly animated.

Adding a TextField to the Stack

In addition to supporting text rotation, the app will also allow custom text to be entered and displayed on the Text view. This will require the addition of a TextField view to the project. To achieve this, either directly edit the View structure or use the Library panel to add a TextField so that the structure reads as follows (note also the addition of a state property in which to store the custom text string and the change to the Text view to use this property):

struct ContentView: View {
    
    @State private var rotation: Double = 0
    @State private var text: String = "Welcome to SwiftUI"
    
    var body: some View {
        VStack {
            VStack {
                Text(text)
                    .font(.largeTitle)
                    .fontWeight(.heavy)
                    .rotationEffect(.degrees(rotation))
                    .animation(.easeInOut(duration: 5))
 
                Slider(value: $rotation, in: 0 ... 360, step: 0.1)
            
                TextField("Enter text here", text: $text)
                    .textFieldStyle(RoundedBorderTextFieldStyle())
            }
        }
    }
}

When the user enters text into the TextField view, that text will be stored in the text state property and will automatically appear on the Text view via the binding. Return to the preview canvas and make sure that the changes work as expected.

Adding a Color Picker

The final view to be added to the stack before we start to tidy up the layout is a Picker view. The purpose of this view will be to allow the foreground color of the Text view to be chosen by the user from a range of color options. Begin by adding some arrays of color names and Color objects, together with a state property to hold the current array index value as follows:

import SwiftUI
 
struct ContentView: View {
    
    var colors: [Color] = [.black, .red, .green, .blue]
    var colornames = ["Black", "Red", "Green", "Blue"]
    
    @State private var colorIndex = 0
    @State private var rotation: Double = 0
    @State private var text: String = "Welcome to SwiftUI"

With these variables configured, display the Library panel, locate the Picker in the Views screen and drag and drop it beneath the TextField view in either the code editor or preview canvas so that it is embedded in the existing VStack layout. Once added, the view entry will read as follows:

Picker(selection: .constant(1, label: Text("Picker") {
    Text("1").tag(1)
    Text("2").tag(2)
}

The Picker view needs to be configured to store the current selection in the colorIndex state property and to display an option for each color name in the colorNames array. To make the Picker more visually appealing, the background color for each Text view will be changed to the corresponding color in the colors array.

For the purposes of iterating through the colorNames array, the code will make use of the SwiftUI ForEach structure. At first glance, ForEach looks like just another Swift programing language control flow statement. In fact, ForEach is very different from the Swift forEach() array method outlined earlier in the book.

ForEach is itself a SwiftUI view structure designed specifically to generate multiple views by looping through a data set such as an array or range. Within the editor, modify the Picker view declaration so that it reads as follows:

Picker(selection: $colorIndex, label: Text("Color")) {
    ForEach (0 ..< colornames.count)  { color in
        Text(colornames[color])
            .foregroundColor(colors[color])
    }
}

In the above implementation, ForEach is used to loop through the elements of the colornames array, generating a Text view for each color, setting the displayed text and background color on each view accordingly.

The ForEach loop in the above example is contained within a closure expression. As outlined in the chapter entitled “Swift Functions, Methods and Closures” this expression can be simplified using shorthand argument names. Using this technique, modify the Picker declaration so that it reads as follows:

Picker(selection: $colorIndex, label: Text("Color")) {
    ForEach (0 ..< colornames.count) { color in
        Text(colornames[$0])
            .foregroundColor(colors[$0])
    }
}

The Picker view may be configured to display the color choices in a range of different ways. For this project, we need to select the WheelPickerStyle (.wheel) style via the pickerStyle() modifier:

Picker(selection: $colorIndex, label: Text("Color")) {
    ForEach (0 ..< colornames.count) {
        Text(colornames[$0])
            .foregroundColor(colors[$0])
    }
}
.pickerStyle(.wheel)

Remaining in the code editor, locate the Text view and add a foreground color modifier to set the foreground color based on the current Picker selection value:

Text(text)
    .font(.largeTitle)
    .fontWeight(.heavy)
    .rotationEffect(.degrees(rotation))
    .animation(.easeInOut(duration: 5))
    .foregroundColor(colors[colorIndex])

Test the app in the preview canvas and confirm that the Picker view appears with all of the color names using the corresponding foreground color and that color selections are reflected in the Text view.

Tidying the Layout

Up until this point the focus of this tutorial has been on the appearance and functionality of the individual views. Aside from making sure the views are stacked vertically, however, no attention has been paid to the overall appearance of the layout. At this point the layout should resemble that shown in Figure 23-9:

Figure 23-9

The first improvement that is needed is to add some space around the Slider, TextField and Picker views so that they are not so close to the edge of the device display. To implement this, we will add some padding modifiers to the views:

Slider(value: $rotation, in: 0 ... 360, step: 0.1)
    .padding()
 
TextField("Enter text here", text: $text)
    .textFieldStyle(RoundedBorderTextFieldStyle())
    .padding()
 
Picker(selection: $colorIndex, label: Text("Color")) {
    ForEach (0 ..< colornames.count) {
        Text(colornames[$0])
            .foregroundColor(colors[$0])
    }
}
.pickerStyle(.wheel)
.padding()

Next, the layout would probably look better if the Views were evenly spaced. One way to implement this is to add some Spacer views before and after the Text view:

VStack {
        Spacer()
        Text(text)
            .font(.largeTitle)
            .fontWeight(.heavy)
            .rotationEffect(.degrees(rotation))
            .animation(.easeInOut(duration: 5))
            .foregroundColor(colors[colorIndex])
        Spacer()
            Slider(value: $rotation, in: 0 ... 360, step: 0.1)
                .padding()
.
.

The Spacer view provides a flexible space between views that will expand and contract based on the requirements of the layout. If a Spacer is contained in a stack it will resize along the stack axis. When used outside of a stack container, a Spacer view can resize both horizontally and vertically.

To make the separation between the Text view and the Slider more obvious, also add a Divider view to the layout:

.
.
VStack {
    Spacer()
    Text(text)
        .font(.largeTitle)
        .fontWeight(.heavy)
        .rotationEffect(.degrees(rotation))
        .animation(.easeInOut(duration: 5))
        .foregroundColor(colors[colorIndex])
    Spacer()
    Divider()
.
.

The Divider view draws a line to indicate separation between two views in a stack container.

With these changes made, the layout should now appear in the preview canvas as shown in Figure 23-10:

Figure 23-10

Summary

The goal of this chapter has been to put into practice some of the theory covered in the previous chapters through the creation of an example app project. In particular, the tutorial made use of a variety of techniques for adding views to a layout in addition to the use of modifiers and state property bindings. The chapter also introduced the Spacer and Divider views and made use of the ForEach structure to dynamically generate views from a data array.

SwiftUI State Properties, Observable, State and Environment Objects

Earlier chapters have described how SwiftUI emphasizes a data driven approach to app development whereby the views in the user interface are updated in response to changes in the underlying data without the need to write handling code. This approach is achieved by establishing a publisher and subscriber binding between the data and the views in the user interface.

SwiftUI offers four options for implementing this behavior in the form of state properties, observable objects, state objects and environment objects, all of which provide the state that drives the way the user interface appears and behaves. In SwiftUI, the views that make up a user interface layout are never updated directly within code. Instead, the views are updated automatically based on the state objects to which they have been bound as they change over time.

This chapter will describe these four options and outline when they should be used. Later chapters (“A SwiftUI Example Tutorial” and “SwiftUI Observable and Environment Objects – A Tutorial”) will provide practical examples that demonstrates their use.

State Properties

The most basic form of state is the state property. State properties are used exclusively to store state that is local to a view layout such as whether a toggle button is enabled, the text being entered into a text field or the current selection in a Picker view. State properties are used for storing simple data types such as a String or an Int value and are declared using the @State property wrapper, for example:

struct ContentView: View {
 
    @State private var wifiEnabled = true
    @State private var userName = ""
 
    var body: some View {
.
.

Note that since state values are local to the enclosing view they should be declared as private properties.

Every change to a state property value is a signal to SwiftUI that the view hierarchy within which the property is declared needs to be re-rendered. This involves rapidly recreating and displaying all of the views in the hierarchy. This, in turn, has the effect of ensuring that any views that rely on the property in some way are updated to reflect the latest value.

Once declared, bindings can be established between state properties and the views contained in the layout. Changes within views that reference the binding are then automatically reflected in the corresponding state property. A binding could, for example, be established between a Toggle view and the Boolean wifiEnabled property declared above. Whenever the user switches the toggle, SwiftUI will automatically update the state property to match the new toggle setting.

A binding to a state property is implemented by prefixing the property name with a ‘$’ sign. In the following example, a TextField view establishes a binding to the userName state property to use as the storage for text entered by the user:

struct ContentView: View {
    
    @State private var wifiEnabled = true
    @State private var userName = ""
 
    var body: some View {
        VStack {
            TextField("Enter user name", text: $userName)
        }
    }
}

With each keystroke performed as the user types into the TextField the binding will store the current text into the userName property. Each change to the state property will, in turn, cause the view hierarchy to be rerendered by SwiftUI.

Of course, storing something in a state property is only one side of the process. As previously discussed, a change of state usually results in a change to other views in the layout. In this case, a Text view might need to be updated to reflect the user’s name as it is being typed. This can be achieved by declaring the userName state property value as the content for a Text view:

var body: some View {
   VStack {
        TextField("Enter user name", text: $userName)
        Text(userName)
    }
}

As the user types, the Text view will automatically update to reflect the user’s input. Note that in this case the userName property is declared without the ‘$’ prefix. This is because we are now referencing the value assigned to the state property (i.e. the String value being typed by the user) instead of a binding to the property.

Similarly, the hypothetical binding between a Toggle view and the wifiEnabled state property described above could be implemented as follows:

var body: some View {
    
    VStack {
        Toggle(isOn: $wifiEnabled) {
            Text("Enable Wi-Fi")
        }
        TextField("Enter user name", text: $userName)
        Text(userName)  
        Image(systemName: wifiEnabled ? "wifi" : "wifi.slash")
    }
}

In the above declaration, a binding is established between the Toggle view and the state property. The value assigned to the property is then used to decide which image is to be displayed on an Image view.

State Binding

A state property is local to the view in which it is declared and any child views. Situations may occur, however, where a view contains one or more subviews which may also need access to the same state properties. Consider, for example, a situation whereby the Wi-Fi Image view in the above example has been extracted into a subview:

.
.
    VStack {
        Toggle(isOn: $wifiEnabled) {
            Text("Enable WiFi")
        }
        TextField("Enter user name", text: $userName)
        WifiImageView()
    }
}
.
.
struct WifiImageView: View {
 
    var body: some View {
        Image(systemName: wifiEnabled ? "wifi" : "wifi.slash")
    }
}

Clearly the WifiImageView subview still needs access to the wifiEnabled state property. As an element of a separate subview, however, the Image view is now out of the scope of the main view. Within the scope of WifiImageView, the wifiEnabled property is an undefined variable.

This problem can be resolved by declaring the property using the @Binding property wrapper as follows:

struct WifiImageView: View {
    
    @Binding var wifiEnabled : Bool
    
    var body: some View {
        Image(systemName: wifiEnabled ? "wifi" : "wifi.slash")
    }
}

Now, when the subview is called, it simply needs to be passed a binding to the state property:

WifiImageView(wifiEnabled: $wifiEnabled)

Observable Objects

State properties provide a way to locally store the state of a view, are available only to the local view and, as such, cannot be accessed by other views unless they are subviews and state binding is implemented. State properties are also transient in that when the parent view goes away the state is also lost. Observable objects, on the other hand are used to represent persistent data that is both external and accessible to multiple views.

An Observable object takes the form of a class or structure that conforms to the ObservableObject protocol. Though the implementation of an observable object will be application specific depending on the nature and source of the data, it will typically be responsible for gathering and managing one or more data values that are known to change over time. Observable objects can also be used to handle events such as timers and notifications.

The observable object publishes the data values for which it is responsible as published properties. Observer objects then subscribe to the publisher and receive updates whenever changes to the published properties occur. As with the state properties outlined above, by binding to these published properties SwiftUI views will automatically update to reflect changes in the data stored in the observable object.

Observable objects are part of the Combine framework, which was first introduced with iOS 13 to make it easier to establish relationships between publishers and subscribers.

The Combine framework provides a platform for building custom publishers for performing a variety of tasks from the merging of multiple publishers into a single stream to transforming published data to match subscriber requirements. This allows for complex, enterprise level data processing chains to be implemented between the original publisher and resulting subscriber. That being said, one of the built-in publisher types will typically be all that is needed for most requirements. In fact, the easiest way to implement a published property within an observable object is to simply use the @Published property wrapper when declaring a property. This wrapper simply sends updates to all subscribers each time the wrapped property value changes.

The following structure declaration shows a simple observable object declaration with two published properties:

import Foundation
import Combine
 
class DemoData : ObservableObject {
    
    @Published var userCount = 0
    @Published var currentUser = ""
    
    init() {
        // Code here to initialize data
        updateData()
    }
    
    func updateData() {
        // Code here to keep data up to date 
    }
}

A subscriber uses either the @ObservedObject or @StateObject property wrapper to subscribe to the observable object. Once subscribed, that view and any of its child views access the published properties using the same techniques used with state properties earlier in the chapter. A sample SwiftUI view designed to subscribe to an instance of the above DemoData class might read as follows:

import SwiftUI
 
struct ContentView: View {
    
    @ObservedObject var demoData : DemoData = DemoData()
    
    var body: some View {
        Text("\(demoData.currentUser), you are user number \(demoData.userCount)")
    }
}
 
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

State Objects

Introduced in iOS 14, the State Object property wrapper (@StateObject) is an alternative to the @ObservedObject wrapper. The key difference between a state object and an observed object is that an observed object reference is not owned by the view in which it is declared and, as such, is at risk of being destroyed or recreated by the SwiftUI system while still in use (for example as the result of the view being re-rendered).

Using @StateObject instead of @ObservedObject ensures that the reference is owned by the view in which it is declared and, therefore, will not be destroyed by SwiftUI while it is still needed, either by the local view in which it is declared, or any child views.

As a general rule, unless there is a specific need to use @ObservedObject, the recommendation is to use a State Object to subscribe to observable objects. In terms of syntax, the two are entirely interchangeable:

import SwiftUI
 
struct ContentView: View {
    
    @StateObject var demoData : DemoData = DemoData()
    
    var body: some View {
        Text("\(demoData.currentUser), you are user number \(demoData.userCount)")
    }
}
.
.

Environment Objects

Observed objects are best used when a particular state needs to be used by a few SwiftUI views within an app. When one view navigates to another view which needs access to the same observed or state object, the originating view will need to pass a reference to the observed object to the destination view during the navigation (navigation will be covered in the chapter entitled “SwiftUI Lists and Navigation”). Consider, for example, the following code:

.
.
@StateObject var demoData : DemoData = DemoData()
.
.
NavigationLink(destination: SecondView(demoData)) {
    Text("Next Screen")
}

In the above declaration, a navigation link is used to navigate to another view named SecondView, passing through a reference to the demoData observed object.

While this technique is acceptable for many situations, it can become complex when many views within an app need access to the same observed object. In this situation, it may make more sense to use an environment object.

An environment object is declared in the same way as an observable object (in that it must conform to the ObservableObject protocol and appropriate properties must be published). The key difference, however, is that the object is stored in the environment of the view in which it is declared and, as such, can be accessed by all child views without needing to be passed from view to view.

Consider the following example observable object declaration:

class SpeedSetting: ObservableObject {
    @Published var speed = 0.0
}

Views needing to subscribe to an environment object simply reference the object using the @EnvironmentObject property wrapper instead of the @StateObject or @ObservedObject wrapper. For example, the following views both need access to the same SpeedSetting data:

struct SpeedControlView: View {
    @EnvironmentObject var speedsetting: SpeedSetting
 
    var body: some View {
        Slider(value: $speedsetting.speed, in: 0...100)
    }
}
 
struct SpeedDisplayView: View {
    @EnvironmentObject var speedsetting: SpeedSetting
 
    var body: some View {
        Text("Speed = \(speedsetting.speed)")
    }
}

At this point we have an observable object named SpeedSetting and two views that reference an environment object of that type, but we have not yet initialized an instance of the observable object. The logical place to perform this task is within the parent view of the above sub-views. In the following example, both views are sub-views of main ContentView:

struct ContentView: View {
    let speedsetting = SpeedSetting()
 
    var body: some View {
        VStack {
            SpeedControlView()
            SpeedDisplayView()
        }
    }
}

If the app were to run at this point, however, it would crash shortly after launching with the following diagnostics:

Thread 1: Fatal error: No ObservableObject of type SpeedSetting found. A View. environmentObject(_:) for SpeedSetting may be missing as an ancestor of this view.

The problem here is that while we have created an instance of the observable object within the ContentView declaration, we have not yet inserted it into the view hierarchy. This is achieved using the environmentObject() modifier, passing through the observable object instance as follows:

struct ContentView: View {
    let speedsetting = SpeedSetting()
 
    var body: some View {
        VStack {
            SpeedControlView()
            SpeedDisplayView()
        }
        .environmentObject(speedsetting)
    }
}

Once these steps have been taken the object will behave in the same way as an observed object, except that it will be accessible to all child views of the content view without having to be passed down through the view hierarchy. When the slider in SpeedControlView is moved, the Text view in SpeedDisplayView will update to reflect the current speed setting, thereby demonstrating that both views are accessing the same environment object:

Figure 22-1

Summary

SwiftUI provides three ways to bind data to the user interface and logic of an app. State properties are used to store the state of the views in a user interface layout and are local to the current content view. These transient values are lost when the view goes away.

For data that is external to the user interface and is required only by a subset of the SwiftUI view structures in an app, the observable object protocol should be used. Using this approach, the class or structure which represents the data must conform to the ObservableObject protocol and any properties to which views will bind must be declared using the @Published property wrapper. To bind to an observable object property in a view declaration the property must use the @ObservedObject or @StateObject property wrapper (@StateObject being the preferred option in the majority of cases).

For data that is external to the user interface, but for which access is required for many views, the environment object provides the best solution. Although declared in the same way as observable objects, environment object bindings are declared in SwiftUI View files using the @EnvironmentObject property wrapper. Before becoming accessible to child views, the environment object must also be initialized before being inserted into the view hierarchy using the environmentObject() modifier.

SwiftUI Stacks and Frames

User interface design is largely a matter of selecting the appropriate interface components, deciding how those views will be positioned on the screen, and then implementing navigation between the different screens and views of the app.

As is to be expected, SwiftUI includes a wide range of user interface components to be used when developing an app such as button, label, slider and toggle views. SwiftUI also provides a set of layout views for the purpose of defining both how the user interface is organized and the way in which the layout responds to changes in screen orientation and size.

This chapter will introduce the Stack container views included with SwiftUI and explain how they can be used to create user interface designs with relative ease.

Once stack views have been explained, this chapter will cover the concept of flexible frames and explain how they can be used to control the sizing behavior of views in a layout.

SwiftUI Stacks

SwiftUI includes three stack layout views in the form of VStack (vertical), HStack (horizontal) and ZStack (views are layered on top of each other).

A stack is declared by embedding child views into a stack view within the SwiftUI View file. In the following view, for example, three Image views have been embedded within an HStack:

struct ContentView: View {
    var body: some View {
        HStack {
            Image(systemName: "goforward.10")
            Image(systemName: "goforward.15")
            Image(systemName: "goforward.30")
        }
    }
}

Within the preview canvas, the above layout will appear as illustrated in Figure 21-1:

Figure 21-1

A similarly configured example using a VStack would accomplish the same results with the images stacked vertically:

VStack {
    Image(systemName: "goforward.10")
    Image(systemName: "goforward.15")
    Image(systemName: "goforward.30")
}

To embed an existing component into a stack, either wrap it manually within a stack declaration, or hover the mouse pointer over the component in the editor so that it highlights, hold down the Command key on the keyboard and left-click on the component. From the resulting menu (Figure 21-2) select the appropriate option:

Figure 21-2

Layouts of considerable complexity can be designed simply by embedding stacks within other stacks, for example:

VStack {
    Text("Financial Results")
        .font(.title)
    
    HStack {
        Text("Q1 Sales")
            .font(.headline)
        
        VStack {
            Text("January")
            Text("February")
            Text("March")
        }
        
        VStack {
            Text("$1000")
            Text("$200")
            Text("$3000")
        }
    }
}

The above layout will appear as shown in Figure 21-3:

Figure 21-3

As currently configured the layout clearly needs some additional work, particularly in terms of alignment and spacing. The layout can be improved in this regard using a combination of alignment settings, the Spacer component and the padding modifier.

Spacers, Alignment and Padding

To add space between views, SwiftUI includes the Spacer component. When used in a stack layout, the spacer will flexibly expand and contract along the axis of the containing stack (in other words either horizontally or vertically) to provide a gap between views positioned on either side, for example:

HStack(alignment: .top) {
 
    Text("Q1 Sales")
        .font(.headline)
    Spacer()
    VStack(alignment: .leading) {
        Text("January")
        Text("February")
        Text("March")
    }
    Spacer()
.
.

In terms of aligning the content of a stack, this can be achieved by specifying an alignment value when the stack is declared, for example:

VStack(alignment: .center) {
            Text("Financial Results")
                .font(.title)

Alignments may also be specified with a corresponding spacing value:

VStack(alignment: .center, spacing: 15) {
            Text("Financial Results")
                .font(.title)

Spacing around the sides of any view may also be implemented using the padding() modifier. When called without a parameter SwiftUI will automatically use the best padding for the layout, content and screen size (referred to as adaptable padding). The following example sets adaptable padding on all four sides of a Text view:

Text("Hello, world!")
    .padding()

Alternatively, a specific amount of padding may be passed as a parameter to the modifier as follows:

Text("Hello, world!")
    .padding(15)

Padding may also be applied to a specific side of a view with or without a specific value. In the following example a specific padding size is applied to the top edge of a Text view:

Text("Hello, world!")
    .padding(.top, 10)

Making use of these options, the example layout created earlier in the chapter can be modified as follows:

VStack(alignment: .center, spacing: 15) {
        Text("Financial Results")
            .font(.title)
    
        HStack(alignment: .top) {
            Text("Q1 Sales")
                .font(.headline)
            Spacer()
            VStack(alignment: .leading) {
                Text("January")
                Text("February")
                Text("March")
            }
            Spacer()
            VStack(alignment: .leading) {
                Text("$10000")
                Text("$200")
                Text("$3000")
            }
            .padding(5)
        }
        .padding(5)
    }
    .padding(5)
}

With the alignments, spacers and padding modifiers added, the layout should now resemble the following figure:

Figure 21-4

More advanced stack alignment topics will be covered in a later chapter entitled “SwiftUI Stack Alignment and Alignment Guides”.

Container Child Limit

Container views are limited to 10 direct descendant views. If a stack contains more than 10 direct children, Xcode will likely display the following syntax error:

Extra arguments at positions #11, #12 in call

If a stack exceeds the 10 direct children limit, the views will need to be embedded into multiple containers. This can, of course, be achieved by adding stacks as subviews, but another useful container is the Group view. In the following example, a VStack can contain 12 Text views by splitting the views between Group containers giving the VStack only two direct descendants:

VStack {
    
    Group {
         Text("Sample Text")
         Text("Sample Text")
         Text("Sample Text")
         Text("Sample Text")
         Text("Sample Text")
         Text("Sample Text")
    }
 
    Group {
         Text("Sample Text")
         Text("Sample Text")
         Text("Sample Text")
         Text("Sample Text")
         Text("Sample Text")
         Text("Sample Text")
    }
}

In addition to providing a way to avoid the 10-view limit, groups are also useful when performing an operation on multiple views (for example, a set of related views can all be hidden in a single operation by embedding them in a Group and hiding that view).

Text Line Limits and Layout Priority

By default, an HStack will attempt to display the text within its Text view children on a single line. Take, for example, the following HStack declaration containing an Image view and two Text views:

HStack {
    Image(systemName: "airplane")
    Text("Flight times:")
    Text("London")
}
.font(.largeTitle)

If the stack has enough room, the above layout will appear as follows:

Figure 21-5

If a stack has insufficient room (for example if it is constrained by a frame or is competing for space with sibling views) the text will automatically wrap onto multiple lines when necessary:

Figure 21-6

While this may work for some situations, it may become an issue if the user interface is required to display this text in a single line. The number of lines over which text can flow can be restricted using the lineCount() modifier. The example HStack could, therefore, be limited to 1 line of text with the following change:

HStack {
    Image(systemName: "airplane")
    Text("Flight times:") 
    Text("London")
}
.font(.largeTitle)
.lineLimit(1)

When an HStack has insufficient space to display the full text and is not permitted to wrap the text over enough lines, the view will resort to truncating the text, as is the case in Figure 21-7:

Figure 21-7

In the absence of any priority guidance, the stack view will decide how to truncate the Text views based on the available space and the length of the views. Obviously, the stack has no way of knowing whether the text in one view is more important than the text in another unless the text view declarations include some priority information. This is achieved by making use of the layoutPriority() modifier. This modifier can be added to the views in the stack and passed values indicating the level of priority for the corresponding view. The higher the number, the greater the layout priority and the less the view will be subjected to truncation.

Assuming the flight destination city name is more important than the “Flight times:” text, the example stack could be modified as follows:

HStack {
    Image(systemName: "airplane")
    Text("Flight times:")
    Text("London")
        .layoutPriority(1)
}
.font(.largeTitle)
.lineLimit(1)

With a higher priority assigned to the city Text view (in the absence of a layout priority the other text view defaults to a priority of 0) the layout will now appear as illustrated in Figure 21-8:

Figure 21-8

Traditional vs. Lazy Stacks

So far in this chapter we have only covered the HStack, VStack and ZStack views. Although the stack examples shown so far contain relatively few child views, it is possible for a stack to contain large quantities of views. This is particularly common when a stack is embedded in a ScrollView. ScrollView is a view which allows the user to scroll through content that extends beyond the visible area of either the containing view or device the screen.

When using the traditional HStack and VStack views, the system will create all the views child views at initialization, regardless of whether those views are currently visible to the user. While this may not be an issue for most requirements, this can lead to performance degradation in situations where a stack has thousands of child views.

To address this issue, SwiftUI also provides “lazy” vertical and horizontal stack views. These views (named LazyVStack and LazyHStack) use exactly the same declaration syntax as the traditional stack views, but are designed to only create child views as they are needed. For example, as the user scrolls through a stack, views that are currently off screen will only be created once they approach the point of becoming visible to the user. Once those views pass out of the viewing area, SwiftUI releases those views so that they no longer take up system resources.

When deciding whether to use traditional or lazy stacks, it is generally recommended to start out using the traditional stacks and to switch to lazy stacks if you encounter performance issues relating to a high number of child views.

SwiftUI Frames

By default, a view will be sized automatically based on its content and the requirements of any layout in which it may be embedded. Although much can be achieved using the stack layouts to control the size and positioning of a view, sometimes a view is required to be a specific size or to fit within a range of size dimensions. To address this need, SwiftUI includes the flexible frame modifier.

Consider the following Text view which has been modified to display a border:

Text("Hello World")
    .font(.largeTitle)
    .border(Color.black)

Within the preview canvas, the above text view will appear as follows:

Figure 21-9

In the absence of a frame, the text view has been sized to accommodate its content. If the Text view was required to have height and width dimensions of 100, however, a frame could be applied as follows:

Text("Hello World")
    .font(.largeTitle)
    .border(Color.black)
    .frame(width: 100, height: 100, alignment: .center)

Now that the Text view is constrained within a frame, the view will appear as follows:

Figure 21-10

In many cases, fixed dimensions will provide the required behavior. In other cases, such as when the content of a view changes dynamically, this can cause problems. Increasing the length of the text, for example, might cause the content to be truncated:

Figure 21-11

This can be resolved by creating a frame with minimum and maximum dimensions:

Text("Hello World, how are you?")
            .font(.largeTitle)
            .border(Color.black)
            .frame(minWidth: 100, maxWidth: 300, minHeight: 100, 
                   maxHeight: 100, alignment: .center)

Now that the frame has some flexibility, the view will be sized to accommodate the content within the defined minimum and maximum limits. When the text is short enough, the view will appear as shown in Figure 21-10 above. Longer text, however, will be displayed as follows:

Figure 21-12

Frames may also be configured to take up all the available space by setting the minimum and maximum values to 0 and infinity respectively:

.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, 
          maxHeight: .infinity)

Remember that the order in which modifiers are chained often impacts the appearance of a view. In this case, if the border is to be drawn at the edges of the available space it will need to be applied to the frame:

Text("Hello World, how are you?")
    .font(.largeTitle)
    .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, 
          maxHeight: .infinity)
    .border(Color.black, width: 5)

By default, the frame will honor the safe areas on the screen when filling the display. Areas considered to be outside the safe area include those occupied by the camera notch on some device models and the bar across the top of the screen displaying the time and Wi-Fi and cellular signal strength icons. To configure the frame to extend beyond the safe area, simply use the edgesIgnoringSafeArea() modifier, specifying the safe area edges to ignore:

.edgesIgnoringSafeArea(.all)

Frames and the Geometry Reader

Frames can also be implemented so that they are sized relative to the size of the container within which the corresponding view is embedded. This is achieved by wrapping the view in a GeometryReader and using the reader to identify the container dimensions. These dimensions can then be used to calculate the frame size. The following example uses a frame to set the dimensions of two Text views relative to the size of the containing VStack:

GeometryReader { geometry in
    VStack {
        Text("Hello World, how are you?")
            .font(.largeTitle)
            .frame(width: geometry.size.width / 2, 
                height: (geometry.size.height / 4) * 3)
        Text("Goodbye World")
            .font(.largeTitle)
            .frame(width: geometry.size.width / 3, 
                height: geometry.size.height / 4)
    }
}

The topmost Text view is configured to occupy half the width and three quarters of the height of the VStack while the lower Text view occupies one third of the width and one quarter of the height.

Summary

User interface design mostly involves gathering components and laying them out on the screen in a way that provides a pleasant and intuitive user experience. User interface layouts must also be responsive so that they appear correctly on any device regardless of screen size and, ideally, device orientation. To ease the process of user interface layout design, SwiftUI provides several layout views and components. In this chapter we have looked at layout stack views and the flexible frame.

By default, a view will be sized according to its content and the restrictions imposed on it by any view in which it may be contained. When insufficient space is available, a view may be restricted in size resulting in truncated content. Priority settings can be used to control the amount by which views are reduced in size relative to container sibling views.

For greater control of the space allocated to a view, a flexible frame can be applied to the view. The frame can be fixed in size, constrained within a range of minimum and maximum values or, using a Geometry Reader, sized relative to the containing view.

Creating Custom Views with SwiftUI

A key step in learning to develop apps using SwiftUI is learning how to declare user interface layouts both by making use of the built-in SwiftUI views as well as building your own custom views. This chapter will introduce the basic concepts of SwiftUI views and outline the syntax used to declare user interface layouts and modify view appearance and behavior.

SwiftUI Views

User interface layouts are composed in SwiftUI by using, creating and combining views. An important first step is to understand what is meant by the term “view”. Views in SwiftUI are declared as structures that conform to the View protocol. In order to conform with the View protocol, a structure is required to contain a body property and it is within this body property that the view is declared.

SwiftUI includes a wide range of built-in views that can be used when constructing a user interface including text label, button, text field, menu, toggle and layout manager views. Each of these is a self-contained instance that complies with the View protocol. When building an app with SwiftUI you will use these views to create custom views of your own which, when combined, constitute the appearance and behavior of your user interface.

These custom views will range from subviews that encapsulate a reusable subset of view components (perhaps a secure text field and a button for logging in to screens within your app) to views that encapsulate the user interface for an entire screen. Regardless of the size and complexity of a custom view or the number of child views encapsulated within, a view is still just an instance that defines some user interface appearance and behavior.

Creating a Basic View

In Xcode, custom views are contained within SwiftUI View files. When a new SwiftUI project is created, Xcode will create a single SwiftUI View file containing a single custom view consisting of a single Text view component. Additional view files can be added to the project by selecting the File -> New -> File… menu option and choosing the SwiftUI View file entry from the template screen.

The default SwiftUI View file is named ContentView.swift and reads as follows:

import SwiftUI
 
struct ContentView: View {
    var body: some View {
        Text("Hello, world!")
            .padding()
    }
}
 
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

The view is named ContentView and is declared as conforming to the View protocol. It also includes the mandatory body property which, in turn contains an instance of the built-in Text view component which is initialized with a string which reads “Hello, world!”.

The second structure in the file is needed to create an instance of ContentView so that it appears in the preview canvas, a topic which will be covered in detail in later chapters.

Adding Additional Views

Additional views can be added to a parent view by placing them in the body. The body property, however, is configured to return a single view. Adding an additional view, as is the case in the following example, will cause Xcode to create a second preview containing just the “Goodbye, world!” text view:

struct ContentView: View {
    var body: some View {
        Text("Hello, world!")
            .padding()
        Text("Goodbye, world!")
    }
}

To correctly add additional views, those views must be placed in a container view such as a stack or form. The above example could, therefore, be modified to place the two Text views in a vertical stack (VStack) view which, as the name suggests, positions views vertically within the containing view:

struct ContentView: View {
    var body: some View {
        VStack {
            Text("Hello, world!")
                .padding()
            Text("Goodbye, world!")
        }
    }
}

SwiftUI views are hierarchical by nature, starting with parent and child views. This allows views to be nested to multiple levels to create user interfaces of any level of complexity. Consider, for example, the following view hierarchy diagram:

Figure 20-1

The equivalent view declaration for the above view would read as follows:

struct ContentView: View {
    var body: some View {
        VStack {
            VStack {
                Text("Text 1")
                Text("Text 2")
                HStack {
                    Text("Text 3")
                    Text("Text 4")
                }
            }
            Text("Text 5")
        }
    }
}

A notable exception to the requirement that multiple views be embedded in a container is that multiple Text views count as a single view when concatenated. The following, therefore, is a valid view declaration:

struct ContentView: View {
    var body: some View {
        Text("Hello, ") + Text("how ") + Text("are you?")
    }
}

Note that in the above examples the closure for the body property does not have a return statement. This is because the closure essentially contains a single expression (implicit returns from single expressions were covered in the chapter entitled “Swift Functions, Methods and Closures”). As soon as extra expressions are added to the closure, however, it will be necessary to add a return statement, for example:

struct ContentView: View {
    var body: some View {
 
    var myString: String = "Welcome to SwiftUI"
        
    return VStack {
            Text("Hello, world!")
                .padding()
            Text("Goodbye, world")
        }
    }
}

Working with Subviews

Apple recommends that views be kept as small and lightweight as possible. This promotes the creation of reusable components, makes view declarations easier to maintain and results in more efficient layout rendering.

If you find that a custom view declaration has become large and complex, identify areas of the view that can be extracted into a subview. As a very simplistic example, the HStack view in the above example could be extracted as a subview named “MyHStackView” as follows:

struct ContentView: View {
    var body: some View {
        VStack {
            VStack {
                Text("Text 1")
                Text("Text 2")
                MyHStackView()
            }
            Text("Text 5")
        }
    }
}
 
struct MyHStackView: View {
    var body: some View {
        HStack {
            Text("Text 3")
            Text("Text 4")
        }
    }
}

Views as Properties

In addition to creating subviews, views may also be assigned to properties as a way to organize complex view hierarchies. Consider the following example view declaration:

struct ContentView: View {
    
    var body: some View {
        
        VStack {
            Text("Main Title")
                .font(.largeTitle)
            HStack {
                Text("Car Image")
                Image(systemName: "car.fill")
            }
        }
    }
}

Any part of the above declaration can be moved to a property value, and then referenced by name. In the following declaration, the HStack has been assigned to a property named carStack which is then referenced within the VStack layout:

struct ContentView: View {
    
    let carStack = HStack {
        Text("Car Image")
        Image(systemName: "car.fill")
    }
    
    var body: some View {
        VStack {
            Text("Main Title")
                .font(.largeTitle)
            carStack
        }
    }
}

Modifying Views

It is unlikely that any of the views provided with SwiftUI will appear and behave exactly as required without some form of customization. These changes are made by applying modifiers to the views.

All SwiftUI views have sets of modifiers which can be applied to make appearance and behavior changes. These modifiers take the form of methods that are called on the instance of the view and essentially wrap the original view inside another view which applies the necessary changes. This means that modifiers can be chained together to apply multiple modifications to the same view. The following, for example, changes the font and foreground color of a Text view:

Text("Text 1")
    .font(.headline)
    .foregroundColor(.red)

Similarly, the following example uses modifiers to configure an Image view to be resizable with the aspect ratio set to fit proportionally within the available space:

Image(systemName: "car.fill")
    .resizable()
    .aspectRatio(contentMode: .fit)

Modifiers may also be applied to custom subviews. In the following example, the font for both Text views in the previously declared MyHStackView custom view will be changed to use the large title font style:

MyHStackView()
    .font(.largeTitle)

Working with Text Styles

In the above example the font used to display text on a view was declared using a built-in text style (in this case the large title style).

iOS provides a way for the user to select a preferred text size which applications are expected to adopt when displaying text. The current text size can be configured on a device via the Settings -> Display & Brightness -> Text Size screen which provides a slider to adjust the font size as shown below:

Figure 20-2

If a font has been declared on a view using a text style, the text size will dynamically adapt to the user’s preferred font size. Almost without exception, the built-in iOS apps adopt the preferred size setting selected by the user when displaying text and Apple recommends that third-party apps also conform to the user’s chosen text size. The following text style options are currently available:

  • Large Title
  • Title, Title2, Title 3
  • Headline
  • Subheadline
  • Body
  • Callout
  • Footnote
  • Caption1, Caption2

If none of the text styles meet your requirements, it is also possible to apply custom fonts by declaring the font family and size. Although the font size is specified in the custom font, the text will still automatically resize based on the user’s preferred dynamic type text size selection:

Text("Sample Text")
    .font(.custom("Copperplate", size: 70)) 

The above custom font selection will render the Text view as follows:

Figure 20-3

Modifier Ordering

When chaining modifiers, it is important to be aware that the order in which they are applied can be significant. Both border and padding modifiers have been applied to the following Text view.

Text("Sample Text")
    .border(Color.black)
    .padding()

The border modifier draws a black border around the view and the padding modifier adds space around the view. When the above view is rendered it will appear as shown in Figure 20-4:

Figure 20-4

Given that padding has been applied to the text, it might be reasonable to expect there to be a gap between the text and the border. In fact, the border was only applied to the original Text view. Padding was then applied to the modified view returned by the border modifier. The padding is still applied to the view, but outside of the border. For the border to encompass the padding, the order of the modifiers needs to be changed so that the border is drawn on the view returned by the padding modifier:

Text("Sample Text")
    .padding()
    .border(Color.black)

With the modifier order switched, the view will now be rendered as follows:

Figure 20-5

If you don’t see the expected effects when working with chained modifiers, keep in mind this may be because of the order in which they are being applied to the view.

Custom Modifiers

SwiftUI also allows you to create your own custom modifiers. This can be particularly useful if you have a standard set of modifiers that are frequently applied to views. Suppose that the following modifiers are a common requirement within your view declarations:

Text("Text 1")
    .font(.largeTitle)
    .background(Color.white)
    .border(Color.gray, width: 0.2)
    .shadow(color: Color.black, radius: 5, x: 0, y: 5)

Instead of applying these four modifiers each time text with this appearance is required, a better solution is to group them into a custom modifier and then reference that modifier each time the modification is needed. Custom modifiers are declared as structs that conform to the ViewModifier protocol and, in this instance, might be implemented as follows:

struct StandardTitle: ViewModifier {
   func body(content: Content) -> some View {
        content
            .font(.largeTitle)
            .background(Color.white)
            .border(Color.gray, width: 0.2)
            .shadow(color: Color.black, radius: 5, x: 0, y: 5)
    }
}

The custom modifier is then applied when needed by passing it through to the modifier() method:

Text("Text 1")
    .modifier(StandardTitle())
Text("Text 2")
    .modifier(StandardTitle())

With the custom modifier implemented, changes can be made to the StandardTitle implementation and those changes will automatically propagate through to all views that use the modifier. This avoids the need to manually change the modifiers on multiple views.

Basic Event Handling

Although SwiftUI is described as being data driven, it is still necessary to handle the events that are generated when a user interacts with the views in the user interface. Some views, such as the Button view, are provided solely for the purpose of soliciting user interaction. In fact, the Button view can be used to turn a variety of different views into a “clickable” button. A Button view needs to be declared with the action method to be called when a click is detected together with the view to act as the button content. It is possible, for example, to designate an entire stack of views as a single button. In most cases, however, a Text view will typically be used as the Button content. In the following implementation, a Button view is used to wrap a Text view which, when clicked, will call a method named buttonPressed():

struct ContentView: View {
    var body: some View {
        Button(action: buttonPressed) {
            Text("Click Me")
        }
    }
    
    func buttonPressed() {
        // Code to perform action here
    } 
}

Instead of specifying an action function, the code to be executed when the button is clicked may also be specified as a closure in-line with the declaration:

Button(action: {
    // Code to perform action here
}) {
    Text("Click Me")
}

Another common requirement is to turn an Image view into a button, for example:

Button(action: {
    print("Button clicked")
}) {
    Image(systemName: "square.and.arrow.down")
}

Building Custom Container Views

As outlined earlier in this chapter, subviews provide a useful way to divide a view declaration into small, lightweight and reusable blocks. One limitation of subviews, however, is that the content of the container view is static. In other words, it is not possible to dynamically specify the views that are to be included at the point that a subview is included in a layout. The only children included in the subview are those that are specified in the original declaration.

Consider the following subview which consists of three TextViews contained within a VStack and modified with custom spacing and font settings.

struct MyVStack: View {
    var body: some View {
        VStack(spacing: 10) {
            Text("Text Item 1")
            Text("Text Item 2")
            Text("Text Item 3")
        }
        .font(.largeTitle)
    }
}

To include an instance of MyVStack in a declaration, it would be referenced as follows:

MyVStack()

Suppose, however, that a VStack with a spacing of 10 and a large font modifier is something that is needed frequently within a project, but in each case, different child views are required to be contained within the stack. While this flexibility isn’t possible using subviews, it can be achieved using the SwiftUI ViewBuilder closure attribute when constructing custom container views.

A ViewBuilder takes the form of a Swift closure which can be used to create a custom view comprised of multiple child views, the content of which does not need to be declared until the view is used within a layout declaration. The ViewBuilder closure takes the content views and returns them as a single view which is, in effect, a dynamically built subview.

The following is an example of using the ViewBuilder attribute to implement our custom MyVStack view:

struct MyVStack<Content: View>: View {
  let content: () -> Content
  init(@ViewBuilder content: @escaping () -> Content) {
    self.content = content
  }
 
  var body: some View {
    VStack(spacing: 10) {
      content()
   }
   .font(.largeTitle)
  }
}

Note that this declaration still returns an instance that complies with the View protocol and that the body contains the VStack declaration from the previous subview. Instead of including static views to be included in the stack, however, the child views of the stack will be passed to the initializer, handled by ViewBuilder and embedded into the VStack as child views. The custom MyVStack view can now be initialized with different child views wherever it is used in a layout, for example:

MyVStack {
    Text("Text 1")
    Text("Text 2")
    HStack {
        Image(systemName: "star.fill")
        Image(systemName: "star.fill")
        Image(systemName: "star")
    }
}

Working with the Label View

The Label view is different from most other SwiftUI views in that it comprises two elements in the form of an icon and text positioned side-by-side. The image can take the form of any image asset, a SwiftUI Shape rendering or an SF Symbol.

SF Symbols is a collection of over 1500 scalable vector drawings available for use when developing apps for Apple platforms and designed to complement Apple’s San Francisco system font.

The full set of symbols can be searched and browsed by installing the SF Symbols macOS app available from the following URL:

https://developer.apple.com/design/downloads/SF-Symbols.dmg

The following is an example of the Label view using an SF Symbol together with a font() modifier to increase the size of the icon and text:

Label("Welcome to SwiftUI", systemImage: "person.circle.fill")
    .font(.largeTitle)

The above view will be rendered as shown in Figure 20-6 below:

Figure 20-6

By referencing systemImage: in the Label view declaration we are indicating that the icon is to be taken from the built-in SF Symbol collection. To display an image from the app’s asset catalog, the following syntax would be used instead:

Label("Welcome to SwiftUI", image: "myimage")

Instead of specifying a text string and an image, the Label may also be declared using separate views for the title and icon. The following Label view declaration, for example, uses a Text view for the title and a Circle drawing for the icon:

Label(
    title: {
        Text("Welcome to SwiftUI")
        .font(.largeTitle)
    },
    icon: { Circle()
        .fill(Color.blue)
        .frame(width: 25, height: 25)
    }
)

When rendered, the above Label view will appear as shown in Figure 20-7:

Figure 20-7

Summary

SwiftUI user interfaces are declared in SwiftUI View files and are composed of components that conform to the View protocol. To conform with the View protocol a structure must contain a property named body which is itself a View.

SwiftUI provides a library of built-in components for use when designing user interface layouts. The appearance and behavior of a view can be configured by applying modifiers, and views can be modified and grouped together to create custom views and subviews. Similarly, custom container views can be created using the ViewBuilder closure property.

When a modifier is applied to a view, a new modified view is returned and subsequent modifiers are then applied to this modified view. This can have significant implications for the order in which modifiers are applied to a view.

The Anatomy of a Basic SwiftUI Project

When a new SwiftUI project is created in Xcode using the Multiplatform App template, Xcode generates a number of different files and folders which form the basis of the project, and on which the finished app will eventually be built.

Although it is not necessary to know in detail about the purpose of each of these files when beginning with SwiftUI development, each of them will become useful as you progress to developing more complex applications. This chapter will provide a brief overview of each element of a basic Xcode project structure.

Creating an Example Project

If you have not already done so, it may be useful to create a sample project to review while working through this chapter. To do so, launch Xcode and, on the welcome screen, select the option to create a new project. On the resulting template selection panel, select the Multiplatform tab followed by the App option before proceeding to the next screen:

Figure 19-1

On the project options screen, name the project DemoProject. Click Next to proceed to the final screen, choose a suitable filesystem location for the project and click on the Create button.

Project Folders

SwiftUI is intended to allow apps to be developed which can, with minimal modification, run on a variety of Apple platforms including iOS, iPadOS, watchOS, tvOS and macOS. In a typical multiplatform project, there will be a mixture of code that is shared by all platforms and code which is specific to an operating system. In recognition of this, Xcode structures the project with a folder for the shared code and files together with folders to hold the code and files specific to macOS as shown in Figure 19-2. Additional folders may be added in which to place iPadOS, watchOS and tvOS specific code if needed:

Figure 19-2

The DemoProjectApp.swift File

The DemoProjectApp.swift file contains the declaration for the App object as described in the chapter entitled SwiftUI Architecture and will read as follows:

import SwiftUI
 
@main
struct DemoProjectApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

As implemented, the declaration returns a Scene consisting of a WindowGroup containing the View defined in the ContentView.swift file. Note that the declaration is prefixed with @main. This indicates to SwiftUI that this is the entry point for the app when it is launched on a device.

The ContentView.swift File

This is a SwiftUI View file that, by default, contains the content of the first screen to appear when the app starts. This file and others like it are where most of the work is performed when developing apps in SwiftUI. By default, it contains a single Text view displaying the words “Hello, world!”:

import SwiftUI
 
struct ContentView: View {
    var body: some View {
        Text("Hello, world!")
            .padding()
    }
}
 
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

Assets.xcassets

The Assets.xcassets folder contains the asset catalog that is used to store resources used by the app such as images, icons and colors.

Summary

When a new SwiftUI project is created in Xcode using the Multiplatform App template, Xcode automatically generates a number of files required for the app to function. All of these files and folders can be modified to add functionality to the app, both in terms of adding resource assets, performing initialization and de-initialization tasks and building the user interface and logic of the app. Folders are used to provide a separation between code that is common to all operating systems and platform specific code.

131

SwiftUI Architecture

A completed SwiftUI app is constructed from multiple components which are assembled in a hierarchical manner. Before embarking on the creation of even the most basic of SwiftUI projects, it is useful to first gain an understanding of how SwiftUI apps are structured. With this goal in mind, this chapter will introduce the key elements of SwiftUI app architecture, with an emphasis on App, Scene and View elements.

SwiftUI App Hierarchy

When considering the structure of a SwiftUI application, it helps to view a typical hierarchy visually. Figure 18-1, for example, illustrates the hierarchy of a simple SwiftUI app:

Figure 18-1

Before continuing, it is important to distinguish the difference between the term “app” and the “App” element outlined in the above figure. The software applications that we install and run on our mobile devices have come to be referred to as “apps”. In this chapter reference will be made both to these apps and the App element in the above figure. To avoid confusion, we will use the term “application” to refer to the completed, installed and running app, while referring to the App element as “App”. The remainder of the book will revert to using the more common “app” when talking about applications.

App

The App object is the top-level element within the structure of a SwiftUI application and is responsible for handling the launching and lifecycle of each running instance of the application.

The App element is also responsible for managing the various Scenes that make up the user interface of the application. An application will include only one App instance.

Scenes

Each SwiftUI application will contain one or more scenes. A scene represents a section or region of the application’s user interface. On iOS and watchOS a scene will typically take the form of a window which takes up the entire device screen. SwiftUI applications running on macOS and iPadOS, on the other hand, will likely be comprised of multiple scenes. Different scenes might, for example, contain context specific layouts to be displayed when tabs are selected by the user within a dialog, or to design applications that consist of multiple windows.

SwiftUI includes some pre-built primitive scene types that can be used when designing applications, the most common of which being WindowGroup and DocumentGroup. It is also possible to group scenes together to create your own custom scenes.

Views

Views are the basic building blocks that make up the visual elements of the user interface such as buttons, labels and text fields. Each scene will contain a hierarchy of the views that make up a section of the application’s user interface. Views can either be individual visual elements such as text views or buttons, or take the form of containers that manage other views. The Vertical Stack view, for example, is designed to display child views in a vertical layout. In addition to the Views provided with SwiftUI, you will also create custom views when developing SwiftUI applications. These custom views will comprise groups of other views together with customizations to the appearance and behavior of those views to meet the requirements of the application’s user interface.

Figure 18-2, for example, illustrates a scene containing a simple view hierarchy consisting of a Vertical Stack containing a Button and TextView combination:

Figure 18-2

Summary

SwiftUI applications are constructed hierarchically. At the top of the hierarchy is the App instance which is responsible for the launching and lifecycle of the application. One or more child Scene instances contain hierarchies of the View instances that make up the user interface of the application. These scenes can either be derived from one of the SwiftUI primitive Scene types such as WindowGroup, or custom built.

On iOS or watchOS, an application will typically contain a single scene which takes the form of a window occupying the entire display. On a macOS or iPadOS system, however, an application may comprise multiple scene instances, often represented by separate windows which can be displayed simultaneously or grouped together in a tabbed interface.