Chapters

Hide chapters

SwiftUI by Tutorials

Fourth Edition · iOS 15, macOS 12 · Swift 5.5 · Xcode 13.1

Before You Begin

Section 0: 4 chapters
Show chapters Hide chapters

3. Diving Deeper Into SwiftUI
Written by Audrey Tam

SwiftUI’s declarative style makes it easy to implement eye-catching designs. In this chapter, you’ll use SwiftUI modifiers to give RGBullsEye a design makeover with neumorphism, the latest design trend.

Views and modifiers

In the ContentView file, with the canvas open, click the + button or press 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 and hid the details.

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.

The first tab lists primitive views, grouped as controls, layout, paint and other views. Many of these, especially the controls, 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, image and 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 order matters, as you’ll soon see.

Neumorphism

Neumorphism is the new skeuomorphism, a pushback against super-flat minimal UI. A neumorphic UI element appears to push up from below its background, producing a flat 3D effect.

Imagine the element protrudes a little from the screen, and the sun is setting northwest of the element. This produces a highlight on the upper-left edge and a shadow at the lower-right edge. Or the sun rises southeast of the element, so the highlight is on the lower-right edge and the shadow is at the upper left edge:

Northwest and southeast highlights and shadows
Northwest and southeast highlights and shadows

You need three colors to create these highlights and shadows:

  • A neutral color for the background and element surface.
  • A lighter color for the highlight.
  • A darker color for the shadow.

This example uses colors that create high contrast, just to make it really visible. In your project, you’ll use colors that create a more subtle effect.

You’ll add highlights and shadows to the color circles, labels and button in RGBullsEye to implement this Figma design:

Figma design
Figma design

This design was laid out for a 375x812-point screen (iPhone X or 13 mini). You’ll set up your design with the size values from the Figma design, then change these to screen-size-dependent values.

Note: Many developers skip the Figma/Sketch design step and just design directly in SwiftUI — it’s that easy!

Colors for neumorphism

Open the starter project. It’s the same as the final challenge project from the previous chapter, but ColorCircle is in its own file with a size parameter, and Assets.xcassets contains Element, Highlight and Shadow colors for both light and dark mode:

  • Element: #F1F3F7; Dark: #292A2D
  • Highlight: #FFFFFF (20% opacity); Dark: #3D3E42
  • Shadow: #BDCDE1; Dark: #1A1A1A

The Model/ColorExtension file includes static properties for these:

static let element = Color("Element")
static let highlight = Color("Highlight")
static let shadow = Color("Shadow")

Shadows for neumorphism

First, you’ll create custom modifiers for northwest and southeast shadows.

Create a new Swift file named ViewExtension and replace its import Foundation statement with the following code:

import SwiftUI

extension View {
  func northWestShadow(
    radius: CGFloat = 16,
    offset: CGFloat = 6
  ) -> some View {
    return self
      .shadow(
        color: .highlight, radius: radius, x: -offset,
          y: -offset)
      .shadow(
        color: .shadow, radius: radius, x: offset, y: offset)
  }

  func southEastShadow(
    radius: CGFloat = 16,
    offset: CGFloat = 6
  ) -> some View {
    return self
      .shadow(
        color: .shadow, radius: radius, x: -offset, y: -offset)
      .shadow(
        color: .highlight, radius: radius, x: offset, y: offset)
  }
}

The shadow(color:radius:x:y:) modifier adds a shadow of the specified color and radius (size) to the view, offset by (x, y). The default Color is black with opacity 0.33 and the default offset is (0, 0).

For your northwest and southeast shadow modifiers, you apply a shadow at the view’s upper-left corner (negative offset values) and a different color shadow at its lower-right corner (positive offset values). For a northwest shadow, the upper-left color is highlight and the lower-right color is shadow. You switch these colors for a southeast shadow.

The colored circles and button use the same radius and offset values, so you set these as default values. Later on, the text labels need smaller values, which you’ll pass as arguments.

It doesn’t matter which order you apply the shadow modifiers. I’ve ordered them with the upper-left corner first, so it’s easy to visualize the direction of the neumorphic shadow.

Setting the background color

For these shadows to work, the view background must be the same color as the UI elements. Head back to ContentView to set this up.

You’ll use a ZStack to set the entire screen’s background color to element. The Z-direction is perpendicular to the screen surface, so it’s a good way to layer views on the screen. Items lower in a ZStack closure appear higher in the stack view. Think of it as placing the first view down on the screen surface, then layering the next view on top of that, and so on.

So here’s what you do: Embed the VStack in a ZStack then add Color.element before the VStack.

ZStack {
  Color.element
  VStack {...}
}

Refresh the preview. You layered the Color below the VStack, but the color doesn’t extend into the safe area. To fix this, add this modifier to Color.element:

.ignoresSafeArea()

Note: You could add this modifier to ZStack instead of to Color, but then the ZStack would feel free to spread its content views into the safe area, which probably isn’t what you want.

Now your app looks the same as before, except the background is not quite white. Next, you’ll give the color circles a border, then apply a highlight and shadow to that border.

Creating a neumorphic border

The easiest way to create a border is to layer the RGB-colored circle on top of an element-colored circle using — you guessed it — a ZStack.

In ColorCircle, replace the contents of body with the following:

ZStack {
  Circle()
    .fill(Color.element)
    .northWestShadow()
  Circle()
    .fill(Color(red: rgb.red, green: rgb.green, blue: rgb.blue))
    .padding(20)
}
.frame(width: size, height: size)

You embed Circle() in a ZStack, add an element-colored Circle before it, then add padding to make the RGB circle smaller. To get the shadow effect, you apply northWestShadow() to the border circle.

Note: The modifier fill(_:style:) can only be applied to shapes, so changing the order of modifiers flags an error:

Circle()
  .padding(20)
  .fill(Color(red: rgb.red, green: rgb.green, blue: rgb.blue))

Finally, you set both width and height to size.

If necessary, refresh the preview to see how this looks:

Neumorphic color circle on white background
Neumorphic color circle on white background

Yes, there’s a shadow, but the ColorCircle preview has a white background, so you don’t see the full effect of the shadow.

Scroll down to previews and change its contents to the following:

ZStack {
  Color.element
  ColorCircle(rgb: RGB(), size: 200)
}
.frame(width: 300, height: 300)
.previewLayout(.sizeThatFits)

You set the background color to element the same way as in ContentView. There’s no safe area to worry about because the preview frame is already set big enough to show off the shadow.

Neumorphic color circle on element-colored background
Neumorphic color circle on element-colored background

Against the not-quite-white background, the highlight on the upper left edge stands out more and the shadow of the lower-right edge appears less dark. Comment out and uncomment Color.element in previews to confirm this for yourself.

Now go back to ContentView and refresh its preview:

Neumorphic target color circle
Neumorphic target color circle

Congratulations, your circles are now neumorphic!

Order of modifiers

When you apply more than one modifier to a view, sometimes the order matters.

Modifiers like padding and frame change the view’s layout or position. Modifiers like background or border fill or wrap a view. Normally, you want to set up a view’s layout and position before you fill or wrap it.

For example, in ContentView, add a border modifier after the padding modifier of Text(guess.intString):

.padding()
.border(Color.purple)

Border around padded text
Border around padded text

The default amount of padding surrounds the Text view’s text, then you put a purple border around the padded text.

Now change the order:

.border(Color.purple)
.padding()

Padding around bordered text
Padding around bordered text

If you apply the border first, it goes around the intrinsic area of the text. If you select padding() in the code editor, you can see where it is, but all it does is keep the neighboring elements at a distance.

Delete .border(Color.purple).

Some modifiers can only be applied to certain kinds of views. For example, these modifiers can only be applied to Text views:

Text modifiers
Text modifiers

Some, but not all, of these modifiers return a Text view. For example, font, fontWeight, bold and italic modify a Text view to produce another Text view. So you can apply these modifiers in any order.

But lineLimit returns some View, so this flags an error:

Text(guess.intString)
  .lineLimit(0)
  .bold()

And this order is OK:

Text(guess.intString)
  .bold()  
  .lineLimit(0)

You’ll learn more about using modifiers in “Intro to Controls: Text & Image”.

Creating a neumorphic button

Next, still in ContentView, let’s make your Hit Me! button pop!

To cast a shadow, the button needs a more substantial shape. Add these modifiers below the action, before the alert:

.frame(width: 327, height: 48)
.background(Capsule())

You set the background to a capsule shape. Capsule is a RoundedRectangle with the corner radius value set to half the length of its shorter side. It fills the frame you specified.

The fill color defaults to primary which, in light mode, is black.

This is a neumorphic button, so add these modifiers to Capsule(), to set its fill color to element and apply a northwest shadow:

.fill(Color.element)
.northWestShadow()

And here’s your neumorphic button:

Neumorphic button
Neumorphic button

Creating a custom button style

When you start customizing a button, it’s a good idea to create a custom button style. Even if you’re not planning to reuse it in this app, your code will be less cluttered. Especially if you decide to add more options to this button style.

So create a new Swift file named NeuButtonStyle and replace import Foundation with the following code:

import SwiftUI

struct NeuButtonStyle: ButtonStyle {
  let width: CGFloat
  let height: CGFloat

  func makeBody(configuration: Self.Configuration)
  -> some View {
    configuration.label
      // Move frame and background modifiers here
  }
}

ButtonStyle is a protocol that provides a ButtonStyleConfiguration with two properties: the button’s label and a Boolean that’s true when the user is pressing the button.

You’ll implement makeBody(configuration:) to modify label.

You already figured out how you want to modify the Button, so cut the frame and background modifiers from the Button in ContentView and paste them below configuration.label in NeuButtonStyle.

Then replace the frame’s width and height values with the corresponding properties of NeuButtonStyle.

Your button style code now looks like this:

struct NeuButtonStyle: ButtonStyle {
  let width: CGFloat
  let height: CGFloat

  func makeBody(configuration: Self.Configuration)
  -> some View {
    configuration.label
      .frame(width: width, height: height)
      .background(
        Capsule()
          .fill(Color.element)
          .northWestShadow()
      )
  }
}

Back in ContentView, modify the Button with this line of code:

.buttonStyle(NeuButtonStyle(width: 327, height: 48))

This width value works for iPhones like the 13 Pro. To support smaller or larger iPhones, you’ll learn how to pass values that fit later on.

Now refresh the preview. It should look the same as before:

Neumorphic button using NeuButtonStyle
Neumorphic button using NeuButtonStyle

But… it’s not the same. The button label is now black, not blue!

Fixing button style issues

When you create a custom button style, you lose the default label color and the default visual feedback when the user taps the button.

Label color isn’t a problem if you’re already using a custom color. If not, you would just add this modifier to configuration.label in the NeuButtonStyle structure:

.foregroundColor(Color(UIColor.systemBlue))

However, the Figma design’s button text is black, so this isn’t a problem.

Now to tackle the visual feedback issue.

Creating a button style actually makes you responsible for defining what happens when the user taps the button. In fact, the configuration label’s description is “a view that describes the effect of pressing the button”.

Live-preview ContentView and tap the button: The button’s appearance doesn’t change at all when you tap it. This isn’t a good user experience. Fortunately, it’s easy to recover the default behavior.

Before you leave the ContentView file, click the pin button in the lower-left corner of the canvas:

Pin the ContentView preview
Pin the ContentView preview

You’re going to be working in the NeuButtonStyle file, making changes that affect ContentView. Pinning its preview means you’ll be able to see the effect of your changes without having to bounce back and forth between the two files.

Now, in NeuButtonStyle, add this line above the frame modifier:

.opacity(configuration.isPressed ? 0.2 : 1)

When the user taps the button, you reduce the label’s opacity, producing the standard dimming effect.

Refresh the live preview of ContentView and check this.

If you want to take advantage of your neumorphic button, you can turn off or switch the direction of the shadow when the user taps the button.

In NeuButtonStyle, replace the contents of background with this:

Group {
  if configuration.isPressed {
    Capsule()
      .fill(Color.element)
  } else {
    Capsule()
      .fill(Color.element)
      .northWestShadow()
  }
}

Group is another SwiftUI container. It doesn’t do any layout. It’s just useful when you need to wrap code that’s more complicated than a single view.

If the user is pressing the button, you show a flat button. Otherwise, you show the shadowed button.

Refresh the live preview then tap the button. Hold down the button to see the shadow disappears.

A variation on this is to apply southEastShadow() when isPressed is true:

Group {
  if configuration.isPressed {
    Capsule()
      .fill(Color.element)
      .southEastShadow()   // Add this line
  } else {
    Capsule()
      .fill(Color.element)
      .northWestShadow()
  }
}

Turn off live preview.

Creating a beveled edge

Next, you’ll create a new look for the color circles’ labels. You’ll use Capsule again, to unify the design. But you’ll create a bevel edge effect, to differentiate it from the button.

Create a new SwiftUI View file and name it BevelText.

Replace the contents of the BevelText structure with the following:

let text: String  
let width: CGFloat
let height: CGFloat

var body: some View {
  Text(text)
}

Back in ContentView, replace the Text views and their padding with BevelText views:

if !showScore {
  BevelText(
    text: "R: ??? G: ??? B: ???", width: 200, height: 48)
} else {
  BevelText(
    text: game.target.intString, width: 200, height: 48)
}
ColorCircle(rgb: guess, size: 200)
BevelText(text: guess.intString, width: 200, height: 48)

BevelText views don’t need padding because their frame height is 48 points.

Now unpin the ContentView preview so you can focus on BevelText.

Back in BevelText, replace the contents of previews with the following:

ZStack {
  Color.element
  BevelText(
    text: "R: ??? G: ??? B: ???", width: 200, height: 48)
}
.frame(width: 300, height: 100)
.previewLayout(.sizeThatFits)

You layer BevelText on top of the element-color background. This is your starting point for creating a capsule with a bevel edge.

BevelText: Getting started
BevelText: Getting started

In the body of BevelText, add these two modifiers to Text:

.frame(width: width, height: height)
.background(
  Capsule()
    .fill(Color.element)
    .northWestShadow(radius: 3, offset: 1)
)

Refresh the preview. This is the outer capsule shape. It’s just a smaller version of NeuButtonStyle:

Outer Capsule with northwest shadow
Outer Capsule with northwest shadow

Now embed this in a ZStack so you can layer another Capsule onto it, inset by 3 points:

ZStack {
  Capsule()
    .fill(Color.element)
    .northWestShadow(radius: 3, offset: 1)
  Capsule()
    .inset(by: 3)
    .fill(Color.element)
    .southEastShadow(radius: 1, offset: 1)
}

Imagine the sun is setting in the northwest: It highlights the outer upper-left edge and the inner lower-right edge and casts shadows from the inner upper-left edge and the outer lower-right edge.

To get this effect, you apply the southeast shadow to the inner Capsule.

BevelText: Finished
BevelText: Finished

Note: Thanks to Caroline Begbie for this elegantly simple implementation.

And now, back to ContentView to enjoy the results:

Neumorphism accomplished!
Neumorphism accomplished!

“Debugging” dark mode

Remember that the color sets in Assets have dark mode values. What does this design look like in dark mode?

It’s easy to preview in dark mode. If live-preview is on, turn it off, then open the preview’s inspector and select Dark color scheme:

Set preview's color scheme to Dark.
Set preview's color scheme to Dark.

Thanks to the magic of color sets, you get dark mode shadows for free!

Neumorphism: Dark mode
Neumorphism: Dark mode

There seems to be a problem, however. Fire up live preview again and tap Hit Me!. The alert’s color scheme isn’t dark?!

Alert's color scheme isn't dark?!
Alert's color scheme isn't dark?!

I spent a lot of time trying to figure out a way around this. But this is a fine example of why you shouldn’t rely entirely on the preview.

Build and run the app on the iPhone 13 Pro simulator. The first thing you notice is the preview’s dark color scheme doesn’t affect the simulator.

Not a problem: Click the Environment Overrides button in the debug toolbar and enable Appearance ▸ Dark:

Override color scheme while running in a simulator.
Override color scheme while running in a simulator.

Note: You can find a list of built-in EnvironmentValues at apple.co/2yJJk7T. Many of these correspond to device user settings like accessibility, locale, calendar and color scheme.

Now the simulator displays the app in dark mode. Tap Hit Me!:

Simulator: Alert's color scheme is dark.
Simulator: Alert's color scheme is dark.

Dark mode, dark alert, just as it should be!

So if the preview doesn’t show what you expect to see, try running it on a simulated or real device before you waste any time trying to fix a phantom problem.

Stop the simulator and live preview.

Delete or comment out this line that the preview inspector inserted into ContentView previews:

.preferredColorScheme(.dark)

Modifying font

You need one more thing to put the finishing touch on the Figma design: All the text needs to be a little bigger and a little bolder.

In ContentView, add this modifier to the VStack that contains all the UI elements:

.font(.headline)

You set a view-level environment value for the VStack that affects all of its child views. So now all the text uses headline font size:

Headline font size applies to all the text.
Headline font size applies to all the text.

You can override this overall Text modifier. For example, add this modifier to the HStack in the ColorSlider structure:

.font(.subheadline)

Now the slider labels use the smaller, not-bold font:

Slider labels use subheadline font size.
Slider labels use subheadline font size.

Adapting to the device screen size

OK, time to see how this design looks on a smaller screen. To check how your design fits in a smaller screen, specify previewDevice for previews. Add this modifier to ContentView(guess: RGB()):

.previewDevice("iPhone 8")

Preview device: iPhone 8
Preview device: iPhone 8

The height of an iPhone 8 screen is only 667 points, so the button isn’t visible. You can fix this problem by making the color circles smaller. But by how much?

Another way to check your design in other screen sizes is to select a simulator from the run destination menu.

Delete the previewDevice(_:) modifier and change the run destination to iPhone 13 Pro Max. The preview updates to use this simulator:

Run destination: iPhone 13 Pro Max
Run destination: iPhone 13 Pro Max

The height of this screen is 926 points, so there’s more blank space. Here, you could make the circles larger. But by how much?

In ContentView, add these properties below the @State properties:

let circleSize: CGFloat = 0.275
let labelHeight: CGFloat = 0.06
let labelWidth: CGFloat = 0.53
let buttonWidth: CGFloat = 0.87

I worked out these proportions from the original 375x812 Figma design, after checking that the safe area height of an iPhone 13 mini is 728 points. These fractions yield close to the values you hard-wired into your code: circleSize * 728 = 200.2, labelHeight * 728 = 43.68, labelWidth * 375 = 198.75, buttonWidth * 375 = 326.25.

The button height is also labelHeight.

Now, if your code can detect the height and width of the screen size, it can calculate the right sizes for these elements.

Getting screen size from GeometryReader

This is what you’ll do: Embed the ZStack in a GeometryReader to access its size and frame values.

Note: Learn more about GeometryReader in “Chapter 18: Drawing & Custom Graphics”.

The Command-click menu has a handy catch-all item Embed…:

Embed ZStack in ... some container.
Embed ZStack in ... some container.

In ContentView, embed the top-level ZStack in this generic Container, then change Container to GeometryReader:

GeometryReader { proxy in
  ZStack {

GeometryReader provides you with a GeometryProxy object that has a frame method and size and safeAreaInset properties. You name this object proxy.

In the two ColorCircle initializers, replace size: 200 with

size: proxy.size.height * circleSize

In the three BevelText initializers, replace width: 200, height: 48 with

width: proxy.size.width * labelWidth,
height: proxy.size.height * labelHeight

Replace (NeuButtonStyle(...)) with

(NeuButtonStyle(
  width: proxy.size.width * buttonWidth,
  height: proxy.size.height * labelHeight))

Change the run destination back to iPhone 13 Pro and refresh the preview to check it still looks the same as before.

Previewing different devices

To see all three screen sizes at once, you could build and run the app on two simulators. Instead, you’ll add previews to ContentView_Previews.

Click the Duplicate Preview button in the Preview toolbar:

Duplicate-preview button
Duplicate-preview button

Another ContentPreview appears in the canvas preview, and now there’s a Group in the code editor.

Group {
  ContentView(guess: RGB())
  ContentView(guess: RGB())
}

Add this modifier to the first ContentView in the Group:

.previewDevice("iPhone 8")

The device name must match one of those in the run destination menu. For example, “iPhone SE” doesn’t work because it’s “iPhone SE (2nd generation)” in the menu.

Now the canvas shows an iPhone 8 and an iPhone 13 Pro (your run destination):

Preview of iPhone 8 and iPhone 13 Pro
Preview of iPhone 8 and iPhone 13 Pro

It’s a tighter fit on the smaller iPhone, but everything’s visible.

Copy and paste the “iPhone 8” code, and change the device name as follows:

.previewDevice("iPhone 13 Pro Max")

And now there are three. And the design elements have resized to fit.

Key points

  • SwiftUI views and modifiers help you quickly implement your design ideas.

  • The Library contains a list of primitive views and a list of modifier methods. You can easily create custom views, button styles and modifiers.

  • Neumorphism is the new skeumorphism. It’s easy to implement with color sets and the SwiftUI shadow modifier.

  • You can use ZStack to layer your UI elements. For example, lay down a background color and extend it into the safe area, then layer the rest of your UI onto this.

  • Usually, you want to apply a modifier that changes the view’s layout or position before you fill it or wrap a border around it.

  • Some modifiers can be applied to all view types, while others can be applied only to specific view types, like Text or shapes. Not all Text modifiers return a Text view.

  • Create a custom ButtonStyle by implementing its makeBody(configuration:) method. You’ll lose some default behavior like label color and dimming when tapped.

  • If the preview doesn’t show what you expect to see, try running it on a simulated or real device before you waste any time trying to fix a phantom problem.

  • Use GeometryReader to access the device’s frame and size properties.

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.