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.
Neumorphism
Neumorphism gained popularity in 2020 as the new skeuomorphism, a pushback against super-flat minimal UI. It can cause accessibility issues and is best used for non-functional elements like your app’s color circles or in designs with obvious UI elements.
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 11 Pro or 13 mini), although most of the screenshots use an iPhone 14 Pro (393 x 852). 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!
Views and Modifiers
Open the project in this chapter’s starter folder. 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 colors for the neumorphic design — more about these soon.
In the ContentView file, 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, 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.
Implementing Neumorphism
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
ZStackinstead of toColor, but then theZStackwould 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:
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 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, 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 Chapter 5: “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:
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 iPhone 14. 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:
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 upper-left corner of the canvas:
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.
Check this in the live preview of ContentView.
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.
Tap the button in the live preview. 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()
}
}
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, in the BevelText canvas, select the Bevel Text preview so you can focus on it. 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, select the ContentView preview 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. Click the Device Settings button and switch on Color Scheme: It defaults to Dark appearance:
Thanks to the magic of color sets, you get dark mode shadows for free!
There seems to be a problem, however. In live preview, 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 14 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: Apple has a list of built-in
EnvironmentValues. 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.
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:
You can override this overall Text modifier: 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. By default, the preview uses the currently active scheme’s run destination. To check how your design fits in a different screen, you can select a different run destination, or you can specify a previewDevice for previews.
Changing the Run Destination
Change the run destination to iPhone 14 Pro Max. Xcode takes a while to start up this simulator in the background, and eventually the preview updates to use this simulator:
The height of this screen is 932 points, so there’s more blank space. You could make the circles larger. But by how much?
Specifying a previewDevice
When you install Xcode 14, there might be only a few iPhone simulators in the destination run menu — mine lists only the iPhone 14 sizes and the SE 3rd generation. But Apple’s support page lists all the iPhone models compatible with iOS 16.
Check your destination run menu: Is iPhone 8 listed? If not, select Add Additional Simulators…, type in iPhone 8, then click Create.
Now, add this modifier to ContentView(guess: RGB()):
.previewDevice(
PreviewDevice(
rawValue: "iPhone 8"))
The height of an iPhone 8 screen is only 667 points, so the top circle is clipped, and only part of the button is visible. You can fix this problem by making the color circles smaller. But by how much?
Calculating Size Proportions
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
GeometryReaderin Chapter 18: “Drawing & Custom Graphics”.
The Command-click menu has a handy catch-all item Embed…:
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))
Next, you’ll preview all three sizes — iPhone 8, iPhone 14 Pro and iPhone 14 Pro Max — to check everything now fits.
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.
In previews, Command-click ContentView and select Group:
You’ve embedded ContentView in a Group.
Now, click anywhere in the ContentView line, then press Command-D to duplicate just this one line.
Next, select the second ContentView line and its previewDevice modifier, then press Command-D to duplicate these two lines. Add a line break to move the duplicate to its own line.
You now have three ContentView items in a Group:
Group {
ContentView(guess: RGB())
ContentView(guess: RGB())
.previewDevice(
PreviewDevice(
rawValue: "iPhone 8"))
ContentView(guess: RGB())
.previewDevice(
PreviewDevice(
rawValue: "iPhone 8"))
}
And the canvas shows three preview buttons:
The first preview button shows the first ContentView in the Group: This is the currently selected run destination, which is still iPhone 14 Pro Max — the largest screen size.
Change the first .previewDevice modifier to display iPhone 14 Pro:
ContentView(guess: RGB())
.previewDevice(
PreviewDevice(
rawValue: "iPhone 14 Pro"))
Click the second preview button to see the iPhone 14 Pro preview, then click the third preview button to view the iPhone 8 preview:
It’s a tighter fit on the smallest iPhone, but everything’s visible, 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
shadowmodifier. - You can use
ZStackto 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
Textor shapes. Not allTextmodifiers return aTextview. - Create a custom
ButtonStyleby implementing itsmakeBody(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
GeometryReaderto access the device’s frame and size properties.