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 ContentView.swift, with the canvas open, click the + button or press Command-Shift-L to open the Library:
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, paints 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:
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:
This design was laid out for a 375x812-point screen (iPhone 12 Pro). 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: #F2F3F7; Dark: #292A2E
- Highlight: #FFFFFF (20% opacity); Dark: #3E3F42
- Shadow: #BECDE2; Dark: #1A1A1A
ColorExtension.swift 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.swift 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 color circles and button use the same radius and offset values, so you set these as default values. But 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.swift 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
:
.edgesIgnoringSafeArea(.all)
Note: You could add this modifier to
ZStack
instead of toColor
, but then theZStack
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.swift, 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:
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.
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.swift and refresh its preview:
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.swift, add a border
modifier after the padding
modifier of Text(guess.intString)
:
.padding()
.border(Color.purple)
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()
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:
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.swift 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:
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.swift 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.swift, modify the Button
with this line of code:
.buttonStyle(NeuButtonStyle(width: 327, height: 48))
This width value works for iPhones like the 12 Pro. To support smaller or larger iPhones, you’ll learn how to pass values that fit.
Now refresh the preview. It should look the same as before:
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 ContentView.swift, click the pin button in the lower-left corner of the canvas:
You’re going to be working in NeuButtonStyle.swift, 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.swift.
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.swift, 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.swift, 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.
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
:
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
.
Note: Thanks to Caroline Begbie for this elegantly simple implementation.
And now, back to ContentView.swift to enjoy the results:
“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:
Thanks to the magic of color sets, you get dark mode shadows for free!
There seems to be a problem, however. Fire up live preview again and tap Hit Me!. The 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 12 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:
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!:
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.
Delete or comment out this line in 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.swift, 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:
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:
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 or larger screen, just select a simulator from the run destination menu. The preview will update to use your selected simulator.
Change the run destination to 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?
Now change the run destination to iPhone 12 Pro Max:
The height of this screen is 896 points, so there’s a tiny bit more blank space. Again, you can make the circles larger. But by how much?
In ContentView.swift, add these properties below the State
variables:
let circleSize: CGFloat = 0.5
let labelWidth: CGFloat = 0.53
let labelHeight: CGFloat = 0.06
let buttonWidth: CGFloat = 0.87
I worked out these proportions from the original 375x812 Figma design. These fractions yield close to the values you hard-wired into your code: circleSize
* 375 = 187.5, labelWidth
* 375 = 199, labelHeight
* 812 = 49, buttonWidth
* 375 = 326.
The button height is also labelHeight
.
The Figma design has the circle diameter equal to the label width, but the circles need a bit more shrinking to fit the smaller screen.
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 “Chapter17: Drawing & Custom Graphics”.
The Command-click menu has a handy catch-all item Embed…:
In ContentView.swift, embed the top-level ZStack
in this generic Container
, then change Container
to GeometryReader
:
GeometryReader { geometry in
ZStack {
GeometryReader
provides you with a GeometryProxy
object that has a frame
method and size
and safeAreaInset
properties. You name this object geometry
.
In the two ColorCircle
initializers, replace size: 200
with
size: geometry.size.width * circleSize
In the three BevelText
initializers, replace width: 200, height: 48
with
width: geometry.size.width * labelWidth,
height: geometry.size.height * labelHeight
Replace (NeuButtonStyle(...))
with
(NeuButtonStyle(
width: geometry.size.width * buttonWidth,
height: geometry.size.height * labelHeight))
Change the run destination back to iPhone 12 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:
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 12 Pro (your run destination):
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 12 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 allText
modifiers return aText
view. -
Create a custom button style 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.