Chapters

Hide chapters

SwiftUI by Tutorials

Second Edition · iOS 13 · Swift 5.2 · Xcode 11

Before You Begin

Section 0: 3 chapters
Show chapters Hide chapters

Section II: Building Blocks of SwiftUI

Section 2: 6 chapters
Show chapters Hide chapters

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:

Library of primitive views and modifiers.
Library of primitive views and modifiers.

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!

Dark mode applied to preview.
Dark mode applied to preview.

But, build and run the app, and you will see the following:

No dark mode in simulator.
No dark mode in simulator.

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.

No dark mode applied to preview.
No dark mode applied to preview.

Then add the .colorScheme modifier to the top level view of bodyNavigationView — 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:

Dark mode applied to top level of body.
Dark mode applied to top level of body.

Then build and run to see your app start up in dark mode!

Dark mode in simulator.
Dark mode in simulator.

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:

Configure default font for top level of body.
Configure default font for top level of body.

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:

Override default font for target color label.
Override default font for target color label.

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:

Padding happens in ColorSlider.
Padding happens in ColorSlider.

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 a VStack, then move the closing brace after the third ColorSlider. The canvas must be open, or you won’t see Embed in VStack in the menu. If Command-click jumps to the definition of ColorSlider, 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:

Move padding from ColorSlider into body.
Move padding from ColorSlider into body.

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:

Adding background color and rounded corners to sliders.
Adding background color and rounded corners to sliders.

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:

Adding modifiers in the wrong order.
Adding modifiers in the wrong order.

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!:

Displaying the target values with the score.
Displaying the target values with the score.

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:

ZStack: Text appears above Color.
ZStack: Text appears above Color.

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:

ZStack: Text appears below Color.
ZStack: Text appears below Color.

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:

Timer text with padding, background color and circle mask.
Timer text with padding, background color and circle mask.

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 your body 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:

Attach a debug session to Live Preview.
Attach a debug session to Live Preview.

This will take a while, but eventually, you get all the normal debugging tools, plus environment overrides, runtime issue scanning and runtime issue breakpoints:

Debug preview session.
Debug preview session.

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:

Environment Overrides: Interface Style
Environment Overrides: Interface Style

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:

Dark mode problem fixed.
Dark mode problem fixed.

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!

Environment overrides.
Environment overrides.

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 in ColorSlider. It would have created a duplicate source of truth, which you’d have to keep in sync with rValue. 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 these private to emphasize that a @State variable is owned and managed by that view specifically.

Note: You can initialize the @State variables in ContentView to remove the need to pass parameters from SceneDelegate. Otherwise, if you make them private, you won’t be able to initialize ContentView 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 the ObservableObject protocol: It implements an objectWillChange property to publish changes to its data. You’ll soon implement a timer as an ObservableObject.

  • @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 and Published provide a general-purpose Combine publisher that you use when there isn’t a more specific Combine publisher for your needs. The Timer class has a Combine publisher TimerPublisher, 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:

Counting the seconds.
Counting the seconds.

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.

Background opacity decreases as you get closer to the target.
Background opacity decreases as you get closer to the target.

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 to ObservableObject. @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.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.