3.
Understanding SwiftUI
Written by Audrey Tam
This chapter gives you an overview of how SwiftUI can help you develop great apps faster. You’ll learn about declarative app development — declarative UI plus declarative data dependencies — and how to “think different” about your app design.
Why SwiftUI?
Interface Builder (IB) and storyboards helped a lot of us get up to speed developing apps, making it easy to layout adaptive user interfaces and setting up segues for navigation.
But many developers prefer to create their production views in code, partly because it’s more efficient to copy or edit UI when it’s written out in code, but mostly because IB and storyboards have built-in gotchas. You edit the name of an IBAction
or IBOutlet
or delete it from your code, and your app crashes because IB doesn’t see changes to code. Or you’ve fumed about stringly-typed identifiers for segues or table view cells that you have to use in your code, but Xcode can’t check for you because they’re strings.
SwiftUI lets you ignore Interface Builder (IB) and storyboards without having to write detailed step-by-step instructions for laying out your UI. You can preview a SwiftUI view side-by-side with its code, and a change to one side will update the other side, so they’re always in sync. There aren’t any identifier strings to get wrong. And it’s code, but a lot less than you’d write for UIKit, so it’s easier to understand, edit and debug. What’s not to love?
SwiftUI doesn’t replace UIKit. Like Swift and Objective-C, you can use both in the same app. In this chapter, you’ll use a non-SwiftUI class as a data source in RGBullsEye. In Chapter 4: “Integrating SwiftUI”, you’ll see how easy it is to use a SwiftUI view in a UIKit app, and vice versa.
The SwiftUI APIs are consistent across platforms, so it’s easy to develop the same-ish app on multiple platforms using the same source code on each. In Chapter 5: “The Apple Ecosystem”, you’ll learn how to take advantage of the features of macOS, watchOS and tvOS.
Is SwiftUI ready for production? Maybe, if you don’t have to support older OS versions—SwiftUI apps need the latest operating systems on all the Apple platforms.
Declarative app development
SwiftUI enables you to do declarative app development. You’ll develop great apps faster… once you learn to “think different.” Declarative app development means you declare both how you want the views in your UI to look and also what data they depend on. The SwiftUI framework takes care of creating views when they should appear and updating them whenever there’s a change to data they depend on.
You declare how your view’s state affects its appearance, and how SwiftUI should react to changes in view’s data dependencies. Yes, there’s a definite reactive feeling to SwiftUI! So if you’re already using one of the reactive programming frameworks, you’ll probably have an easier time picking up SwiftUI.
These features help to speed up your app development:
-
Views: Declarative UI stays in sync with code, with no stringly-typed identifiers. Use views for layout and navigation, and encapsulate presentation logic for a piece of data. Another benefit of declarative UI: the API is consistent across platforms, so you can learn once, then apply everywhere. Controls describe their role, not their appearance, so the same control looks appropriate for the platform. You’ll learn more about the other platforms in Chapter 5: “The Apple Ecosystem”.
-
Data: Declarative data dependencies update views when data changes. The framework recomputes the view and all its children, then renders what has changed. A view’s state depends on its data, so you declare how the view uses data: how the view reacts to data changes or how data affect the view. You declare the possible states for your view, and how the view appears for each state.
-
Navigation: Conditional subviews can replace navigation: see Chapter 11: “Lists & Navigation”.
-
Integration: It’s easy to integrate SwiftUI into a UIKit app and vice versa: see Chapter 4: “Integrating SwiftUI”.
Getting started
Open the starter project, or continue with your project from the previous chapter.
SwiftUI vs. UIKit
Also, open the UIKit version of RGBullsEye, and take a closer look at the differences between UIKit and SwiftUI.
To create the UIKit app, I laid out several labels, a button and three sliders on the storyboard, connected them to outlets and actions in the view controller, then wrote code in the actions and some helper methods to keep the UI in sync with changes to the slider values. When the user moves a slider, its action updates a color value, a label and a label’s background color. I had to think about the correct order to do things. It would be easy to forget a step.
To create the SwiftUI app, you listed the Color
, Text
, Button
and Slider
subviews in the order you wanted them to appear — much easier than setting auto-layout constraints! — and declared within each subview how it depends on changes to the app’s data. SwiftUI manages data dependencies to keep views consistent with their state, so you don’t have to worry about doing things in the right order or forgetting to update a UI object. The canvas preview means you don’t need a storyboard. The subviews keep themselves updated, so you don’t need a view controller either. And live preview means you rarely need to launch the simulator.
Time-efficient, right?
Declaring views
A SwiftUI view is a piece of your UI: You combine small views to build larger views. There are lots of primitive views like Text
and Color
, which you can use as basic building blocks for your custom views.
With the canvas open, click the + button (or Command-Shift-L) to open the Library:
Note: To save space, I switched to icon view.
The first tab lists primitive views for control, layout, paints and Other Views. Many of these, especially the control views, are familiar to you as UIKit elements, but some are unique to SwiftUI. You’ll learn how to use them in upcoming chapters.
The second tab lists modifiers for controls, effects, layout, text and many more. A modifier is a method that creates a new view from the existing view. You can chain modifiers like a pipeline to customize any view.
SwiftUI encourages you to create small reusable views, then customize them with modifiers for the specific context where you use them. And don’t worry, SwiftUI collapses the modified view into an efficient data structure, so you get all this convenience with no visible performance hit.
You can apply many of these modifiers to any type of view. And sometimes the ordering matters, as you’ll soon see.
Environment values
Several environment values affect your whole app. Many of these correspond to device user settings like accessibility, locale, calendar and color scheme. You can try out environment values in previews
, to anticipate and solve problems that might arise from these settings on a user’s device. Later in this chapter, you’ll see another (easier) way to check for environment issues.
You can find a list of built-in EnvironmentValues
at apple.co/2yJJk7T.
To see how these work, open up ContentView.swift. Scroll down to ContentView_Previews
and add this environment
modifier to ContentView
:
.environment(\.colorScheme, .dark)
Next, in ContentView
, add this modifier to the top-level VStack
:
.background(Color(.systemBackground))
You’re making sure the view’s background color changes to black for dark mode.
Refresh the preview, and now it’s in dark mode!
But, build and run the app, and you will see the following:
Modifying the preview doesn’t affect your app. If you want your app to default to dark mode at startup, you need to set the environment value for the app’s top-level view.
To do this, first delete or comment out the preview’s .environment
modifier you just added, and refresh the preview (Option-Command-P) to confirm it’s back to light mode.
Then add the .colorScheme
modifier to the top level view of body
— NavigationView
— instead:
var body: some View {
NavigationView {
VStack {
HStack { ... }
Button(...)
ColorSlider(...)
ColorSlider(...)
ColorSlider(...)
}
}
// prevent split view in landscape on iPhone 11 Pro Max
.navigationViewStyle(StackNavigationViewStyle())
.colorScheme(.dark)
}
Note:
.colorScheme(.dark)
is a simpler syntax for.environment(\.colorScheme, .dark)
. The only advantage to using the longer syntax is to remind yourself that you’re setting an environment value.
Now, refresh the preview:
Then build and run to see your app start up in dark mode!
To see the full effect of my future magic trick ;], delete or comment out the .colorScheme
modifier.
Local environment
You can also set view-level environment values that affect all child views. For example, configure the default font for the outermost VStack
:
VStack {
...
}
.font(Font.subheadline.lowercaseSmallCaps().weight(.light))
Refresh the preview:
All the Text
views now use subheadline font size and light font weight with small capitals for all lower-case letters.
You can override the default environment value for specific child views. To make the main instruction “Match this color” stand out, give it greater weight with a fontWeight
modifier:
Text("Match this color")
.fontWeight(.semibold)
Refresh the preview to see the target color’s label now has a heavier font weight:
Comment out or delete these font environment modifiers.
Modifying reusable views
Now scroll down in ContentView.swift to the body
of the ColorSlider
view you created in the previous chapter:
HStack {
Text("0")
.foregroundColor(textColor)
Slider(value: $value)
Text("255")
.foregroundColor(textColor)
}
.padding(.horizontal)
The HStack
has a padding()
modifier that adds some space at either end.
Your UI has three ColorSlider
views, just bundled into the top-level VStack
, at the same level as the HStack
with the Color
views and the button:
VStack {
HStack { ... }
Button(...)
ColorSlider(value: $rGuess, textColor: .red)
ColorSlider(value: $gGuess, textColor: .green)
ColorSlider(value: $bGuess, textColor: .blue)
}
Here’s how it currently looks:
But these three ColorSlider
views are a logical unit, and it makes sense to manage the padding for the unit, not for each individual ColorSlider
. If you embed them in a VStack
, then you can add padding to the VStack
so it fits just right in your UI. padding()
is one of those modifiers that can be applied to any type of view.
So embed the three ColorSlider
views in a VStack
and add horizontal padding to the VStack
:
VStack {
ColorSlider(value: $rGuess, textColor: .red)
ColorSlider(value: $gGuess, textColor: .green)
ColorSlider(value: $bGuess, textColor: .blue)
}
.padding(.horizontal)
Note: Command-click the first
ColorSlider
to embed it in aVStack
, then move the closing brace after the thirdColorSlider
. The canvas must be open, or you won’t see Embed in VStack in the menu. If Command-click jumps to the definition ofColorSlider
, use Control-Command-click instead.
Then remove the padding from the HStack
in the ColorSlider
view, so it looks like this:
struct ColorSlider: View {
@Binding var value: Double
var textColor: Color
var body: some View {
HStack {
Text("0")
.foregroundColor(textColor)
Slider(value: $value)
Text("255")
.foregroundColor(textColor)
}
}
}
Now refresh the preview (Option-Command-P) to see that it looks the same:
The difference is that now you can tweak the padding of the 3-ColorSlider
VStack
as you fine-tune your UI. You might decide to add padding all around, or some top and side padding, but no bottom padding. And ColorSlider
is just that little bit more reusable, now that it doesn’t bring along its own horizontal padding.
Adding modifiers in the right order
SwiftUI applies modifiers in the order that you add them. Adding a background color then padding produces a different visual effect than adding padding then background color.
To start, add modifiers to Slider
in ColorSlider
, so it looks like this:
Slider(value: $value)
.background(textColor)
.cornerRadius(10)
You’re adding a background color to match the 0 and 255 labels, then rounding the corners a little.
Then refresh the preview (Option-Command-P) to see the effect:
Now swap the order: With the cursor on the current cornerRadius
line, press Option-Command-[ to move it up.
Slider(value: $value)
.cornerRadius(10)
.background(textColor)
And refresh the preview:
What, no rounded corners!? Well, they’re there, but there isn’t anything “underneath” for the corner-rounding to clip. So the background color affects the whole rectangle.
Press Option-Command-] on the cornerRadius
line to switch the modifiers back to the first ordering, so the background modifier returns a Slider
with background color, then the cornerRadius
modifier returns a Slider
with background color with rounded corners.
Note: Because the order of modifiers can make a difference, moving a line up with Option-Command-[ and down with Option-Command-] are very useful keyboard shortcuts. If you need to look them up, they’re listed in the Xcode menu under Editor▸Structure.
Showing conditional views
RGBullsEye already has a view that appears only when a certain condition is true: Alert
appears when showAlert
is true. The condition is in the .alert
modifier:
.alert(isPresented: $showAlert)
You can also write explicit conditions.
In the target color VStack
, replace Text("Match this color")
with the following:
self.showAlert ? Text("R: \(Int(rTarget * 255.0))"
+ " G: \(Int(gTarget * 255.0))"
+ " B: \(Int(bTarget * 255.0))")
: Text("Match this color")
Now when you show the user their score, you also display the target color values to provide additional feedback to the user.
Refresh the preview (Option-Command-P), then start the Live Preview, and tap Hit Me!:
And there are the target values!
Turn off Live Preview for now.
Using ZStack
When you play RGBullsEye, there’s no incentive to match the target color quickly. You can keep moving the sliders back and forth for as long as it takes, or until you give up.
So, to make it more edgy, you’ll add a time counter to the game! But where? How about in the center of the guess Color
view? But how to do that with just HStack
and VStack
? This is a job for ZStack
!
First, embed the guess Color
view in a ZStack
:
ZStack {
Color(red: rGuess, green: gGuess, blue: bGuess)
}
Note: The Command-click menu doesn’t include Embed in ZStack, so just embed it in an
HStack
, then change “H” to “Z”.
Z Stack!? The Z-direction is perpendicular to the screen surface. Items lower in a ZStack
closure appear higher in the stack view. It’s similar to how the positive Y-direction in the window is down.
To see this, add a Text
view to the ZStack
, below the Color
view:
ZStack {
Color(red: rGuess, green: gGuess, blue: bGuess)
Text("60")
}
Refresh the preview:
And there’s the Text
view!
Now move the Text
above Color
(on Text
line, press Option-Command-[):
ZStack {
Text("60")
Color(red: rGuess, green: gGuess, blue: bGuess)
}
And refresh the preview:
You can see the Text
view’s outline, but it’s now hidden by the Color
view. If you don’t see anything, click the Text
view in the code, to highlight it in the canvas.
Next, move Text
back below Color
, then modify it:
Text("60")
.padding(.all, 5)
.background(Color.white)
.mask(Circle())
You’ve added padding around the text, set the background color to white, so it shows up against the guess color, and added a circle mask, to make it look nice.
Next, add a center-alignment to your ZStack
so the text is centered:
ZStack(alignment: .center) {
...
}
Refresh the preview to admire your work:
You’ll soon replace the constant string “60” with a data dependency on a real Timer
object. But now is a good time to explore runtime debugging.
Debugging
Note: To see the effect of the following instructions, make sure you’ve deleted or commented out any
colorScheme
modifiers applied to yourbody
and preview.
Here’s how you do runtime debugging in Xcode’s Live Preview: Control-click or Right-click the Live Preview button, then select Debug Preview from the menu:
This will take a while, but eventually, you get all the normal debugging tools, plus environment overrides, runtime issue scanning and runtime issue breakpoints:
Note: The debug session is tied to the lifetime of the preview, so be sure to keep the preview open if you open the view debugger by using the new editor split feature: Option-click the view debugger icon.
For now, just look at the environment overrides options: Click the Environment Overrides button — the one with two toggles — and switch on Interface Style. The Live Preview changes to dark mode:
It looks very cool, but the timer text is invisible because its color defaults to the color scheme’s primary color, which is white for dark mode. So add the .foregroundColor
modifier:
Text("60")
.padding(.all, 5)
.background(Color.white)
.mask(Circle())
.foregroundColor(.black)
You’re overriding the dark mode default text color so the timer text is always black.
Refresh the live debug preview (Option-Command-P as usual), and make sure Environment Overrides ▸ Interface Style is enabled:
And now the problem is fixed!
Earlier in this chapter, you added a dark mode modifier to previews
in ContentView_Previews
, but environment overrides don’t need any code… or forethought!
Notice you can also try out different text sizes and accessibility modifiers, all on the fly!
Awesome, right? But for now, turn off the debug preview.
Note: It’s well worth your time to watch Apple’s WWDC 2019 Session 412 “Debugging in Xcode 11”. It’s all about debugging SwiftUI, from the nine-minute mark, which you can access here: apple.co/2Kfcm5F.
Declaring data dependencies
SwiftUI has two guiding principles for managing how data flows through your app:
-
Data access = dependency: Reading a piece of data in your view creates a dependency for that data in that view. Every view is a function of its data dependencies — its inputs or state.
-
Single source of truth: Every piece of data that a view reads has a source of truth, which is either owned by the view or external to the view. Regardless of where the source of truth lies, you should always have a single source of truth. This is why you didn’t declare
@State value
inColorSlider
. It would have created a duplicate source of truth, which you’d have to keep in sync withrValue
. Instead, you declared@Binding value
, which means the view depends on a@State
variable from another view.
In UIKit, the view controller keeps the model and view in sync. In SwiftUI, the declarative view hierarchy plus this single source of truth means you no longer need the view controller.
Tools for data flow
SwiftUI provides several tools to help you manage the flow of data in your app.
Property wrappers augment the behavior of variables. SwiftUI-specific wrappers — @State
, @Binding
, @ObservedObject
and @EnvironmentObject
— declare a view’s dependency on the data represented by the variable.
Each wrapper indicates a different source of data:
-
@State
variables are owned by the view.@State var
allocates persistent storage, so you must initialize its value. Apple advises you to mark theseprivate
to emphasize that a@State
variable is owned and managed by that view specifically.
Note: You can initialize the
@State
variables inContentView
to remove the need to pass parameters fromSceneDelegate
. Otherwise, if you make themprivate
, you won’t be able to initializeContentView
as the root view.
-
@Binding
declares dependency on a@State var
owned by another view, which uses the$
prefix to pass a binding to this state variable to another view. In the receiving view,@Binding var
is a reference to the data, so it doesn’t need an initial value. This reference enables the view to edit the state of any view that depends on this data. -
@ObservedObject
declares dependency on a reference type that conforms to theObservableObject
protocol: It implements anobjectWillChange
property to publish changes to its data. You’ll soon implement a timer as anObservableObject
. -
@EnvironmentObject
declares dependency on some shared data — data that’s visible to all views in the app. It’s a convenient way to pass data indirectly, instead of passing data from parent view to child to grandchild, especially if the child view doesn’t need it.
You normally don’t use @State
variables in a reusable view. Use @Binding
or @ObservedObject
instead. You should create a private @State var
only if the view should own the data, like the highlighted
property of Button
. Think about whether the data should be owned by a parent view or by an external source.
Observing a reference type object
OK, it’s time to add a real timer to RGBullsEye! Create a new (plain old) Swift file, and name it TimeCounter.swift. Add this import below import Foundation
:
import Combine
That’s right, you’ll be using the new Combine framework! You’ll set up TimeCounter
to be a publisher, and your ContentView
will subscribe to it. Learn more about it in our book Combine: Asynchronous Programming with Swift.
Now, start creating your TimeCounter
class:
class TimeCounter: ObservableObject {
var timer: Timer?
@Published var counter = 0
@objc func updateCounter() {
counter += 1
}
}
The magic is in the ObservableObject
protocol and the Published
property wrapper. Whenever counter
changes, it publishes itself to any subscribers.
You must expose updateCounter()
to Objective-C because you’ll pass it to #selector()
in the next step.
Note:
ObservableObject
andPublished
provide a general-purpose Combine publisher that you use when there isn’t a more specific Combine publisher for your needs. TheTimer
class has a Combine publisherTimerPublisher
, but it’s better to learn about that in our Combine book.
Next, initialize timer
to call updateCounter()
every second:
init() {
timer = Timer.scheduledTimer(timeInterval:1, target: self,
selector:#selector(updateCounter), userInfo: nil,
repeats: true)
}
And finally, add this method to get rid of timer
when it’s no longer needed:
func killTimer() {
timer?.invalidate()
timer = nil
}
That’s your TimeCounter
done. Now head back to ContentView
to subscribe to it.
First, add this new property:
@ObservedObject var timer = TimeCounter()
You’re declaring a data dependency on the TimeCounter
class, which conforms to the ObservableObject
protocol. In Combine terminology, you’re subscribing to the TimeCounter
publisher.
Next, down in your ZStack
, edit Text("60")
so it looks like this:
Text(String(timer.counter))
This will update your UI whenever timer
updates its counter
— after each second.
Lastly, add this line to the button’s action
:
self.timer.killTimer()
You want the timer to stop when the user taps Hit Me!.
And that’s all there is to it!
Build and run. Watch the timer count the seconds, then tap Hit Me! to see the timer stop:
Congratulations, you’ve just integrated something non-SwiftUI into your SwiftUI app! There are other ways to integrate SwiftUI with UIKit, and you’ll learn about these in Chapter 4: Integrating SwiftUI.
Challenge
Challenge: Opacity feedback for BullsEye
When you play RGBullsEye, you get continuous feedback on how close you are to the target color. But you don’t get any help when playing BullsEye. Your challenge is to add some feedback, by changing the slider background color’s opacity as the user moves closer to or further away from the target.
Open the BullsEye app in the challenge/starter folder:
- Add a background color to the slider, and set the color to blue.
- Add an opacity modifier whose value decreases as the score increases.
As you get closer to the target value, the slider effectively vanishes. If you go past the target, the increase in opacity indicates you’ve gone too far.
The solution is in the challenge/final folder for this chapter.
Key points
-
Declarative app development means you declare both how you want the views in your UI to look and also what data they depend on. The SwiftUI framework takes care of creating views when they should appear and updating them whenever there’s a change to data they depend on.
-
The Library contains a list of primitive views and a list of modifier methods.
-
Some modifiers can be applied to all view types, while others can be applied only to specific view types, like
Text
. Changing the ordering of modifiers can change the visual effect. -
Data access = dependency: Reading a piece of data in your view creates a dependency for that data in that view.
-
Single source of truth: Every piece of data has a source of truth, internal or external. Regardless of where the source of truth lies, you should always have a single source of truth.
-
Property wrappers augment the behavior of variables:
@State
,@Binding
,@ObservedObject
and@EnvironmentObject
declare a view’s dependency on the data represented by the variable. -
@Binding
declares dependency on a@State var
owned by another view.@ObservedObject
declares dependency on a reference type that conforms toObservableObject
.@EnvironmentObject
declares dependency on some shared data. -
For runtime debugging, Control-click or Right-click the Live Preview button, then select Debug Preview from the menu. You get all the normal debugging tools, plus runtime issues scanning and runtime breakpoints. Option-click the view debugger icon to open a view debugger.