9.
Refining Your App
Written by Caroline Begbie
While you’ve been toiling making your app functional, your designer has been busy coming up with a stunning eye-catching design. One of the strengths of SwiftUI is that, as long as you’ve been encapsulating views and separating them out along the way, it’s easy to restyle the UI without upsetting the main functionality.
In this chapter, you’ll style some of the views for iPhone, making sure that they work on all iPhone devices.
Creating individual reusable elements is a good place to start. Looking at the design, you’ll have to style:
- A raised button for Get Started and Start Exercise.
- An embossed button for History and the exercise rating. The History button is a capsule shape, while the rating is round.
- A shaped gray background view with a gradient behind.
The starter app contains the colors and images that you’ll need in the asset catalog. There’s also some code for creating the welcome image and text in WelcomeImages.swift.
Neumorphism
Skills you’ll learn in this section: neumorphism
The style of design used in HIITFit, where the background and controls are one single color, is called neumorphism. You achieve the look with shading rather than with colors.
When iPhone was first released, peak design was skeuomorphic interfaces with realistic surfaces, so you had wood and fabric textures with dials that looked real throughout your UI. iOS 7 went in the opposite direction focussing on content with minimalistic flat design. Since then design trends include gradients and depth.
The name Neumorphism comes from New + Skeuomorphism and refers to minimalism combined with realistic shadows.
Essentially, you choose a theme color. You then choose a lighter tint and a darker shade of that theme color for the highlight and shadow. You can define colors with either red, green, blue (RGB) or hue, saturation and lightness (HSL). When shifting tones within one color, HSL is the easier model to use as you keep the same hue. The base color in the picture above is Hue: 166, Saturation: 54, Lightness: 59. The lighter highlight color has the same Hue and Saturation, but a Lightness: 71. Similarly, the darker shadow color has a Lightness: 30.
Creating a Neumorphic Button
The first button you’ll create is the Get Started raised button.
➤ Open the starter project for this chapter. The starter project has extra assets in Assets.xcassets that you’ll use when styling the app.
➤ In the Views folder, create a new folder called Styling. In the Styling folder, create a new SwiftUI View file called RaisedButton.swift.
Replace RaisedButton and the preview with:
struct RaisedButton: View {
var body: some View {
Button(action: {}, label: {
Text("Get Started")
})
}
}
#Preview(traits: .sizeThatFitsLayout) {
ZStack {
RaisedButton()
.padding(20)
}
.background(Color.background)
}
Here you create a plain vanilla button with a preview sized to fit the button. Assets.xcassets holds the background color.
Note: Being able to use
Color.backgroundas a symbol, rather than using the namedColor("background"), is controlled by Xcode build settings Generate Asset Symbols and Generate Swift Asset Symbol extensions. In Xcode 16 these are turned on by default.
Preview the button using Selectable to see only the button.
The text style on both the raised buttons in your app will have the same design.
➤ Add this code after RaisedButton:
extension Text {
func raisedButtonTextStyle() -> some View {
self
.font(.body)
.fontWeight(.bold)
}
}
Here you style the text with a bold font.
➤ In RaisedButton, add the new modifier to Text("Get Started"):
.raisedButtonTextStyle()
Abstracting the style into a modifier makes your app more robust. If you want to change the text style of the buttons, simply change raisedButtonTextStyle() and the changes will reflect wherever you used this style.
Styles
Skills you’ll learn in this section: view styles; button style; shadows
Apple knows that you often want to style objects, so it created a range of style protocols for you to customize. You’ve already used one of these styles, the built-in PageTabViewStyle, on your TabView. Styling text is not on that list, which is why you created your own view modifier.
You can customize buttons by setting up a structure that conforms to ButtonStyle.
➤ Add this new structure to RaisedButton.swift:
struct RaisedButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.background(Color.red)
}
}
Here you make a simple style giving the button text a red background. ButtonStyle has one required method: makeBody(configuration:). The configuration gives you the button’s label text and a Boolean isPressed telling you whether the button is currently depressed.
Swift Tip: If you want to customize how the button action triggers with gestures, you can use
PrimitiveButtonStyleinstead ofButtonStyle.
➤ Still in RaisedButton.swift, add a new extension to ButtonStyle:
extension ButtonStyle where Self == RaisedButtonStyle {
static var raised: RaisedButtonStyle {
.init()
}
}
This makes using the button style more Swift-y. Instead of adding a button style: buttonStyle(RaisedButtonStyle()), you can instead use: buttonStyle(.raised).
You can use this button style to change all your buttons in a view hierarchy.
➤ Open HIITFitApp.swift and temporarily add this new modifier to ContentView():
.buttonStyle(.raised)
You tell ContentView that whenever there’s a button in the hierarchy, it should use your custom style.
➤ Build and run the app.
All the buttons in your app will use your new style with the red background. Notice that when you use a style, the button text color changes from the default accent color of blue to the primary color. That’s black in Light Mode and white in Dark Mode.
➤ The buttons in your app won’t all use the same style so remove .buttonStyle(.raised) from HIITFitApp.
➤ Open RaisedButton.swift and in #Preview add a new modifier to RaisedButton() :
.buttonStyle(.raised)
You can now preview your button style as you change it.
➤ In RaisedButtonStyle, change makeBody(configuration:) to:
func makeBody(configuration: Configuration) -> some View {
configuration.label
.frame(maxWidth: .infinity)
.padding([.top, .bottom], 12)
.background(
Capsule()
)
}
When you set frame(maxWidth:) to .infinity, you ask the view to take up as much width as its parent gives it. Add some padding around the label text at top and bottom. For the background, use a Capsule shape.
When you use Shapes, such as Rectangle, Circle and Capsule, the default fill color is black, so you’ll change that in your neumorphic style to match the background color.
Shadows
You have two choices when adding shadows. You can choose a simple all round shadow, with a radius. The radius is how many pixels to blur out to. A default shadow with radius of zero places a faint gray line around the object, which can be attractive.
The other alternative is to specify the color, the amount of blur radius, and the offset of the shadow from the center.
➤ In makeBody(configuration:), add new modifiers to Capsule() one at a time:
.foregroundStyle(Color.background)
.shadow(color: Color.dropShadow, radius: 4, x: 6, y: 6)
.shadow(color: Color.dropHighlight, radius: 4, x: -6, y: -6)
Watch the button preview change as you add these modifiers. Your darker shadow is offset by six pixels to the right and down, whereas the highlight is offset by six pixels to the left and up. When you add the highlight, the button really pops off the screen.
The buttons work in Dark Mode too, because each color in the asset catalog has a value for both Light Mode and Dark Mode. You’ll learn more about the asset catalog in Chapter 16, “Adding Assets to Your App”.
Note: You may notice that
Color.dropShadowis called “drop-shadow” in the asset catalog. Because hyphens aren’t allowed in property names, Xcode automatically recognizes the name in camel case.
Abstracting Your Button
Skills you’ll learn in this section: passing closures to views
Your button is finished, so you can now replace your three buttons in your app with this one.
➤ Open WelcomeView.swift and locate the button code for Get Started. Replace the button code and all the button modifiers with:
Button(action: { selectedTab = 0 }) {
Text("Get Started")
.raisedButtonTextStyle()
}
.buttonStyle(.raised)
.padding()
Here you use your new text and button styles to create your new button. In Live Preview, even though you haven’t yet changed the background color, it looks great.
You could change the other button in the same way, or you could make RaisedButton more abstract by passing in text and an action. You became familiar with closures in the previous chapter, and here’s another way you might use one.
➤ Open RaisedButton.swift and change RaisedButton to:
struct RaisedButton: View {
let buttonText: String
let action: () -> Void
var body: some View {
Button(action: {
action()
}, label: {
Text(buttonText)
.raisedButtonTextStyle()
})
.buttonStyle(.raised)
}
}
You pass in the button text and an action closure. The action closure of type () -> Void takes no parameters and returns nothing. Inside Button’s action closure, you perform action().
➤ In the preview where you have a compile error, change RaisedButton() to:
RaisedButton(
buttonText: "Get Started",
action: {
print("Hello World")
})
When the user taps the button marked Get Started, the preview prints Hello World in the Previews Debug console.
When a closure is the method’s last parameter, the preferred way of calling it is to use special trailing closure syntax.
➤ Replace the code above with:
RaisedButton(buttonText: "Get Started") {
print("Hello World")
}
With trailing closure syntax, you remove the action label and take the closure out of the method’s calling parentheses.
Open WelcomeView.swift and create a new property for the Get Started button:
var getStartedButton: some View {
RaisedButton(buttonText: "Get Started") {
selectedTab = 0
}
.padding()
}
➤ In body, change your previous Get Started button code, including modifiers, to:
getStartedButton
That code is a lot more succinct but still descriptive and has the same functionality as before.
➤ Open ExerciseView.swift and replace startButton with:
var startButton: some View {
RaisedButton(buttonText: "Start Exercise") {
showTimer.toggle()
}
}
At the end of the next chapter, the challenge project moves the Done button to a new modal view, so you don’t need to change it here.
The Embossed Button
Skills you’ll learn in this section: stroking a shape
The History button will have an embossed border in the shape of a capsule. If you remember from the start of the chapter, the rating view will also have an embossed border. The rating view contains Images, so your new button needs to be able to contain any content, not just text. For this reason, you’ll create just a new button style and not a new button structure.
➤ In the Styling folder, create a new SwiftUI View file named EmbossedButton.swift.
➤ Remove EmbossedButton entirely as you won’t be needing it.
➤ Copy RaisedButtonStyle from RaisedButton.swift to EmbossedButton.swift, and change the name of the copied RaisedButtonStyle to EmbossedButtonStyle.
➤ Replace #Preview with:
#Preview(traits: .sizeThatFitsLayout) {
Button("History") {}
.fontWeight(.bold)
.buttonStyle(EmbossedButtonStyle())
.padding(40)
}
You show a History button using the embossed button style.
➤ Set up the Color Scheme Variants:
➤ In EmbossedButtonStyle, replace makeBody(configuration:) with:
func makeBody(configuration: Configuration) -> some View {
let shadow = Color.dropShadow
let highlight = Color.dropHighlight
return configuration.label
.padding(10)
.background(
Capsule()
.stroke(Color.background, lineWidth: 2)
.foregroundStyle(Color.background)
.shadow(color: shadow, radius: 1, x: 2, y: 2)
.shadow(color: highlight, radius: 1, x: -2, y: -2)
.offset(x: -1, y: -1))
}
Here you use stroke(_:linewidth:) to outline the capsule instead of filling it with color. You’ll learn more about shapes and fills in Chapter 18, “Paths & Custom Shapes”. You offset the capsule outline by half the width of the stroke, which centers the content.
The padding doesn’t look enough for the text, but different content may require minimal padding, so you’ll add the padding to the content you provide for the button instead of inside the button style.
Your capsule-shaped button is now ready for use in your app. However, looking back at the design at the beginning of the chapter, the designer has placed the ratings in a circular embossed button. You can make your button more useful by allowing different shapes.
➤ Add a new enumeration to EmbossedButton.swift:
enum EmbossedButtonShape {
case circle, capsule
}
➤ In EmbossedButtonStyle, below makeBody(configuration:), add a new method:
func shape() -> some View {
Capsule()
}
Here, you will determine the shape depending on a passed-in parameter.
➤ In makeBody(configuration:), replace Capsule() with:
shape()
You get a compile error, as stroke(_:lineWidth:) is only allowed on actual shapes such as Rectangle or Capsule, not on some View.
➤ Place your cursor on .stroke(Color.background, lineWidth: 2), and press Option-Command-] repeatedly to move the line down to below Capsule() in shape(). The compile error will then go away.
➤ Add a new property to EmbossedButtonStyle:
var buttonShape = EmbossedButtonShape.capsule
If you don’t provide a shape, the embossed button will be a capsule.
➤ Change shape() to:
func shape() -> some View {
switch buttonShape {
case .circle:
Circle()
.stroke(Color.background, lineWidth: 2)
case .capsule:
Capsule()
.stroke(Color.background, lineWidth: 2)
}
}
Here you return the desired shape. Unfortunately, you get a compile error. You’ll look at this problem in more depth in Section 2, but for now, you just need to understand that the compiler expects some View to be one type of view. You’re returning either a Circle or a Capsule, determined at run time, so the compiler doesn’t know which type some View should be at compile time.
@ViewBuilder
Skills you’ll learn in this section: view builder attribute
There are several ways of dealing with this problem. One way is to return a Group from shape() and place switch inside Group.
Another way is to use the function builder @ViewBuilder. Various built-in views, such as HStack and VStack are made up of various types of views, and they achieve this by using @ViewBuilder.
➤ Add this above func shape() -> some View {:
@ViewBuilder
Your code now magically compiles.
Internally, ViewBuilder is a type of result builder that takes in a list of views in a closure and combines them into one TupleView. A tuple is a loosely formed type made up of several items.
ViewBuilder uses advanced features such as generics and the new parameter packs, making it easy for you to ignore what’s going on under the hood.
SwiftUI uses result builders extensively. As well as the VStack view builder, SwiftUI provides view modifiers such as View.toolbar(content:), which is also a result builder called ToolbarContentBuilder. A toolbar contains a list of ToolbarItems.
All you need to know for the present is that you can create a closure with a list of views, and assign it the ViewBuilder attribute to combine the views into a single View.
@ViewBuilder func shape(), where you return either a Circle or a Capsule, is a simple example. Shortly, you’ll create your own container view where you can stack up other views just as VStack does.
➤ In #Preview, change .buttonStyle(EmbossedButtonStyle()) to:
.buttonStyle(EmbossedButtonStyle(buttonShape: .circle))
The circle takes its diameter from the height of the button.
➤ To visualize this, choose the selectable preview, and in makeBody(configuration:), click configuration.label to view the text outline in the preview:
The size of the circle should be the larger of either the width or the height of the button contents. You’ve already used GeometryReader to find out the size of a view, and that’s what you’ll use here.
➤ In makeBody(configuration:), embed shape() in GeometryReader and add a size parameter to shape. This is the contents of background(_:):
.background(
GeometryReader { geometry in
shape(size: geometry.size)
.foregroundStyle(Color.background)
.shadow(color: shadow, radius: 1, x: 2, y: 2)
.shadow(color: highlight, radius: 1, x: -2, y: -2)
.offset(x: -1, y: -1)
})
➤ Change func shape() -> some View to:
func shape(size: CGSize) -> some View {
You’re now passing to shape(size:) the size of the contents of the button, so you can determine the larger of width or height.
➤ In shape(size:), add this modifier to Circle() after the stroke modifier:
.frame(
width: max(size.width, size.height),
height: max(size.width, size.height))
Here you set the frame to the larger of the width or height.
In the selectable preview, you can see that the circle takes the correct diameter of the width of the button contents, but starts at the top.
➤ Add this after the previous modifier:
.offset(x: -1)
.offset(y: -max(size.width, size.height) / 2 +
min(size.width, size.height) / 2)
You offset the circle in the x direction by half of the width of the stroke. In the y direction, you offset the circle by half the diameter plus the smaller of half the width or height.
Your embossed button is now complete and ready to use.
➤ Open WelcomeView.swift and add a new property:
var historyButton: some View {
Button(
action: {
showHistory = true
}, label: {
Text("History")
.fontWeight(.bold)
.padding([.leading, .trailing], 5)
})
.padding(.bottom, 10)
.buttonStyle(EmbossedButtonStyle())
}
Here you format a new History button and use the default capsule shape for the button style.
➤ In body, replace:
Button("History") {
showHistory.toggle()
}
.sheet(isPresented: $showHistory) {
HistoryView(showHistory: $showHistory)
}
.padding(.bottom)
with:
historyButton
.sheet(isPresented: $showHistory) {
HistoryView(showHistory: $showHistory)
}
➤ Copy the var historyButton code, open ExerciseView.swift and paste the code into ExerciseView.
➤ In body, replace:
Button("History") {
showHistory.toggle()
}
with:
historyButton
Notice as you replace body’s button code with properties describing the views, the code becomes a lot more readable.
➤ In RatingView.swift, in body, replace the contents of ForEach with the new round button:
Button(action: {
updateRating(index: index)
}, label: {
Image(systemName: "waveform.path.ecg")
.foregroundStyle(
index > rating ? offColor : onColor)
.font(.body)
})
.buttonStyle(EmbossedButtonStyle(buttonShape: .circle))
.onChange(of: ratings) {
convertRating()
}
.onAppear {
convertRating()
}
You embed Image inside the new embossed button as the label, and this time, you use the round embossed style.
➤ Build and run and admire your new buttons:
ViewBuilder Container View
Skills you’ll learn in this section: container views
Looking at the design at the beginning of the chapter, the tab views have a purple/blue gradient background for the header and a gray background with round corners for the rest of the view.
You can make this gray background into a container view and embed WelcomeView and ExerciseView inside it. The container view will be a @ViewBuilder. It will take in any kind of view content as a parameter and add its own formatting to the view stack. This is how HStack and VStack work.
➤ In the Styling folder, create a new SwiftUI View file named ContainerView.swift.
➤ Change struct ContainerView: View { to:
struct ContainerView<Content: View>: View {
var content: Content
Content is a generic. Generics make Swift very flexible and let you create methods that work on multiple types without compile errors. Here, Content takes on the type with which you initialize the view. You’ll learn more about generics in Chapter 15, “Structures, Classes & Protocols”.
➤ Create an initializer for ContainerView:
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
You’ll recognize the argument of the initializer as a closure. It’s a closure that takes in no parameters and returns a generic value Content. In the initializer, you run the closure and place the result of the closure in ContainerView’s local storage.
You mark the closure method with the @ViewBuilder attribute, allowing it to return a view containing multiple child views of any type.
➤ Change body to:
var body: some View {
content
}
The view here is the result of the content closure that the initializer performed.
Now, you can test your container view in the preview.
➤ Change #Preview to:
#Preview(traits: .sizeThatFitsLayout) {
ContainerView {
VStack {
RaisedButton(buttonText: "Hello World") {}
.padding(50)
Button("Tap me!") {}
.buttonStyle(EmbossedButtonStyle(buttonShape: .circle))
}
}
.padding(50)
}
You create a VStack of two buttons. You send ContainerView the VStack as the content closure parameter. ContainerView then shows the result of running the closure content.
In this example, ContainerView merely returns the content, which is a VStack. Your container view will format the background on which the content resides. You can then present any content and the background will be the same.
➤ In ContainerView replace body with:
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 25.0)
.foregroundStyle(Color.background)
VStack {
Spacer()
Rectangle()
.frame(height: 25)
.foregroundStyle(Color.background)
}
content
}
}
Here you create a rounded rectangle using the background color from the asset catalog. You don’t want the bottom corners to be rounded, so you add a rectangle with sharp corners at the bottom to cover up the corners.
Your container view is now finished. You can construct any views and present them with the same background. It’s a good idea not to add unnecessary padding to the actual container view, as that reduces the flexibility. Here the preview provides the padding, but shortly you’ll make the container view go right to the edges.
Designing WelcomeView
Skills you’ll learn in this section: refactoring with view properties; the safe area
➤ Open WelcomeImages.swift. This is a file included in your starter project which contains some images and formatted text to use in WelcomeView.
One interesting formatting tip to note in welcomeText is the text kerning in the modifier .kerning(2). This gives you control over the spacing between the letters.
➤ Open WelcomeView.swift and replace body with:
var body: some View {
VStack {
HeaderView(
selectedTab: $selectedTab,
titleText: "Welcome")
Spacer()
// container view
VStack {
WelcomeView.images
WelcomeView.welcomeText
getStartedButton
Spacer()
historyButton
}
}
.sheet(isPresented: $showHistory) {
HistoryView(showHistory: $showHistory)
}
}
Here you use the images and text from WelcomeImages.swift. Wherever you can refactor your code into smaller chunks, you should. This code is much clearer and easier to read.
➤ Embed the second VStack — the one containing the images and text — in your ContainerView:
// container view
ContainerView {
VStack {
...
}
}
ContainerView receives the VStack and formats it with the gray background.
Gradients
Skills you’ll learn in this section: gradient views
The design for this app calls for a background gradient.
SwiftUI makes using gradients really easy. You simply define the gradient colors in an array. As a background behind the header view, you’re going to use a lovely purple to blue gradient, using the predefined colors in the asset catalog.
➤ In the Styling folder, create a new SwiftUI View file called GradientBackground.swift and add a new property to GradientBackground:
var gradient: Gradient {
Gradient(colors: [
Color.gradientTop,
Color.gradientBottom
])
}
This defines the gradient colors.
➤ Change body to:
var body: some View {
LinearGradient(
gradient: gradient,
startPoint: .top,
endPoint: .bottom)
}
You start the gradient at the top and continue down to the bottom. If you want the gradient to be diagonal, you can use .topLeading as the start point and .bottomTrailing as the end point.
➤ Open ContentView.swift to add your gradient background. Add this new modifier to TabView:
.background(GradientBackground())
In Live Preview, your gradient shows behind the header view, but doesn’t cover the dynamic island or the bottom of the screen.
The Safe Area
A safe area on a device, as its name suggests, is an area where you should never place interactive views. This area might be covered by the dynamic island, a navigation bar or a toolbar.
All current devices don’t have a physical home button, so they have a safe area at the bottom of the screen where you swipe up to leave the app.
By default, a view will size itself respecting the safe areas, but you can override this.
➤ Pin ContentView in the canvas and open GradientBackground.swift.
➤ In body, add this modifier to LinearGradient:
.ignoresSafeArea()
The gradient now stretches to all screen edges. This doesn’t look great at the bottom of the screen, but you can cover that area with the gray background color.
➤ Include the gray background color in the list of colors:
Gradient(colors: [
Color.gradientTop,
Color.gradientBottom,
Color.background
])
➤ Compare the ContentView and GradientBackground previews.
Although the gradient works well on ContentView, you can see on GradientBackground that the gradient is now divided equally between the three colors and gives a less pleasing purple to blue gradient.
You can control where the gradient changes using stops.
➤ Replace gradient with:
var gradient: Gradient {
let color1 = Color.gradientTop
let color2 = Color.gradientBottom
let background = Color.background
return Gradient(
stops: [
Gradient.Stop(color: color1, location: 0),
Gradient.Stop(color: color2, location: 0.9),
Gradient.Stop(color: background, location: 0.9),
Gradient.Stop(color: background, location: 1)
])
}
Here you use purple to blue for 90% of the gradient. At the 90% mark, you switch to the background color for the rest of the gradient. As you have two stops right next to each other, you get a sharp line across instead of a gradient.
If you want a striped background, you can achieve this using color stops in this way.
Your app styling is almost complete. However the gray area of the Welcome view is slightly too high.
➤ With ContentView pinned, open WelcomeView.swift.
Currently you are using dynamic layout with Spacer() forcing HeaderView to the top and ContainerView to the bottom. However, the container view should consistently take up 80% of the view, while the header should take up 20%.
➤ Remove Spacer() from WelcomeView.body so that you can explore another way of laying out a view.
containerRelativeFrame
You could embed the whole view hierarchy in GeometryReader and use GeometryReader.size to calculate the frames of the views. However, SwiftUI provides a view modifier containerRelativeFrame(_:alignment:_:) for relative sizing of views.
➤ Add this modifier to ContainerView:
.containerRelativeFrame(.vertical) { length, _ in
length * 0.8
}
➤ And this modifier to HeaderView:
.containerRelativeFrame(.vertical) { length, _ in
length * 0.2
}
You choose the axis, in this case vertical. The parameters you are given are length, which is the height (or width in the case of a horizontal axis) of the parent container and the current axis, which you don’t use here.
ContainerView, shown in red below, takes up 80% of its parent view which is VStack, shown in yellow. HeaderView, shown in green, takes up 20% of the VStack.
To remove the gap between the views, you can change the top VStack to VStack(spacing: 0).
Your app is looking fantastic, but you should check that it looks great in all circumstances.
➤ Change the preview device to iPhone SE (3rd generation) and preview the pinned ContentView with Dynamic Type Variants.
This will show the app at various accessibility levels on a small device.
On the larger type variants, the text is forcing WelcomeView to grow larger than its allocated 80% of the parent view. The exercise buttons are covered and the History button is disappearing off the foot of the view.
ViewThatFits
Using ViewThatFits, you can present alternative layouts. Work out what is important for interaction with your app. For the larger size text variants, you could dispense with the images.
➤ In WelcomeView.swift, locate ContainerView.
➤ Embed the VStack inside ContainerView in ViewThatFits. Then highlight the VStack with its contents. Press Command-D to duplicate, and remove WelcomeView.images from the second VStack.
ViewThatFits {
VStack {
WelcomeView.images
WelcomeView.welcomeText
getStartedButton
Spacer()
historyButton
}
VStack {
WelcomeView.welcomeText
getStartedButton
Spacer()
historyButton
}
}
Your app will use the first VStack wherever it can, but when space is tight, it will use the alternative one.
The images won’t show on small devices with large text. Always remember to preview your app on multiple devices with all the variants.
➤ Unpin ContentView and change your run destination back to iPhone 16 Pro.
➤ Preview your final result on all the devices you can. Also make sure that you check your layout works as far as possible with accessibility dynamic type.
You could come up with better layouts for iPad, and you now have all the tools at your disposal to do that.
Challenge
Your challenge is to continue styling. With ContentView pinned, style HeaderView.
Functionality will remain the same, but instead of numbers, you’ll have circles. A faded circle behind the circle indicates the current page. You can achieve transparency with the modifier opacity(:), where opacity is between zero and one.
ExerciseView doesn’t look so hot with the gradient background, so embed all views in VStack, then in ContainerView just as you did in WelcomeView.
Add the container relative frames to ExerciseView. Finally, match the rating color with the design color using the supplied color “ratings”.
As always, check out the solution in the challenge folder for this chapter.
Key Points
- It’s not always possible to spend money on hiring a designer, but you should definitely spend time making your app as attractive and friendly as possible. Try various designs out and offer them to your testers for their opinions.
- Neumorphism is a simple style that works well. Keep up with designer trends at https://dribbble.com.
- Style protocols allow you to customize various view types to fit in with your desired design.
- Using
@ViewBuilder, you can return varying types of views from methods and properties. It’s easy to create custom container views that have added styling or functionality. - You can layer background colors in the safe area, but don’t place any of your user interface there.
- Gradients are an easy way to create a stand-out design. You can find interesting gradients at https://uigradients.com.