Ng Simon Mastering Swiftui Ios 17 Updated 2024
Ng Simon Mastering Swiftui Ios 17 Updated 2024
Preface
Chapter 1 - Introduction to SwiftUI
Declarative vs Imperative Programming
No more Interface Builder and Auto Layout
The Combine Approach
Learn Once, Apply Anywhere
Interfacing with UIKit/AppKit/WatchKit
Use SwiftUI for Your Next Project
Chapter 2 - Getting Started with SwiftUI and Working with Text
Creating a New Project for Playing with SwiftUI
Displaying a Simple Text
Changing the Font Type and Color
Using Custom Fonts
Working with Multiline Text
Setting the Padding and Line Spacing
Rotating the Text
Summary
Chapter 3 - Working with Images
Understanding SF Symbols
Displaying a System Image
Using Your Own Images
Resizing an Image
Aspect Fit and Aspect Fill
Creating a Circular Image
Adjusting the Opacity
Applying an Overlay to an Image
Darken an Image Using Overlay
Wrap Up
Chapter 4 - Layout User Interfaces with Stacks
Understanding VStack, HStack, and ZStack
Creating a New Project with SwiftUI enabled
Using VStack
Using HStack
Using ZStack
Exercise #1
Handling Optionals in SwiftUI
Using Spacer
Exercise #2
Chapter 5 - Understanding ScrollView and Building a Carousel UI
Creating a Card-like UI
Introducing ScrollView
Exercise #1
Creating a Carousel UI with Horizontal ScrollView
Hiding the Scroll Indicator
Grouping View Content
Resize the Text Automatically
Exercise #2
Chapter 6 - Working with SwiftUI Buttons and Gradient
Customizing the Button's Font and Background
Adding Borders to the Button
Creating a Button with Images and Text
Published: 31/10/2019 | Last updated: 20/9/2023 | AppCoda © 2023
Using Label
Creating a Button with Gradient Background and Shadow
Creating a Full-width Button
Styling Buttons with ButtonStyle
Exercise
Summary
Chapter 7 - Understanding State and Binding
Controlling the Button's State
Exercise #1
Working with Binding
Exercise #2
Summary
Chapter 8 - Implementing Path and Shape for Line Drawing and Pie Charts
Understanding Path
Using Stroke to Draw Borders
Drawing Curves
Fill and Stroke
Drawing Arcs and Pie Charts
Understanding the Shape Protocol
Using the Built-in Shapes
Creating a Progress Indicator Using Shapes
Drawing a Donut Chart
Summary
Chapter 9 - Basic Animations and Transitions
Implicit and Explicit Animations
Creating a Loading Indicator Using RotationEffect
Creating a Progress Indicator
All right reserved. No part of this book may be used or reproduced, stored or transmitted
in any manner whatsoever without written permission from the publisher.
I have been doing iOS programming for over 10 years and already get used to developing
UIs with UIKit. I love to use a mix of storyboards and Swift code for building UIs.
However, whether you prefer to use Interface Builder or create UI entirely using code,
the approach of UI development on iOS doesn't change much. Everything is still relying
on the UIKit framework.
To me, SwiftUI is not merely a new framework. It's a paradigm shift that fundamentally
changes the way you think about UI development on iOS and other Apple platforms.
Instead of using the imperative programming style, Apple now advocates the
declarative/functional programming style. Instead of specifying exactly how a UI
component should be laid out and function, you focus on describing what elements you
need in building the UI and what the actions should perform when programming in
declarative style.
If you have worked with React Native or Flutter before, you will find some similarities
between the programming styles and probably find it easier to build UIs in SwiftUI. That
said, even if you haven't developed in any functional programming languages before, it
would just take you some time to get used to the syntax. Once you manage the basics, you
will love the simplicity of coding complex layouts and animations in SwiftUI.
SwiftUI has evolved so much in these four years. Apple has packed even more features
and brought more UI components to the SwiftUI framework, which comes alongside with
Xcode 15. It just takes UI development on iOS, iPadOS, and macOS to the next level. You
can develop some fancy animations with way less code, as compared to UIKit. Most
The release of SwiftUI doesn't mean that Interface Builder and UIKit are deprecated right
away. They will still stay for many years to come. However, SwiftUI is the future of app
development on Apple's platforms. To stay at the forefront of technological innovations,
it's time to prepare yourself for this new way of UI development. And I hope this book
will help you get started with SwiftUI development and build some amazing UIs.
Simon Ng
Founder of AppCoda
As always, we will explore SwiftUI with you by using the "Learn by doing" approach. This
new book features a lot of hands-on exercises and projects. Don't expect you can just read
the book and understand everything. You need to get prepared to write code and debug.
Audience
This book is written for both beginners and developers with some iOS programming
experience. Even if you have developed an iOS app before, this book will help you
understand this brand-new framework and the new way to develop UI. You will also
learn how to integrate UIKit with SwiftUI.
If you are new to iOS app development, Xcode is an integrated development environment
(IDE) provided by Apple. Xcode provides everything you need to kick start your app
development. It already bundles the latest version of the iOS SDK (short for Software
Development Kit), a built-in source code editor, graphic user interface (UI) editor,
debugging tools and much more. Most importantly, Xcode comes with an iPhone (and
iPad) simulator so you can test your app without the real devices. With Xcode 15, you can
instantly preview the result of your SwiftUI code and test it on the fly.
Installing Xcode
To install Xcode, go up to the Mac App Store and download it. Simply search "Xcode" and
click the "Get" button to download it. At the time of this writing, the latest official version
of Xcode is 15.0. Once you complete the installation process, you will find Xcode in the
Launchpad.
Yes, you still need to know the Swift programming language before using SwiftUI.
SwiftUI is just a UI framework written in Swift. Here, the keyword is UI, meaning
that the framework is designed for building user interfaces. However, for a complete
application, other than UI, there are many other components such as network
components for connecting to remote server, data components for loading data from
internal database, business logic component for handling the flow of data, etc. All
these components are not built using SwiftUI. So, you should be knowledgeable
about Swift and SwiftUI, as well as, other built-in frameworks (e.g. Map) in order to
build an app.
The short answer is Both. That said, it all depends on your goals. If you target to
become a professional iOS developer and apply for a job in iOS development, you
better equip yourself with knowledge of SwiftUI and UIKit. Over 90% of the apps
published on the App Store were built using UIKit. To be considered for hire, you
should be very knowledgeable with UIKit because most companies are still using the
framework to build the app UI. However, like any technological advancement,
companies will gradually adopt SwiftUI in new projects. This is why you need to
learn both to increase your employment opportunities.
On the other hand, if you just want to develop an app for your personal or side
project, you can develop it entirely using SwiftUI. However, since SwiftUI is very
new, it doesn't cover all the UI components that you can find in UIKit. In some
cases, you may also need to integrate UIKit with SwiftUI.
SwiftUI is an innovative, exceptionally simple way to build user interfaces across all
Apple platforms with the power of Swift. Build user interfaces for any Apple device
using just one set of tools and APIs.
- Apple (https://developer.apple.com/xcode/swiftui/)
For a long time, developers have been debating whether to use Storyboards or build the
app UI programmatically. With the introduction of SwiftUI, Apple has provided an
answer to this debate. This brand new framework offers developers a new way to create
user interfaces. Take a look at the figure below and examine the code.
With the release of SwiftUI, it's now possible to develop an app's UI with a declarative
Swift syntax in Xcode. This means that the UI code is easier and more natural to write,
and compared to existing UI frameworks like UIKit, you can create the same UI with
much less code.
In the past, the preview function in Xcode has been a weak point, as it was only possible
to preview simple layouts in Interface Builder, and it wasn't possible to preview the
complete UI until the app was loaded onto the simulators. However, with SwiftUI, you
get immediate feedback on the UI you're coding. For example, if you add a new record to
a table, Xcode renders the UI change on the fly in a preview canvas. If you want to
preview how your UI looks in dark mode, you just need to change an option. This instant
preview feature makes UI development a breeze and iteration much faster.
In this book, you will delve deep into SwiftUI, learning how to lay out built-in
components and create complex UIs with the framework. If you already have experience
in iOS development, let me first walk you through the major differences between the
existing framework you're using (e.g., UIKit) and SwiftUI. However, if you're completely
new to iOS development or have no programming experience, you can use this
information as a reference or even skip the following sections. I don't want to discourage
you from learning SwiftUI; it's an awesome framework for beginners.
If you're new to programming, you may not need to worry too much about the difference
as everything is new to you. However, if you have some experience in object-oriented
programming or have developed with UIKit before, this paradigm shift can affect how
you think about building user interfaces. You may need to unlearn some old concepts and
relearn new ones.
So, what's the difference between imperative and declarative programming? If you search
for the terms on Wikipedia, you'll find these definitions:
Imagine you're instructing someone else (a helper) to prepare a pizza (or any dish you
like). You can either do it imperatively or declaratively. To cook the pizza imperatively,
you would give your helper specific instructions like a recipe:
On the other hand, if you cook pizza in a declarative way, you wouldn't need to specify
step-by-step instructions. Instead, you'd describe how you'd like the pizza cooked. Do you
want a thick or thin crust? Pepperoni and bacon, or just a classic Margherita with tomato
sauce? 10-inch or 16-inch? The helper would figure out the rest and cook the pizza
accordingly.
That's the core difference between imperative and declarative programming. Now, let's
relate this to UI programming. Imperative UI programming requires developers to write
detailed instructions to lay out the UI and control its states. Conversely, declarative UI
programming lets developers describe what the UI looks like and how it should respond
when a state changes.
Take a look at figure 2. That's the code you need to implement the heart button. In
around 20 lines of code, you create an interactive button with a scale animation. This is
the power of the SwiftUI declarative UI framework.
Auto layout has always been a challenging topic when learning iOS development.
However, with SwiftUI, you no longer need to learn how to define layout constraints and
resolve conflicts. Instead, you can compose the desired UI using stacks, spacers, and
padding. We will discuss this concept in detail in later chapters.
Instead, communication and data sharing between views are now handled by a new
framework called Combine. This new approach completely replaces the role of the view
controller in UIKit. In this book, we will cover the basics of Combine and how to use it to
handle UI events.
However, with the introduction of SwiftUI, Apple has provided developers with a unified
UI framework for building user interfaces on all Apple devices. The UI code written for
iOS can be easily adapted to your watchOS/macOS/watchOS app with minimal or no
modifications. This is possible due to the declarative UI framework, where code describes
how the user interface should look. Depending on the platform, the same piece of code in
SwiftUI can result in different UI controls.
For example, the following code snippet demonstrates how to declare a toggle switch:
Toggle(isOn: $isOn) {
Text("Wifi")
.font(.system(.title))
.bold()
}.padding()
For iOS and iPadOS, the toggle is rendered as a switch. On the other hand, SwiftUI
renders the control as a checkbox for macOS.
The beauty of this unified framework is that you can reuse most of the code on all Apple
platforms without having to make any changes. SwiftUI automatically renders the
corresponding controls and layouts, doing most of the heavy lifting for you.
However, it's important not to consider SwiftUI as a "Write once, run anywhere"
solution. As Apple emphasized in a WWDC talk, that's not the primary goal of SwiftUI.
Therefore, you shouldn't expect to turn a beautiful app designed for iOS into a tvOS app
without any modifications.
There are definitely going to be opportunities to share code along the way, just
where it makes sense. And so we think it's kind of important to think about SwiftUI
less as write once and run anywhere and more like learn once and apply anywhere.
While the UI code is portable across Apple platforms, you still need to provide
specialization that targets for a particular type of device. You should always review each
edition of your app to make sure the design is right for the platform. That said, SwiftUI
already saves you a lot of time from learning another platform-specific framework, plus
you should be able to reuse most of the code.
Say, you have a custom view developed using UIKit, you can adopt the
UIViewRepresentable protocol for that view and make it into SwiftUI. Figure 6 shows the
sample code of using WKWebView in SwiftUI.
However, it's essential to carefully consider whether you should apply SwiftUI to your
commercial projects. One significant drawback of SwiftUI is that the device must run on
at least iOS 13, macOS 10.15, tvOS 13, or watchOS 6. If your app requires support for
lower platform versions (e.g. iOS 12), you may need to wait before adopting SwiftUI.
Although SwiftUI is still relatively new, it has already matured into a reliable framework.
It's clear that SwiftUI is the future of UI development for Apple platforms, including
Apple's new visionOS. Even if it isn't yet applicable to your production projects, I
recommend starting a side project and exploring the framework. Once you try out
SwiftUI and understand its benefits, you'll enjoy developing UIs in a declarative way.
In this chapter, we'll show you how to work with Text to present information. You'll also
learn how to customize the text with different colors, fonts, backgrounds, and apply
rotation effects.
Select Next to proceed to the next screen and type a name for your project. I've named it
SwiftUIText, but you're free to choose any name you like. For the organization name, you
can use your company or organization's name. The organization identifier is a unique
identifier for your app. Here, I've used com.appcoda, but you should set it to your own
value. If you have a website, use your domain in reverse domain name notation.
To use SwiftUI, you have to choose SwiftUI in the Interface option. The language should
be set to Swift. Click Next and choose a folder to create the project.
Once you save the project, Xcode should load the ContentView.swift file and display a
design/preview canvas. If you can't see the design canvas, you can go up to the Xcode
menu and choose Editor > Canvas to enable it. To give yourself more space for writing
code, you can hide both the project navigator and the inspector (see figure 2).
By default, Xcode generates some SwiftUI code for ContentView.swift . In Xcode 15, the
preview canvas should automatically render the app preview in a simulator that you
choose in the simulator selection (e.g. iPhone 14/15 Pro). For older version of Xcode, you
may have to click the Resume button in order to see the preview.
To display text on screen, you initialize a Text object and pass to it the text (e.g. Hello
World) to display. Update the code of body like this:
The preview canvas should display the text Stay Hungry. Stay Foolish. on the screen.
This is the basic syntax for creating a text view in SwiftUI. You are free to change the text
to any value you want, and the canvas will update instantaneously to show you the
change.
To access a modifier in SwiftUI, use the dot syntax. As you type a dot, Xcode will display
a list of possible modifiers or values that you can use. For example, when you type a dot
after the fontWeight modifier, you will see various font weight options. You can select
bold to make the text bold. To make it even bolder, you can use heavy or black .
When you call fontWeight with the value .bold in SwiftUI, it returns a new view with
the text bolded. In SwiftUI, it's possible to chain this new view with other modifiers. For
example, if you want to make the bolded text slightly bigger, you can write the code like
this:
Since we may chain multiple modifiers together, we usually write the code above in the
following format:
The functionality is the same, but I believe that you will find the code above easier to
read. We will continue to use this coding convention throughout the rest of this book.
In SwiftUI, the font modifier allows you to change the font properties of a text view. In
the code above, we specify the title font style to enlarge the text. SwiftUI provides several
built-in text styles, such as title, largeTitle, body, and others. If you want to further
You can also use the font modifier to specify the font design. Let's say, if you want the
font to be rounded, you can write the font modifier like this:
Here, you specify to use the system font with title text style and rounded design. The
preview canvas should immediately respond to the change and show you the rounded
text.
Dynamic Type is an iOS feature that automatically adjusts the font size based on the
user's setting (Settings > Display & Brightness > Text Size). When you use text styles
(such as .title ), the font size will be adjusted automatically by your app to match the
user's preferred font size. This ensures that the text is always legible and accessible,
regardless of the user's visual acuity.
.font(.system(size: 20))
You can chain other modifiers to further customize the text. Let's change the font color.
To do that, you use the foregroundStyle modifier like this:
.foregroundStyle(.green)
The foregroundStyle modifier accepts a value of Color . Here we specify .green , which
is a built-in color. You may use other built-in values like .red , .purple , etc.
Text("Your time is limited, so don’t waste it living someone else’s life. Don’t be
trapped by dogma—which is living with the results of other people’s thinking. Don
’t let the noise of others’ opinions drown out your own inner voice. And most impo
rtant, have the courage to follow your heart and intuition.")
.fontWeight(.bold)
.font(.title)
.foregroundStyle(.gray)
You're free to replace the paragraph of text with your own text. Just make sure it's long
enough. Once you have made the change, the design canvas will render a multiline text
label.
To center align the text, insert the multilineTextAlignment modifier after the .foreground
.multilineTextAlignment(.center)
In some cases, you may want to limit the number of lines to a certain number. You use
the lineLimit modifier to control it. Here is an example:
.lineLimit(3)
Another modifier, truncationMode specifies where to truncate the text within the text
view. You can truncate at the beginning, middle, or end of the text view. By default, the
system is set to use tail truncation. To modify the truncation mode of the text, you use the
truncationMode modifier and set its value to .head or .middle like this:
.truncationMode(.head)
After the change, your text should look like the figure below.
Earlier, I mentioned that the Text control displays multiple lines by default. The reason
is that the SwiftUI framework has set a default value of nil for the lineLimit modifier.
You can change the value of .lineLimit to nil and see the result:
.lineLimit(nil)
.lineSpacing(10)
As you see, the text is too close to the left and right side of the edges. To give it some
more space, you can use the padding modifier, which adds some extra space to each side
of the text. Insert the following line of code after the lineSpacing modifier:
.padding()
.rotationEffect(.degrees(45))
If you insert the above line of code after padding() , you will see the text is rotated by 45
degrees.
By default, the rotation happens around the center of the text view. If you want to rotate
the text around a specific point (say, the top-left corner), you write the code like this:
Not only can you rotate the text in 2D, SwiftUI provides a modifier called
rotation3DEffect that allows you to create some amazing 3D effects. The modifier takes
two parameters: rotation angle and the axis of the rotation. Say, you want to create a
perspective text effect, you write the code like this:
With just a line of code, you have created the Star Wars perspective text!
You can further insert the following line of code to create a drop shadow effect for the
perspective text:
The shadow modifier will apply the shadow effect to the text. All you need to do is specify
the color and radius of the shadow. Optionally, you can tell the system the position of the
shadow by specifying the x and y values.
Assuming you've downloaded the font files, you should first add them to your Xcode
project. You can simply drag the font files to the project navigator and insert them under
the SwiftUIText folder. For the purposes of this demo, I just add the regular font file (i.e.
Nunito-Regular.ttf). If you need to use the bold or italic font, you will need to add the
corresponding font files as well.
Once you added the font, Xcode will prompt you an option dialog. Please make sure you
enable Copy items if added and check the SwiftUIText target.
After adding the font file, it is not immediately usable in the app. Xcode requires
developers to register the font in the project configuration. To do this, select SwiftUIText
in the project navigator and then click on SwiftUIText under Targets. This will bring up
the Info tab, where you can configure the project.
Under the Info tab, scroll down to find the Custom iOS Target Properties section.
Expand the section by clicking the disclosure indicator next to it, and then click on the +
button to add a new row. Set the key name to Fonts provided by application. Expand the
entry by clicking on the disclosure indicator and then set the value for item 0 to Nunito-
Regular.ttf , which is the font file you have just added. If you have added multiple font
files, you can click the + button to add additional items.
Now you can go back to ContentView.swift . To use the custom font, you can replace the
following line of code:
.font(.title)
With:
Rather than using the system font style, the code above uses .custom and specifies the
preferred font name. The font names can be found in the "Font Book" application. To
access Font Book, open Finder and navigate to the "Applications" folder. From there,
click on "Font Book" to launch the application.
SwiftUI has a built-in capability for rendering Markdown. For those unfamiliar with
Markdown, it is a way of styling plain text using a simple and easy-to-read format. If you
would like to learn more about Markdown, you can check out this guide
(https://www.markdownguide.org/getting-started/).
To use Markdown for rendering text in your SwiftUI app, all you need to do is to specify
the text in Markdown format. The Text view will automatically render the text for you.
Here is an example:
Text("**This is how you bold a text**. *This is how you make text italic.* You can
[click this link](https://www.appcoda.com) to go to appcoda.com")
.font(.title)
If you write the code in the ContentView file, you will be able to see how the given text is
rendered. To test the hyperlink, you will need to run the app in the iOS simulator. When
you tap on the link, the iOS device will redirect you to mobile Safari and open the URL.
Summary
Do you enjoy creating user interfaces with SwiftUI? Hopefully, you do. The declarative
syntax of SwiftUI makes the code more readable and easier to understand. As you have
experienced, it only takes a few lines of code in SwiftUI to create fancy 3D-style text.
For your reference, you can download the complete project for the text example by
following this link:
After saving the project, Xcode should load the ContentView.swift file and display a
design/preview canvas.
Understanding SF Symbols
With over 5,000 symbols, SF Symbols is a library of iconography designed to
integrate seamlessly with San Francisco, the system font for Apple
platforms.Symbols come in nine weights and three scales, and automatically align
with text. They can be exported and edited using vector graphics editing tools to
create custom symbols with shared design characteristics and accessibility features.
SF Symbols 5 introduces a collection of expressive animations, over 700 new
symbols, and enhanced tools for custom symbols.
Before I show you how to display an image on the screen, let's discuss where the images
come from. You can provide your own images for use in the app, but starting from iOS 13,
Apple introduced a large set of system images called SF Symbols that developers can use
in any apps. With the release of iOS 17, Apple further improved the image set by releasing
SF Symbols 5, which features over 700 new symbols and supports a collection of
expressive animations.
To use the symbols, all you need is the name of the symbol. With over 5,000 symbols
available for your use, Apple has released an app called SF Symbols
(https://developer.apple.com/sf-symbols/), which allows you to easily explore the
symbols and find the one that fits your needs. It is highly recommended that you install
the app before proceeding to the next section.
Image(systemName: "cloud.heavyrain")
This will create an image view and load the specified system image. As mentioned before,
SF Symbols are seamlessly integrated with the San Francisco font. You can easily scale
the image by applying the font modifier:
Image(systemName: "cloud.heavyrain")
.font(.system(size: 100))
Again, since SF Symbols are actually fonts, you can apply modifiers, such as
foregroundStyle , that you learned in the previous chapter, to change their appearance.
For example, if you want to change the symbol's color to blue, you can write the code like
this:
Image(systemName: "cloud.heavyrain")
.font(.system(size: 100))
.foregroundStyle(.blue)
Image(systemName: "cloud.heavyrain")
.font(.system(size: 100))
.foregroundStyle(.blue)
.shadow(color: .gray, radius: 10, x: 0, y: 10)
Before you can use an image in your project, you must import the image into the asset
catalog ( Assets ). Assuming you have already prepared the image ( paris.jpg ), press
Command+0 in Xcode to reveal the project navigator, then choose Assets . Open Finder
and drag the image to the outline view. Alternatively, you can right-click the blank area of
the outline view and select Import....
If you're new to iOS app development, the asset catalog is where you store application
resources such as images, colors, and data. Once you add an image to the asset catalog,
you can load the image by referring to its name. Additionally, you can configure on which
device the image can be loaded (e.g., iPhone only).
To display the image on the screen, write the code like this (see figure 6):
Image("paris")
Resizing an Image
To resize the image, the resizable modifier is used:
Image("paris")
.resizable()
By default, the image resizes the image using the stretch mode. This means the original
image will be scaled to fill the whole screen (except the top and bottom area).
Technically speaking, the image fills the whole safe area as defined by iOS. The concept of
the safe area has been around for quite a long time. The safe area is defined as the view
area that is safe to lay out UI components. For example, as you can see in the figure, the
safe area is the view area that excludes the top bar (i.e., status bar) and the bottom bar.
The safe area will prevent you from accidentally hiding system UI components, such as
the status bar, navigation bar, and tab bar.
If you want to display a full-screen image, you can ignore the safe area by setting the
ignoresSafeArea modifier.
You can also choose to ignore the safe area for a specific edge. To ignore the safe area for
the top edge but keep it for the bottom edge, you can specify the parameter .bottom like
this:
Image("paris")
.resizable()
.scaledToFit()
Alternatively, you can use the aspectRatio modifier and set the content mode to .fit .
This will achieve the same result.
Image("paris")
.resizable()
.aspectRatio(contentMode: .fit)
In some cases you may want to keep the aspect ratio of the image but stretch the image to
as large as possible, to do this, apply the .fill content mode:
Image("paris")
.resizable()
.aspectRatio(contentMode: .fill)
To get a better understanding of the difference between these two modes, Let's limit the
size of the image. The frame modifier allows you to control the size of a view. By setting
the frame's width to 300 points, the image's width will be limited to 300 points.
Image("paris")
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 300)
The image will be scaled down in size but the original aspect ratio is kept. If you change
the content mode to .fill , the image looks pretty much the same as figure 7. However,
if you switch over to the Selectable mode and look at the image carefully, the aspect ratio
of the original image is maintained.
One thing you may notice is that the image's width still takes up the whole screen width.
To make it scale correctly, you use the clipped modifier to eliminate extra parts of the
view (the left and right edges).
Image("paris")
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 300)
.clipShape(Circle())
Image("paris")
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 300)
.clipShape(.circle)
Here, we specify to clip the image into a circular shape. You can pass different
parameters to create an image with various shapes. Figure 13 shows you some examples.
For instance, if you apply the opacity modifier to an image view and set its value to 0.5,
the image will appear partially transparent.
Image("paris")
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 300)
.clipShape(.circle)
.overlay(
Image(systemName: "heart.fill")
.font(.system(size: 50))
.foregroundColor(.black)
.opacity(0.5)
)
The .overlay modifier takes in a View as parameter. In the code above, we create
another image (i.e. heart.fill) and lay it over the existing image (i.e. Paris).
In fact, you can apply any view as an overlay. For example, you can overlay a Text view
on the image, like this:
Image("paris")
.resizable()
.aspectRatio(contentMode: .fit)
.overlay(
Text("If you are lucky enough to have lived in Paris as a young man, then
wherever you go for the rest of your life it stays with you, for Paris is a moveab
le feast.\n\n- Ernest Hemingway")
.fontWeight(.heavy)
.font(.system(.headline, design: .rounded))
.foregroundStyle(.white)
.padding()
.background(Color.black)
.cornerRadius(10)
.opacity(0.8)
.padding(),
alignment: .top
It's important to note the alignment parameter, which is optional for the overlay
modifier. You can use this parameter to adjust the alignment of the view, which is set to
center by default. In this case, we want to position the text overlay at the top part of the
image. To achieve this, change the value of the alignment parameter from .center to
.top . You can see how this works by making this change to the code.
To apply a darkening effect to an image, we can draw a Rectangle over it and set its
foreground color to black. Then we can set the opacity of the Rectangle to 0.4, giving it a
40% opacity. This will cause the image to appear darker.
Alternatively, we can achieve the same effect by rewriting the code in the following way:
Image("paris")
.resizable()
.aspectRatio(contentMode: .fit)
.overlay(
Color.black
.opacity(0.4)
)
In SwiftUI, Color is also a view, which means we can use it as the top layer to darken the
image underneath. This technique is especially useful if you want to overlay light-colored
text on a bright image to make the text more legible. To achieve this, replace the existing
Image code with the following:
As mentioned earlier, the overlay modifier is not limited to Image views, but can be
applied to any other view. In the code above, we use Color.black to darken the image
and then apply an overlay to place a Text view on top of it. If you have made the change
correctly, you should see the word "Paris" in bold white, positioned over the darkened
image.
In SwiftUI, you can attach the symbolRenderingMode modifier to change the mode. To
create the same symbol with multiple colors, you can write the code like this:
Image(systemName: "square.and.arrow.down")
.symbolRenderingMode(.palette)
.foregroundStyle(.indigo, .yellow, .gray)
.font(.system(size: 200))
We specify in the code to use the palette mode and then apply the colors by using the
foregroundStyle modifier.
Variable Colors
SF Symbols also comes with a feature called Variable Color. You can adjust the color of
the symbol by changing a percentage value. This is especially useful when you use some
of the symbols to indicate a progress.
Variable Color is compatible with every available rendering mode in SF Symbols, and you
can switch between modes to observe the various effects.
To set the percentage value programmatically, you can instantiate the Image view with an
additional variableValue parameter and pass the desired percentage value as follows:
Wrap Up
In this chapter, I showed you how to work with images in SwiftUI, highlighting how
developers can easily display images and apply various modifiers to achieve desired
image effects. The introduction of SF Symbols is particularly advantageous for indie
To access the sample project, please download it from the following link:
In this chapter, I will guide you through all types of stacks and show you how to build a
grid layout using stacks. Our project will involve laying out a simple grid interface step by
step. By the end of this chapter, you will be able to effectively combine views with stacks
and build the UI you desire.
The figure below demonstrates how these stacks can be used to organize views.
Once you save the project, Xcode will load the ContentView.swift file and display a
preview in the design canvas.
Using VStack
We're going to build the UI as displayed in figure 1. To accomplish this, we will first
deconstruct the UI into smaller components. Let's start with the heading, as illustrated
below.
At this point, Xcode should have already generated the following code to display the
"Hello, World!" label:
To display the text as shown in figure 4, we will combine two Text views within a
VStack like this:
When you embed views in a VStack , the views will be arranged vertically like this:
By default, the views embedded in the stack are centered. To align both views to the left,
you can specify the alignment parameter and set its value to .leading , like this:
You can also adjust the spacing between embedded views by using the spacing
parameter. In the code above, we added the spacing parameter to the VStack and set its
value to 2 . The resulting view is shown below:
Using HStack
Both the Basic and Pro components are arranged side by side. To lay out views
horizontally, we can use HStack . Stacks can be nested, which means you can have stack
views within other stack views. Since the pricing plan block sits right below the heading
view, which is a VStack , we can use another VStack to embed a vertical stack (i.e.
Choose Your Plan) and a horizontal stack (i.e. the pricing plan block).
Now that you have a general idea of how we'll be using VStack and HStack to implement
the UI, let's dive into the code.
To embed an existing VStack in another VStack , hold down the control key and click the
VStack keyword. This will bring up a context menu showing all the available options.
Choose Embed in VStack to embed the VStack .
Xcode will then generate the necessary code to embed the stack. Your code should now
resemble the following:
Extracting a View
Xcode has a built-in feature for refactoring SwiftUI code. To extract code from the
VStack that holds the text views (i.e., line 13), hold down the control key and click on the
VStack . Select Extract Subview to extract the code.
Xcode will extract the code block and create a default struct named ExtractedView . To
give it a more meaningful name, rename ExtractedView to HeaderView . See the figure
below for details:
The UI remains the same. However, if you look at the code block in ContentView , it is now
much cleaner and easier to read.
Let's continue implementing the UI for the pricing plans. We'll start by creating the UI
for the Basic plan. Update ContentView as follows:
VStack {
Text("Basic")
.font(.system(.title, design: .rounded))
.fontWeight(.black)
.foregroundColor(.white)
Text("$9")
.font(.system(size: 40, weight: .heavy, design: .rounded))
.foregroundColor(.white)
Text("per month")
.font(.headline)
.foregroundColor(.white)
}
.padding(40)
.background(Color.purple)
.cornerRadius(10)
}
}
}
Here we add another VStack under HeaderView . This VStack holds three text views to
display the Basic plan. We won't go into detail about padding , background , and
cornerRadius because we have already covered these modifiers in earlier chapters.
Next, we'll implement the UI for the Pro plan. This Pro plan should be placed directly to
the right of the Basic plan. To do this, you need to embed the VStack of the Basic plan in
an HStack . Hold down the control key and click on the VStack keyword. Choose Embed
in HStack.
Xcode should insert the code for the HStack and embed the selected VStack in the
horizontal stack, like this:
HStack {
VStack {
Text("Basic")
.font(.system(.title, design: .rounded))
.fontWeight(.black)
.foregroundColor(.white)
Text("$9")
.font(.system(size: 40, weight: .heavy, design: .rounded))
.foregroundColor(.white)
Text("per month")
.font(.headline)
.foregroundColor(.white)
}
.padding(40)
.background(Color.purple)
.cornerRadius(10)
}
VStack {
Text("Pro")
.font(.system(.title, design: .rounded))
.fontWeight(.black)
Text("$19")
.font(.system(size: 40, weight: .heavy, design: .rounded))
Text("per month")
.font(.headline)
.foregroundColor(.gray)
}
.padding(40)
.background(Color(red: 240/255, green: 240/255, blue: 240/255))
.cornerRadius(10)
As soon as you insert the code, you should see the layout below in the canvas.
If you refer to figure 1 again, both pricing blocks have the same size. To adjust both
blocks to have the same size, you can use the .frame modifier to set the maxWidth to
.infinity like this:
Edited:
If you refer back to Figure 1, you'll notice that both pricing blocks have the same size. To
adjust both blocks to have the same size, you can use the .frame modifier to set the
maxWidth to .infinity , like this:
When maxWidth is set to .infinity , iOS will fill the blocks equally for two pricing blocks.
Now insert the above line of code into each of the pricing blocks. Your result should
resemble Figure 17.
To add some spacing between the horizontal stack and the VStack above it, you can use
the .padding modifier, like this:
To streamline the code and improve reusability, we can extract the VStack code block
and make it adaptable to different values of the pricing plan.
Go back to the code editor. Hold down the control key and click on the VStack of the
Basic plan. Once Xcode extracts the code, rename the subview from ExtractedView to
PricingView .
as follows:
We added variables for the title, price, text, and background color of the pricing block.
Furthermore, we used these variables in the code to update the title, price, text, and
background color accordingly.
Once you make the changes, you'll see an error indicating that there are some missing
arguments for the PricingView .
Also, you can replace the VStack of the Pro plan using PricingView like this:
The layout of the pricing blocks is the same but the underlying code, as you can see, is
much cleaner and easier to read.
Using ZStack
Now that you've laid out the pricing blocks and refactored the code, there is still one
thing missing for the Pro pricing block. We want to overlay a message in yellow on the
pricing block. To do that, we can use the ZStack view, which allows you to overlay a view
on top of an existing view.
Embed the PricingView of the Pro plan within a ZStack and add the Text view, like
this:
The order of the views embedded in the ZStack determines how the views are overlaid
with each other. For the code above, the Text view will overlay on top of the pricing
view. On the canvas, you should see the pricing layout like this:
.offset(x: 0, y: 87)
The Best for designer label will move to the bottom of the block. A negative value of y
will move the label to the top part of the block if you want to re-position it.
Optionally, if you want to adjust the spacing between the Basic and Pro pricing blocks,
you can specify the spacing parameter within the HStack , like this:
HStack(spacing: 15) {
...
}
Exercise #1
Additionally, I want to discuss how we handle optionals in SwiftUI and introduce another
view component called Spacer .
Please don't look at the solution yet, try to develop your own solution.
We can reuse the PricingView to create the Team plan. However, as you are aware, the
Team plan has an icon that sits above the title. In order to lay out this icon, we need to
modify PricingView to accomodate an icon. Since the icon is not mandatory for a pricing
If you're new to Swift, an optional means that the variable may or may not have a value.
In the example above, we defined a variable named icon of type String? , which means
it's an optional string. The call to the method is expected to pass the image name if the
pricing plan is required to display an icon. Otherwise, this variable is set to nil by
default.
So, how do you handle an optional in SwiftUI? In Swift, there are a couple of ways to
unwrap an optional. One way is to check if the optional has a non-nil value and then
unwrap the value by using the exclamation mark. For example, we need to check if icon
has a value before displaying an image. We can write the code like this:
if icon != nil {
Image(systemName: icon!)
.font(.largeTitle)
.foregroundColor(textColor)
A better and more common way to handle optional is to use if let . The same piece of
code can be rewritten like this:
Image(systemName: icon)
.font(.largeTitle)
.foregroundColor(textColor)
To support the rendering of an icon, the final code of PricingView should be updated as
below:
Image(systemName: icon)
.font(.largeTitle)
.foregroundColor(textColor)
Text(title)
.font(.system(.title, design: .rounded))
.fontWeight(.black)
.foregroundColor(textColor)
Text(price)
.font(.system(size: 40, weight: .heavy, design: .rounded))
.foregroundColor(textColor)
Text("per month")
.font(.headline)
.foregroundColor(textColor)
}
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 100)
.padding(40)
.background(bgColor)
.cornerRadius(10)
}
}
Once you made this change, you can create the Team plan by using ZStack and
PricingView . You put the code in ContentView and insert it after .padding(.horiontal) :
Using Spacer
When comparing your current UI with that of Figure 1, you may notice a couple of
differences:
In UIKit, you would define auto layout constraints to position the views. However,
SwiftUI doesn't have auto layout. Instead, it provides a view called Spacer for you to
create complex layouts.
A flexible space that expands along the major axis of its containing stack layout, or
on both axes if not contained in a stack.
- SwiftUI documentation
(https://developer.apple.com/documentation/swiftui/spacer)
To fix the left alignment, let's update the HeaderView like this:
Spacer()
}
.padding()
}
}
Here we embed the original VStack and add a Spacer within a HStack . By using a
Spacer , we push the VStack to the left. Figure 25 illustrates how the spacer works.
You may now know how to fix the second difference. The solution is to add a spacer at the
end of the VStack of ContentView like this:
HStack(spacing: 15) {
...
}
.padding(.horizontal)
ZStack {
...
}
// Add a spacer
Spacer()
}
}
}
To access the sample project, please download it from the following link:
By utilizing stacks, image views, and text views, you should be able to construct the
desired UI. While I'll guide you through the implementation step by step later on, I
encourage you to take some time to tackle the exercise and devise your own solution.
Creating a Card-like UI
If you haven't opened Xcode, fire it up and create a new project using the App template
(under iOS). In the next screen, set the product name to SwiftUIScrollView (or whatever
name you like) and fill in all the required values. Make sure you select SwiftUI for the
Interface option.
So far, we have coded the user interface in the ContentView.swift file. It's very likely you
wrote your solution there. That's completely fine, however, I would like to introduce a
better way to organize your code. For the implementation of the card view, let's create a
In the User Interface section, choose the SwiftUI View template and click Next to create
the file. Name the file CardView and save it in the project folder.
Figure 5. Just like ContentView.swift, you can preview CardView.swift in the canvas
Let's start with the image section. I'll make the image resizable and scale it to fit the
screen while retaining the aspect ratio. You write the code like this:
view. Next, let's implement the text description. You may write the code like this:
VStack(alignment: .leading) {
Text("SwiftUI")
.font(.headline)
.foregroundColor(.secondary)
Text("Drawing a Border with Rounded Corners")
.font(.title)
.fontWeight(.black)
.foregroundColor(.primary)
.lineLimit(3)
Text("Written by Simon Ng".uppercased())
.font(.caption)
.foregroundColor(.secondary)
}
You need to use Text to create the text view. Since we have three text views in the
description, which are vertically aligned, we can use a VStack to encapsulate them. For
the VStack , we specify the alignment as .leading , which aligns the text views to the left
within the stack.
The modifiers available for the Text object have all been discussed in the chapter
dedicated to it. You can refer back to that chapter if you find any of the modifiers
confusing. However, one important topic to highlight is the use of .primary and
.secondary colors.
While you can manually specify standard colors such as .black and .purple using the
foregroundColor modifier, iOS provides a set of system colors that include primary,
secondary, and tertiary variants. By utilizing these color variants, your app can easily
support both light and dark modes. For instance, the primary color of the text view is set
to black by default in light mode. When the app switches to dark mode, the primary color
will automatically adjust to white. This behavior is handled automatically by iOS,
eliminating the need to write extra code to support dark mode. We will delve deeper into
dark mode in a later chapter.
We are not yet finished! There are still a couple of things we need to implement. First, we
should ensure that the text description block is left-aligned to the edge of the image.
Based on what we've learned, we can embed the VStack of the text views in a HStack .
And then, we will use a Spacer to push the VStack to the left. Let's see if this works.
If you have updated the code to match the one depicted in Figure 8, the VStack of text
views should now be aligned to the left edge of the screen.
It would be better to add some padding around the HStack . Insert the padding modifier
like this (line 34 in figure 9) :
HStack {
VStack(alignment: .leading) {
Text("SwiftUI")
.font(.headline)
.foregroundColor(.secondary)
Text("Drawing a Border with Rounded Corners")
.font(.title)
.fontWeight(.black)
.foregroundColor(.primary)
.lineLimit(3)
Text("Written by Simon Ng".uppercased())
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
.padding()
}
.cornerRadius(10)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(Color(.sRGB, red: 150/255, green: 150/255, blue: 150/255,
opacity: 0.1), lineWidth: 1)
)
.padding([.top, .horizontal])
}
}
Next, replace the values of the Image and Text views with the variables like this:
HStack {
VStack(alignment: .leading) {
Text(category)
.font(.headline)
.foregroundColor(.secondary)
Text(heading)
.font(.title)
.fontWeight(.black)
.foregroundColor(.primary)
.lineLimit(3)
Text("Written by \(author)".uppercased())
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
.padding()
}
Once you made the changes, you will see an error in #Preview . This is because we've
introduced some variables in CardView . We have to specify the parameters when using it.
This modification should resolve the error. Great job! You have successfully built a
flexible CardView that can accommodate various images and text inputs.
Introducing ScrollView
Please take another look at Figure 2. That is the user interface we are aiming to
implement. Initially, one might assume that we can embed four card views using a
VStack . To proceed, you can switch to the ContentView.swift file and insert the following
code snippet:
VStack {
CardView(image: "swiftui-button", category: "SwiftUI", heading: "Drawing a Bor
der with Rounded Corners", author: "Simon Ng")
CardView(image: "macos-programming", category: "macOS", heading: "Building a S
imple Editing App", author: "Gabriel Theodoropoulos")
CardView(image: "flutter-app", category: "Flutter", heading: "Building a Compl
ex Layout with Flutter", author: "Lawrence Tan")
CardView(image: "natural-language-api", category: "iOS", heading: "What's New
in Natural Language API", author: "Sai Kambampati")
}
If you did that, the card views will be squeezed to fit the screen because VStack is non-
scrollable, just like that shown in figure 12.
To support scrollable content, SwiftUI provides a view called ScrollView . When the
content is wrapped within a ScrollView , it becomes scrollable. To implement this, you
simply need to enclose the VStack containing the card views within a ScrollView . By
doing so, the views will become scrollable. In the preview canvas, you can drag the views
to scroll through the content.
Exercise #1
Your task is to add a header to the existing scroll view. The result is displayed in figure 14.
If you understand VStack and HStack thoroughly, you should be able to create the
layout.
ScrollView(.horizontal) {
HStack {
CardView(image: "swiftui-button", category: "SwiftUI", heading: "D
rawing a Border with Rounded Corners", author: "Simon Ng")
.frame(width: 300)
CardView(image: "macos-programming", category: "macOS", heading: "
Building a Simple Editing App", author: "Gabriel Theodoropoulos")
.frame(width: 300)
CardView(image: "flutter-app", category: "Flutter", heading: "Buil
ding a Complex Layout with Flutter", author: "Lawrence Tan")
.frame(width: 300)
CardView(image: "natural-language-api", category: "iOS", heading:
"What's New in Natural Language API", author: "Sai Kambampati")
.frame(width: 300)
}
}
}
}
1. We specify that the ScrollView should use a horizontal scroll view by passing the
.horizontal value.
2. Since we are using a horizontal scroll view, we also need to change the stack view
from VStack to HStack .
3. For each card view, we set the frame's width to 300 points. This adjustment is
necessary because the image is too wide to fit within the default width.
After making these code changes, you will notice that the card views are now arranged
horizontally, and they are scrollable in a carousel-like manner.
HStack {
Group {
CardView(image: "swiftui-button", category: "SwiftUI", heading: "Drawing a
Border with Rounded Corners", author: "Simon Ng")
CardView(image: "macos-programming", category: "macOS", heading: "Building
a Simple Editing App", author: "Gabriel Theodoropoulos")
CardView(image: "flutter-app", category: "Flutter", heading: "Building a C
omplex Layout with Flutter", author: "Lawrence Tan")
CardView(image: "natural-language-api", category: "iOS", heading: "What's
New in Natural Language API", author: "Sai Kambampati")
}
.frame(width: 300)
}
.minimumScaleFactor(0.5)
SwiftUI will automatically scale down the text to fit the available space. The value sets the
minimum amount of scaling that the view permits. In this case, SwiftUI can draw the text
in a font size as small as 50% of the original font size.
Exercise #2
Here comes the final exercise. Modify the existing code and rearrange it to match the
layout shown in Figure 16. Please ensure that the title and date remain visible to users
when they scroll through the card views.
For reference, you can download the complete project from the following link:
Demo project
(https://www.appcoda.com/resources/swiftui5/SwiftUIScrollView.zip)
I don't think I need to explain what a button is, as it is a fundamental UI control present
in all applications. A button has the ability to handle users' touch, triggering a specific
action. If you are already familiar with iOS programming, you will find that the Button
Throughout this chapter, we will delve into SwiftUI's button control, covering the
following techniques:
By the end of this chapter, you will have gained a comprehensive understanding of
SwiftUI's button capabilities and the various techniques associated with them.
Once you save the project, Xcode should load the ContentView.swift file and display a
preview.
It's very easy to create a button using SwiftUI. Basically, you use the code snippet below
to create a button:
Button {
// What to perform
} label: {
// How the button looks like
}
1. What action to perform - the code to perform after the button is tapped or
selected by the user.
2. How the button looks - the code block that describes the look & feel of the button.
For example, if you just want to turn the Hello World label into a button, you can update
the code like this:
Both code snippets are functionally identical; the difference lies in coding style. In this
book, we prefer using the first approach.
Upon implementing a button, the Hello World text transforms into a tappable button, as
you can see in the canvas.
Text("Hello World")
.background(Color.purple)
.foregroundColor(.white)
Text("Hello World")
.background(Color.purple)
.foregroundColor(.white)
.font(.title)
After the change, your button should look like the figure below.
As you have noticed, the button's appearance could be improved. Wouldn't it be great to
add some space around the text? To achieve this, you can utilize the padding modifier
like this:
Text("Hello World")
.padding()
.background(Color.purple)
.foregroundColor(.white)
.font(.title)
Once you make the modification, the canvas will automatically update to reflect the
changes. You should see a great improvement in the button's appearance, as it now
incorporates the desired spacing around the text.
If you place the padding modifier after the background modifier, the button will still
receive the desired padding, but without the intended background color. To understand
why this occurs, let's examine the modifiers in the following manner:
Text("Hello World")
.background(Color.purple) // 1. Change the background color to purple
.foregroundColor(.white) // 2. Set the foreground/font color to white
.font(.title) // 3. Change the font type
.padding() // 4. Add the paddings with the primary color (i.e.
white)
On the other hand, if you position the padding modifier before the background modifier,
the modifiers will function in the following manner:
You can change the code of the Text control like below:
Text("Hello World")
.foregroundColor(.purple)
.font(.title)
.padding()
.border(Color.purple, width: 5)
Now, let's consider the following button design presented by a designer. How can we
implement it using the knowledge we have acquired? Take a few moments to figure out
the solution before proceeding to the next paragraph.
Text("Hello World")
.fontWeight(.bold)
.font(.title)
.padding()
.background(Color.purple)
.foregroundColor(.white)
.padding(10)
.border(Color.purple, width: 5)
Now, let's explore a more complex example. What if you wanted a button with rounded
borders like this?
.cornerRadius(40)
Creating a border with rounded corners requires a slightly different approach, as the
border modifier alone doesn't support rounded corners. We need to draw a separate
border and overlay it onto the button. Here is the final code snippet to achieve the
desired result:
The overlay modifier enables us to overlay an additional view on top of the current view.
In the provided code, we utilize the stroke modifier of the RoundedRectangle object to
draw a border with rounded corners. The stroke modifier allows customization of the
stroke's color and line width.
Button(action: {
print("Delete button tapped!")
}) {
Image(systemName: "trash")
.font(.largeTitle)
.foregroundColor(.red)
}
For convenience, we can leverage the built-in SF Symbols library to create the image
button. In this example, we utilize the SF Symbol "trash" as the image. To make the
image slightly larger, we apply the .largeTitle option to the font modifier. The
Similarly, if you want to create a circular image button with a solid background color, you
can apply the modifiers we discussed earlier. Figure 12 shows you an example.
You can use both text and image to create a button. Say, you want to put the word
"Delete" next to the icon. Replace the code like this:
In this code snippet, we embed both the image and text controls within a horizontal stack
( HStack ). This arrangement allows the trash icon and the Delete text to be displayed side
by side. The modifiers applied to the HStack specify the background color, padding, and
rounded corners of the button. Figure 13 showcases the resulting button design.
Using Label
Button {
print("Delete button tapped")
} label: {
Label(
title: {
Text("Delete")
.fontWeight(.semibold)
.font(.title)
},
icon: {
Image(systemName: "trash")
.font(.title)
}
)
.padding()
.foregroundColor(.white)
.background(.red)
.cornerRadius(40)
}
.background(.red)
With:
The SwiftUI framework comes with several built-in gradient effects. The code above
applies a linear gradient from left ( .leading ) to right ( .trailing ). It begins with red on
the left and ends with blue on the right.
If you want to apply the gradient from top to bottom, you replace the .leading with
.top and the .trailing with .bottom like this:
Feel free to utilize your preferred colors to render the desired gradient effect. Let's say,
your designer tells you to use the following gradient:
There are multiple ways to convert the color code from hex to the compatible format in
Swift. Here is one approach. In the project navigator, choose the asset catalog ( Assets ).
Right click the blank area (under AppIcon) and select New Color Set.
Next, choose the color well for Any Appearance and click the Show inspector button.
Then click the Attributes inspector icon to reveal the attributes of a color set. In the name
field, set the name to DarkGreen. In the Color section, change the input method to 8-bit
Hexadecimal.
Now you can set the color code in the Hex field. For this example, enter #11998e to
define the color. Name the color set DarkGreen. Repeat the same procedure to define
another color set. Enter #38ef7d for the additional color. Name this color LightGreen.
Color("DarkGreen")
Color("LightGreen")
To render the gradient with the DarkGreen and LightGreen color sets, all you need is to
update the background modifier like this:
If you've made the change correctly, your button should have a nice gradient background
as shown in figure 19.
There is one more modifier I want to show you in this section. The shadow modifier
allows you to draw a shadow around the button (or any view). Just add this line of code
after the cornerRadius modifier to see the shadow:
.shadow(radius: 5.0)
To create a full-width button, you can modify the Button code as follows:
Button {
print("Delete tapped!")
} label: {
Label(
title: {
Text("Delete")
.fontWeight(.semibold)
.font(.title)
},
icon: {
Image(systemName: "trash")
.font(.title)
}
)
.frame(minWidth: 0, maxWidth: .infinity)
.padding()
.foregroundColor(.white)
.background(LinearGradient(gradient: Gradient(colors: [Color("DarkGreen"), Col
or("LightGreen")]), startPoint: .leading, endPoint: .trailing))
.cornerRadius(40)
.padding(.horizontal, 20)
}
If you want to give the button some more horizontal space, insert a padding modifier
after .cornerRadius(40) :
.padding(.horizontal, 20)
Button {
print("Edit button tapped")
} label: {
Label(
title: {
Text("Edit")
.fontWeight(.semibold)
.font(.title)
},
icon: {
Image(systemName: "square.and.pencil")
.font(.title)
}
)
.frame(minWidth: 0, maxWidth: .infinity)
.padding()
.foregroundColor(.white)
.background(LinearGradient(gradient: Gradient(colors: [Color("Dark
Green"), Color("LightGreen")]), startPoint: .leading, endPoint: .trailing))
.cornerRadius(40)
.padding(.horizontal, 20)
}
Button {
print("Delete button tapped")
} label: {
Label(
title: {
Text("Delete")
.fontWeight(.semibold)
.font(.title)
},
icon: {
Image(systemName: "trash")
.font(.title)
}
}
As you can see from the code above, you need to replicate all modifiers for each of the
buttons. What if you or your designer want to modify the button style? You'll need to
modify all the modifiers. That's quite a tedious task and not good coding practice. How
can you generalize the style and make it reusable?
SwiftUI provides a protocol called ButtonStyle for you to create your own button style.
To create a reusable style for our buttons, Create a new struct called
GradientBackgroundStyle that conforms to the ButtonStyle protocol. Insert the following
code snippet and put it right above #Preview :
property applies modifiers to change the button's style. In the code above, we apply the
same set of modifiers that we used before.
So, how do you apply the custom style to a button? SwiftUI provides a modifier called
.buttonStyle for you to apply the button style like this:
Button {
print("Delete button tapped")
} label: {
Label(
title: {
Text("Delete")
.fontWeight(.semibold)
.font(.title)
},
icon: {
Image(systemName: "trash")
.font(.title)
}
)
}
.buttonStyle(GradientBackgroundStyle())
Cool, right? The code is now simplified and you can easily apply the button style to any
button with just one line of code.
You can also determine if the button is pressed by accessing the isPressed property. This
allows you to alter the style of the button when the user taps on it. For example, let's say
we want to make the button a bit smaller when someone presses the button. You add a
line of code like this:
The line of code scales down the button (e.g., 0.9 ) when it is pressed and restores it to
its original size (e.g., 1.0 ) when the user lifts their finger. When you tap the button in
the preview, you should see a smooth animation that scales the button up and down. This
is the power of SwiftUI. You do not need to write any extra lines of code and it comes
with built-in animation.
Exercise
Your exercise is to create an animated button which shows a plus icon. When a user
presses the button, the plus icon will rotate (clockwise/counterclockwise) to become a
cross icon.
As a hint, the modifier rotationEffect may be used to rotate the button (or other view).
I believe you know how to create a button as shown in figure 25. In iOS 15 (or later),
Apple introduced a number of modifiers for the Button view. To create the button, you
can write the code like this:
Button {
} label: {
Text("Buy me a coffee")
}
.tint(.purple)
.buttonStyle(.borderedProminent)
.buttonBorderShape(.roundedRectangle(radius: 5))
.controlSize(.large)
The tint modifier specifies the color of the button. By applying the .borderedProminent
style, iOS renders the button with purple background and display the text in white. The
.buttonBorderShape modifier lets you set the border shape of the button. Here, we set it to
.roundedRectangle to round the button’s corners.
The .controlSize allows you to change the size of the button. The default size is
.regular . Other valid values includes .large , .small , and .mini . The figure below
shows you how the button looks for different sizes.
Other than using .roundedRectangle , SwiftUI provides another border shape named
.capsule for developers to create a capsule shape button.
You can also use the .automatic option to let the system adjust the shape of the button.
So far, we use the .borderProminent button style. The new version of SwiftUI provides
other built-in styles including .bordered , .borderless , and .plain . The .bordered style
is the one you will usually use. The figure below displays a sample button using the
.bordered style.
VStack {
Button(action: {}) {
Text("Add to Cart")
.font(.headline)
}
Button(action: {}) {
Text("Discover")
.font(.headline)
.frame(maxWidth: 300)
}
Button(action: {}) {
Text("Check out")
.font(.headline)
}
}
.tint(.purple)
.buttonStyle(.bordered)
.controlSize(.large)
iOS will display the delete button in red automatically. Figure 29 shows you the
appearance of the button for different roles and button styles.
Summary
To access the complete Xcode project, you can follow the link below:
SwiftUI provides several built-in features for state management. One of these features is
the @State property wrapper. When you annotate a property with @State , SwiftUI
automatically stores it within your application. Furthermore, views that utilize this
property are automatically notified of any changes to its value. As a result, when the state
changes, SwiftUI recomputes the affected views and updates the application's appearance
accordingly.
Doesn't that sound great? However, I understand that state management can be a bit
confusing at first. To gain a better understanding of state and binding, it would be helpful
to go through the coding examples and exercises in this chapter. By working on these
Once you save the project, Xcode will load the ContentView.swift file and display a
preview in the canvas. To create the Play button, you can use the following code snippet:
We make use of the system image play.circle.fill and color the button green.
So, how can we implement that? Obviously, we need a variable to keep track of the
button's state. Let's name it isPlaying . It's a boolean variable indicating whether the app
is in the Playing state or not. If isPlaying is set to true , the app should display a Stop
We modify the image's name and color based on the value of the isPlaying variable. If
you update the code in your project, you will see a Play button in the preview canvas.
However, if you set the default value of isPlaying to true , you will see a Stop button
instead.
Now, the question arises: how can the app monitor changes in the state (i.e., isPlaying )
and automatically update the button? In SwiftUI, achieving this functionality is
straightforward. All you need to do is prefix the isPlaying property with @State :
By declaring the isPlaying property as a state variable, SwiftUI takes care of managing
its storage and monitoring its value changes. Whenever the value of isPlaying changes,
SwiftUI automatically recomputes the views that rely on the isPlaying state. This
ensures that the user interface reflects the updated state accurately.
We still haven't implemented the button's action. So, let's do that now:
Button {
// Switch between the play and stop button
isPlaying.toggle()
} label: {
Image(systemName: isPlaying ? "stop.circle.fill" : "play.circle.fill")
.font(.system(size: 150))
.foregroundColor(isPlaying ? .red : .green)
}
In the action closure, we call the toggle() method to toggle the Boolean value between
false and true . This allows us to switch between the Play and Stop button. In the
preview canvas, you can click the play icon to trigger the toggle and observe the button's
appearance change accordingly.
Indeed, you may have noticed that SwiftUI renders a fade animation when toggling
between the buttons. This animation is automatically generated and built-in to SwiftUI.
It smoothly transitions the appearance of the button, providing a seamless user
experience.
In subsequent chapters of the book, we will delve further into the topic of animations.
You will learn how to customize and create your own animations using SwiftUI. As you
can see, SwiftUI simplifies the process of implementing UI animations, making it more
accessible for all developers.
Exercise #1
Your exercise is to create a counter button that displays the number of taps. When a user
taps the button, the counter should increase by one and display the total number of taps.
Now let's further modify the code to display three counter buttons, as shown in Figure 7.
All three buttons will share the same counter. Regardless of which button is tapped, the
counter will increase by 1, and all the buttons will update to display the updated count.
As you can see, all the buttons have the same look and feel. To avoid duplicating code, it
is good practice to extract common views into reusable subviews. In this case, we can
extract the Button view to create an independent subview. Here's an example of how you
can achieve this:
The CounterButton view accepts two parameters: counter and color. You can create a
button in red like this:
You may have noticed that the counter variable in the CounterButton view is annotated
with @Binding . Additionally, when you create an instance of CounterButton , the value of
the counter parameter is prefixed with a $ sign.
After extracting the button into a separate view, CounterButton becomes a subview of
ContentView . The counter increment is now performed within the CounterButton view
instead of the ContentView . To manage the state variable in the ContentView , the
CounterButton needs a way to access and update it.
By using @Binding and the $ sign, changes made to the counter value within the
CounterButton view will be reflected in the parent view (i.e. ContentView ), and vice versa.
This enables the synchronized updating of the counter value across all the buttons in the
parent view.
Now that you understand how bindings work, you can proceed to create the other two
buttons and align them vertically using a VStack . Here's an example of how you can
accomplish this:
After making the changes, you can test the app in the preview canvas. Tapping any of the
buttons will increase the count by one, and all the buttons will update to reflect the
updated count.
Presently, all the buttons share the same count. For this exercise, you need to modify the
code so that each button has its own counter. When the user taps the blue button, only
the counter for the blue button should increase by 1. Furthermore, you need to provide a
master counter that sums up the counters of all the buttons. Figure 10 shows the sample
layout for this exercise.
This chapter introduces the basics of state management in SwiftUI. As you progress, you
will learn more about how to utilize @State for view animations and how to manage
shared states among multiple views.
For reference, you can download the sample project and solution to exercise from the
links below:
In this chapter, you will learn about drawing lines, arcs, pie charts, and donut charts
using the Path and built-in Shape APIs in SwiftUI. The topics covered in this chapter
include:
Figure 1 provides a visual representation of the shapes and charts that we will be creating
in this chapter.
Understanding Path
In SwiftUI, you can draw lines and shapes using the Path struct. According to Apple's
documentation (https://developer.apple.com/documentation/swiftui/path), a Path is a
struct that represents the outline of a 2D shape. It allows you to define a series of points
and draw lines between them.
Let's take a look at an example using a rectangle, as shown in figure 2. We will walk
through how this rectangle is drawn.
If you were to describe how to draw the rectangle step by step, you would probably
provide the following explanation:
Following these steps, you can create a rectangular shape by moving to the starting point
and drawing lines to form the outline. Finally, you can fill the shape with the specified
color, resulting in a green rectangle.
That's how Path is works! Let's translate these verbal descriptions into code:
You create the rectangle shape by initializing a Path object and providing detailed
instructions within a closure. To start, you call the move(to:) method to move to a
specific coordinate. Then, you use the addLine(to:) method to draw a line from the
current point to another point. By default, the path is filled with the default foreground
color, which is black. To fill the shape with a different color, you can apply the .fill
To test the code, create a new Xcode project using the App template and name it
SwiftUIShape (or any name you prefer). Then, paste the given code snippet into the body
Because we didn't include a step to draw a line from the last point back to the starting
point, the path is left open-ended. To close the path and create a closed shape, you can
call the closeSubpath() method at the end of the Path closure. This method
automatically connects the current point with the starting point of the path, completing
the shape.
Drawing Curves
Path() { path in
path.move(to: CGPoint(x: 20, y: 60))
path.addLine(to: CGPoint(x: 40, y: 60))
path.addQuadCurve(to: CGPoint(x: 210, y: 60), control: CGPoint(x: 125, y: 0))
path.addLine(to: CGPoint(x: 230, y: 60))
path.addLine(to: CGPoint(x: 230, y: 100))
path.addLine(to: CGPoint(x: 20, y: 100))
}
.fill(Color.purple)
The addQuadCurve method allows you to draw a curve by defining an anchor point and a
control point. In the context of drawing the dome shape on top of the rectangle (as shown
in figure 6), the anchor points are (40, 60) and (210, 60), while the control point is (125,
0).
Feel free to experiment with different control point values to observe how they affect the
shape of the curve. This will provide you with a better understanding of how the control
point influences the appearance of the curve.
Path() { path in
path.move(to: CGPoint(x: 20, y: 60))
path.addLine(to: CGPoint(x: 40, y: 60))
path.addQuadCurve(to: CGPoint(x: 210, y: 60), control: CGPoint(x: 125, y: 0
))
path.addLine(to: CGPoint(x: 230, y: 60))
path.addLine(to: CGPoint(x: 230, y: 100))
path.addLine(to: CGPoint(x: 20, y: 100))
path.closeSubpath()
}
.stroke(Color.black, lineWidth: 5)
}
We create two Path objects with the same path and overlay one on top of the other using
a ZStack . The one at the bottom is filled with a purple color using the fill modifier,
creating a dome rectangle. The one overlaid on top only draws the borders with a black
color using the stroke modifier. Figure 7 illustrates the resulting shape with a filled
dome and border.
Path { path in
path.move(to: CGPoint(x: 200, y: 200))
path.addArc(center: .init(x: 200, y: 200), radius: 150, startAngle: .degrees(0
), endAngle: .degrees(90), clockwise: true)
}
.fill(.green)
Enter this code in the body, you will see an arc that fills with green color in the preview
canvas.
In the code, we first move to the starting point (200, 200). Then we call addArc to create
the arc. The addArc method accepts several parameters:
If you just look at the names of the startAngle and endAngle parameters, you might be a
bit confused about their meaning. To better understand how these parameters work, take
a look at Figure 9. It provides a visual representation of how the startAngle and
endAngle affect the shape of the arc.
By using addArc , you can easily create a pie chart with different colored segments. To
achieve this, you can overlay different pie segments using a ZStack . Each segment has
different values for startAngle and endAngle to compose the chart. Here is an example:
Path { path in
path.move(to: CGPoint(x: 187, y: 187))
path.addArc(center: .init(x: 187, y: 187), radius: 150, startAngle: .degre
es(190), endAngle: .degrees(110), clockwise: true)
}
.fill(.teal)
Path { path in
path.move(to: CGPoint(x: 187, y: 187))
path.addArc(center: .init(x: 187, y: 187), radius: 150, startAngle: .degre
es(110), endAngle: .degrees(90), clockwise: true)
}
.fill(.blue)
Path { path in
path.move(to: CGPoint(x: 187, y: 187))
path.addArc(center: .init(x: 187, y: 187), radius: 150, startAngle: .degre
es(90), endAngle: .degrees(360), clockwise: true)
}
.fill(.purple)
This code will render a pie chart with 4 segments. If you want to have more segments,
you can create additional Path objects with different angle values. As a side note, the
colors used in the example come from the standard color objects provided in iOS. You
can find the full set of color objects at
https://developer.apple.com/documentation/uikit/uicolor/standard_colors.
Path { path in
path.move(to: CGPoint(x: 187, y: 187))
path.addArc(center: .init(x: 187, y: 187), radius: 150, startAngle: .degrees(90
), endAngle: .degrees(360), clockwise: true)
}
.fill(.purple)
.offset(x: 20, y: 20)
Optionally, you can overlay a border to further catch people's attention. If you want to
add a label to the highlighted segment, you can also overlay a Text view like this:
Path { path in
path.move(to: CGPoint(x: 187, y: 187))
path.addArc(center: .init(x: 187, y: 187), radius: 150, startAngle: .degrees(90
), endAngle: .degrees(360), clockwise: true)
path.closeSubpath()
}
.fill(.purple)
.offset(x: 20, y: 20)
.overlay(
Text("25%")
.font(.system(.largeTitle, design: .rounded))
.bold()
.foregroundColor(.white)
.offset(x: 80, y: -110)
)
This path has the same starting and end angle as the purple segment, but it only draws
the border and adds a text view to make the segment stand out. Figure 10 shows the end
result.
Okay, to build this shape, you create a Path using addLine and addQuadCurve :
Path() { path in
path.move(to: CGPoint(x: 0, y: 0))
path.addQuadCurve(to: CGPoint(x: 200, y: 0), control: CGPoint(x: 100, y: -20))
path.addLine(to: CGPoint(x: 200, y: 40))
path.addLine(to: CGPoint(x: 200, y: 40))
path.addLine(to: CGPoint(x: 0, y: 40))
}
.fill(Color.green)
If you've read the documentation for Path , you may have come across another function
called addRect , which allows you to draw a rectangle with a specific width and height.
Let's use it to create the same shape:
Path() { path in
path.move(to: CGPoint(x: 0, y: 0))
path.addQuadCurve(to: CGPoint(x: 200, y: 0), control: CGPoint(x: 100, y: -20))
path.addRect(CGRect(x: 0, y: 0, width: 200, height: 40))
}
.fill(Color.green)
When is it useful to adopt the Shape protocol? To answer this, let's consider a scenario
where you want to create a button with the dome shape but with a flexible size. Is it
possible to reuse the Path that you have just created?
Looking at the code above, you created a Path with absolute coordinates and size.
However, if you want to create the same shape but with a variable size, you can create a
struct that adopts the Shape protocol and implement the path(in:) function. When the
path(in:) function is called by the framework, you will be provided with the rect size.
You can then draw the path within that rect .
In the following code, we create the Dome shape by implementing the path(in:)
function:
return path
}
}
By adopting the Shape protocol, we are given the rectangular area for drawing the path.
From the rect parameter, we can obtain the width and height of the rectangular area,
which allows us to compute the control point and draw the rectangular base.
Button(action: {
// Action to perform
}) {
Text("Test")
.font(.system(.title, design: .rounded))
.bold()
.foregroundColor(.white)
.frame(width: 250, height: 50)
.background(Dome().fill(Color.red))
}
We apply the Dome shape as the background of the button, setting its width and height
based on the specified frame size.
Let's say you want to create a stop button like the one shown in figure 13. It's composed
of a rounded rectangle and a circle. You can write the code for it like this:
Circle()
.foregroundColor(.green)
.frame(width: 200, height: 200)
.overlay(
RoundedRectangle(cornerRadius: 5)
.frame(width: 80, height: 80)
.foregroundColor(.white)
)
Here, we initialize a Circle view and then overlay a RoundedRectangle view on it.
This progress indicator is actually composed of two circles. We have a gray outline of a
circle underneath. On top of the grey circle, is an open outline of a circle indicating the
completion progress. In your project, write the code in ContentView like this:
ZStack {
Circle()
.stroke(Color(.systemGray6), lineWidth: 20)
.frame(width: 300, height: 300)
}
}
}
We use the stroke modifier to draw the outline of the grey circle. You can adjust the
value of the lineWidth parameter to make the lines thicker or thinner according to your
preference. The purpleGradient property represents the purple gradient that we will use
later when drawing the open circle.
Now, insert the following code in ZStack to create the open circle:
To create an open circle, you can add the trim modifier. By specifying a from value and
a to value, you can indicate which segment of the circle should be shown. In this case,
we want to show a progress of 85%, so we set the from value to 0 and the to value to
0.85.
To display the completion percentage, we overlay a text view in the middle of the circle.
That's the technique we use to create a donut chart, and here is the code:
Circle()
.trim(from: 0.4, to: 0.6)
.stroke(Color(.systemTeal), lineWidth: 80)
Circle()
.trim(from: 0.6, to: 0.75)
.stroke(Color(.systemPurple), lineWidth: 80)
Circle()
.trim(from: 0.75, to: 1)
.stroke(Color(.systemYellow), lineWidth: 90)
.overlay(
Text("25%")
.font(.system(.title, design: .rounded))
.bold()
.foregroundColor(.white)
.offset(x: 80, y: -100)
)
}
.frame(width: 250, height: 250)
The first segment represents 40% of the circle. The second segment represents 20% of
the circle, but note that the from value is 0.4 instead of 0. This ensures that the second
segment starts where the first segment ends.
For the last segment, I intentionally set the line width to a larger value so that this
segment stands out from the others. If you prefer a different line width, you can modify
the value of lineWidth from 90 to 80 .
Summary
I hope you enjoyed reading this chapter and working on the demo projects. With the
drawing APIs provided by the framework, you have the power to create custom shapes
for your application. There is so much more you can do with Path and Shape , and I've
only scratched the surface in this chapter. I encourage you to apply what you've learned
and continue exploring these powerful APIs—they truly are magical!
For reference, you can download the project files for the shapes chapter below:
Note: With the release of iOS 17, SwiftUI now includes built-in features for creating pie
charts and donut charts. Please refer to Chapter 46 to learn more.
SwiftUI empowers you to animate changes for individual views and transitions between
views. The framework comes with a number of built-in animations to create different
effects.
In this chapter, you will learn how to animate views using implicit and explicit
animations provided by SwiftUI. As usual, we'll work on a few demo projects and learn
the programming techniques along the way.
Explicit animations offer more fine control over the animations you want to present.
Instead of attaching a modifier to the view, you tell SwiftUI what state changes you want
to animate inside the withAnimation() block.
Feeling a bit confused? That's completely fine. You will have a better understanding after
going through a couple of examples.
(or choose any name you prefer). Make sure to select SwiftUI as the interface framework!
Take a look at figure 1. It's a simple tappable view consisting of a red circle and a heart.
When a user taps the heart or circle, the circle's color changes to light gray and the heart's
color changes to red. Additionally, the heart icon grows bigger in size. We have various
state changes here:
To implement the tappable circle using SwiftUI, add the following code to
ContentView.swift :
ZStack {
Circle()
.frame(width: 200, height: 200)
.foregroundStyle(circleColorChanged ? Color(.systemGray5) : .red)
Image(systemName: "heart.fill")
.foregroundStyle(heartColorChanged ? .red : .white)
.font(.system(size: 100))
.scaleEffect(heartSizeChanged ? 1.0 : 0.5)
}
.onTapGesture {
circleColorChanged.toggle()
heartColorChanged.toggle()
heartSizeChanged.toggle()
}
}
}
We define three state variables to represent the states of the circle color, heart color, and
heart size, with the initial value set to false . We use a ZStack to overlay the heart image
on top of the circle. SwiftUI comes with the onTapGesture modifier to detect tap gestures,
which can be attached to any view to make it tappable. In the onTapGesture closure, we
toggle the state variables to change the appearance of the view.
In the preview canvas, when you tap the heart view, the color of the circle and heart icon
should change accordingly. However, these changes are not animated by default.
To animate the changes, you need to attach the animation modifier to both the Circle
Circle()
.frame(width: 200, height: 200)
.foregroundStyle(circleColorChanged ? Color(.systemGray5) : .red)
.animation(.default, value: circleColorChanged)
Image(systemName: "heart.fill")
.foregroundStyle(heartColorChanged ? .red : .white)
.font(.system(size: 100))
.scaleEffect(heartSizeChanged ? 1.0 : 0.5)
.animation(.default, value: heartSizeChanged)
The animation modifier can be applied not only to a single view but also to a group of
views. For example, you can rewrite the code above by attaching the animation modifier
to the ZStack like this:
ZStack {
Circle()
.frame(width: 200, height: 200)
.foregroundColor(circleColorChanged ? Color(.systemGray5) : .red)
Image(systemName: "heart.fill")
.foregroundColor(heartColorChanged ? .red : .white)
.font(.system(size: 100))
.scaleEffect(heartSizeChanged ? 1.0 : 0.5)
}
.animation(.default, value: circleColorChanged)
.animation(.default, value: heartSizeChanged)
.onTapGesture {
self.circleColorChanged.toggle()
self.heartColorChanged.toggle()
self.heartSizeChanged.toggle()
}
In the example, we used the default animation, which is the spring animation on iOS 17.
SwiftUI also offers a variety of built-in animations to choose from, including linear ,
easeIn , easeOut , and easeInOut . The linear animation animates the changes at a
constant speed, while the easing animations have different speeds and acceleration
curves. For more details, you can refer to websites like www.easings.net to see the visual
representations of each easing function.
To use an alternate animation, you just need to set the specific animation in the
animation modifier. Let's say, you want to use the linear animation, you can change
.default to the following:
To customize the spring animation, you can call the spring function and specify the
parameters for the animation, such as the duration and the type of bounce:
This renders a spring-based animation that gives the heart a bumpy effect. Adjusting
these parameters allows you to create different animation effects.
Explicit Animations
That's how you animate views using implicit animation. Let's see how we can achieve the
same result using explicit animation. As explained before, you need to wrap the state
changes in a withAnimation block. To create the same animated effect, you can write the
code like this:
ZStack {
Circle()
.frame(width: 200, height: 200)
.foregroundColor(circleColorChanged ? Color(.systemGray5) : .red)
Image(systemName: "heart.fill")
.foregroundColor(heartColorChanged ? .red : .white)
.font(.system(size: 100))
.scaleEffect(heartSizeChanged ? 1.0 : 0.5)
}
.onTapGesture {
withAnimation(.default) {
self.circleColorChanged.toggle()
self.heartColorChanged.toggle()
self.heartSizeChanged.toggle()
}
}
To use the spring animation, you can update the withAnimation block as follows:
With explicit animation, you have fine-grained control over which state changes you want
to animate. If you don't want to animate the size change of the heart icon, you can
exclude that line of code from the withAnimation block:
.onTapGesture {
withAnimation(.spring(.bouncy, blendDuration: 1.0)) {
self.circleColorChanged.toggle()
self.heartColorChanged.toggle()
}
self.heartSizeChanged.toggle()
}
In this case, SwiftUI excludes the scaling animation and only animates the color changes.
You may wonder if we can disable the scale animation by using implicit animation. You
can! You can reorder the .animation modifier to prevent SwiftUI from animating a
certain state change. Here is the code that achieves the same effect:
You may wonder if we can disable the scale animation by using implicit animation. By
reordering the .animation modifier, you can achieve the desired effect. Here's the
modified code:
Image(systemName: "heart.fill")
.foregroundStyle(heartColorChanged ? .red : .white)
.font(.system(size: 100))
.animation(.spring(.bouncy, blendDuration: 1.0), value: heartColorChanged)
.scaleEffect(heartSizeChanged ? 1.0 : 0.5)
}
.onTapGesture {
self.circleColorChanged.toggle()
self.heartColorChanged.toggle()
self.heartSizeChanged.toggle()
}
For the Image view, we place the animation modifier right before scaleEffect . This will
disable the animation. SwiftUI will only animate the color changes and exclude the
scaling animation.
While you can achieve the same animation using implicit animation, in my opinion, it's
more convenient to use explicit animation in this case.
For example, let's create a simple loading indicator that you can commonly find in a real-
world application like "Medium". To create a loading indicator similar to the one shown
in figure 3, we can start with an open ended circle like this:
Circle()
.trim(from: 0, to: 0.7)
.stroke(Color.green, lineWidth: 5)
.frame(width: 100, height: 100)
How do we rotate the circle? We can use the rotationEffect and animation modifiers.
The idea is to apply a rotation effect of 360 degrees to the circle, and animate it to create
the spinning effect. Here's the code to achieve that:
The rotationEffect modifier is used to specify the rotation degree, in this case, 360
degrees. In the provided code, we have a state variable isLoading that controls the
loading status. When isLoading is set to true , the rotation degree is set to 360, causing
the circle to rotate. The animation modifier is applied with .default animation, but with
a difference. We specify the repeatForever option to make the animation repeat
indefinitely. This repetition creates the continuous loading animation effect.
*Note: If you can't see the animation in the preview canvas, run the app in the
simulator.*
If you wish to adjust the speed of the animation, you can use the linear animation type
and specify a duration. This can be achieved by updating the animation modifier as
follows:
The greater the duration value, the slower the rotation animation will be.
Once you have mastered this technique, you can customize the design and create various
versions of loading indicators. For example, you can overlay an arc on a circle to create a
more sophisticated loading indicator.
Circle()
.stroke(Color(.systemGray5), lineWidth: 14)
.frame(width: 100, height: 100)
Circle()
.trim(from: 0, to: 0.2)
.stroke(Color.green, lineWidth: 7)
.frame(width: 100, height: 100)
.rotationEffect(Angle(degrees: isLoading ? 360 : 0))
.animation(.linear(duration: 1).repeatForever(autoreverses: false)
, value: isLoading)
.onAppear() {
self.isLoading = true
}
}
}
}
The loading indicator doesn't have to be circular. You can also use Rectangle or
RoundedRectangle to create the indicator. Instead of changing the rotation angle, you can
modify the value of the offset to create an animation like this.
To create the animation, we overlay two rounded rectangles together. The rectangle on
top is much shorter than the one below. When the loading begins, we update its offset
value from -110 to 110.
Text("Loading")
.font(.system(.body, design: .rounded))
.bold()
.offset(x: 0, y: -25)
RoundedRectangle(cornerRadius: 3)
.stroke(Color(.systemGray5), lineWidth: 3)
.frame(width: 250, height: 3)
RoundedRectangle(cornerRadius: 3)
.stroke(Color.green, lineWidth: 3)
.frame(width: 30, height: 3)
.offset(x: isLoading ? 110 : -110, y: 0)
.animation(.linear(duration: 1).repeatForever(autoreverses: false)
, value: isLoading)
}
.onAppear() {
self.isLoading = true
}
}
}
This moves the green rectangle along the line. When you repeat the same animation over
and over, it becomes a loading animation. Figure 6 illustrates the offset values.
ZStack {
Text("\(Int(progress * 100))%")
.font(.system(.title, design: .rounded))
.bold()
Circle()
.stroke(Color(.systemGray5), lineWidth: 10)
.frame(width: 150, height: 150)
Circle()
.trim(from: 0, to: progress)
.stroke(Color.green, lineWidth: 10)
.frame(width: 150, height: 150)
.rotationEffect(Angle(degrees: -90))
}
.onAppear() {
Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { timer in
self.progress += 0.05
print(self.progress)
if self.progress >= 1.0 {
timer.invalidate()
}
}
}
}
}
Delaying an Animation
Not only does the SwiftUI framework allow you to control the duration of an animation,
you can also delay an animation through the delay function like this:
Animation.default.delay(1.0)
This will introduce a 1-second delay before starting the animation. The delay function
can be applied to other animations as well.
By combining different values for duration and delay, you can create interesting
animations, such as the dot loading indicator shown below.
This indicator consists of five dots, each of which is animated to scale up and down with
different time delays. Here is the code implementation:
We first use an HStack to lay out the circles horizontally. Since all five circles (dots) are
the same size and color, we use ForEach to create the circles. The scaleEffect modifier is
used to scale the circle's size. By default, it is set to 1, which is its original size. When the
loading starts, the value is updated to 0, which minimizes the dot.
The line of code for rendering the animation may appear complicated, so let's break it
down and examine it step by step:
The first part of the code snippet creates a linear animation with a duration of 0.6
seconds. To make the animation run repeatedly, the repeatForever function is called.
Without the delay function, all the dots would scale up and down simultaneously.
However, this is not the desired effect. Each dot should resize independently instead of
all at once. To achieve this, the delay function is called, and a different delay value is
Feel free to experiment with different values for the duration and delay to customize the
animation to your liking.
The trick to morphing a rectangle into a circle is to use the RoundedRectangle shape and
animate the change of the corner radius. When the corner radius is set to half of the
rectangle's width, assuming the width and height are equal, it transforms into a circle.
Here is the implementation of the morphing button:
RoundedRectangle(cornerRadius: recordBegin ? 30 : 5)
.frame(width: recordBegin ? 60 : 250, height: 60)
.foregroundColor(recordBegin ? .red : .green)
.overlay(
Image(systemName: "mic.fill")
.font(.system(.title))
.foregroundStyle(.white)
.scaleEffect(recording ? 0.7 : 1)
)
}
.onTapGesture {
withAnimation(.default) {
self.recordBegin.toggle()
}
withAnimation(.default.repeatForever().delay(0.5)) {
self.recording.toggle()
}
}
}
}
We have two state variables here: recordBegin and recording , which control two
separate animations. The first variable controls the morphing of the button. As explained
before, we utilize the corner radius for the transformation. Initially, the width of the
This is how we transform a rectangle into a circle. SwiftUI automatically renders the
animation of this transformation.
The recording state variable handles the scaling of the microphone image. When in the
recording state, we change the scaling ratio from 1 to 0.7. By repeatedly running the same
animation, it creates the pulsing animation effect.
Please note that the code above uses the explicit approach to animate the views.
However, it's not mandatory. If you prefer, you can use the implicit animation approach
to achieve the same result.
Understanding Transitions
What we have discussed so far is animating a view that already exists in the view
hierarchy. We achieved this by scaling the view up and down to animate its size.
SwiftUI allows developers to do more than that. You can define how a view is inserted or
removed from the view hierarchy, creating smooth transitions. In SwiftUI, this is known
as transitions. By default, the framework uses fade-in and fade-out transitions for view
insertion and removal. Additionally, SwiftUI provides several built-in transitions such as
slide, move, opacity, and more. You also have the freedom to develop your own custom
transitions or combine different types of transitions to create unique effects.
Let's explore a simple example to gain a better understanding of transitions and how they
work in conjunction with animations. To begin, create a new project named
SwiftUITransition and update the ContentView with the following code snippet:
RoundedRectangle(cornerRadius: 10)
.frame(width: 300, height: 300)
.foregroundStyle(.purple)
.overlay(
Text("Well, here is the details")
.font(.system(.largeTitle, design: .rounded))
.bold()
.foregroundStyle(.white)
)
}
}
}
In the code above, we lay out two squares vertically using VStack . At first, the purple
rectangle should be hidden. It's displayed only when a user taps the green rectangle (i.e.
Show details). In order to show the purple square, we need to make the green square
tappable.
To do that, we need to declare a state variable to determine whether the purple square is
shown or not. Insert this line of code in ContentView :
Next, to hide the purple square, we wrap the purple square within a if clause like this:
if show {
RoundedRectangle(cornerRadius: 10)
.frame(width: 300, height: 300)
.foregroundStyle(.purple)
.overlay(
Text("Well, here is the details")
.font(.system(.largeTitle, design: .rounded))
.bold()
.foregroundStyle(.white)
)
}
.onTapGesture {
withAnimation(.default) {
self.show.toggle()
}
}
Once a user taps the stack, we toggle the show variable to display the purple square.
When you run the app in the simulator or preview canvas, you will only see the green
square initially. Tapping the green square will smoothly fade in and out the purple
rectangle, thanks to the transition animation.
As mentioned, if you do not specify the transition you want to use, SwiftUI will default to
the fade in and out transition. To use an alternative transition, you can attach the
transition modifier to the purple square, like this:
The transition modifier takes a parameter of type AnyTransition . In this case, we use
the scale transition with the anchor set to .bottom . That's all you need to modify the
transition. To test the animations, it is recommended to run the app in a simulator rather
than relying solely on the preview canvas, as the preview may not always render the
transition correctly.
This time, the purple square slides in from the left when it's inserted into the VStack .
Combining Transitions
You can combine two or more transitions together by calling the combined(with:) method
to create an even more slick transition. For example, to combine the offset and scale
animation, you write the code like this:
Sometimes you need to define a reusable animation. You can define an extension on
AnyTransition like this:
extension AnyTransition {
static var offsetScaleOpacity: AnyTransition {
AnyTransition.offset(x: -600, y: 0).combined(with: .scale).combined(with:
.opacity)
}
}
Then you can use the offsetScaleOpacity animation in the transition modifier directly:
Test the transition again in the preview canvas or using the simulator. Does it look great?
Asymmetric Transitions
The transitions we just discussed are all symmetric, meaning that the insertion and
removal of the view use the same transition. For example, if you apply the scale transition
to a view, SwiftUI scales up the view when it's inserted into the view hierarchy, and scales
it back down to the original size when it's removed.
But what if you want to use a scale transition when the view is inserted and an offset
transition when the view is removed? This is known as Asymmetric Transitions in
SwiftUI. Using this type of transition is straightforward. You just need to call the
.asymmetric method and specify both the insertion and removal transitions. Here is the
sample code:
Again, if you need to reuse the transition, you can define an extension on AnyTransition
like this:
extension AnyTransition {
static var scaleAndOffset: AnyTransition {
AnyTransition.asymmetric(
insertion: .scale(scale: 0, anchor: .bottom),
removal: .offset(x: -600, y: 00)
)
}
}
Add this code after the ContentView block and before the #Preview block. Run the app
using the built-in simulator or in the preview canvas. You should see the scale transition
when the purple square appears on screen. When you tap the rectangles again, the purple
rectangle will slide off the screen.
It's quite a challenging project that will test your knowledge of SwiftUI animation and
transitions. You will need to combine everything you've learned so far to work out the
solution.
In the demo button shown in figure 16, the processing takes around 4 seconds. You do
not need to perform a real operation. To help you with this exercise, I've provided the
following code to simulate an operation.
Summary
Animation plays a special role in mobile UI design. Well-thought-out animations
improve user experience and add meaning to UI interactions. A seamless and effortless
transition between two views can delight and impress your users. With over 2 million
apps on the App Store, it can be challenging to make your app stand out. However, a
well-designed UI with animations can make a significant difference.
Even for experienced developers, creating slick animations is not an easy task.
Fortunately, the SwiftUI framework has simplified the development of UI animations
and transitions. You simply define how the view should look at the beginning and the
end, and SwiftUI takes care of the rest, rendering smooth and visually appealing
animations.
In this chapter, I have covered the basics of animation and transition in SwiftUI. As you
can see, you have already built some delightful animations and transitions with just a few
lines of code.
I hope you have enjoyed reading this chapter and find the techniques useful. For
reference, you can download the sample projects and exercise solutions below:
Exercise #1
(https://www.appcoda.com/resources/swiftui5/SwiftUIAnimationExercise1.zip)
Exercise #2
(https://www.appcoda.com/resources/swiftui5/SwiftUIAnimationExercise2.zip)
Instead of using UITableView , we use List in SwiftUI to present rows of data. If you've
built a table view with UIKit before, you are aware that it can take you significant effort to
implement a simple table view and even more effort to customize the layout of the cells.
In this chapter, we will start with a simple list. Once you grasp the basics, I will show you
how to present a list of data with a more complex layout, as shown in Figure 2.
Xcode will generate the "Hello World" code in the ContentView.swift file. Replace the
"Hello World" text object with the following:
That's all the code you need to build a simple list or table. When you embed the text
views in a List , the list view will present the data in rows. Here, each row shows a text
view with different description.
The same code snippet can be written like this using ForEach :
Since the text views are very similar, you can use ForEach in SwiftUI to create views in a
loop.
You can provide ForEach with a collection of data or a range. However, it's important to
inform ForEach how to uniquely identify each item in the collection. This is achieved
through the id parameter. Why does ForEach need this identifier? SwiftUI has the
ability to automatically update the UI when some or all items in the collection change. To
accomplish this, it needs a unique identifier to track and update the items accurately.
In the code snippet above, we pass a range of values to ForEach to iterate through. The
identifier is set to the value itself (i.e., 1, 2, 3, or 4). The index parameter holds the
current value of the loop. For example, if it starts with a value of 1, the index parameter
will be 1.
Within the closure, you define the code that renders the views. In this case, we create a
text view with a description that varies based on the value of index in the loop. This is
how we create four items in the list with different titles.
Now, let me show you an alternative technique. The same code snippet can be further
simplified as follows:
You can omit the index parameter and use the shorthand $0 , which refers to the first
parameter of the closure.
Let's further simplify the code by directly passing the collection of data to the List view.
Here is the updated code:
As you can see, you only need a couple lines of code to build a simple list/table.
If you've read our book, Beginning iOS Programming with Swift, this example should be
very familiar to you. Let's use it as an example and see how easy it is to build the same
table using SwiftUI.
To build the table using UIKit, you would need to create a table view or table view
controller and then customize the prototype cell. Additionally, you would have to code
the table view data source to provide the data. That's quite a few steps to build a table UI.
Let's now see how the same table view is implemented in SwiftUI.
Now, switch over to ContentView.swift to code the UI. First, let's declare two arrays in
ContentView . These arrays will be used to store restaurant names and images. Here is the
complete code snippet:
Both arrays have the same number of items. The restaurantNames array stores the names
of the restaurants, and the restaurantImages array stores the names of the images you
just imported. To create a list view like the one shown in figure 4, all you need to do is
update the body property like this:
We've made a few changes in the code. First, instead of a fixed range, we pass the array of
restaurant names (i.e., restaurantNames.indices ) to the List view. The restaurantNames
array has 21 items, so we'll have a range from 0 to 20 (arrays are 0-indexed). This
approach works when both arrays are of the same size, as the index of one array is used
as an index for the other array.
In the closure, the code was updated to create the row layout. I won't go into the details,
as the code is similar to the stack views we've created before. To change the style of the
List view, we applied the listStyle modifier and set the style to plain .
With less than 10 lines of code, we have created a list (or table) view with a custom
layout.
Instead of holding the restaurant data in two separate arrays, we'll create a Restaurant
struct to better organize the data. This struct has two properties: name and image . Insert
the following code at the end of the ContentView.swift file:
struct Restaurant {
var name: String
var image: String
}
With this struct, we can combine both restaurantNames and restaurantImages arrays into
a single array. Delete the restaurantNames and restaurantImages variables and replace
them with this variable in the ContentView :
If you're new to Swift, each item in the array represents a restaurant object containing
both the name and image for each restaurant. Once you have replaced the array, you may
encounter an error in Xcode, complaining that the restaurantNames variable is missing.
This is expected because we have just removed it.
Take a look at the parameters we pass to List . Instead of passing the range, we now
pass the restaurants array and specify the name property as the identifier for each
restaurant. The List will iterate through the array and provide us with the current
restaurant being processed in the closure. Within the closure, we define how we want to
present each restaurant row. In this case, we use a HStack to display both the restaurant
image and name.
The resulting UI remains unchanged, but the underlying code has been modified to
leverage List with a collection of data.
Take note that we are only changing the value of the name property to "Homei" while
keeping the image as "upstate" for the 11th item in the restaurants array. Please check
the preview pane again to see the result.
Do you see the issue in figure 8? We now have two records with the name Homei, and as
a result, both records show the same image. In the code, we told the List to use the
restaurant's name as the unique identifier. However, when two restaurants have the
same name, iOS considers them to be the same restaurant and reuses the same view.
That's pretty easy. You need to provide each restaurant with a unique identifier. You can
update the Restaurant struct by adding a property called id of type UUID . This will
ensure that each restaurant has a unique identifier. Here's the updated code:
struct Restaurant {
var id = UUID()
var name: String
var image: String
}
To make things work correctly, we need to update the List view by changing the value
of the id parameter from \.name to \.id . This tells the List to use the id property
of each restaurant as the identifier. Here's the updated code:
This tells the List view to use the id property of the restaurants as the unique
identifier. If you take a look at the preview, you'll see that the second Homei record now
correctly shows the upstate image.
We can further simplify the code by making the Restaurant struct conform to the
Identifiable protocol. This protocol has only one requirement: the type implementing
the protocol should have some sort of id as a unique identifier. Update the Restaurant
List(restaurants) { restaurant in
HStack {
Image(restaurant.image)
.resizable()
.frame(width: 40, height: 40)
.cornerRadius(5)
Text(restaurant.name)
}
.listStyle(.plain)
}
Xcode immediately shows an error after making the change because the extracted
subview, BasicImageRow , doesn't have a restaurant property. To resolve this, update the
BasicImageRow struct to declare the restaurant property, like this:
Now everything should work without errors. The list view still looks the same, but the
underlying code is more readable and organized. It's also more adaptable to code
changes. For example, if you decide to create another layout for the row, you can easily
do so by updating the BasicImageRow struct, like this:
Text(restaurant.name)
.font(.system(.title, design: .rounded))
.fontWeight(.black)
.foregroundStyle(.white)
}
}
}
This row layout is designed to show a larger image with the restaurant name overlaid on
top. Since we've refactored our code, it's very easy to change the app to use the new
layout. All you need to do is replace BasicImageRow with FullImageRow in the closure of
the List , like this:
By changing one line of code, the app instantly switches to another layout.
You can further mix the row layouts to build a more interesting UI. For example, our list
can use FullImageRow for the first two rows of data, and the rest of the rows will utilize
the BasicImageRow . To achieve this, you can update the List like this:
Since we need to retrieve the index of the rows, we pass the List the index range of the
restaurant data. In the closure, we check the value of index to determine which row
layout to use.
Figure 12. Building a list view with two different row layouts
List(restaurants) { restaurant in
ForEach(restaurants.indices, id: \.self) { index in
if (0...1).contains(index) {
FullImageRow(restaurant: self.restaurants[index])
} else {
BasicImageRow(restaurant: self.restaurants[index])
}
}
.listRowSeparatorTint(.green)
}
.listStyle(.plain)
In the code above, we change the color of the line separators to green.
List {
ForEach(restaurants.indices) { index in
if (0...1).contains(index) {
FullImageRow(restaurant: self.restaurants[index])
} else {
BasicImageRow(restaurant: self.restaurants[index])
}
}
.listRowSeparator(.hidden)
}
.listStyle(.plain)
If you want to have finer control over the line separators, you can use an alternate version
of .listRowSeparator by specifying the edges parameter. For example, if you want to
keep the separator only at the top of the list view, you can write the code like this:
Starting from iOS 16, you can customize the color of the scrollable area of the list view.
Simply set the background color using the background modifier and attach the
scrollContentBackground modifier to the List view. By setting the parameter to .hidden ,
you can change the scroll view's background to yellow.
Other than using a solid color, you can use an image as the background. Update the code
like this to give it a try:
List(restaurants) { restaurant in
.
.
.
}
.background {
Image("homei")
.resizable()
.scaledToFill()
.clipped()
.ignoresSafeArea()
}
.scrollContentBackground(.hidden)
We use the background modifier to set the background image, and then we set the
scrollContentBackground modifier to .hidden to make the scrollable area transparent.
Exercise
Before you move on to the next chapter, challenge yourself by building the list view
shown in figure 13. It may appear complicated, but if you have a solid understanding of
this chapter, you should be able to build the UI. Take some time to work on this exercise.
I guarantee you'll learn a lot!
For reference, you can download the complete list project and the solution to the exercise
from the following links:
NavigationView {
List {
ForEach(restaurants) { restaurant in
BasicImageRow(restaurant: restaurant)
}
}
.listStyle(.plain)
}
In iOS 16 (or later), Apple replaced NavigationView with NavigationStack . Although you
can still use NavigationView to create a navigation view, it is recommended to use
NavigationStack as NavigationView will eventually be removed from the SDK.
NavigationStack {
List {
ForEach(restaurants) { restaurant in
BasicImageRow(restaurant: restaurant)
}
}
.listStyle(.plain)
}
Once you have made the change, you should see an empty navigation bar. To assign a
title to the bar, insert the navigationBarTitle modifier like below:
NavigationStack {
List {
ForEach(restaurants) { restaurant in
BasicImageRow(restaurant: restaurant)
}
}
.listStyle(.plain)
.navigationTitle("Restaurants")
}
Let's start with the detail view. Insert the following code at the end of the
ContentView.swift file to create the detail view:
Text(restaurant.name)
.font(.system(.title, design: .rounded))
.fontWeight(.black)
Spacer()
}
}
}
The detail view is similar to other SwiftUI views and conforms to the View protocol. Its
layout is simple, displaying the restaurant image and name. The RestaurantDetailView
struct also takes a Restaurant object as a parameter to retrieve the image and name of
the restaurant.
Now that the detail view is ready, the question is how to pass the selected restaurant from
the content view to this detail view?
SwiftUI provides a special button called NavigationLink , which can detect user touches
and trigger the navigation presentation. The basic usage of NavigationLink is as follows:
NavigationLink(destination: DetailView()) {
Text("Press me for details")
}
You specify the destination view in the destination parameter and customize its
appearance in the closure. For the demo app, the NavigationLink should navigate to the
RestaurantDetailView when any of the restaurants is tapped. To achieve this, we can apply
the NavigationLink to each of the rows. Update the List view as follows:
In the code above, we specify that the NavigationLink should navigate to the
RestaurantDetailView when users select a restaurant. We also pass the selected restaurant
to the detail view for display. With these few lines of code, we are able to build a
navigation interface and perform data passing seamlessly.
Figure 5. You should see the disclosure indicator for each row
In the canvas, you should notice that each row of data has been added with a disclosure
icon, indicating that it is a navigational link. In the preview canvas, you should be able to
navigate to the detail view after selecting one of the restaurants. Furthermore, you can
.navigationBarTitleDisplayMode(.inline)
The parameter specifies the appearance of the navigation bar, determining whether it
should appear as a large title bar or a compact title. By default, it is set to .automatic ,
which means that the large title is used. In the code above, we set it to .inline ,
indicating that a compact bar should be used instead.
Figure 6. Setting the display mode to .inline to use the compact bar
.navigationBarTitleDisplayMode(.automatic)
To change the title color to red and the font to Arial Rounded MT Bold, we can create a
UINavigationBarAppearance object in the init() function and configure the desired
attributes. Insert the following function in ContentView :
init() {
let navBarAppearance = UINavigationBarAppearance()
navBarAppearance.largeTitleTextAttributes = [.foregroundColor: UIColor.red, .f
ont: UIFont(name: "ArialRoundedMTBold", size: 35)!]
navBarAppearance.titleTextAttributes = [.foregroundColor: UIColor.red, .font:
UIFont(name: "ArialRoundedMTBold", size: 20)!]
UINavigationBar.appearance().standardAppearance = navBarAppearance
UINavigationBar.appearance().scrollEdgeAppearance = navBarAppearance
UINavigationBar.appearance().compactAppearance = navBarAppearance
}
If desired, you can create separate appearance objects for scrollEdgeAppearance and
compactAppearance and assign them individually.
Let's see how this customization works. To change the indicator image, you can call the
setBackIndicatorImage method and provide your own UIImage . Here, I'll set it to the
system image arrow.turn.up.left .
For the back button color, you can change it by setting the tint property like this:
NavigationStack {
.
.
.
}
.tint(.black)
Test the app again. The back button should be like that shown in figure 9.
.navigationBarBackButtonHidden(true)
SwiftUI also provides a modifier called toolbar for creating your own navigation bar
items. For example, you can create a back button with the name of the selected
restaurant like this:
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button {
dismiss()
} label: {
Text("\(Image(systemName: "chevron.left")) \(restaurant.name)")
.foregroundColor(.black)
}
}
}
In the closure of the .toolbar modifier, we create a ToolbarItem object with the
placement set to .navigationBarLeading . This specifies that the button should be placed
on the leading edge of the navigation bar.
To put the following code into action and update RestaurantDetailView like below:
Text(restaurant.name)
.font(.system(.title, design: .rounded))
.fontWeight(.black)
Spacer()
}
.navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button {
dismiss()
} label: {
Text("\(Image(systemName: "chevron.left")) \(restaurant.name)"
)
.foregroundColor(.black)
}
}
}
}
}
SwiftUI offers a wide range of built-in environment values. To dismiss the current view
and go back to the previous view, we can retrieve the environment value using the
.dismiss key. Then we can call the dismiss() function to dismiss the current view.
Then you can call the dismiss function of the presentation mode like this:
presentationMode.wrappedValue.dismiss()
Now test the app in the preview canvas and select any of the restaurants. You will see a
back button with the restaurant name. Tapping the back button will navigate back to the
main screen.
Exercise
To ensure you understand how to build a navigation UI, I have an exercise for you. Please
download the starter project from
https://www.appcoda.com/resources/swiftui5/SwiftUINavigationStarter.zip. Once you
open the project, you'll see a demo app displaying a list of articles.
This project is similar to the one you've worked on before, with the main difference being
the addition of the Article.swift file. This file contains the articles array, which holds
sample data. If you examine the Article struct closely, you'll notice the inclusion of the
content property, which stores the full article content.
Your task is to embed the list in a navigation view and create a detail view. When a user
taps on one of the articles in the content view, it should navigate to the detail view,
displaying the full article. I will show the solution in the next section, but I encourage you
to try to come up with your own solution first.
To better organize the code, instead of creating the detail view in the ContentView.swift
file, we will create a separate file for it. In the project navigator, right-click the
SwiftUINavigation folder and select New File... Choose the SwiftUI View template and
name the file ArticleDetailView.swift.
Since the detail view is going to display the full article , we need to have this property for
the caller to pass the article. So, declare an article property in ArticleDetailView :
Next, update the body like this to lay out the detail view:
Group {
Text(article.title)
.font(.system(.title, design: .rounded))
.fontWeight(.black)
.lineLimit(3)
Text("By \(article.author)".uppercased())
.font(.subheadline)
.foregroundColor(.secondary)
}
.padding(.bottom, 0)
.padding(.horizontal)
Text(article.content)
.font(.body)
.padding()
.lineLimit(1000)
.multilineTextAlignment(.leading)
}
}
}
We use a ScrollView to wrap all the views and enable scrollable content. I won't go over
the code line by line since you already understand how Text , Image , and VStack work.
However, I want to highlight the use of the Group modifier. This modifier allows you to
Now, you may encounter an error in Xcode complaining about the #Preview section. The
preview won't work because we added the article property in ArticleDetailView . To fix
this error, you need to pass a sample article in the preview. Update the #Preview section
as follows to resolve the error:
#Preview {
ArticleDetailView(article: articles[0])
}
Here, we simply select the first article from the articles array for the preview. Feel free
to modify it to a different value if you want to preview other articles. Once you make this
change, the preview canvas should accurately render the detail view.
Let's try one more thing. Since this view is going to be embedded in a NavigationView ,
you can modify the preview code to visualize how it looks within a navigation view:
.navigationTitle("Article")
}
}
By updating the code, you will see a navigation bar in the preview canvas.
Now that we've completed the layout of the detail view, it's time to go back to
ContentView.swift to implement the navigation. Update the ContentView struct like this:
NavigationStack {
List(articles) { article in
NavigationLink(destination: ArticleDetailView(article: article)) {
ArticleRow(article: article)
}
.listRowSeparator(.hidden)
}
.listStyle(.plain)
.navigationTitle("Your Reading")
}
}
}
In the code above, we embed the List view in a NavigationStack and apply a
NavigationLink to each of the rows. The destination of the navigation link is set to the
detail view we just created. With this setup, you can preview and interact with the app,
allowing you to navigate to the detail view by selecting an article from the list.
SwiftUI does not offer a direct option to disable or hide the disclosure indicator. To work
around this issue, we won't apply NavigationLink directly to the article row. Instead, we
will create a ZStack with two layers. Let's update the NavigationStack of the ContentView
as follows:
.listRowSeparator(.hidden)
}
}
.listStyle(.plain)
.navigationTitle("Your Reading")
}
The lower layer represents the article row, while the upper layer is an empty view. By
applying the NavigationLink to the empty view, we prevent iOS from rendering the
disclosure button. Once you make this change, the disclosure indicator will disappear
while still allowing navigation to the detail view.
Please switch over to ArticleDetailView.swift . Although I didn't mention the issue during
the design of the detail view, you may have noticed it in the preview (see figure 13).
The reason for the empty space above the image is the presence of the navigation bar.
This space is actually occupied by a large-size navigation bar with a blank title. When the
app navigates from the content view to the detail view, the navigation bar transitions to a
standard-size bar. To resolve this issue, we can explicitly specify the use of a standard-
size navigation bar.
Insert the following line of code after the closing bracket of the ScrollView :
.navigationBarTitleDisplayMode(.inline)
By setting the navigation bar to the inline mode, the navigation bar will be minimized,
resulting in a more compact appearance. Please proceed to ContentView.swift and test
the app again. The detail view should now look much better without the empty space
above the image.
In this last section, I want to show you how to build an even more elegant detail view by
hiding the navigation bar and building your own back button. First, let's take a look at the
final design displayed in figure 14. Doesn't it look great?
To position content that extends outside the safe areas, you can use the ignoresSafeArea
modifier. In our project, we want the scroll view to extend beyond the top edge of the safe
area. To achieve this, we apply the modifier as follows:
Now let's move on to the second issue, which is creating our own back button. This is a
bit trickier but still achievable. Here's how we can implement it:
modifier. Setting its value to true will hide the back button:
.navigationBarBackButtonHidden(true)
Once the back button is hidden, you can replace it with your own button. The toolbar
modifier allows you to configure the navigation bar items. In the closure, we create the
custom back button using ToolbarItem and assign the button as the left button of the
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button(action: {
// Navigate to the previous screen
}) {
Image(systemName: "chevron.left.circle.fill")
.font(.largeTitle)
}
.tint(.white)
}
}
You can attach the above modifiers to the ScrollView . Once the change is applied, you
should see our custom back button in the preview canvas.
You may have noticed that the action closure of the button was left empty. The back
button has been laid out nicely but the problem is that it doesn't function!
Next, in the action of our custom back button, insert this line of code:
dismiss()
Here, we call the dismiss() method to dismiss the detail view when the back button is
tapped. Run the app and test it again. You should now be able to navigate between the
content view and the detail view.
Summary
Navigation UI is a common and crucial concept in mobile app development.
Understanding how to navigate between screens and present content is key to building a
functional and user-friendly app. While the data in the examples provided may be static,
the principles and techniques discussed can be applied to apps with dynamic content as
well. With this understanding, you are well-equipped to build a simple content-based app
and expand upon it with dynamic data and additional features.
To further study navigation view, you can also refer to the documentation provided by
Apple:
For iPhone users, modal views are a familiar concept. They are commonly used to
present forms for user input. For example, the Calendar app presents a modal view for
users to create a new event. Similarly, the built-in Reminders and Contacts apps also use
modal views to prompt for user input.
If you're a long-time iOS user, you may notice that the visual appearance of the modal
views shown in figure 1 differs from the traditional ones. In previous versions of iOS,
modal views covered the entire screen. However, starting with iOS 13, modal views are
displayed in a card-like format by default. The modal view no longer covers the entire
screen but instead partially covers the underlying content view. You can still see the top
edge of the content/parent view. Additionally, with this update, modal views can be
dismissed by swiping down from anywhere on the screen. Enabling this gesture requires
no additional code as it is built-in and provided by iOS. However, if you prefer to dismiss
a modal view using a button, you can still implement that functionality.
I will demonstrate how to present the same detail view we implemented in the previous
chapter using a modal view. While modal views are commonly used for presenting forms,
they can also be used to present other types of information. In addition to modal views,
we will explore how to create a floating button within the detail view. While modal views
can be dismissed using the swipe gesture, I want to provide a Close button for users to
easily dismiss the detail view. Furthermore, we will also discuss Alerts, which are another
type of modal view.
Before we delve into the implementation, let me provide you with a brief introduction to
the card-like presentation of modal views. In SwiftUI, the card presentation style is used
to achieve this effect, and it is the default presentation style for modal views.
To present a modal view using the card presentation style, you apply the sheet modifier,
as shown below:
.sheet(isPresented: $showModal) {
DetailView()
}
.sheet(item: $itemToDisplay) {
DetailView()
}
The sheet modifier also allows you to trigger the display of modal views by passing an
optional binding. If the optional has a value, iOS will bring up the modal view. If you
remember our discussion on actionSheet in an earlier chapter, you will find that the
usage of sheet is very similar to actionSheet .
By default, it's set to false . The value of this variable will be set to true when one of the
rows is clicked. Later, we will make this change in the code.
When presenting the detail view, the view requires us to pass the selected article. So, we
also need to declare a state variable to store the user's selection. In ContentView , declare
another state variable for this purpose:
To implement the modal view, we attach the sheet modifier to the List like this:
NavigationStack {
List(articles) { article in
ArticleRow(article: article)
.listRowSeparator(.hidden)
}
.listStyle(.plain)
.sheet(isPresented: $showDetailView) {
.navigationTitle("Your Reading")
}
The presentation of the modal view depends on the value of the showDetailView property.
This is why we specify it in the isPresented parameter. The closure of the sheet
modifier describes the layout of the view to be presented. Here we present the
ArticleDetailView .
instances to detect the users' touch. Modify the NavigationStack in the body variable like
this:
NavigationStack {
List(articles) { article in
ArticleRow(article: article)
.onTapGesture {
self.showDetailView = true
self.selectedArticle = article
}
.listRowSeparator(.hidden)
}
.listStyle(.plain)
.sheet(isPresented: $showDetailView) {
.navigationTitle("Your Reading")
}
In the closure of onTapGesture , we set the showDetailView to true . This is used to trigger
the presentation of the modal view. We also store the selected article in the
selectedArticle variable.
Test the app in the preview canvas. You should be able to bring up the detail view
modally.
Note: For the very first time the app brings up the modal view, it shows a blank view.
Wipe down the dialog to dismiss it, select another article (not the same article) and you
should get the correct rendering. This is a known issue and we will discuss the fix in the
In this case, when using the sheet modifier with an optional binding, it requires you to
pass the binding of the selectedArticle variable. This means that the modal view will
only be presented if the selectedArticle variable has a value. The code in the closure
With this approach, the sheet modifier will pass the selected article as a parameter to
the closure. The article parameter contains the selected article, which is guaranteed to
have a value. This allows us to directly initialize an ArticleDetailView using the selected
article.
Since we no longer use the showDetailView variable, you can remove this line of code:
.onTapGesture {
self.showDetailView = true
...
}
After changing the code, you can test the app again. Everything should work like the first
version but the underlying code is cleaner than the original code.
Let's proceed with adding the Close button to the modal view as an alternative dismissal
method. Switch over to ArticleDetailView.swift .
Do you know how to position the button at the top-right corner? Try to come up with
your own implementation without peeking at my code.
Similar to the NavigationStack , we can dismiss the modal view by using the dismiss
.overlay(
HStack {
Spacer()
VStack {
Button {
dismiss()
} label: {
Image(systemName: "chevron.down.circle.fill")
.font(.largeTitle)
.foregroundStyle(.white)
}
.padding(.trailing, 20)
.padding(.top, 40)
Spacer()
}
}
)
The button will be overlaid on top of the scroll view, creating a floating effect. Even if you
scroll down the view, the button remains fixed in the same position. To position the
button at the top-right corner, we can utilize a combination of HStack , VStack , and
Spacer . To dismiss the view, simply call the dismiss() function.
Switch over to ContentView and run it in the canvas. You should be able to dismiss the
modal view by clicking the close button.
Using Alerts
In addition to the card-like modal views, Alerts are another type of modal view that
completely blocks the screen when presented. Unlike other modal views, an alert requires
the user to choose one of the options provided before it can be dismissed. Figure 7 shows
a sample alert that we're going to implement in our demo project. We will display this
alert after the user taps the close button.
In SwiftUI, you create an alert using the .alert modifier. Here is an example of .alert :
The sample code initializes an alert view with the title "Warning" and displays the
message "Are you sure you want to leave?" to the user. The alert view includes two
buttons: Confirm and Cancel.
}, message: {
Text("Are you sure you are finished reading the article?")
})
It's similar to the previous code snippet, except that the labels of the buttons are
different. This alert asks the user whether they have finished reading the article. If the
user chooses Yes, the modal view will be closed. Otherwise, the modal view will stay
Now that we have the code for creating the alert, the question is how can we trigger the
display of the alert? SwiftUI provides the alert modifier that you can attach to any view.
Again, you use a boolean variable to control the display of the alert. So, declare a state
variable in ArticleDetailView :
There is still one thing left. When should we trigger this alert? In other words, when
should we set showAlert to true ?
Obviously, the app should display the alert when someone taps the close button. So,
replace the close button's action like this:
Button {
self.showAlert = true
} label: {
Image(systemName: "chevron.down.circle.fill")
.font(.largeTitle)
.foregroundColor(.white)
}
Instead of dismissing the modal view directly, we instruct iOS to show the alert by setting
showAlert to true . You're now ready to test the app. When you tap the close button,
you'll see the alert. The modal view will be dismissed if you choose "Yes."
Summary
You've learned how to present a modal view, implement a floating button, and show an
alert. The latest release of iOS continues to encourage people to interact with the device
using gestures and provides built-in support for common gestures. Without writing a line
of code, you can let users swipe down the screen to dismiss a modal view.
For reference, you can download the complete modal project here:
In the SwiftUI framework, there is a special UI control called Form. With this new
control, you can easily build a form. In this chapter, I will show you how to build a form
using this Form component. While building out a form, you will also learn how to work
with common controls like picker, toggle, and stepper.
Okay, let's discuss the project we'll be working on. Take a look at Figure 1. We're going to
build a Settings screen for the Restaurant app that we've been working on in earlier
chapters. This screen will provide users with options to configure their order and filter
preferences. This type of form is commonly seen in real-life projects. Once you
understand how it works, you will be able to create your own form in your app projects.
In this chapter, our focus will be on implementing the layout of the form. You'll learn
how to use the Form component to structure the Setting screen. We'll also implement a
picker for selecting a sort preference, as well as a toggle and a stepper for indicating filter
preferences. Once you understand how to lay out a form, in the next chapter, I'll show
you how to make the app fully functional by updating the list based on the user's
preferences. You'll learn how to store user preferences, share data between views, and
monitor data updates using @Environment .
The Restaurant struct now has three more properties: type, phone, and priceLevel. I
think both type and phone are self explanatory. Price level stores an integer of range 1 to
5 reflecting the average cost of the restaurant. The restaurants array has been
prepopulated with some sample data. For later testing, some of the restaurants have
isFavorite and isCheckIn set to true . This is why you see some check-in and favorite
indicators displayed in the preview.
To begin, let's create a separate file for the Settings screen. In the project navigator, right-
click the SwiftUIForm folder and select "New File...". Choose the SwiftUI View template
and name the file SettingView.swift.
Now, let's start by creating the form. Replace SettingView with this:
.navigationBarTitle("Settings")
}
}
}
To organize the form layout, you can utilize the Form container. Within the Form , you
can add sections and various form components such as text fields, pickers, toggles, and
more. In the code snippet provided, we create two sections: Sort Preference and Filter
Preference. Each section contains a text view. If you have correctly implemented the
code, the canvas should display a preview similar to the one shown in figure 4.
For the sort preference, users will be able to choose the display order of the restaurant
list. We will offer three options for them to choose from:
1. Alphabetically
2. Show Favorite First
3. Show Check-in First
To handle this input, we can utilize a Picker control. We can start by declaring an array
named displayOrders in the SettingView file:
To use a picker, you also need to declare a state variable to store the user's selected
option. In SettingView , declare the variable like this:
Here, 0 means the first item of displayOrders . Now replace the SORT PREFERENCE
section like this:
In the canvas, you should see that the Display Order is initially set to Alphabetical. This
is because selectedOrder is set to the default value of 0 . If you click the Play button to
test the view, tapping the Display Order row will bring you to the next screen, showing
you all the available options. You can select any of the options (e.g. Show Favorite First)
for testing. When you go back to the Setting screen, the Display Order will reflect your
selection. This is the power of the @State keyword. It automatically monitors changes
and helps you store the state of the selection.
Creating a toggle switch using SwiftUI is quite straightforward. Similar to the Picker
control, we need to declare a state variable to store the current setting of the toggle.
Declare the following variable in SettingView :
You use the Toggle view to create a toggle switch and pass it the current state of the
toggle. In the closure, you present the description of the toggle. Here, we simply use a
Text view.
The canvas should now show a toggle switch under the Filter Preference section. If you
test the app, you should be able to switch it between the ON and OFF states. Similarly,
the state variable showCheckInOnly will always keep track of the user's selection.
Using Steppers
The last UI control in the setting form is a Stepper. Referring to figure 1, users can filter
the restaurants by setting the pricing level. Each restaurant has a pricing indicator with a
range of 1 to 5. Users can adjust the price level to narrow down the number of restaurants
displayed in the list view.
In the setting form, we will implement a stepper for users to adjust this setting. Basically,
a Stepper in iOS shows a text field, and plus and minus buttons to perform increment
and decrement actions on the text field.
To implement a stepper in SwiftUI, we first need a state variable to hold the current value
of the stepper. In this case, this variable stores the user's price level filter. Declare the
state variable in SettingView like this:
Stepper(onIncrement: {
self.maxPriceLevel += 1
if self.maxPriceLevel > 5 {
self.maxPriceLevel = 5
}
}, onDecrement: {
self.maxPriceLevel -= 1
if self.maxPriceLevel < 1 {
self.maxPriceLevel = 1
}
}) {
Text("Show \(String(repeating: "$", count: maxPriceLevel)) or below")
}
}
You create a stepper by initiating a Stepper component. For the onIncrement parameter,
you specify the action to perform when the + button is clicked. In the code, we simply
increase maxPriceLevel by 1. Conversely, the code specified in the onDecrement parameter
will be executed when the - button is clicked.
Since the price level is in the range of 1 to 5, we perform a check to make sure the value of
maxPriceLevel is between the value of 1 and 5. In the closure, we display the text
description of the filter preference. The maximum price level is indicated by dollar signs.
Test the app in the preview canvas. The number of $ signs will be adjusted when you click
the + / - button.
Switch over to ContentView.swift . Assuming you're familiar with the concept of modal
views, I will not explain the code in depth. First, we need a variable to keep track of the
state (i.e. shown or not shown) of the modal view. Insert the following line of code to
declare the state variable:
The navigationBarItems modifier allows you to add a button to the navigation bar. You
can position the button on the leading or trailing side of the navigation bar. In this case,
we use the trailing parameter to position the button on the top-right corner. The
sheet modifier is then used to present the SettingView as a modal view.
In the canvas, you should now see a gear icon in the navigation bar. When you click the
gear icon, it should bring up the Setting view.
Exercise
The only way to dismiss the Setting view is by using the swipe-down gesture. In the
modal view chapter, you learned how to dismiss a modal view programmatically. As a
refresher exercise, please create two buttons (Save & Cancel) in the navigation bar.
Although you are not required to implement the functionality of these buttons, when a
user taps either of the buttons, simply dismiss the setting view.
For reference, you can download the complete form project from the following link:
If you haven't completed the exercise from the previous chapter, I encourage you to
spend some time on it. However, if you can't wait to read this chapter, you can download
the project from the following link:
https://www.appcoda.com/resources/swiftui5/SwiftUIForm.zip.
An enumeration defines a common type for a group of related values and enables
you to work with those values in a type-safe way within your code.
init(type: Int) {
switch type {
case 0: self = .alphabetical
case 1: self = .favoriteFirst
case 2: self = .checkInFirst
default: self = .alphabetical
}
}
What makes enum great is that we can work with these values in a type-safe way within
our code. Additionally, enum in Swift is a first-class type in its own right. This means that
we can create instance methods to provide additional functionality related to the values.
Later on, we will add a function for handling the filtering. For now, let's create a new
Swift file named SettingStore.swift to store the enum . You can right-click SwiftUIForm
in the project navigator and choose New File... to create the file.
After creating SettingStore.swift , insert the code snippet above in the file. Next, go back
to SettingView.swift . We will update the code to use the DisplayOrder enumeration
instead of the displayOrders array.
Here, we set the default display order to alphabetical. Comparing this to the previous
value, the code becomes more readable after switching to use an enumeration. Next, you
also need to change the code in the Sort Preference section. Specifically, update the code
in the ForEach loop as follows:
Since we have adopted the CaseIterable protocol in the DisplayOrder enum, we can
obtain all the display orders by accessing the allCases property, which contains an array
of all the enum's cases.
Now you can test the Settings screen again. It should work and look the same. However,
the underlying code is more manageable and readable.
There are multiple ways to store the settings. For saving small amounts of data like user
settings on iOS, the built-in "defaults" database is a good option. This "defaults" system
allows an app to store user preferences in key-value pairs. To interact with this defaults
database, you use a programmatic interface called UserDefaults .
init() {
UserDefaults.standard.register(defaults: [
"view.preferences.showCheckInOnly" : false,
"view.preferences.displayOrder" : 0,
"view.preferences.maxPriceLevel" : 5
])
}
With the SettingStore ready, let's switch over to the SettingView.swift file to implement
the Save operation. First, declare a property in SettingView for the SettingStore :
For the Save button, find the Save button code (in the ToolbarItem(placement:
Button {
self.settingStore.showCheckInOnly = self.showCheckInOnly
self.settingStore.displayOrder = self.selectedOrder
self.settingStore.maxPriceLevel = self.maxPriceLevel
dismiss()
} label: {
Text("Save")
.foregroundStyle(.primary)
}
We added three lines of code to the exiting save button to save the user's preference. To
load the user's preferences when the Settings view is brought up, you can add the
onAppear modifier to the NavigationStack like this:
The onAppear modifier will be called when the view appears. We load the user's settings
from the defaults system in its closure.
Before you can test the changes, you have to update the #Preview section like this:
#Preview {
SettingView(settingStore: SettingStore())
}
.sheet(isPresented: $showSettings) {
SettingView(settingStore: self.settingStore)
}
#Preview {
ContentView(settingStore: SettingStore())
}
Next, change the line code in the WindowGroup block to the following to fix the error:
ContentView(settingStore: settingStore)
You should now be able to execute app and play around with the settings. Once you save
the settings, they are stored permanently in the local defaults system. You can stop the
app and launch it again. The saved settings should be loaded in the Setting screen.
Let's recap what we have so far. When a user taps the "Save" button on the "Settings"
screen, we save the selected options in the local defaults system. The "Settings" screen is
then dismissed, and the app brings the user back to the list view. We have two options to
solve the problem. We can either instruct the list view to reload the settings, or the list
view must be capable of monitoring the changes of the defaults system and trigger an
update of the list.
So, how can the list view know the user's preference is modified and trigger the update
itself?
I know it's a bit confusing. You will have a better understanding once we go through the
code.
import Combine
The SettingStore class should adopt the ObservableObject protocol. Update the class
declaration like this:
Next, insert the @Published annotation for all the properties like this:
By using the @Published property wrapper, the publisher informs subscribers whenever
there is a value change of the property (e.g., an update of displayOrder ).
As you can see, it's pretty easy to inform a changed value with Combine. We haven't
actually written any new code but simply adopted a required protocol and inserted a
marker.
Now let's switch over to SettingView.swift . The settingStore should now be declared as
an environment object so that we can share the data with other views. Update the
settingStore variable as follows:
#Preview {
SettingView().environmentObject(SettingStore())
}
Here, we inject an instance of SettingStore into the environment for the preview.
Okay, all our work has been on the Publisher side. What about the Subscriber? How can
we monitor the change of defaults and update the UI accordingly?
In the demo project, the list view is the Subscriber side. It needs to monitor the changes
of the setting store and re-render the list view to reflect the user's setting. Now let's open
ContentView.swift to make some changes. Similar to what we've just done, the
settingStore should now declared as an environment object:
Due to the change, the code in the sheet modifier should be modified to grab this
environment object:
.sheet(isPresented: $showSettings) {
SettingView().environmentObject(self.settingStore)
}
Also, for testing purposes, the preview code should be updated accordingly to inject the
environment object:
#Preview {
ContentView().environmentObject(SettingStore())
}
Here, we inject the setting store into the environment by calling the environmentObject
method. Now the instance of setting store is available to all views within the app. In other
words, both the Setting and List views can access it automatically.
Our final task is to implement the filtering and sorting options to display only the
restaurants that match the user preferences. Let's begin with the implementation of these
two filtering options:
This function takes in a restaurant object and tells the caller whether the restaurant
should be displayed. In the code above, we check if the "Show Check-in Only" option is
selected and verify the price level of the given restaurant.
if self.shouldShowItem(restaurant: restaurant) {
BasicImageRow(restaurant: restaurant)
.contextMenu {
...
}
}
Here, we first call the shouldShowItem function we just implemented to check if the
restaurant should be displayed.
Now run the app to have a quick test. In the settings screen, turn on the Show Check-in
Only option and configure the price level option to show restaurants with a price level of
3 (i.e., $$$) or below. Once you tap the Save button, the list view should be automatically
refreshed (with animation) and show you the filtered records.
method. When using this method, you need to provide a predicate that returns true
For instance, to sort the restaurants array in alphabetical order, you can use the
sort(by:) method as follows:
Conversely, if you want to sort the restaurants in alphabetical descending order, you can
write the code like this:
How can we sort the array to show "check-in" first or show "favorite" first? We can use
the same method but provide a different predicate like this:
To better organize our code, we can put these predicates in the DisplayOrderType enum.
In SettingStore.swift , add a new function in DisplayOrderType like this:
This function simply returns the predicate, which is a closure, for the corresponding
display order. Now we are ready to make the final change. Go back to ContentView.swift
ForEach(restaurants) {
...
}
To:
That's it! Test the app and change the sorting preference. When you update the sort
option, the list view will get notified and re-order the restaurants accordingly.
Demo project
(https://www.appcoda.com/resources/swiftui5/SwiftUIFormData.zip)
Before we dive into the code, take a look at figure 1. That is the user registration screen
we're going to build. Under each of the input fields, it lists out the requirements. As soon
as the user fills in the information, the app validates the input in real-time and crosses
out the requirement if it's been fulfilled. The sign up button is disabled until all the
requirements are matched.
If you have experience in Swift and UIKit, you know there are various types of
implementation to handle the form validation. In this chapter, however, we're going to
explore how you can utilize the Combine framework to perform form validation.
component. For the password fields, SwiftUI provides a secure text field called
SecureField .
To create a text field, you initiate a TextField with a field name and a binding. This
renders an editable text field with the user's input stored in your given binding. Similar to
other form fields, you can modify its look & feel by applying the associated modifiers.
Here is a sample code snippet:
The usage of these two components are very similar except that the secure field
automatically masks the user's input:
I know these two components are new to you, but try your best to build the form before
looking at the solution.
Are you able to create the form? Even if you can't finish the exercise, that's completely
fine. Download this project from
https://www.appcoda.com/resources/swiftui5/SwiftUIFormRegistrationUI.zip. I will go
through my solution with you.
Open the ContentView.swift file and preview the layout in the canvas. Your rendered view
should look like that shown in figure 2. Now, let's briefly go over the code. Let's start with
the RequirementText view.
First, why do I create a separate view for the requirements text (see figure 3)? If you look
at all of the requirements text, each requirement has an icon and a description. Instead of
creating each of the requirements text from scratch, we can generalize the code and build
a generic view for it.
This will render the square with an x in it (xmark.square) and the text as shown in figure
3. In some cases, the requirement text should be crossed out and display a different
icon/color. The code can be written like this:
You specify a different system icon name, color, and set the isStrikeThrough option to
true . This will allow you to create a requirement text like that displayed in figure 4.
Now that you understand how the RequirementText view works and why I created that,
let's take a look at the FormField view. Again, if you look at all the text fields, they all
have a common style - a text field with rounded font style. This is the reason why I
extracted the common code and created a FormField view.
VStack {
if isSecure {
SecureField(fieldName, text: $fieldValue)
.font(.system(size: 20, weight: .semibold, design: .rounded))
.padding(.horizontal)
} else {
TextField(fieldName, text: $fieldValue)
.font(.system(size: 20, weight: .semibold, design: .rounded))
.padding(.horizontal)
}
Divider()
.frame(height: 1)
.background(Color(red: 240/255, green: 240/255, blue: 240/255))
.padding(.horizontal)
}
}
}
Since this generic FormField needs to take care of both text fields and secure fields, it has
a property named isSecure . If it's set to true , the form field will be created as a secure
field. In SwiftUI, you can make use of the Divider component to create a line. In the
code, we use the frame modifier to change its height to 1 point.
To create the username field, you write the code like this:
Okay, let's head back to the ContentView struct and see how the form is laid out.
HStack {
Text("Already have an account?")
.font(.system(.body, design: .rounded))
.bold()
Button(action: {
// Proceed to Sign in screen
}) {
Text("Sign in")
.font(.system(.body, design: .rounded))
.bold()
.foregroundColor(Color(red: 251/255, green: 128/255, blue:
128/255))
}
}.padding(.top, 50)
Spacer()
}
.padding()
}
The Sign up button is created using the Button component and has an empty action. I
intend to leave the action closure blank because our focus is on form validation. Again, I
believe you should know how a button can be customized, so I will not go into detail here.
You can always refer to the Button chapter for more information.
Lastly, we have the description text Already have an account. This text and the Sign in
button are completely optional. I'm mimicking the layout of a common sign-up form.
That's how I laid out the user registration screen. If you've tried out the exercise, you
might have come up with a different solution, and that's completely fine. Here, I just
wanted to show you one approach to building the form. You can use it as a reference and
come up with an even better implementation.
Understanding Combine
Before we dive into the code for form validation, it's better for me to give you some more
background information on the Combine framework. As mentioned in the previous
chapter, this new framework provides a declarative API for processing values over time.
What does it mean by "processing values over time," and what are these values?
Let's use the registration form as an example. The app generates UI events as it interacts
with users. Each keystroke a user enters in the text field triggers an event, creating a
stream of values, as illustrated in figure 5.
These UI events are one type of "values" the framework refers to. Another example of
these values is network events (e.g. downloading a file from a remote server).
The Combine framework provides a declarative approach for how your app
processes events. Rather than potentially implementing multiple delegate callbacks
or completion handler closures, you can create a single processing chain for a given
event source. Each part of the chain is a Combine operator that performs a distinct
action on the elements received from the previous step.
Publisher and Subscriber are the two core elements of the Combine framework. With
Combine, a Publisher sends events, and a Subscriber subscribes to receive values from
that Publisher. Let's continue using the text field as an example. When using Combine,
each keystroke the user inputs in the text field triggers a value change event. The
subscriber, which is interested in monitoring these values, can subscribe to receive these
events and perform further operations, such as validation.
For instance, let's say you are writing a form validator that has a property indicating
whether the form is ready to submit. In this case, you can mark that property with the
@Published annotation, like this:
Every time you change the value of isReadySubmit , it publishes an event to the subscriber.
The subscriber receives the updated value and continues processing. For example, the
subscriber may use that value to determine whether the submit button should be enabled
or not.
You may find that @Published works similarly to @State in SwiftUI. While they have
similar functionality in this example, @State is specifically designed for properties that
belong to a particular SwiftUI view. However, if you want to create a custom type that
doesn't belong to a specific view or that can be used across multiple views, you need to
create a class that conforms to ObservableObject and mark the relevant properties with
the @Published annotation.
You may have a few questions at this point. First, why do we need to create a view model?
Can't we add the form properties and perform the validation directly in ContentView?
Certainly, you can do that. However, as your project grows or the view becomes more
complex, it's a good practice to separate concerns and break down a complex component
into multiple layers.
Username
Password
Password confirm
On top of that, this view model will hold the states of the requirements text, indicating
whether they should be crossed out or not. These requirements include:
Therefore, the view model will have seven properties, and each of these properties will
publish its value change to those interested in receiving the value. The basic structure of
the view model can be defined as follows:
// Output
@Published var isUsernameLengthValid = false
@Published var isPasswordLengthValid = false
@Published var isPasswordCapitalLetter = false
@Published var isPasswordConfirmValid = false
}
properties hold the value of the username, password, and password confirm text fields
respectively. This class should conform to ObservableObject . All these properties are
annotated with @Published because we want to notify the subscribers whenever there is a
value change and perform the validation accordingly.
publisher informs the subscriber about the validation status of the user input. Nearly all
controls in SwiftUI are subscribers, so the requirements text view will listen to changes in
the validation result and update its style accordingly (e.g. with a strikethrough or
without). Figure 6 illustrates how we use Combine to validate the username input.
If you transform the requirements above into code, the code snippet would look like this:
$username
.receive(on: RunLoop.main)
.map { username in
return username.count >= 4
}
.assign(to: \.isUsernameLengthValid, on: self)
The Combine framework provides two built-in subscribers: sink and assign. The sink
subscriber creates a general-purpose subscriber to receive values, while the assign
subscriber allows you to create a subscriber that updates a specific property of an object.
In this case, we use the assign subscriber to directly assign the validation result
(true/false) to the isUsernameLengthValid property.
Let's dive deeper into the code above, line by line. $username is the source of value
changes that we want to listen to. Since we're subscribing to the changes of UI events, we
call the receive(on:) function to ensure that the subscriber receives values on the main
thread (i.e., RunLoop.main ).
The value sent by the publisher is the username input by the user. But what the
subscriber is interested in is whether the length of the username meets the minimum
requirement. Here, the map function is an operator in Combine that takes an input,
processes it, and transforms the input into something that the subscriber expects. So,
what we did in the code above is:
The value sent by the publisher is the username input by the user. However, what the
subscriber is interested in is whether the length of the username meets the minimum
requirement. Here, the map function is an operator in Combine that takes an input,
With the validation result, the subscriber simply sets the result to the
isUsernameLengthValid property. Since isUsernameLengthValid is also a publisher, we can
update the RequirementText control like this to subscribe to the changes and update the
UI accordingly:
Both the icon color and the strike-through status depend on the validation result (i.e.
isUsernameLengthValid ).
This is how we use Combine to validate a form field. Although we haven't applied the
code changes to our project yet, it's important to understand the concept of publishers
and subscribers and how we perform validation using this approach. In the next section,
we will apply what we've learned and make the necessary code changes.
$password
.receive(on: RunLoop.main)
.map { password in
let pattern = "[A-Z]"
if let _ = password.range(of: pattern, options: .regularExpression) {
return true
} else {
return false
}
}
.assign(to: \.isPasswordCapitalLetter, on: self)
The first subscriber subscribes to the verification result of the password length and
assigns it to the isPasswordLengthValid property. The second subscriber handles the
validation of the uppercase letter. We use the range method to test if the password
contains at least one uppercase letter. Once again, the subscriber assigns the validation
result directly to the isPasswordCapitalLetter property.
Now, let's move on to validating the password confirm field. In this case, the requirement
is that the password confirm field should have the same value as the password field. Both
password and passwordConfirm are publishers. To verify if both publishers have the same
value, we use Publisher.combineLatest to receive and combine the latest values from the
publishers. We can then compare the two values to check if they are the same. Here is the
code snippet:
import Foundation
import Combine
// Output
@Published var isUsernameLengthValid = false
@Published var isPasswordLengthValid = false
@Published var isPasswordCapitalLetter = false
@Published var isPasswordConfirmValid = false
init() {
$username
.receive(on: RunLoop.main)
.map { username in
return username.count >= 4
}
.assign(to: \.isUsernameLengthValid, on: self)
$password
.receive(on: RunLoop.main)
.map { password in
return password.count >= 8
}
.assign(to: \.isPasswordLengthValid, on: self)
.store(in: &cancellableSet)
$password
.receive(on: RunLoop.main)
.map { password in
let pattern = "[A-Z]"
if let _ = password.range(of: pattern, options: .regularExpression
) {
return true
} else {
return false
}
}
.assign(to: \.isPasswordCapitalLetter, on: self)
.store(in: &cancellableSet)
Publishers.CombineLatest($password, $passwordConfirm)
.receive(on: RunLoop.main)
.map { (password, passwordConfirm) in
return !passwordConfirm.isEmpty && (passwordConfirm == password)
}
.assign(to: \.isPasswordConfirmValid, on: self)
.store(in: &cancellableSet)
}
}
The code is nearly the same as what we discussed in earlier sections. To use Combine,
you first need to import the Combine framework. In the init() method, we initialize all
the subscribers to listen to the value changes of the text fields and perform the
corresponding validations. One thing you may notice is the cancellableSet variable.
Additionally, for each subscriber, we call the store function at the end.
The assign function, which creates the subscriber, returns a cancellable instance. You
can use this instance to cancel the subscription at the appropriate time. The store
function allows us to save the cancellable reference into a set for later cleanup. If you
don't store the reference, the app may end up with memory leak issues.
So, when will the cleanup happen for this demo? Because cancellableSet is defined as a
property of the class, the cleanup and cancellation of the subscriptions will happen when
the class is deinitialized.
Now, switch back to ContentView.swift and update the UI controls. First, replace the
following state variables:
Next, update the text field and the requirement text of username like this:
Similarly, update the UI code for the password and password confirm fields like this:
VStack {
RequirementText(iconName: "lock.open", iconColor: userRegistrationViewModel.is
PasswordLengthValid ? Color.secondary : Color(red: 251/255, green: 128/255, blue:
128/255), text: "A minimum of 8 characters", isStrikeThrough: userRegistrationView
Model.isPasswordLengthValid)
That's it! You're now ready to test the app. If you've made all the changes correctly, the
app should now validate the user input.
Summary
I hope you now have gained some basic knowledge of the Combine framework. The
introduction of SwiftUI and Combine has completely changed the way we build apps.
Functional Reactive Programming (FRP) has become increasingly popular in recent
years. This is the first time Apple has released its own functional reactive framework. To
me, it represents a major paradigm shift. The company has finally taken a position on
FRP and recommends that Apple developers embrace this new programming
methodology.
However, once you become comfortable with the Combine framework, you will
appreciate its benefits, as it allows for more maintainable and modular code. As you can
now see, together with SwiftUI, communication between a view and a view model
becomes much easier.
For reference, you can download the complete form validation project here:
Demo project
(https://www.appcoda.com/resources/swiftui5/SwiftUIFormRegistration.zip)
Previously, you learned about swipe-to-delete and action sheets, as shown in Figure 1.
These two user interface elements have been part of iOS for several years. Context
menus, on the other hand, were introduced in iOS 13 and resemble the peek and pop
functionality of 3D Touch. When a user force-touches a view configured with a context
menu, iOS displays a popover menu. As a developer, it's your responsibility to configure
the action items that appear in the menu.
While this chapter focuses on interactions within a list, the techniques I'm about to show
you can also be applied to other user interface controls, such as buttons.
If you have a keen eye, you may have noticed that the starter project uses ForEach to
implement the list instead of directly passing the collection of data to List . You might
wonder why I made this choice. The main reason is that the onDelete handler, which I
will explain shortly, only works with ForEach . It allows us to easily implement the swipe-
to-delete feature for individual rows in the list.
Implementing Swipe-to-delete
List {
ForEach(restaurants) { restaurant in
BasicImageRow(restaurant: restaurant)
}
.onDelete { (indexSet) in
self.restaurants.remove(atOffsets: indexSet)
}
}
.listStyle(.plain)
In the closure of onDelete , we pass an indexSet storing the index of the rows to be
deleted. We then call the remove method with the indexSet to delete the specific items
in the restaurants array.
Before the swipe-to-delete feature works, there is one more thing we need to do.
Whenever a user removes a row from the list, the UI should be updated accordingly. In
SwiftUI, we can use the @State keyword to notify SwiftUI to monitor the property and
update the UI whenever the value of the property changes. To do that, add the @State
Once you have made the change, you're ready to test the delete feature in the preview
canvas. Swipe any of the rows to the left to reveal the Delete button. Tap it and that row
will be removed from the list. By the way, do you notice the nice animation while the row
is being removed? You don't need to write any extra code. This animation is
automatically generated by SwiftUI. Cool, right?
If you've implemented the same feature using UIKit, I'm sure you're amazed by SwiftUI.
With just a few lines of code and a keyword, you can implement the swipe-to-delete
feature.
SwiftUI has made it very simple to implement a context menu. All you need to do is
attach the contextMenu container to the view and configure its menu items.
For our demo app, we want to trigger the context menu when users touch and hold any of
the rows. The menu will provide two action buttons for users to choose from: Delete and
Favorite. When the Delete button is selected, the corresponding row will be removed
from the list. The Favorite button will mark the selected row with a star indicator.
List {
ForEach(restaurants) { restaurant in
BasicImageRow(restaurant: restaurant)
.contextMenu {
Button(action: {
// delete the selected restaurant
}) {
HStack {
Text("Delete")
Image(systemName: "trash")
}
}
Button(action: {
// mark the selected restaurant as favorite
}) {
HStack {
Text("Favorite")
Image(systemName: "star")
}
}
}
}
.onDelete { (indexSet) in
self.restaurants.remove(atOffsets: indexSet)
}
}
.listStyle(.plain)
We haven't implemented any of the button actions yet. However, if you test the app, the
app will bring up the context menu when you touch and hold one of the rows.
Let's continue by implementing the delete action. Unlike the onDelete handler, the
contextMenu doesn't give us the index of the selected restaurant. To figure it out, it would
require a little bit of work. Create a new function in ContentView :
The delete function takes a restaurant object as input and searches for its index in the
restaurants array. To find the index, we use the firstIndex function and specify the
search criteria. The function iterates through the array and compares the id of the given
restaurant with the id values in the array. If there is a match, the firstIndex function
returns the index of the given restaurant. Once we have the index, we can remove the
restaurant from the restaurants array by calling remove(at:) .
Next, insert the following line of code under // delete the selected restaurant :
We simply call the delete function when the user selects the Delete button. Now you're
ready to test the app. Click the Play button in the canvas to run the app or simply test it
in the preview canvas. Press and hold one of the rows to bring up the context menu.
Choose Delete and you should see the selected restaurant removed from the list.
Let's move on to the implementation of the Favorite button. When this button is
selected, the app will place a star in the selected restaurant's row. To implement this
feature, we first need to modify the Restaurant struct and add a new property named
isFavorite like this:
Similar to the Delete feature, we'll create a separate function in ContentView for setting a
favorite restaurant. Insert the following code to create the new function:
The code is very similar to that of the delete function. We first find out the index of the
given restaurant. Once we have the index, we change the value of its isFavorite
property. Here we invoke the toggle function to toggle the value. For example, if the
Next, we have to handle the UI for the row. Whenever the restaurant's isFavorite
property is set to true , the row should present a star indicator. Update the
BasicImageRow struct like this:
if restaurant.isFavorite {
Spacer()
Image(systemName: "star.fill")
.foregroundColor(.yellow)
}
}
}
}
In the code above, we just add a code snippet in the HStack . If the isFavorite property
of the given restaurant is set to true , we add a spacer and a system image to the row.
That's how we implement the Favorite feature. Lastly, insert the following line of code
under // mark the selected restaurant as favorite to invoke the setFavorite function:
self.setFavorite(item: restaurant)
The SwiftUI framework comes with an ActionSheet view for you to create an action
sheet. Basically, you can create an action sheet like this:
parameter accepts an array of buttons. In the sample code above, it provides a default
button titled Delete.
To activate an action sheet, you attach the actionSheet modifier to a button or any view.
If you look into SwiftUI's documentation, you have two ways to bring up an action sheet.
You can control the appearance of an action sheet by using the isPresented parameter:
func actionSheet<T>(item: Binding<T?>, content: (T) -> ActionSheet) -> some View w
here T : Identifiable
We will use both approaches to present the action sheet, so you'll understand when to use
which approach.
For the first approach, we need a Boolean variable to represent the status of the action
and also a variable of the type Restaurant to store the selected restaurant. So, declare
these two variables in ContentView :
By default, the showActionSheet variable is set to false , indicating that the action sheet
is not shown. We will toggle this variable to true when a user selects a row. The
selectedRestaurant variable, as its name suggests, is designed to hold the chosen
restaurant. Both variables are marked with the @State keyword because we want
SwiftUI to monitor their changes and update the UI accordingly.
Next, attach the onTapGesture and actionSheet modifiers to the List view like this:
...
}
.onTapGesture {
self.showActionSheet.toggle()
self.selectedRestaurant = restaurant
}
.actionSheet(isPresented: self.$showActionSheet) {
.destructive(Text("Delete"), action: {
if let selectedRestaurant = self.selectedRestaurant {
self.delete(item: selectedRestaurant)
}
}),
.cancel()
])
}
}
.onDelete { (indexSet) in
self.restaurants.remove(atOffsets: indexSet)
}
}
The onTapGesture modifier, attached to each row, is used to detect users' touch. When a
row is tapped, the code block within onTapGesture will be executed. Here, we toggle the
showActionSheet variable and set the selectedRestaurant variable to the selected
Earlier, I explained the usage of the actionSheet modifier. In the code above, we pass the
isPresented parameter with the binding of showActionSheet . When showActionSheet is set
to true , the code block will be executed. We initialize an ActionSheet with three
buttons: Mark as Favorite, Delete, and Cancel. Action sheets have three types of buttons:
default, destructive, and cancel. The default button type is used for ordinary actions. A
destructive button is similar to a default button but with red font color to indicate
destructive actions, such as delete. The cancel button is a special type for dismissing the
action sheet.
The Mark as Favorite button is our default button. In the action closure, we call the
setFavorite function to add the star. For the destructive button, we use Delete. Similar
to the Delete button of the context menu, we call the delete function to remove the
selected restaurant.
If you've made the changes correctly, you should be able to bring up the action sheet
when you tap one of the rows in the list view. Selecting the Delete button will remove the
row. If you choose the Mark as Favorite option, you will mark the row with a yellow star.
The second approach triggers the action sheet through an optional Identifiable binding:
func actionSheet<T>(item: Binding<T?>, content: (T) -> ActionSheet) -> some View w
here T : Identifiable
In plain English, this means the action sheet will be shown when the item you pass has a
value. In our case, the selectedRestaurant variable is an optional that conforms to the
Identifiable protocol. To use the second approach, you just need to pass the binding of
selectedRestaurant to the actionSheet modifier like this:
.destructive(Text("Delete"), action: {
self.delete(item: restaurant)
}),
.cancel()
])
}
If the selectedRestaurant has a value, the app will bring up the action sheet. From the
closure's parameter, you can retrieve the selected restaurant and perform the operations
accordingly.
When you use this approach, you no longer need the boolean variable shownActionSheet .
You can remove it from the code:
Also, in the tapGesture modifier, remove the line of the code that toggles the
showActionSheet variable:
self.showActionSheet.toggle()
Test the app again. The action sheet looks still the same, but you implemented the action
sheet with a different approach.
Please take some time to work on the exercise before checking out the solution. Have fun!
The SwiftUI framework provides several built-in gestures, such as the tap gesture we
have used before. Additionally, there are gestures like DragGesture,
MagnificationGesture, and LongPressGesture that are ready to use. We will explore a
couple of these gestures and see how to work with them in SwiftUI. Moreover, you will
learn how to build a generic view that supports the drag gesture.
If you want to try out the code, create a new project using the App template and make
sure you select SwiftUI for the Interface option. Then, paste the code into
ContentView.swift .
By modifying the code above slightly and introducing a state variable, we can create a
simple scale animation when the star image is tapped. Here is the updated code:
When you run the code in the canvas or simulator, you should see a scaling effect. This
demonstrates how to use the .gesture modifier to detect and respond to specific touch
events. If you need a refresher on how animations work, please refer back to chapter 9.
Modify the code in the .gesture modifier like this to implement the LongPressGesture :
.gesture(
LongPressGesture(minimumDuration: 1.0)
.onEnded({ _ in
self.isPressed.toggle()
})
)
In the preview canvas, you have to press and hold the star image for at least a second
before it toggles its size.
To implement the animation, you need to keep track of the state of gestures. During the
performance of the long press gesture, we have to differentiate between tap and long
press events. So, how do we do that?
SwiftUI provides a property wrapper called @GestureState which conveniently tracks the
state change of a gesture and lets developers decide the corresponding action. To
implement the animation we just described, we can declare a property using
@GestureState like this:
This gesture state variable indicates whether a tap event is detected during the
performance of the long press gesture. Once you have the variable defined, you can
modify the code of the Image view like this:
We only made a couple of changes in the code above. First, we added the .opacity
modifier. When the tap event is detected, we set the opacity value to 0.4 so that the
image becomes dimmer.
Second, we added the updating method of the LongPressGesture . During the performance
of the long press gesture, this method will be called. It accepts three parameters: value,
state, and transaction:
The value parameter is the current state of the gesture. This value varies from
gesture to gesture, but for the long press gesture, a true value indicates that a tap is
detected.
The state parameter is actually an in-out parameter that lets you update the value of
the longPressTap property. In the code above, we set the value of state to
currentState . In other words, the longPressTap property always keeps track of the
latest state of the long press gesture.
The transaction parameter stores the context of the current state-processing
update.
After you make the code change, run the project in the preview canvas to test it. The
image immediately becomes dimmer when you tap it. Keep holding it for one second, and
then the image resizes itself.
state = value.translation
})
)
}
}
To recognize a drag gesture, you initialize a DragGesture instance and listen for updates.
In the update function, we use a gesture state property to keep track of the drag event.
Similar to the long press gesture, the closure of the update function accepts three
Test the project in the preview canvas and try dragging the image around. When you
release it, the image returns to its original position.
You may be wondering why the image returns to its starting point. As explained in the
previous section, one advantage of using @GestureState is that it resets the value of the
property to its original value when the gesture ends. Therefore, when you end the drag
and release the press, the dragOffset is reset to .zero , which is its original position.
But what if you want the image to stay at the end point of the drag? How can you achieve
that? Take a few minutes to think about how to implement it.
Since the @GestureState property wrapper will reset the property to its original value, we
need another state property to save the final position. Let's declare a new state property
called finalOffset as a CGSize to store the final position of the dragged image:
state = value.translation
})
.onEnded({ (value) in
self.position.height += value.translation.height
self.position.width += value.translation.width
})
)
}
1. We implemented the onEnded function which is called when the drag gesture ends.
In the closure, we compute the new position of the image by adding the drag offset.
2. The .offset modifier was also updated, such that we take the current position into
account.
Now when you run the project and drag the image, the image stays where it is even after
the drag ends.
Combining Gestures
In some cases, you need to use multiple gesture recognizers in the same view. Let's say,
we want the user to press and hold the image before starting the drag, we have to
combine both long press and drag gestures. SwiftUI allows you to easily combine
gestures to perform more complex interactions. It provides three gesture composition
types including simultaneous, sequenced, and exclusive.
When you need to detect multiple gestures at the same time, you use the simultaneous
composition type. When you combine gestures using the exclusive composition type,
SwiftUI recognizes all the gestures you specify but it will ignore the rest when one of the
gestures is detected.
As the name suggests, if you combine multiple gestures using the sequenced composition
type, SwiftUI recognizes the gestures in a specific order. This is the type of the
composition that we will use to sequence the long press and drag gestures.
state = currentState
})
.sequenced(before: DragGesture())
.updating($dragOffset, body: { (value, state, transaction) in
switch value {
case .first(true):
print("Tapping")
case .second(true, let drag):
state = drag?.translation ?? .zero
default:
break
}
})
.onEnded({ (value) in
self.position.height += drag.translation.height
self.position.width += drag.translation.width
})
)
}
}
You should already be familiar with some parts of the code snippet as we are combining
the previously implemented long press gesture with the drag gesture.
Let's go through the code in the .gesture modifier line by line. We require the user to
press and hold the image for at least one second before they can begin dragging it. We
start by creating the LongPressGesture . Similar to our previous implementation, we have a
isPressed gesture state property that controls the opacity of the image when tapped.
The keyword sequenced is used to link the long press and drag gestures together. We tell
SwiftUI that the LongPressGesture should occur before the DragGesture .
The code in both the updating and onEnded functions looks quite similar, but the value
parameter now contains data from both gestures (i.e., long press and drag). We use a
switch statement to differentiate between the gestures. You can use the .first and
.second cases to determine which gesture is being handled. Since the long press gesture
should be recognized before the drag gesture, the first gesture here refers to the long
press gesture. In the code, we simply print the Tapping message for reference.
When the long press is confirmed, we reach the .second case. Here, we extract the drag
data and update the dragOffset property with the corresponding translation.
When the drag ends, the onEnded function is called. Similarly, we update the final
position by retrieving the drag data (i.e., .second case).
Now you're ready to test the combination of gestures. Run the app in the preview canvas
using the debug preview so that you can see the message in the console. You won't be
able to drag the image until you hold the star image for at least one second.
We have three states here: inactive, pressing, and dragging. These states are good
enough to represent the states during the performance of the long press and drag
gestures. For the dragging state, we associate it with the translation of the drag.
With the DragState enum, we can modify the original code like this:
switch value {
case .first(true):
state = .pressing
case .second(true, let drag):
state = .dragging(translation: drag?.translation ?? .zero)
default:
break
}
})
.onEnded({ (value) in
self.position.height += drag.translation.height
self.position.width += drag.translation.width
})
)
}
}
The outcome of the code remains the same. However, it is considered good practice to
use an enum to track complex states of gestures.
There is a better way to implement that. Let's see how we can build a generic draggable
view.
In the project navigator, right click the SwiftUIGesture folder and choose New File....
Select the SwiftUI View template and name the file DraggableView .
Declare the DragState enum and update the DraggableView struct like this:
enum DraggableState {
case inactive
case pressing
case dragging(translation: CGSize)
switch value {
case .first(true):
state = .pressing
case .second(true, let drag):
state = .dragging(translation: drag?.translation ?? .zero)
default:
break
}
})
.onEnded({ (value) in
self.position.height += drag.translation.height
All of the code is very similar to what we have written before. The key is to declare
DraggableView as a generic view and create a content property that accepts any View .
We then apply the long press and drag gestures to this content view.
Now you can test this generic view by replacing the #Preview code block like this:
#Preview {
DraggableView() {
Image(systemName: "star.circle.fill")
.font(.system(size: 100))
.foregroundColor(.green)
}
}
In the code, we initialize a DraggableView and provide our own content, which in this case
is the star image. By doing so, we create a reusable DraggableView that supports the long
press and drag gestures, and we can use it with any content we want.
So, what if we want to build a draggable text view? You can replace the code snippet with
the following code:
#Preview {
DraggableView() {
Text("Swift")
.font(.system(size: 50, weight: .bold, design: .rounded))
.bold()
.foregroundColor(.red)
}
}
If you want to create a draggable circle, you can replace the code like this:
#Preview {
DraggableView() {
Circle()
.frame(width: 100, height: 100)
.foregroundColor(.purple)
}
}
That's how you create a generic draggable. Try to replace the circle with other views to
make your own draggable view and have fun!
Exercise
Summary
The SwiftUI framework has made gesture handling incredibly easy. As you've learned in
this chapter, the framework provides several ready-to-use gesture recognizers. Enabling
a view to support a specific type of gesture is as simple as attaching the .gesture
modifier to it. Composing multiple gestures has also become much more straightforward.
It's a growing trend to build gesture-driven user interfaces for mobile apps. With the
user-friendly API provided by SwiftUI, you can now empower your apps with useful
gestures to delight your users.
The size of bottom sheets can vary depending on the information being displayed. Some
bottom sheets, known as backdrops, occupy 80-90% of the screen. Users can interact
with the sheet using drag gestures, sliding it up to expand or down to minimize or
dismiss it.
In this chapter, we will create an expandable bottom sheet using SwiftUI gestures. The
demo app features a list of restaurants in the main view. When a user taps on a
restaurant record, a bottom sheet will appear, displaying the details of the selected
restaurant. You can expand the sheet by sliding it up. To dismiss the sheet, you can slide
it down.
However, starting from iOS 16, SwiftUI includes a built-in modifier called
presentationDetents that allows for presenting resizable bottom sheets.
To present a bottom sheet using SwiftUI, you can use the sheet view and apply the
presentationDetents modifier. Here is an example of how to use it:
Spacer()
}
}
}
You specify a set of detents in the presentationDetents modifier. In the example provided,
the bottom sheet supports both medium and large sizes. When initially presented, the
bottom sheet appears in the medium size. However, you can expand it to the large size by
dragging the sheet upwards.
The starter project comes with a set of restaurant images and the restaurant data. If you
look in the Model folder in the project navigator, you should find a file named
Restaurant.swift . This file contains the Restaurant struct and the set of sample
restaurant data.
init() {
self.init(name: "", type: "", location: "", phone: "", description: "", im
age: "", isVisited: false)
}
}
I've created the main view for you that displays a list of restaurants. You can open the
ContentView.swift file to check out the code. I am not going to explain the code in details
as we have gone through the implementation of list in chapter 10.
However, if you would like to refer to my implementation, I'll be glad to assist you. Let's
begin building the detail view, breaking it down into multiple parts for easier
implementation:
The handlebar - it is a small rounded rectangle that serves as a handle for the bottom
sheet.
The title bar - this section contains the title of the detail view.
We will implement each of the above using a separate struct to better organize our
code. Now create a new file using the SwiftUI View template and name it
RestaurantDetailView.swift . All the code discussed below will be put in this new file.
Handlebar
Let's start with the handlebar. It's a small rectangle with rounded corners. We can use a
Rectangle shape and apply rounded corners to it. Open the RestaurantDetailView.swift
file and add the following code snippet to create the handlebar:
Title Bar
Next, it's the title bar. The implementation is simple since it's just a Text view. Let's
create another struct for it:
Spacer()
}
.padding()
}
}
Header View
The header view consists of an image view and two text views. The text views are overlaid
on top of the image view. To implement the header view, let's create a separate struct
for it. In the RestaurantDetailView.swift file, add the following code snippet:
Text(restaurant.type)
.font(.system(.headline, design: .rounded))
.padding(5)
.foregroundStyle(.white)
.background(Color.red)
.cornerRadius(5)
}
Spacer()
}
.padding()
)
}
}
Since we need to display the restaurant data, the HeaderView has the restaurant
property. For the layout, we create an Image view and set the content mode to
scaleToFill . The height of the image is fixed at 300 points. To ensure the image stays
within its frame, we attach the .clipped() modifier to hide any content that extends
beyond the edges of the image.
So, wouldn't it be great to build a view which is flexible to handle both field types? Here is
the code snippet:
Spacer()
}.padding(.horizontal)
}
}
The DetailInfoView takes two parameters: icon and info . The icon parameter is
optional, which means it can either have a value or be nil.
To present a data field with an icon, you can use the DetailInfoView as follows:
As you can see, by building a generic view to handle similar layout, you make the code
more modular and reusable.
HandleBar()
TitleBar()
HeaderView(restaurant: self.restaurant)
Before you can test the detail view, you have to modify the code of
RestaurantDetailView_Previews like this:
The code above is self-explanatory. We use the components that were built in the earlier
sections and embed them in a vertical stack. The VStack originally has a transparent
background, but to ensure that the detail view has a white background, we attach the
background modifier.
To test the detail view, you need to modify the code of the #Preview section as follows:
#Preview {
RestaurantDetailView(restaurant: restaurants[0])
}
In the code, we pass a sample restaurant (i.e., restaurants[0] ) for testing. If you've
followed everything correctly, Xcode should display a similar detail view in the preview
canvas, resembling figure 6.
Make It Scrollable
Do you notice that the detail view can't display the full description? To fix the issue, we
have to make the detail view scrollable by embedding the content in a ScrollView like
this:
Do you notice that the detail view cannot display the full description? To fix this issue, we
need to make the detail view scrollable. We can achieve this by embedding the content in
a ScrollView like this:
HandleBar()
ScrollView(.vertical) {
TitleBar()
HeaderView(restaurant: self.restaurant)
Except for the handlebar, the rest of the views are wrapped within the scroll view. If you
run the app in the preview canvas again, you will notice that the detail view is now
scrollable.
In the ContentView struct, declare a state variable called selectedRestaurant to store the
user's chosen restaurant. This variable will be of type Restaurant? to represent an
optional restaurant selection.
As you've learned in an earlier chapter, you can attach the onTapGesture modifier to
detect the tap gesture. So, when a tap is recognized, we update the value of
selectedRestaurant like this:
List {
ForEach(restaurants) { restaurant in
BasicImageRow(restaurant: restaurant)
.onTapGesture {
self.selectedRestaurant = restaurant
}
}
}
The detail view, which functions as the bottom sheet, is intended to overlay on top of the
list view. We check if the detail view is enabled and initialize it accordingly. Here is the
code snippet:
NavigationStack {
.
.
.
}
.sheet(item: $selectedRestaurant) { restaurant in
RestaurantDetailView(restaurant: restaurant)
.ignoresSafeArea()
.presentationDetents([.medium, .large])
}
Since the presentation detents support both medium and large sizes, you can drag the
bottom sheet upward to expand it.
RestaurantDetailView(restaurant: restaurant)
.ignoresSafeArea()
.presentationDetents([.medium, .large])
.presentationDragIndicator(.hidden)
However, if you want to remember the last selected detent and restore it when the
bottom sheet is reopened, you can declare a state variable to keep track of the currently
selected detent.
For the presentationDetents modifier, you specify the binding of the state variable in the
selection parameter. This allows SwiftUI to store the currently selected detent in the
state variable.
SwiftUI then stores the last selected detent in the state variable and restore when the
bottom sheet is presented again.
Summary
In this chapter, I have demonstrated how to create a bottom sheet using the new
presentationDetents modifier. This highly anticipated view component has been long
awaited by many developers. With the customizable bottom sheet, you now have the
ability to easily display supplementary content that anchors to the bottom of the screen.
For further reference and to explore the complete implementation of the bottom sheet,
you can download the project from the following link:
Demo project
(https://www.appcoda.com/resources/swiftui5/SwiftUIBottomSheet.zip)
In this chapter, our goal is to create a simple app featuring a Tinder-like UI. The app will
present users with a deck of travel cards and enable them to utilize the swipe gesture to
either like or dislike a card.
Let's dive into the project and explore the process of implementing this interactive and
engaging user interface. Please note that we are not going to build a fully functional app
but focus only on the Tinder-like UI.
Project Preparation
It would be great if you could use your own images. However, to save you time in
preparing trip images, I have created a starter project for you. You can download it from
https://www.appcoda.com/resources/swiftui5/SwiftUITinderTripStarter.zip. This
project already includes a set of photos for the travel cards.
I have also prepared the test data for the demo app and created the Trip.swift file to
represent a trip:
#if DEBUG
var trips = [ Trip(destination: "Yosemite, USA", image: "yosemite-usa"),
Trip(destination: "Venice, Italy", image: "venice-italy"),
Trip(destination: "Hong Kong", image: "hong-kong"),
Trip(destination: "Barcelona, Spain", image: "barcelona-spain"),
Trip(destination: "Braies, Italy", image: "braies-italy"),
Trip(destination: "Kanangra, Australia", image: "kanangra-australia"
),
Trip(destination: "Mount Currie, Canada", image: "mount-currie-canad
a"),
Trip(destination: "Ohrid, Macedonia", image: "ohrid-macedonia"),
Trip(destination: "Oia, Greece", image: "oia-greece"),
Trip(destination: "Palawan, Philippines", image: "palawan-philippine
s"),
Trip(destination: "Salerno, Italy", image: "salerno-italy"),
Trip(destination: "Tokyo, Japan", image: "tokyo-japan"),
Trip(destination: "West Vancouver, Canada", image: "west-vancouver-c
anada"),
Trip(destination: "Singapore", image: "garden-by-bay-singapore"),
Trip(destination: "Perhentian Islands, Malaysia", image: "perhentian
-islands-malaysia")
]
#endif
In case if you prefer to use your own images and data, simply replace the images in the
asset catalog and update Trip.swift .
Card View
First, let's create a card view. If you want to challenge yourself, I highly recommend
stopping here and implementing it without following this section. Otherwise, keep
reading.
To better organize the code, we will implement the card view in a separate file. In the
project navigator, create a new file using the SwiftUI View template and name it
CardView.swift .
The main screen is going to display a deck of card views. Later, we will use ForEach to
loop through an array of card views and present them. If you still remember the usage of
ForEach , SwiftUI needs to know how to uniquely identify each item in the array.
Therefore, we will make CardView conform to the Identifiable protocol and introduce
an id variable like this:
.
.
.
}
In case you forgot what the Identifiable protocol is, please refer to chapter 10. Now let's
continue implementing the card view and update the body variable like this:
Text(title)
.font(.system(.headline, design: .rounded))
.fontWeight(.bold)
.padding(.horizontal, 30)
.padding(.vertical, 10)
.background(.white)
.cornerRadius(5)
}
.padding([.bottom], 20)
}
}
The card view is composed of an image and a text component, which is overlayed on top
of the image. We set the image to the scaleToFill mode and round the corners using the
cornerRadius modifier. The text component is used to display the destination of the trip.
We have discussed a similar implementation of the card view in chapter 5. If you don't
fully understand the code, please refer to that chapter.
You can't preview the card view yet because you have to provide the values of both image
and title in the preview code. Therefore, update the #Preview section like this:
#Preview {
CardView(image: "yosemite-usa", title: "Yosemite, USA")
}
Let's begin by opening ContentView.swift and implementing the top bar menu. Create a
new struct for the top bar menu like this:
The three icons in the top bar menu are arranged using a horizontal stack with equal
spacing. We will follow a similar implementation for the bottom bar menu. Insert the
following code in ContentView.swift to create the menu bar:
Button {
// Book the trip
} label: {
Text("BOOK IT NOW")
.font(.system(.subheadline, design: .rounded))
.bold()
.foregroundColor(.white)
.padding(.horizontal, 35)
.padding(.vertical, 15)
.background(.black)
.cornerRadius(10)
}
.padding(.horizontal, 20)
Image(systemName: "heart")
.font(.system(size: 30))
.foregroundStyle(.black)
}
}
}
We won't implement the "Book Trip" feature, so the action block is left blank. The rest of
the code should be self-explanatory if you understand how stacks and images work.
Before building the main UI, let me show you a trick to preview these two menu bars. It's
not mandatory to include these bars in the ContentView in order to preview their look and
feel.
Now insert the following code snippets for previewing the top and bottom bar menus:
#Preview("BottomBarMenu") {
BottomBarMenu()
}
For the TopBarMenu and BottomBarMenu views, we added two more #Preview sections.
Additionally, we gave each view a distinct name. If you take a look at the preview canvas,
you will see three previews: ContentView, TopBarMenu, and BottomBarMenu. Simply
click on each view to preview its layout. Figure 5 gives you a better idea of what the
preview looks like.
Okay, let's continue to lay out the main UI. Update the ContentView like this:
Spacer(minLength: 20)
BottomBarMenu()
}
}
}
In the code, we simply arrange the UI components we have built using a VStack . Your
preview should now show you the main screen layout.
You can imagine a Tinder-like UI as a deck of piled photo cards. For our demo app, each
photo represents a destination of a trip. Swiping the topmost card (i.e., the first trip)
slightly to the left or right reveals the next card (i.e., the next trip) underneath. If the user
releases the card, the app brings it back to its original position. However, if the user
swipes hard enough, they can throw away the card, and the app will bring the next card
forward to become the new topmost card.
The main screen we have implemented only contains a single card view. So, how can we
implement the pile of card views?
return views
}()
ZStack {
ForEach(cardViews) { cardView in
cardView
}
}
Spacer(minLength: 20)
BottomBarMenu()
}
}
}
In the provided code, we initialize an array called cardViews that contains all the trips,
which are defined in the Trip.swift file. Within the body variable, we iterate through
each card view in the array and overlay them on top of each other using a ZStack .
In the preview canvas, you should see the same UI as before, but with a different image
displayed.
Why did it display another image? In the current implementation, the displayed image is
determined by the order of the trips in the trips array. Since the last trip in the array is
placed at the top of the ForEach loop, it becomes the topmost card in the deck.
1. The first trip of the trips array is supposed to be the topmost card, however, it's
now the lowermost card.
2. We rendered 15 card views for 15 trips. What if we have 10,000 trips or even more in
the future? Should we create one card view for each of the trips? Is there a resource
efficient way to implement the card deck?
Let's first fix the card order issue. SwiftUI provides the zIndex modifier for you to
indicate the order of the views in a ZStack. A view with a higher value of zIndex is placed
on top of those with a lower value. So, the topmost card should have the largest value of
zIndex .
return index == 0
}
This function takes a card view as input, determines its index within the array, and
checks if it is the topmost card.
With the helper function in place, we can now update the code block for the ZStack to
assign a higher zIndex value to the topmost card. This ensures that the topmost card is
visually displayed above the other cards. Here's the updated code block:
ZStack {
ForEach(cardViews) { cardView in
cardView
.zIndex(self.isTopCard(cardView: cardView) ? 1 : 0)
}
}
We added the zIndex modifier for each of the card views. The topmost card is assigned a
higher value of zIndex . In the preview canvas, you should now see the photo of the first
trip (i.e. Yosemite, USA).
For the second issue, we need to find a solution that allows the card deck to handle a
large number of card views efficiently.
Upon closer examination, it becomes clear that we don't actually need to instantiate
individual card views for each trip photo. Instead, we can create just two card views and
overlay them with each other.
When the topmost card view is swiped away, the card view underneath becomes the new
topmost card. Simultaneously, we can instantiate a new card view with a different photo
and position it behind the current topmost card. By adopting this approach, the app only
Now that you understand how we are going to construct the card deck, let’s move onto
the implementation.
First, update the cardViews array, we no longer need to initialize all the trips but only the
first two. Later, when the first trip (i.e. the first card) is thrown away, we will add another
one to it.
return views
}()
After the code change, the UI should look exactly the same. But in the underlying
implementation, the app now only shows two card views in the deck.
To begin, let's define the DragState enum in the ContentView struct. This enum will
represent the various drag states:
Once again, if you don't understand what an enum is used for, stop here and review the
chapters on gestures. Next, let's define a @GestureState variable to store the drag state,
which is set to inactive by default:
ZStack {
ForEach(cardViews) { cardView in
cardView
.zIndex(self.isTopCard(cardView: cardView) ? 1 : 0)
.offset(x: self.dragState.translation.width, y: self.dragStat
e.translation.height)
.scaleEffect(self.dragState.isDragging ? 0.95 : 1.0)
.rotationEffect(Angle(degrees: Double( self.dragState.translat
ion.width / 10)))
.animation(.interpolatingSpring(stiffness: 180, damping: 100),
value: self.dragState.translation)
.gesture(LongPressGesture(minimumDuration: 0.01)
.sequenced(before: DragGesture())
.updating(self.$dragState, body: { (value, state, transact
ion) in
switch value {
case .first(true):
state = .pressing
case .second(true, let drag):
state = .dragging(translation: drag?.translation ?
? .zero)
default:
break
}
})
)
}
}
Spacer(minLength: 20)
BottomBarMenu()
.opacity(dragState.isDragging ? 0.0 : 1.0)
Basically, we apply the knowledge we gained from the gesture chapter to implement the
dragging functionality. The .gesture modifier is used with two gesture recognizers: long
press and drag. When the drag gesture is recognized, we update the dragState variable
and store the drag translation.
We also made some modifications to the BottomBarMenu . While the user is dragging the
card view, we hide the bottom bar by applying the .opacity modifier and setting its
value to zero when the dragging state is active.
After making these changes, run the project in a simulator to test it out. You should be
able to drag the card and move it around. When you release the card, it should return to
its original position.
Do you notice a problem here? While the drag functionality is working, it's actually
dragging the entire card deck instead of just the topmost card. Additionally, the scaling
effect is being applied to the entire deck rather than just the topmost card.
To address these issues, we need to make some changes to the code of the offset ,
scaleEffect , and rotationEffect modifiers. These modifications will ensure that
dragging only affects the topmost card view and that the scaling effect is applied
exclusively to that card.
})
)
}
}
Just focus on the changes to the offset , scaleEffect , and rotationEffect modifiers.
The rest of the code remains unchanged. We have introduced an additional check within
these modifiers to ensure that the effects are only applied to the topmost card view.
If you run the app again, you should see that the card underneath is visible, and you can
now drag the topmost card without affecting the other cards in the deck.
Let's continue implementing these features. To begin, let's declare a drag threshold in
ContentView :
Once the translation of a drag exceeds the threshold, we want to overlay an icon (either a
heart or xmark) on the card. Additionally, when the user releases the card, we need to
remove it from the deck, create a new one, and place the new card at the back of the deck.
To overlay the icon, we can use the .overlay modifier on the cardViews . Insert the
following code below the .zIndex modifier:
Image(systemName: "heart.circle")
.foregroundColor(.white)
.font(.system(size: 100))
.opacity(self.dragState.translation.width > self.dragThreshold && self
.isTopCard(cardView: cardView) ? 1.0 : 0.0)
}
}
By default, both images are hidden by setting their opacity to zero. The translation's
width has a positive value if the drag is to the right, otherwise, it's a negative value.
Depending on the drag direction, the app will reveal either the heart or xmark icon when
the drag's translation exceeds the threshold.
You can run the project to have a quick test. When you exceed the threshold during the
drag, the heart or xmark icon will appear on the card.
First, let's mark the cardViews array with @State so that we can update its value and
refresh the UI:
return views
}()
Okay, here comes to the core function for removing and inserting the card views. Define a
new function called moveCard :
self.lastIndex += 1
let trip = trips[lastIndex % trips.count]
cardViews.append(newCardView)
}
This function first removes the topmost card from the cardViews array using the
removeFirst() method. Then, it instantiates a new card view with the subsequent trip's
image and adds it to the cardViews array using the append() method. Since cardViews is
defined as a state property, SwiftUI will automatically re-render the card views once the
array's value is changed. This is how we remove the topmost card and insert a new one to
the deck.
For this demo, the intention is to have the card deck continuously display trips. After the
last photo of the trips array is displayed, the app will cycle back to the first element
using the modulus operator % in the code above. This ensures that the card deck keeps
showing trips in a loop, providing a seamless user experience.
Next, update the .gesture modifier and insert the .onEnded function:
self.moveCard()
}
})
)
When the drag gesture ends, the code checks if the drag's translation exceeds the
threshold, and if so, it calls the moveCard() function accordingly.
To test this functionality, run the project in the preview canvas. Drag the image to the
right or left until the heart or xmark icon appears. Then release the drag, and you will
observe that the topmost card is replaced by the next card in the deck.
To implement the desired animation, we can utilize the transition modifier and apply
an appropriate transition to the card views. By specifying an asymmetric transition, we
can control the animation differently during insertion and removal of the views.
Add the extension, AnyTransition to the bottom of ContentView.swift and define two
transition effects:
The reason for using asymmetric transitions is to apply animation specifically when the
card view is removed from the deck. When a new card view is inserted, we want it to
appear instantly without any animation.
The trailingBottom transition is used when the card view is thrown away to the right of
the screen, while we apply the leadingBottom transition when the card view is thrown
away to the left.
Next, declare a state property that holds the transition type. It's set to trailingBottom by
default.
Now attach the .transition modifier to the card view. You can place it after the
.animation modifier:
.transition(self.removalTransition)
.gesture(LongPressGesture(minimumDuration: 0.01)
.sequenced(before: DragGesture())
.updating(self.$dragState, body: { (value, state, transaction) in
switch value {
case .first(true):
state = .pressing
case .second(true, let drag):
state = .dragging(translation: drag?.translation ?? .zero)
default:
break
}
})
.onChanged({ (value) in
guard case .second(true, let drag?) = value else {
return
}
})
.onEnded({ (value) in
self.moveCard()
}
})
The code sets the removalTransition . The transition type is updated according to the
swipe direction. Now you're ready to run the app again. You should now see an improved
animation when the card is thrown away.
Summary
With SwiftUI, you can easily build some cool animations and mobile UI patterns. This
Tinder-like UI is an examples.
I hope you fully understand what I covered in this chapter, so you can adapt the code to
fit your own project. It’s quite a huge chapter. I wanted to document my thought process
instead of just presenting you with the final solution.
For reference, you can download the complete tinder project here:
Demo project
(https://www.appcoda.com/resources/swiftui5/SwiftUITinderTrip.zip)
Project Preparation
To keep you focused on learning animations and view transitions, begin with this starter
project (https://www.appcoda.com/resources/swiftui5/SwiftUIWalletStarter.zip). The
starter project already bundles the required credit card images and comes with a built-in
transaction history view. If you want to use your own images, please replace them in the
asset catalog.
To create the card view, right click the View group in the project navigator and create a
new file. Choose the SwiftUI View template and name the file CardView.swift . Next,
update the code like this:
VStack(alignment: .leading) {
Text(card.number)
.bold()
HStack {
Text(card.name)
.bold()
Text("Valid Thru")
.font(.footnote)
Text(card.expiryDate)
.font(.footnote)
}
}
.foregroundColor(.white)
.padding(.leading, 25)
.padding(.bottom, 20)
, alignment: .bottomLeading)
.shadow(color: .gray, radius: 1.0, x: 0.0, y: 1.0)
}
}
We declare a card property to take in the card data. To display the personal information
and card number on the card image, we use the overlay modifier and layout the text
components with a vertical stack view and a horizontal stack view.
To preview the cards, update the #Preview code block like this:
The testCards variable was defined in Card.swift . We just pick one of them for preview.
Xcode will layout the card like that shown in figure 5.
In the project navigator, you should see the ContentView.swift file. Delete it and then
right click the View folder to create a new one. In the dialog, choose SwiftUI View as the
template and name the file WalletView.swift .
If you preview the WalletView or run the app on simulator, Xcode should display an error
because the ContentView is set to the initial view and it was deleted. To fix the error, open
SwiftUIWalletApp.swift and change the following line of code in WindowGroup from:
ContentView()
To:
WalletView()
Spacer()
Image(systemName: "plus.circle.fill")
.font(.system(.title))
}
.padding(.horizontal)
.padding(.top, 20)
}
}
The code is very straightforward. We laid out the title and the plus image using a
horizontal stack.
Next, we create the card deck. First, declare a property in the WalletView struct for the
array of credit cards:
For demo purpose, we simply set the default value to testCards which was defined in the
Card.swift file. To lay out the wallet view, we use both a VStack and ZStack . Update the
body variable like this:
TopNavBar()
.padding(.bottom)
Spacer()
ZStack {
ForEach(cards) { card in
CardView(card: card)
.padding(.horizontal, 35)
}
}
Spacer()
}
}
If you run the app on simulator or preview the UI directly, you should only see the last
card in the card deck like that shown in figure 7.
1. The cards are now overlapped with each other - we need to figure out a way to
spread out the deck of cards.
2. The Discover card is supposed to be the last card - In a ZStack view, the items stack
on top of each other based on the order in which they are added. The first item being
put into the ZStack becomes the lowermost layer, while the last item is the
uppermost layer. In the testCards array in Card.swift , the first card is the Visa
card, while the last card is the Discover card.
Okay, so how are we going to fix these issues? For the first issue, we can make use of the
offset modifier to spread out the deck of cards. For the second issue, obviously we can
alter the zIndex for each card in the CardView to change the order of the cards. Figure 8
illustrates how the solution works.
return -Double(cardIndex)
}
return index
}
Both functions work together to figure out the correct z-index of a given card. To
compute a correct z-index, the first thing we need is the index of the card in the cards
array. The index(for:) function is designed to get the array index of the given card. Once
we have the index, we can turn it into a negative value. This is what the zIndex(for:)
function does.
Now, you can attach the zIndex modifier to the CardView like this:
CardView(card: card)
.padding(.horizontal, 35)
.zIndex(self.zIndex(for: card))
Once you make the change, the Visa card should move to the top of the deck.
Now that you should understand how we are going to spread the cards, let's write the
code. Declare the default vertical offset in WalletView :
Next, create a new function called offset(for:) that is used to compute the vertical offset
of the given card:
CardView(card: card)
.padding(.horizontal, 35)
.offset(self.offset(for: card))
.zIndex(self.zIndex(for: card))
That's how we spread the card using the offset modifier. If everything is correct, you
should see a preview like that shown in figure 9.
First, we need a way to trigger the transition animation. Let's declare a state variable at
the beginning of CardView :
This variable indicates whether the cards should be presented on screen. By default, it's
set to false . Later, we will set this value to true to trigger the view transition.
Each of the cards is a view. To implement an animation like that displayed in figure 10,
we need to attach both the transition and animation modifiers to the CardView like
this:
For the transition, we combine the default slide transition with the move transition. As
mentioned before, the transition will not be animated without the animation modifier.
This is why we also attach the animation modifier. Since each card has its own
animation, we create a function called transitionAnimation(for:) to compute the
animation. Insert the following code to create the function:
In fact, all the cards have a similar animation, which is a spring animation. The difference
is in the delay. The last card of the deck will appear first, thus the value of the delay
should be the smallest. The formula below is how we compute the delay for each of the
cards. The smaller the index, the longer the delay.
So, how can we trigger the view transition of the card views when the app is launched?
The trick is to attach an id modifier to each card view:
The value is set to isCardPresented . Now attach the onAppear modifier to the ZStack :
.onAppear {
isCardPresented.toggle()
}
When the ZStack appears, we change the value of isCardPresented from false to true .
When the id value changes, SwiftUI considers this to be a new view. Thus, this triggers
the view transition animation of the cards.
After applying the changes, hit the Play button to test the app in a simulator. The app
should render the animation when it launches.
To implement this feature, we need two more state variables. Declare these variables in
WalletView :
The isCardPressed variable indicates if a card is selected, while the selectedCard variable
stores the card selected by the user.
To handle the tap gesture, we attach the above gesture modifier to the CardView (just
below .animation(self.transitionAnimation(for: card)) ) and use the built-in TapGesture
to capture the tap event. In the code block, we simply toggle the state of isCardPressed
To move the selected card (and those underneath) upward and the rest of the cards move
off the screen, update the offset(for:) function like this:
if isCardPressed {
guard let selectedCard = self.selectedCard,
let selectedCardIndex = index(for: selectedCard) else {
return .zero
}
return offset
}
We added an if clause to check if a card is selected. If the given card is the one selected
by the user, we set the offset to .zero . For the cards right below the selected card, we
also move them upward, hence the offset of .zero . For the rest of the cards, we move
them off the screen by setting the vertical offset to 1400 points.
Now we are ready to write the code for bringing up the transaction history view. As
mentioned at the very beginning, the starter project comes with this transaction history
view. Therefore, you do not need to build it yourself.
We can use the isCardPressed state variable to determine if the transaction history view
should be shown or not. Insert the following code right before Spacer() :
In the code above, we set the transition to .move to bring the view up from the bottom of
the screen. Feel free to change it to suit your preference.
1. To initiate the dragging action, the user must tap and hold the card. A simple tap will
only bring up the transaction history view.
2. Once the user successfully holds a card, the app will move it a slighly upward. This
provides feedback to the user, indicating that the card is ready to be dragged.
3. As the user drags the card, they should be able to move it across the deck,
Figure 12. Moving a card across the deck using the drag gesture
To begin, insert the following code in WalletView.swift to create the DragState enum so
that we can easily keep track of the drag state:
If you've read the chapter about SwiftUI gestures, you should already know how to detect
a long press and drag gesture. However, this time it will be a bit different. We need to
handle the tap gesture, the drag, and the long press gesture at the same time.
Additionally, the app should ignore the tap gesture if the long press gesture is detected.
})
.onEnded({ (value) in
)
)
SwiftUI allows you to combine multiple gestures exclusively. In the code above, we
specify that either the tap gesture or the long press gesture should be recognized, but not
both at the same time. This ensures that SwiftUI ignores the long press gesture once the
tap gesture is detected.
Before you can drag the card, you have to update the offset(for:) function like this:
if isCardPressed {
guard let selectedCard = self.selectedCard,
let selectedCardIndex = index(for: selectedCard) else {
return .zero
}
return offset
}
// Handle dragging
var pressedOffset = CGSize.zero
var dragOffsetY: CGFloat = 0.0
switch dragState.translation.width {
case let width where width < -10: pressedOffset.width = -20
case let width where width > 10: pressedOffset.width = 20
default: break
}
We added a block of code to handle the dragging. Please bear in the mind that only the
selected card is draggable. Therefore, we need to check if the given card is the one being
dragged by the user before making the offset change.
Earlier, we stored the card's index in the dragState variable. So, we can easily compare
the given card index with the one stored in dragState to figure out which card to drag.
For the dragging card, we add an additional offset both horizontally and vertically.
Run the app to test it out, tap & hold a card and then drag it around.
// The default z-index of a card is set to a negative value of the card's inde
x,
// so that the first card will have the largest z-index.
let defaultZIndex = -Double(cardIndex)
The default z-index is still set to the negative value of the card's index. For the dragging
card, we need to compute a new z-index as the user drags across the deck. The updated z-
index is calculated based on the translation's height and the default offset of the card (i.e.
50 points).
Run the app and drag the Visa card again. Now the z-index is continuously updated as
you drag the card.
The trick here is to update the items of the cards array, so as to trigger a UI update.
First, we need to mark the cards variable as a state variable like this:
Next, let's create another new function for rearranging the cards:
When you drag the card over the adjacent cards, we need to update the z-index once the
drag's translation is greater than the default offset. Figure 15 shows the expected
behaviour of the drag.
Lastly, insert the following line of code under // Rearrange the cards to call the function:
withAnimation {
self.rearrangeCards(with: card, dragOffset: drag.translation)
}
After that, you are ready to run the app to test it out. Congratulations, You've built the
Wallet-like animation.
Summary
After going through this chapter, I hope you have gained a deeper understanding of
SwiftUI animation and view transitions. If you compare SwiftUI with the original UIKit
framework, you'll notice that SwiftUI has made it significantly easier to work with
animations. Remember how you rendered the card animation when the user released the
dragging card? All you had to do was update the state variable, and SwiftUI took care of
the heavy lifting. That's the power of SwiftUI!
For reference, you can download the complete wallet project here:
In this chapter, we will discuss how you can work with JSON while building an app using
the SwiftUI framework. If you have never worked with JSON, I would recommend you
read this free chapter from our Intermediate programming book. It provides a detailed
explanation of the two different approaches for handling JSON in Swift.
As part of our learning process, we will create a simple JSON app that utilizes a JSON-
based API provided by Kiva.org to explore JSON and its related APIs. Kiva is a non-profit
organization dedicated to connecting people through lending in order to alleviate
poverty. It enables individuals to lend as little as $25 to support various opportunities
worldwide. Kiva offers free web-based APIs that developers can access to retrieve data.
In our demo app, we will leverage the Kiva API to fetch the most recent fundraising loans
and display them in a list view, as shown in Figure 1. Furthermore, we will showcase the
usage of the Slider, one of the many built-in UI controls provided by SwiftUI. By utilizing
the slider, we will implement a data filtering feature in the app, allowing users to filter
the loan data in the displayed list.
https://api.kivaws.org/v1/loans/newest.json
{
"loans": [
{
"activity": "Fruits & Vegetables",
"basket_amount": 25,
"bonus_credit_eligibility": false,
"borrower_count": 1,
"description": {
"languages": [
"en"
]
},
"funded_amount": 0,
"id": 1929744,
"image": {
"id": 3384817,
...
"paging": {
"page": 1,
"page_size": 20,
"pages": 284,
"total": 5667
}
}
Alternatively, you can format the JSON data on Mac by using the following command:
Now that you have seen JSON, let's learn how to parse JSON data in Swift. Starting with
Swift 4, Apple introduced a new way to encode and decode JSON data by adopting a
protocol called Codable .
Codable simplifies the entire process by offering developers a different way to decode (or
encode) JSON. As long as your type conforms to the Codable protocol, along with the
new JSONDecoder , you will be able to decode the JSON data into your specified instances.
Figure 3 illustrates the decoding of sample loan data into an instance of Loan using
JSONDecoder .
}
"""
Assuming you're new to JSON parsing, let's make things simple. The above is a simplified
JSON response, similar to that shown in the previous section.
As you can see, the structure adopts the Codable protocol. The variables defined in the
structure match the keys of the JSON response. This is how you let the decoder know
how to decode the data.
do {
let loan = try decoder.decode(Loan.self, from: jsonData)
print(loan)
} catch {
print(error)
}
}
If you run the project, you should see a message displayed in the console. That's a Loan
You just need to call the decode method of the decoder with the JSON data and specify
the type of value to decode (i.e. Loan.self ). The decoder will automatically parse the
JSON data and convert them into a Loan object.
Cool, right?
}
"""
As you can see, the key amount is now loan_amount. In order to decode the JSON data,
you can modify the property name from amount to loan_amount . However, we really want
to keep the name amount . In this case, how can we define the mapping?
To define the mapping between the key and the property name, you are required to
declare an enum called CodingKeys that has a raw value of type String and conforms to
the CodingKey protocol.
In the enum, you define all the property names of your model and their corresponding
keys in the JSON data. For example, the case amount is defined to map to the key
loan_amount . If both the property name and the key of the JSON data are the same, you
can omit the assignment.
}
"""
We've added the location key that has a nested JSON object with the nested key
country . So, how do we decode the value of country from the nested object?
}
}
Similar to what we have done earlier, we have to define an enum CodingKeys . For the
case country , we specify to map to the key location . To handle the nested JSON object,
we also need to define an additional enumeration. In the code above, we name it
LocationKeys and declare the case country , which matches the key country of the nested
object.
protocol to handle the decoding of all properties. In the init method, we first invoke the
container method of the decoder with CodingKeys.self to retrieve the data related to the
specified coding keys, which are name , location , use and amount .
To decode a specific value, we call the decode method with the specific key (e.g. .name )
and the associated type (e.g. String.self ). The decoding of the name , use and amount
is pretty straightforward. For the country property, the decoding is a little bit tricky. We
have to call the nestedContainer method with LocationKeys.self to retrieve the nested
JSON object. From the values returned, we further decode the value of country .
In the example above, there are two loans in the json variable. How do you decode it
into an array of Loan ?
To do that, declare another struct named LoanStore that also adopts Codable :
This LoanStore only has a loans property that matches the key loans of the JSON data.
And, its type is defined as an array of Loan .
The decoder will automatically decode the loans JSON objects and store them into the
loans array of LoanStore . To print out the loans replace the line print(loan) with
Let's begin by creating the model class to store the latest loans. We will handle the
implementation of user interface later.
}
}
The code is almost the same as we discussed in the previous section. We just use an
extension to adopt the Codable protocol. Other than Codable , this structure also adopts
the Identifiable protocol and has an id property defaulting to UUID() . Later, we will
use SwiftUI's List control to present the loans. This is why we make this structure adopt
the Identifiable protocol.
Next, create another file using the Swift File template and name it LoanStore.swift . This
class is responsible for connecting to Kiva's web API, decoding the JSON data, and
storing them locally.
Let's write the LoanStore class step by step, so you can better understand how I came up
with the implementation. Insert the following code in LoanStore.swift :
array of LoanStore . This is why we create the LoanStore like above. The code looks very
similar to the LoanStore structure we created before. However, it adopts the Decodable
If you look into the documentation of Codable , it is just a type alias of a protocol
composition:
Decodable and Encodable are the two actual protocols you need to work with. Since
LoanStore is only responsible for handling the JSON decoding, we adopt the Decodable
protocol.
As mentioned earlier, we will display the loans using a List view. So, other than
Decodable , we have to adopt the ObservableObject protocol and mark the loans variable
with the @Published property wrapper like this:
By doing so, SwiftUI will manage the UI update automatically whenever there is any
change to the loans variable. If you have forgotten what ObservableObject is, please read
chapter 14 again.
Once you add the @Published property wrapper, Xcode shows you an error. The
Decodable (or Codable ) protocol doesn't play well with @Published .
To fix the error, it requires some extra work. When the @Published property wrapper is
used, we need to manually implement the required method of Decodable . If you look into
the documentation (link), here is the method to adopt:
Actually, we've implemented the method before when decoding the nested JSON objects.
Now, update the class like this:
init() {
}
}
We added the CodingKeys enum that explicitly specifies the key to decode. And then, we
implemented the custom initializer to handle the decoding.
func fetchLatestLoans() {
guard let loanUrl = URL(string: Self.kivaLoanURL) else {
return
}
}
})
task.resume()
}
do {
} catch {
print(error)
}
return loans
}
The fetchLatestLoans() method connects to the web API using URLSession . Once it
receives the data returned by the API, it passes the data to the parseJsonData method to
decode the JSON and convert the loan data into an array of Loan .
You may wonder why we need to wrap the following line of code with
DispatchQueue.main.async :
DispatchQueue.main.async {
self.loans = self.parseJsonData(data: data)
}
When calling the web API, the operation is performed in a background queue. Here, the
loans variable is marked as @Published . That means, for any modification of the
variable, SwiftUI will trigger an update of the user interface. UI updates are required to
run in the main queue. This is why we wrap it using DispatchQueue.main.async .
And, instead of coding the UI in one file, we will break it down into three views:
Let's begin with the cell view. In the project navigator, right click SwiftUIKivaLoan and
choose New file.... Select the SwiftUI View template and name the file
LoanCellView.swift .
Spacer()
VStack {
Text("$\(loan.amount)")
.font(.system(.title, design: .rounded))
.bold()
}
}
.frame(minWidth: 0, maxWidth: .infinity)
}
}
This view takes in a Loan and renders the cell view. The code is self-explanatory but if
you want to preview the cell view, you will need to modify the #Preview code as shown
below:
#Preview {
LoanCellView(loan: Loan(name: "Ivan", country: "Uganda", use: "to buy a plot o
f land", amount: 575))
}
Now go back to ContentView.swift to implement the list view. First, declare a variable
named loanStore :
Since we want to observe the change of loan store and update the UI, the loanStore is
marked with the @ObservedObject property wrapper.
List(loanStore.loans) { loan in
LoanCellView(loan: loan)
.padding(.vertical, 5)
}
.navigationTitle("Kiva Loan")
}
.task {
self.loanStore.fetchLatestLoans()
}
}
If you've read chapters 10 and 11, you should understand how to present a list view and
embed it in a navigation view. That's what the code above does. The code in the task
closure will be invoked when the view appears. We call the fetchLatestLoans() method to
retrieve the latest loans from Kiva.
If this is the first time you're using .task , it's very similar to .onAppear . Both allow you
to run asynchronous tasks when the view appears. The main difference is that .task will
automatically cancel the task when the view is destroyed. It's more appropriate in this
case.
Now test the app in the preview or on a simulator. You should be able to see the loan
records.
Again, we want our code to be better organized. So, create a new file for the filter view
and name it LoanFilterView.swift .
HStack {
HStack {
Text("\(Int(minAmount))")
.font(.system(.footnote, design: .rounded))
Spacer()
Text("\(Int(maxAmount))")
.font(.system(.footnote, design: .rounded))
}
}
.padding(.horizontal)
.padding(.bottom, 10)
}
}
I assume you fully understand stack views. Therefore, I'm not going to discuss how they
are used to create the layout. But let's talk a bit more about the Slider control. It's a
standard component provided by SwiftUI. You can instantiate the slider by passing it the
The step controls the amount of change when the user drags the slider. If you let the user
have finer control, set the step to a smaller number. For the code above, we set it to 100.
In order to preview the filter view, update the FilterView_Previews like this:
#Preview {
LoanFilterView(amount: .constant(10000))
}
Figure 10. The filter view for setting the display criteria
First, declare the following variable which is used to store a copy of the loan records for
the filter operation:
To save the copy, insert the following line of code after self.loans =
self.parseJsonData(data: data) :
self.cachedLoans = self.loans
This function takes in the value of maximum amount and filter those loan items that are
below this limit.
Let's go back to ContentView.swift to present the filter view. What we are going to do is
add a navigation bar button at the top-right corner. When a user taps this button, the app
presents the filter view.
NavigationStack {
VStack {
if filterEnabled {
LoanFilterView(amount: self.$maximumLoanAmount)
.transition(.opacity)
}
List(loanStore.loans) { loan in
LoanCellView(loan: loan)
.padding(.vertical, 5)
}
}
.navigationTitle("Kiva Loan")
}
Loan") :
This adds a navigation bar button at the top-right corner. When the button is tapped, we
toggle the value of filterEnabled to show/hide the filter view. Additionally, we call the
filterLoans function to filter the loan item.
Now test the app in the preview. You should see a filter button on the navigation bar. Tap
it once to bring up the filter view. You can then set a new limit (e.g. $500). Tap the
button again and the app will only show you the loan records that are below $500.
Summary
We covered quite a lot in this chapter. You should know how to consume web APIs, parse
the JSON content, and present the data in a list view. We also briefly covered the usage of
the Slider control.
If you've developed an app using UIKit before, you will be amazed by the simplicity of
SwiftUI. Take a look at the code of ContentView again. It only takes around 40 lines of
code to create the list view. Most importantly, you don't need to handle UI updates
manually or pass data around. Everything just works behind the scenes.
For reference, you can download the complete loan project here:
Since the ToDo demo app makes use of List and Combine to handle data presentation
and sharing, I'll assume that you've read the following chapters:
If you haven't done so or have forgotten what Combine and Environment Objects are,
please go back and read the chapters again.
What are we going to do in this chapter to understand SwiftData? Instead of building the
ToDo app from scratch, I've already built the core parts of the app. However, it can't save
data permanently. To be more specific, it can only save the to-do items in an array.
Whenever the user closes the app and starts it again, all the data is lost. We will modify
the app and convert it to use SwiftData for saving the data permanently to the local
database. Figure 1 shows some sample screenshots of the ToDo app.
Before we perform the modification, I will guide you through the starter project so that
you can fully understand how the code works. In addition to learning about Swift Data,
you will also learn how to customize the style of a toggle. Take a look at the screenshots
above. The checkbox is actually a toggle view in SwiftUI. I will show you how to create
these checkboxes by customizing the Toggle's style.
Understanding SwiftData
Before we check out the starter project of the ToDo app, let me give you a quick
introduction to Swift Data and how you're going to work with it in SwiftUI projects.
Regardless of whether you're using Core Data or the SwiftData framework, both tools
serve to shield developers from the complexities of the underlying persistent store.
Consider the SQLite database, for instance. With SwiftData, there's no need to worry
about connecting to the database or understanding SQL in order to retrieve data records.
Instead, developers can focus on working with APIs and Swift Macros, such as @Query
In iOS 17, Apple finally introduces a native framework called SwiftData for Swift on
persistent data management and data modeling. It's built on top of Core Data but the
APIs are completely redesigned to make the most out of Swift.
class Song {
var title: String
var artist: String
var album: String
var genre: String
var rating: Double
}
To use SwiftData, the new @Model macro is the key for storing persistent data using
SwiftUI. Instead of building the data model with model editor, SwiftData just requires
you to annotate the model class with the @Model macro like this:
This is how you define the schema of the data model in code. With this simple keyword,
SwiftData automatically enables persistence for the data class and offers other data
management functionalities such as iCloud sync. Attributes are inferred from properties
and it supports basic value types such as Int and String.
SwiftData allows you to customize how your schema is built using property metadata.
You can add uniqueness constraints by using the @Attribute annotation, and delete
propagation rules with the @Relationship annotation. If there are certain properties you
do not want included, you can use the @Transient macro to tell SwiftData to exclude
them. Here is an example:
To drive the data persistent operations, there are two key objects of SwiftData that you
should be familiar with: ModelContainer and ModelContext . The ModelContainer serves as
the persistent backend for your model types. To create a ModelContaine r, you simply need
to instantiate an instance of it.
// Basic
let container = try ModelContainer(for: [Song.self, Album.self])
// With configuration
let container = try ModelContainer(for: [Song.self, Album.self],
configurations: ModelConfiguration(url: URL("p
ath"))))
In SwiftUI, you can set up the model container at the root of the application:
import SwiftData
import SwiftUI
@main
struct MusicApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer (for: [Song.self, Album.self]))
}
}
With the context, you are ready to fetch data. The simplest way is to use the @Query
property wrapper. You can easily load and filter anything stored in your database with a
single line of code.
To insert item in the persistent store, you can call the insert method of the model context
and pass it the model objects to insert.
modelContext.insert(song)
Similarly, you can delete the item via the model context like this:
modelContext.delete(song)
This is a brief introduction of SwiftData. If you're still feeling confused about how to use
SwiftData? No worries. You will understand its usage after building a ToDO app.
To run the app, simply open it in the preview canvas or a simulator. From there, tap the +
button to add a to-do item. Repeat the procedure to add a few more items. The app will
then list all of the to-do items you've added. To mark an item as complete, simply tap the
checkbox next to it, and it will be crossed out.
init(id: UUID = UUID(), name: String = "", priority: Priority = .normal, isCom
plete: Bool = false) {
self.id = id
self.name = name
self.priority = priority
self.isComplete = isComplete
}
}
The ToDo app demo is a simplified version of an ordinary ToDo app. Each to-do item (or
task), has three properties: name, priority, and isComplete (i.e. the status of the task).
This class has the @Observable macro, which is a new feature in iOS 17. It adds
observation support to ToDoItem and make it conform to the Observable protocol.
With the macro, the three properties are automatically marked with @Published so that
the subscribers are informed whenever there are any changes of the values. Later, in the
implementation of ContentView , SwiftUI listens for value changes and updates the views
accordingly. For example, when the value of isComplete changes, it toggles the checkbox.
This class also conforms to the Identifiable protocol such that each instance of
ToDoItem has an unique identifier. Later, we will use the ForEach and List to display
the to-do items. This is why we need to adopt the protocol and create the id property.
Now let's move onto the views and begin with the ContentView.swift file. Assuming
you've read chapter 10, you should understand most of the code. The content view has
three main parts, which are embedded in a ZStack :
VStack {
HStack {
Text("ToDo List")
.font(.system(size: 40, weight: .black, design: .rounded))
Spacer()
Button(action: {
self.showNewTask = true
}) {
Image(systemName: "plus.circle.fill")
.font(.largeTitle)
.foregroundStyle(.purple)
}
}
.padding()
List {
ForEach(todoItems) { todoItem in
ToDoListRow(todoItem: todoItem)
}
}
.listStyle(.plain)
}
.rotation3DEffect(Angle(degrees: showNewTask ? 5 : 0), axis: (x: 1, y: 0, z: 0))
.offset(y: showNewTask ? -50 : 0)
.animation(.easeOut, value: showNewTask)
To hold all of the to-do items, I've declared a state variable named todoItems . It's marked
with @State so that the list will be automatically refreshed whenever changes are made.
In the List view, we use ForEach to loop through the items in the array.
Spacer()
Circle()
.frame(width: 10, height: 10)
.foregroundColor(self.color(for: self.todoItem.priority))
}
}.toggleStyle(CheckboxStyle())
}
This view takes in a to-do item, which is a ObservableObject . This means for any changes
of that to-do item, the view that subscribes to the item will be invalidated automatically.
In iOS 17, you can use a @Bindable property wrapper to hold the binding of the
obersvable object.
The second and third parts of the code are relatively straightforward. However, the
checkbox control warrants a deeper discussion. In SwiftUI, there's a standard control
called Toggle that's typically used to create a Settings screen. The toggle is presented as
a switch that allows users to flip between "on" and "off". However, in the ToDo app, we
want the toggle to look more like a checkbox.
return HStack {
configuration.label
}
}
}
To use the CheckboxStyle , attach the toggleStyle modifier to the Toggle and specify the
checkbox style like this:
.toggleStyle(CheckboxStyle())
Since we have a ZStack to embed the views, it's pretty easy to control the appearance of
this empty view, which is only displayed when the array is empty.
The NewToDoView takes in two bindings: isShow and todoItems . The isShow parameter
controls whether the "Add New Task" view should appear on screen. The todoItems
variable holds a reference to the array of to-do items. We need the caller to pass us the
binding to todoItems so that we can update the array with the new task.
Within the view, users are able to input the name of the task and set its priority
(low/normal/high). The state variable isEditing indicates whether the user is currently
editing the task name. To ensure that the editing view isn't obscured by the software
keyboard, the app will shift the view upward while the user is editing the text field.
self.isEditing = editingChanged
})
...
Once the user taps the Save button, we first verify whether the text field is empty. If it's
not empty, we create a new ToDoItem and call the addTask function to append it to the
todoItems array. If the text field is empty, we take no action.
self.isShow = false
self.addTask(name: self.name, priority: self.priority)
}) {
Text("Save")
.font(.system(.headline, design: .rounded))
.frame(minWidth: 0, maxWidth: .infinity)
.padding()
.foregroundColor(.white)
.background(Color.purple)
.cornerRadius(10)
}
.padding(.bottom)
import SwiftData
init(id: UUID = UUID(), name: String = "", priority: Priority = .normal, isCom
plete: Bool = false) {
self.id = id
self.name = name
self.priority = priority
self.isComplete = isComplete
}
}
set {
self.priorityNum = Int(newValue.rawValue)
}
}
@Attribute(originalName: "priority") var priorityNum: Priority.RawValue
init(id: UUID = UUID(), name: String = "", priority: Priority = .normal, isCom
plete: Bool = false) {
self.id = id
self.name = name
self.priority = priority
self.isComplete = isComplete
}
}
In the code above, we added a new property called priorityNum , which has the same type
as the raw value of the Priority enum (i.e. Int ). This raw value will be directly stored
in the persistent store. For the priorityNum property, we also instructed SwiftData to use
a different field name for the underlying schema.
After the changes, you should be able to build and run the app in a simulator. Please note
that you can't preview the app due to a known bug in the beta version of Xcode 15.
Since we no longer use an array to hold the to-do items, you can remove this line of code:
modelContext.insert(task)
}
To insert a new record into the database, simply call the insert method of the model
context and pass it the to-do item that needs to be saved.
Since we removed the todoItems binding, we need to update the preview code:
#Preview {
NewToDoView(isShow: .constant(true), name: "", priority: .normal)
}
Now let's move back to ContentView.swift . Similarly, you should see an error in the
ContentView (see figure 5).
You can now run the app and add a few new tasks. They should be saved permanently
into the database.
import SwiftData
Next, we had an array variable that held all of the to-do items, which was also marked
with @State :
Since we are moving to store the items in database, we need to modify this line of code
and fetch the data from it. Apple introduced a new property wrapper called @Query . This
makes it very easy to load data from the database.
Simply replace @State with @Query and remove the default value like this:
This @Query property automatically fetches the required data for you. In the code above,
we specify to fetch the ToDoItem instances. Optionally, you can configure the property
wrapper with the sort option and specify how the results should be ordered. The below
definition will sort the items based on priority:
List {
ForEach(todoItems) { todoItem in
ToDoListRow(todoItem: todoItem)
}
You're now ready to test the app again. Upon launching it, you should be able to see all of
the tasks that you created in the previous section.
To test out the update behavior, simply run it on a simulator. You should be able to add
new tasks, which will appear in the list view immediately. The checkbox functionality
should also be working as expected. Most importantly, all of the changes are now saved
permanently in the device's database. Even after restarting the app, all of the items will
still be there.
This function takes an index set that stores the indices of the items to be deleted. To
remove an item from the persistent store, simply call the delete function of the model
context and specify the item to be deleted.
Now that we have prepared the delete function, where should we invoke it? Attach the
onDelete modifier to ForEach of the list view like this:
List {
ForEach(todoItems) { todoItem in
ToDoListRow(todoItem: todoItem)
}
.onDelete(perform: deleteTask)
The onDelete modifier automatically enables the swipe-to-delete feature in the list view.
When a user deletes an item, we call the deleteTask function to remove the item from
the database.
To try this out, simply run the app and swipe to delete an item. This will completely
remove the item from the database.
First, we need to create an in-memory data store and populate it with some test data.
Open ContentView.swift and declare a preview container like this:
return container
} catch {
fatalError("Failed to create container")
}
}()
configuration set. Then we add 10 sample to-do items and save them to the data store.
Now let's switch over to the ContentView.swift and update the preview code like this:
#Preview {
ContentView()
.modelContainer(previewContainer)
}
By attaching the modelContainer modifier and set it to the preview container, the content
view can now load the sample to-do items and display them in the preview canvas.
Summary
Throughout this chapter, we transformed a Todo list app from storing data in memory to
a persistent store. I hope that you now have a better understanding of how to integrate
SwiftData into a SwiftUI project and how to perform all basic CRUD (create, read, update
For reference, you can download the complete ToDoList project here:
If you're new to UIKit, UISearchBar is a built-in component of the framework that allows
developers to present a search bar for data search. Figure 1 showcases the standard
search bar in iOS. However, SwiftUI didn't provide this standard UI component out of
the box when it's first released. To implement a search bar in a SwiftUI project at the
time, one approach is to leverage the UISearchBar component from UIKit.
To ensure backward compatibility, Apple introduced a couple of new protocols in the iOS
SDK, namely UIViewRepresentable and UIViewControllerRepresentable . These protocols
enable you to wrap a UIKit view (or view controller) and make it accessible within your
SwiftUI project.
To see how this works, we will enhance our Todo app with a search function. We will add
a search bar just below the app title, allowing users to filter the to-do items by entering a
search term.
Understanding UIViewRepresentable
To use a UIKit view in SwiftUI, you can wrap the view with the UIViewRepresentable
protocol. Essentially, all you need to do is create a struct in SwiftUI that adopts the
protocol to create and manage a UIView object. Below is the basic structure of a custom
wrapper for a UIKit view:
In the actual implementation, you replace some UIView with the UIKit view you want to
wrap. Let's say, we want to use UISearchBar in UIKit. The code can be written like this:
return UISearchBar()
}
In the makeUIView method, we return an instance of UISearchBar . This is how you wrap a
UIKit view and make it available to SwiftUI. To use the SearchBar , you can treat it like
any SwiftUI view and create it like this:
import SwiftUI
searchBar.searchBarStyle = .minimal
searchBar.autocapitalizationType = .none
searchBar.placeholder = "Search..."
return searchBar
}
uiView.text = text
}
}
#Preview {
SearchBar(text: .constant(""))
}
The code is similar to the code shown in the previous section, but with a few key
differences:
Now switch over to ContentView.swift . Declare a state variable to hold the search text:
To present the search bar, insert the following code before the List :
SearchBar(text: $searchText)
.padding(.top, -20)
The SearchBar is just like any other SwiftUI views. You can apply modifiers like padding
to adjust the layout. If you run the app in a simulator or simply test it in the preview, you
should see a search bar, though it doesn't function yet.
The search bar has a companion protocol called UISearchBarDelegate . This protocol
provides several methods for managing the search text. In particular, the following
method is called whenever the user changes the search text:
To make the search bar functional, we have to adopt the UISearchBarDelegate protocol.
This is where things become more complex.
protocol. If you need to work with a delegate in UIKit and communicate back to SwiftUI,
you must implement the makeCoordinator method and provide a Coordinator instance.
This Coordinator acts as a bridge between the UIView's delegate and SwiftUI. Let's take a
look at the code so that you can better understand what this means.
In the SearchBar struct ( SearchBar.swift file), create a Coordinator class and implement
the makeCoordinator method like this:
searchBar.showsCancelButton = true
text = searchText
print("textDidChange: \(text)")
}
}
Now that we have a Coordinator that adopts the UISearchBarDelegate protocol, we need
to make one more modification. In the makeUIView method, insert the following line of
code to assign the coordinator to the search bar:
searchBar.delegate = context.coordinator
That's it! Test the app again in the preview and type in the search field. You should see
the "textDidChange:" message in the console. If you can't see the message, go up to
Xcode menu and choose View > Debug Area > Activate Console to enable the console.
return true
}
You can perform a quick test by running the app in a simulator. Tapping the Cancel
button while editing should dismiss the software keyboard.
We'll use the filter function to perform the search because todoItems is synchronized
with the to-do items stored in the database. In Swift, you can use the filter function to
loop over a collection and get an array of items that matches the filter criteria. Here is an
example:
todoItems.filter({ $0.name.contains("Buy") })
The filter function takes a closure as an argument that specifies the filter criteria. For
example, the code above will return those items that contain the keyword "Buy" in its
name field.
To implement the search, we can replace the ForEach loop of the List like this:
In the closure of the filter function, we first check if the search text has a value. If not,
we simply return true , which means that it returns all items. Otherwise, we check if the
name field contains the search term.
Summary
In this chapter, you learned how to use the UIViewRepresentable protocol to integrate
UIKit views with SwiftUI. While SwiftUI is still very new and doesn't come with all the
standard UI components, this backward compatibility allows you to tap into the old
framework and utilize any views you need.
Demo project
(https://www.appcoda.com/resources/swiftui5/SwiftUIToDoListUISearchBar.zip)
Not only will you learn how to create the search bar view, but we will also delve into the
realm of custom bindings. We've touched upon bindings before, but we haven't shown
you how to create a custom binding. Custom bindings are particularly useful when you
need to incorporate additional program logic during read and write operations.
Furthermore, we will explore how to dismiss the software keyboard in SwiftUI.
Figure 1 showcases the search bar we're about to build. It will have the same look and feel
as the UISearchBar in UIKit. Additionally, we will implement a Cancel button, which will
only appear when the user begins typing in the search field.
Open the SearchBar.swift file, which is the file we will be focusing on. We will rewrite the
entire code while keeping the struct name intact. We will still refer to it as SearchBar ,
which will still accept a binding of search text as an argument. From the perspective of
the caller (i.e., ContentView), there is no need to make any changes. The usage will
remain the same, as follows:
SearchBar(text: $searchText)
If you have no idea how the UI is built, let's create it together. Replace the SearchBar
if isEditing {
Button(action: {
self.text = ""
}) {
Image(systemName: "multiply.circle.fill")
.foregroundStyle(.gray)
.padding(.trailing, 8)
}
}
}
)
.padding(.horizontal, 10)
.onTapGesture {
if isEditing {
Button(action: {
self.isEditing = false
self.text = ""
}) {
Text("Cancel")
}
.padding(.trailing, 10)
.transition(.move(edge: .trailing))
}
}
}
}
First, we declare two variables: one for the binding of the search text and another for
storing the state of the search field (whether it is being edited or not).
We utilize a HStack to arrange the text field and the Cancel button. The text field is
adorned with an overlay of a magnifying glass icon and the cross icon (represented by
multiply.circle.fill ), which is only displayed when the search field is in editing mode.
Similarly, the Cancel button appears when the user taps the search field.
In order to preview the search bar, we have already added the following preview code:
#Preview {
SearchBar(text: .constant(""))
}
In the preview canvas, you should be able to preview the search field. When you select
the text field, the Cancel button should appear.
What's more is that the search bar already works! Switch over to ContentView.swift and
perform a search in the preview canvas. It should filter the result based on the search
term.
To resolve the issue, we need to add a line of code in the action block of the Cancel
button in SearchBar.swift :
In the provided code, we invoke the sendAction method to resign the first responder and
dismiss the keyboard. You can now run the app using a simulator. When you tap the
cancel button, the search field should be cleared, and the software keyboard should be
dismissed.
The current implementation works well for our needs. However, let's consider a scenario
where we require additional logic when reading or writing to this binding. For instance,
how would we capitalize each word in the search field?
Swift offers a built-in feature for capitalizing a string. You can utilize the capitalized
property of the text to retrieve the capitalized version of the string. The question now
arises: how do we update the binding of the text property?
In this case, you will need to create a custom binding in SearchBar.swift like this:
return Binding<String>(
get: {
self.text.capitalized
}, set: {
self.text = $0
}
)
}
In the provided code snippet, we create a custom binding named searchText using
closures to handle the reading (get) and writing (set) of the binding value. For the get
part, we customize the value of the text binding by accessing the capitalized property.
This allows us to capitalize each word that the user types in the search field. As for the
As a side note, you can omit the return keyword and write the binding like this:
Binding<String>(
get: {
self.text.capitalized
}, set: {
self.text = $0
}
)
}
This is a notable feature that was introduced in Swift 5.1, in case you were not aware of it.
We still need to pass the text binding to the TextField . However, before the custom
binding change takes effect, we need to make one more modification. Update the
parameter in the TextField and ensure that you pass searchText as the binding.
Now run the app on a simulator. Type a few words into the search field. The app should
automatically capitalize each word as you type.
Summary
In this chapter, we have presented an alternative approach to implementing a search bar.
As you can see, it's not difficult to build one entirely using SwiftUI. You have also
acquired knowledge on how to create a custom binding. This skill is highly valuable when
you require the inclusion of additional program logic during the process of setting or
retrieving the binding value.
For reference, you can download the complete search bar project here:
Demo project
(https://www.appcoda.com/resources/swiftui5/SwiftUIToDoListSearchBarView.zip
)
This app is not too complicated to build but you will learn quite a lot about SwiftUI and
understand how to apply the techniques you learned in developing this real-world app. In
brief, here are some of the topics we will cover:
Let me stress this once again. This app is the result of what you have learned so far.
Therefore, I assume you have already read the book from chapter 1 to chapter 24. You
should understand how a bottom sheet is built (chapter 18), how form validation with
Combine works (chapter 14 & 15), and how to persist data using SwiftData (chapter 22).
If you haven't read these chapters, I suggest you go read them first. In this chapter, we
will primarily focus on techniques that haven't been discussed before.
The app uses SwiftData for data management. The records are persisted locally in the
built-in database, so you should see the records even after restarting the app.
Throughout the rest of this chapter, I will explain how the code works in detail. However,
I encourage you to take a look at the code first to assess your understanding.
set {
self.typeNum = Int(newValue.rawValue)
}
}
@Attribute(originalName: "type") var typeNum: PaymentCategory.RawValue
init(paymentId: UUID = UUID(), date: Date, name: String, address: String? = nil
, amount: Double, memo: String? = nil, type: PaymentCategory) {
self.paymentId = paymentId
self.date = date
self.name = name
self.address = address
self.amount = amount
self.memo = memo
self.type = type
}
}
Since we use SwiftData to persist the payment activity, this PaymentActivity class is
annotated with the @Model macro. With this keyword, SwiftData automatically enables
persistence for the data class. The @Attribute annotation adds the uniqueness constraint
for the paymentId property. Again, if you don't understand SwiftData, please refer to
chapter 22.
The payment type (i.e. typeNum ), is saved as an integer in the database. Therefore, we
need a conversion between the integer and the actual enumeration. This is one approach
to save an enum in a persistent storage.
@main
struct PFinanceApp: App {
var body: some Scene {
WindowGroup {
DashboardView()
}
.modelContainer(for: PaymentActivity.self)
}
}
To drive the data management operations with SwiftData, we need to prepare the model
container which serves as the persistent backend. Later, in the SwiftUI views, we can
easily retrieve the model context from the environment for further operations.
}
}
}
Do you notice the two validation errors under the form title? Since these validation
messages have a similar format, we also create a generic view for this kind of message:
Spacer()
}
}
}
With these two common views created, laying out the form becomes straightforward. We
utilize a ScrollView along with a VStack to arrange the form fields. The validation error
messages are displayed only when an error is detected:
Group {
if !paymentFormViewModel.isNameValid {
ValidationErrorText(text: "Please enter the payment name")
}
if !paymentFormViewModel.isAmountValid {
ValidationErrorText(text: "Please enter a valid amount")
}
if !paymentFormViewModel.isMemoValid {
ValidationErrorText(text: "Your memo should not exceed 300 characters")
}
}
VStack(alignment: .leading) {
Text("TYPE")
.font(.system(.subheadline, design: .rounded))
.fontWeight(.bold)
.foregroundStyle(.primary)
.padding(.vertical, 10)
HStack(spacing: 0) {
Button(action: {
self.paymentFormViewModel.type = .income
}) {
Text("Income")
.font(.headline)
.foregroundStyle(self.paymentFormViewModel.type == .income ? Color
.white : Color.primary)
}
.frame(minWidth: 0.0, maxWidth: .infinity)
.padding()
.background(self.paymentFormViewModel.type == .income ? Color("IncomeCard"
) : Color(.systemBackground))
Button(action: {
self.paymentFormViewModel.type = .expense
}) {
Text("Expense")
.font(.headline)
.foregroundStyle(self.paymentFormViewModel.type == .expense ? Color
.white : Color.primary)
}
.frame(minWidth: 0.0, maxWidth: .infinity)
.padding()
.background(self.paymentFormViewModel.type == .expense ? Color("ExpenseCar
d") : Color(.systemBackground))
}
.border(Color("Border"), width: 1.0)
}
The date field is implemented using the DatePicker component. Using the DatePicker is
straightforward. You simply need to provide the label, the binding to the date value, and
the display components of the date.
Since the release of iOS 14, the built-in DatePicker has been enhanced with improved UI
and additional styles. When you run the view and tap the date field, the app presents a
full calendar view, allowing users to select the desired date. The user interface is
significantly enhanced compared to the older version of the date picker.
The memo field is not a text field but a text editor. When SwiftUI was first released, it
doesn't come with a multiline text field. To support multiline text editing, you will need
to tap into the UIKit framework and wrap UITextView into a SwiftUI component. Starting
from iOS 14, Swift introduced a new component called TextEditor for displaying and
editing long-form text. In PaymentFormView.swift , you should find the following struct:
The memo field is not a text field but a text editor. When SwiftUI was initially released, it
did not include a multiline text field. To enable multiline text editing, you need to
leverage the UIKit framework and wrap UITextView into a SwiftUI component. However,
starting from iOS 14, Swift introduced a new component called TextEditor specifically
designed for displaying and editing long-form text. In the PaymentFormView.swift file, you
should find the following struct:
TextEditor(text: $value)
.frame(minHeight: height)
.font(.headline)
.foregroundStyle(.primary)
.padding()
.border(Color("Border"), width: 1.0)
}
}
}
The usage of TextEditor is very similar to TextField . All you need is to pass it the
binding to a String variable. Like any other SwiftUI view, you can apply view modifiers to
style its appearance. This is how we created the Memo field for users to input long form
text.
At the end of the form is the Save button. By default, this button is disabled and only
becomes enabled when all the required fields are filled. We utilize the disabled modifier
to control the button's state.
}
.padding()
.disabled(!paymentFormViewModel.isFormInputValid)
When the button is tapped, it calls the save() method to permanently save the payment
activity into the database. Subsequently, it invokes the dismiss() method to dismiss the
view.
Form Validation
That's essentially how we layout the form UI. Now let's discuss how the form validation is
implemented. We followed the approach discussed in chapter 15 to perform form
validation using Combine. Here is what we have done:
We created a view model class to hold the values of the form fields. You can switch over
to PaymentFormViewModel.swift to view the code:
// Input
@Published var name = ""
// Output
@Published var isNameValid = false
@Published var isAmountValid = true
@Published var isMemoValid = true
@Published var isFormInputValid = false
init(paymentActivity: PaymentActivity?) {
$name
.receive(on: RunLoop.main)
.map { name in
return name.count > 0
}
.assign(to: \.isNameValid, on: self)
.store(in: &cancellableSet)
$amount
.receive(on: RunLoop.main)
.map { amount in
guard let validAmount = Double(amount) else {
return false
}
return validAmount > 0
}
.assign(to: \.isAmountValid, on: self)
.store(in: &cancellableSet)
This class conforms to ObservableObject . All the properties are annotated with
@Published because we want to notify the subscribers whenever there is a value change
and perform the validation accordingly.
Whenever there are any changes to the form's input values, this view model executes the
validation code, updates the results, and notifies the subscribers.
The PaymentFormView subscribes to the changes of the view model. When any of the
validation variables (e.g. isNameValid ) are updated, PaymentFormView will be notified and
the view itself will refresh to display the validation error on screen.
Form Initialization
Do you notice the initialization method? It accepts a PaymentActivity object and
initializes the view model.
The PaymentFormView allows the user to create a new payment activity or edit an existing
one. This is why the init method takes in an optional payment activity object. If the
object is nil , an empty form is displayed. Otherwise, the form fields are filled with the
values from the given PaymentActivity object.
init(payment: PaymentActivity) {
self.payment = payment
self.viewModel = PaymentDetailViewModel(payment: payment)
}
Since we need to perform some initialization to create the view model, we implement a
custom init method.
- See Building a Registration Form with Combine and View Model (Chapter 15)
To separate the actual view data from the view UI, we have created a view model named
PaymentDetailViewModel :
Why do we need to create an additional view model to hold the view's data? Let's take a
look at the icon right next to the title Payment Details. This is a dynamic icon that
changes based on the payment type. Additionally, notice the formatting of the amount. A
requirement for our app is to format the amount with only two decimal places. We could
implement all these logic in the view itself, but as we keep adding more and more logic to
the view, it will become overly complex and difficult to maintain.
switch payment.type {
case .income: icon = "arrowtriangle.up.circle.fill"
case .expense: icon = "arrowtriangle.down.circle.fill"
}
return icon
}
init(payment: PaymentActivity) {
self.payment = payment
}
As you can see, we implement all the conversion logic in this view model. Can we put this
logic back into the view? Yes, of course. However, I believe the code is much cleaner
when breaking the view into two parts.
VStack(alignment: .center) {
Text(Date.today.string(with: "EEEE, MMM d, yyyy"))
.font(.caption)
.foregroundColor(.gray)
Text("Personal Finance")
.font(.title)
.fontWeight(.black)
}
Spacer()
}
Button(action: {
self.showPaymentForm = true
}) {
Image(systemName: "plus.circle")
.font(.title)
.foregroundColor(.primary)
}
}
}
ZStack {
Rectangle()
.foregroundColor(Color("IncomeCard"))
.cornerRadius(15.0)
VStack {
Text("Income")
.font(.system(.title, design: .rounded))
.fontWeight(.black)
.foregroundColor(.white)
Text(NumberFormatter.currency(from: income))
.font(.system(.title, design: .rounded))
.fontWeight(.bold)
.foregroundColor(.white)
.minimumScaleFactor(0.1)
}
}
.frame(height: 150)
}
}
return total
}
return total
}
The paymentActivities variable holds the collection of payment activities. To calculate the
total income, we utilize the filter function to filter the activities with type .income , and
then employ the reduce function to compute the cumulative amount. The same
technique is applied to calculate the total expense. Higher-order functions in Swift are
incredibly valuable. If you are unfamiliar with how to use filter and reduce, you can refer
to this tutorial: https://www.appcoda.com/higher-order-functions-swift/.
HStack(spacing: 20) {
VStack(alignment: .leading) {
Text(transaction.name)
.font(.system(.body, design: .rounded))
Text(transaction.date.string())
.font(.system(.caption, design: .rounded))
.foregroundColor(.gray)
}
Spacer()
}
.padding(.vertical, 5)
}
}
ForEach(paymentDataForView) { transaction in
TransactionCellView(transaction: transaction)
.onTapGesture {
self.showPaymentDetails = true
self.selectedPaymentActivity = transaction
}
.contextMenu {
Button(action: {
// Edit payment details
self.editPaymentDetails = true
self.selectedPaymentActivity = transaction
}) {
HStack {
Text("Edit")
Image(systemName: "pencil")
}
}
Button(action: {
// Delete the selected payment
self.delete(payment: transaction)
}) {
HStack {
Text("Delete")
Image(systemName: "trash")
}
}
}
}
.sheet(isPresented: self.$editPaymentDetails) {
PaymentFormView(payment: self.selectedPaymentActivity)
}
In the Recent Transactions section, the app provides three options for the user to filter
the payment activities including all, income, and expense. For example, if the expense
option is selected, the app only shows those activities related to expenses.
switch listType {
case .all:
return paymentActivities
.sorted(by: { $0.date.compare($1.date) == .orderedDescending })
case .income:
return paymentActivities
.filter { $0.type == .income }
.sorted(by: { $0.date.compare($1.date) == .orderedDescending })
case .expense:
return paymentActivities
.filter { $0.type == .expense }
.sorted(by: { $0.date.compare($1.date) == .orderedDescending })
}
}
.sheet(isPresented: $showPaymentDetails) {
PaymentDetailView(payment: selectedPaymentActivity!)
.presentationDetents([.medium, .large])
}
SwiftData makes it very easy to perform a fetch request. Once the payment activities are
retrieved, SwiftUI will automatically update the list views or any other views.
Deleting an activity from the database is also very straightforward. We call the delete
function of the context and pass it with the activity object to remove:
modelContext.insert(newPayment)
}
To add a payment activity to the database, we simply call the insert function of the
model context.
extension NumberFormatter {
static func currency(from value: Double) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
This function takes in a value, converts it to a string and prepends it with the dollar sign
($).
.keyboardAdaptive()
This is a custom view modifier, developed for handling the software keyboard. For iOS
14, this modifier is no longer required but I intentionally added it because you may need
it if your app supports iOS 13.
On iOS 13, the software keyboard blocks parts of the form when it's brought up without
applying the modifier. For example, if you try to tap the memo field, it's completely
hidden behind the keyboard. Conversely, if you attach the modifier to the scroll view, the
form will move up automatically when the keyboard appears. On iOS 14, the mobile
operating system itself automatically handles the appearance of the software keyboard,
preventing it from blocking the input field.
Now let's check out the code ( KeyboardAdaptive.swift ) and see how we handle keyboard
events:
NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNoti
fication
).compactMap { (notification) in
notification.userInfo?["UIKeyboardFrameEndUserInfoKey"] as? CGRect
}.map { rect in
rect.height
}.subscribe(Subscribers.Assign(object: self, keyPath: \.currentHeight))
NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNoti
fication
).compactMap { _ in
CGFloat.zero
}.subscribe(Subscribers.Assign(object: self, keyPath: \.currentHeight))
}
}
extension View {
func keyboardAdaptive() -> some View {
ModifiedContent(content: self, modifier: KeyboardAdaptive())
}
}
Whenever the keyboard appears (or disappears), iOS sends a notification to the app:
So, how do we utilize these notifications to scroll up the form? When the app receives the
keyboardWillShowNotification, it adds padding to the form to move it up. Conversely,
we set the padding to zero when the keyboardWillHideNotification is received.
In the provided code, there is a state variable that stores the height of the keyboard. By
utilizing the Combine framework, we have a publisher that captures the
keyboardWillShowNotification and emits the current height of the keyboard.
Additionally, we have another publisher that listens to the keyboardWillHideNotification
and emits a value of zero. For both publishers, we employ the built-in assign subscriber
to assign the emitted value to the currentHeight variable.
This is how we detect the appearance of the keyboard, capture its height, and add the
bottom padding. However, why do we need to have the View extension?
The code works without the extension. You write the code like this to detect the keyboard
events:
.modifier(KeyboardAdaptive())
To make the code cleaner, we create the extension and add the keyboardAdaptive()
function. After that, we can attach the modifier to any view like this:
.keyboardAdaptive()
Since this view modifier is only applicable to iOS 13, we use the #available check to
verify the OS version in the keyboardAdaptive() function:
Summary
This is how we built the personal finance app from scratch. Most of the techniques we
employed should not be unfamiliar to you. By combining what you have learned in the
earlier chapters, you were able to develop this app.
SwiftUI is an incredibly powerful and promising framework that enables you to build the
same app with less code compared to UIKit. If you have prior programming experience
with UIKit, you are aware that it would require more time and lines of code to create the
personal finance app. I genuinely hope you enjoy learning SwiftUI and constructing user
interfaces with this innovative framework.
In this chapter, we will construct a list view and implement an animated transition using
SwiftUI that is similar to the one we discussed earlier. Specifically, you will learn the
following techniques:
We will build the app from scratch. But to save you time from typing some of the code, I
have prepared a starter project for you. You can download it from
https://www.appcoda.com/resources/swiftui5/SwiftUIAppStoreStarter.zip. After
downloading the project, unzip it and open SwiftUIAppStore.xcodeproj to take a look.
If you look a bit closer into the card views shown in figure 4, you will find that the size of
card views varies according to the height of the image. However, the height of the card
will not exceed 500 points.
Let's also examine how the card view looks in full content mode. As shown in the figure
below, the card view expands to full screen and displays the content. Additionally, the
image is slightly larger, and the sub-headline is hidden. Furthermore, the close button
appears on the screen for users to dismiss the view. Please note that this is a scrollable
view.
First, let's begin with the excerpt view, which is the view overlayed on top of the image
(see figure 5). Insert the following code in the file:
Rectangle()
.frame(minHeight: 100, maxHeight: 150)
.overlay(
HStack {
VStack(alignment: .leading) {
Text(self.category.uppercased())
.font(.subheadline)
.fontWeight(.bold)
.foregroundColor(Color.secondary)
Text(self.headline)
.font(.title)
.fontWeight(.bold)
.foregroundStyle(Color.primary)
.minimumScaleFactor(0.1)
.lineLimit(2)
.padding(.bottom, 5)
if !self.isShowContent {
Text(self.subHeadline)
.font(.subheadline)
.foregroundStyle(Color.secondary)
.minimumScaleFactor(0.1)
.lineLimit(3)
}
}
.padding()
Spacer()
}
)
}
}
}
There are various ways to layout the excerpt view. In the code above, we create a
Rectangle view and overlay it with the headline and sub-headline. You should be familiar
with most of the modifiers attached to the Text view, but the minimumScaleFactor
modifier is worth mentioning. By applying the modifier, the system automatically shrinks
the font size of the text to fit the available space. For example, if the headline contains too
much text, iOS will scale it down to 10% of its original size before truncating it.
Previewing the UI
To preview the excerpt view, you can modify the preview code like this:
and the other set to true . The sampleArticles array is the test data that comes with the
starter project.
With the excerpt view ready, let's implement the article card view. Update the
ArticleCardView struct like this:
// Content
if self.isShowContent {
Text(self.content)
.foregroundStyle(Color(.darkGray))
.font(.system(.body, design: .rounded))
.padding(.horizontal)
.padding(.bottom, 50)
.transition(.move(edge: .bottom))
}
}
}
.shadow(color: Color(.sRGB, red: 64/255, green: 64/255, blue: 64/255, opac
ity: 0.3), radius: self.isShowContent ? 0 : 15)
}
}
To arrange the layout of the card view, we overlay the ArticleExcerptView on top of an
Image view. The image view is set to .scaledToFill , with the height not exceeding 500
points. The content is only displayed when the isShowContent binding is set to true .
To preview the article card view, you can insert the following code:
Once you have made the changes, you should be able to see the card UI in the preview
canvas. Additionally, you should see two simulators such that one displays the excerpt
view and the other displays the full content.
Using GeometryReader
Let's look at our code again. For the Image view, we only limited the height of the image,
we don't have any limits on its width:
To resolve the issue, we need to set the frame's width and ensure it doesn't exceed the
screen's width. The question is, how do we determine the screen width? SwiftUI provides
a container view called GeometryReader that allows you to access the size of its parent
view. Therefore, we need to embed the ScrollView within a GeometryReader , like this:
Within the closure of GeometryReader , it has a parameter that provides you with extra
information about the view such as size and position. So, to limit the width of the frame
to the size of the screen, you can modify the .frame modifier like this:
In the code, we set the width equal to that of the screen. Once you complete the change,
the card view should look great.
Xcode will automatically indent the code and embed the scroll view in the ZStack . Now
change ZStack to set its alignment to .topTrailing because we want to place the close
button near the top-right corner. Your code should look like this:
if self.isShowContent {
HStack {
Spacer()
Button {
withAnimation(.easeInOut) {
self.isShowContent = false
}
} label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 26))
.foregroundStyle(Color.white)
.opacity(0.7)
}
}
.padding(.top, 50)
.padding(.trailing)
}
After the modification, the preview should display the close button when the value of
isShowContent is set to true .
I believe you should know how to create the layout by using VStack and HStack . To
better organize the code, I will create the top bar and the avatar in two separate structs.
Insert the following code in ContentView.swift :
Spacer()
}
}
TopBarView()
.padding(.horizontal, 20)
.padding(.horizontal, 20)
.frame(height: min(sampleArticles[index].image.size.height/
3, 500))
}
}
}
}
}
We embed a VStack in a ScrollView to create the vertical scroll view. In the code block,
we loop through all the sampleArticles using ForEach and create an ArticleCardView for
each article. If your code works properly, the preview canvas should show you a list of
articles.
By default, all card views are in the excerpt state. Thus, the value of the showContents
variable is set to false . Later, when a card is tapped, we will change the state from
false to true .
We also need a variable to keep track of the index of the selected card. Declare one more
state variable:
.onTapGesture {
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.65, blendD
uration: 0.1)) {
self.selectedArticleIndex = index
self.showContent.toggle()
}
}
When a tap gesture is detected, we change the showContent variable from false to
true . At the same time, we save the index of the selected card view.
Let's perform a quick test to see how the app functions after making the changes. When
you run the app in the preview canvas, tap any of the card views to see the result.
Although it may not work as expected, the card view should display the content of the
article and hide the sub-headline. Additionally, you should be able to tap the close button
to return to excerpt mode. If you can't see the content, drag up the card view to reveal it.
Note: If you haven't read chapter 33, please go through the chapter first.
In this demo, the initial view is the card view in excerpt mode, while the final view is the
card view showing the full content. What we are going to do is embed the current scroll
view in a ZStack view. Initially, the app displays the list of card views. When the user
taps any of the card views, we overlay the full content view on top of the existing scroll
view.
Now hold the command key and click ScrollView . Choose Embed in ZStack.
Set the alignment parameter of the ZStack view to .top like this:
ZStack(alignment: .top) {
ScrollView {
.
.
.
}
Next, insert the following code after the closing bracket of the scroll view:
When a user taps one of the card views, the value of showContent is changed to true and
selectedArticleIndex is set to the index of the selected card view. In this case, we display
the card view in full content mode by setting the isShowContent parameter to true .
If you test the app in the preview pane, tapping a card view will expand its content to full
screen.
It works but the result doesn't look good. The list behind the full-content card view is still
visible. We need to hide it away when any of the card views is selected. To fix the issue,
attach a opacity modifier to ScrollView :
When the app is showing full content of a card view, we set the opacity of the scroll view
to 0 . Test the app again. The card view should display full content properly.
Figure 18. The app hides the list view when a card is selected
The last thing we need to do is to animate the transition. As explained at the beginning of
this section, we can make use of the matchedGeometryEffect modifier to let SwiftUI render
the transition animation.
loop:
When the card view is displaying the article content, the height of the image is now
adjusted to 70% of the screen height. You may alter the value to suit your preference.
Now go back to ContentView.swift and test the change. The featured image becomes
larger in full content mode.
Run the app in the simulator or in the preview canvas. You will see a slick animation
when the card view expands to full screen.
Summary
Congratulations! You have built an App Store-like animation using SwiftUI. After
implementing this demo project, I hope you understand how to create complex view
animations.
Animation is an essential part of UI design these days. As you can see, SwiftUI has made
it very easy for developers to build beautiful animations and screen transitions. In your
next app project, don't forget to apply the techniques you learned in this chapter to
improve the user experience of your app.
This chapter will teach you how to build an image carousel entirely in SwiftUI. There are
several methods to implement a carousel, such as integrating with the UIKit component
UIPageViewController to create it. However, we will explore an alternative approach and
construct the carousel entirely in SwiftUI.
To save you time from building the app from scratch and to focus on developing the
carousel, I've created a starter project for you. Please download it from
https://www.appcoda.com/resources/swiftui5/SwiftUICarouselStarter.zip and unzip the
package.
The code above embeds an HStack with a horizontal ScrollView to create the image
slider. Within the HStack , we loop through the sampleTrips array and create a
TripCardView for each trip. To have better control over the card size, we use two
GeometryReader s: outerView and innerView. The outer view represents the size of the
device's screen, while the inner view wraps around the card view to control its size. If you
haven't read the previous chapter and do not understand what GeometryReader is, please
refer to Chapter 26.
This looks simple, right? If you run the code in the preview canvas, it should result in a
horizontal scroll view. You can swipe the screen to scroll through all the card views.
Have we completed the carousel? Not quite. There are a couple of major issues to
address:
1. The current implementation does not support paging. In other words, users can
swipe the screen to continuously scroll through the content, and the scroll view can
stop at any location. For instance, take a look at Figure 4. The scroll stops between
two card views, which is not the desired behavior. We expect the scrolling to stop on
paging boundaries of the content view.
2. When a card view is tapped, we need to determine its index and display its details in
a separate view. The problem is that it is not easy to determine which card view the
user has tapped with the current implementation.
Both issues are related to the built-in ScrollView . The UIKit version of the scroll view
supports paging, but Apple did not include that feature in the SwiftUI framework until
iOS 17. To address this issue, we need to build our own horizontal scroll view with paging
support.
At first, you may think that developing our own scroll view is challenging. However, in
reality, it is not that difficult. If you understand the usage of HStack and DragGesture ,
you can build a horizontal scroll view with paging support.
Figure 5. Understanding how to create a horizontal scroll view using HStack and
DragGesture
In the code above, we start by laying out all card views within an HStack . By default, the
horizontal stack tries to fit all the card views within the available screen space. You
should see something similar to Figure 6 in the preview canvas.
Obviously, this isn't the horizontal stack we want to build. We expect each card view to
takes up the width of the screen. To do so, we have to wrap the HStack in a
GeometryReader to retrieve the screen size. Update the code in the body like this:
The outerView parameter provides us with the screen width and height, while the
innerView parameter allows us to have better control over the size and position of the
card view.
In the code above, we attach the .frame modifier to the card view and set its width to the
screen width (i.e., outerView.size.width ). This ensures that each card view takes up the
entire screen width. For the height of the card view, we set it to 500 points to make it
slightly smaller. After making the changes, you should see the card view displaying the
"London" image.
Why the "London" card view? If you switch to the Selectable mode, the preview canvas
should display something similar to what is shown in Figure 8. We have 13 items in the
sampleTrips array. Since each card view has a width equal to the screen width, the
horizontal stack view has to expand beyond the screen. It happens that the "London"
card view is the center (7th) item of the array. This is why you see the "London" card
view.
The default alignment is set to .center . This is why the 7th element of the horizontal
view is shown on screen. Once you change the alignment to .leading , you should see the
first element.
If you want to understand how the alignment affects the horizontal stack view, you can
change its value to .center or .trailing to see its effect. Figure 10 shows what the stack
view looks likes with different alignment settings.
Did you notice the gap between each of the card views? This is also related to the default
setting of HStack . To eliminate the spacing, you can update the HStack and set its
spacing to zero, like this:
HStack(spacing: 0)
Adding padding
Optionally, you can add horizontal padding to the image. I think this will make the card
view look better. Insert the following line of code and attach it to the GeometryReader that
wraps the card view (before .frame(width: outerView.size.width, height: 500) ):
It's just simple math! The card view's width is equal to the width of the screen. Suppose
the screen width is 300 points, and we want to display the third card view. In that case,
we can shift the horizontal stack to the left by 600 points (300 x 2). Figure 12 shows the
result.
To translate the description above into code, we first declare a state variable to keep track
of the index of the visible card view:
By default, I want to display the third card view. This is why I set the currentTripIndex
To move the horizontal stack to the left, we can attach the .offset modifier to the
HStack like this:
The outerView 's width is actually the width of the screen. To display the third card view,
as explained before, we need to move the stack by 2x the screen width. This is why we
multiply the currentTripIndex with the outerView 's width. A negative value for the
horizontal offset will shift the stack view to the left.
Once you have made the change, you should see the "Amsterdam" card view in your
preview canvas.
The drag gesture of the horizontal stack is expected to work like this:
To translate the description above into code, we first declare a variable to hold the drag
offset:
Next, we attach the .gesture modifier to the HStack and initialize a DragGesture like
this:
.gesture(
!self.isCardTapped ?
DragGesture()
.updating(self.$dragOffset, body: { (value, state, transaction) in
state = value.translation.width
})
.onEnded({ (value) in
let threshold = outerView.size.width * 0.65
var newIndex = Int(-value.translation.width / threshold) + self.curren
tTripIndex
newIndex = min(max(newIndex, 0), sampleTrips.count - 1)
self.currentTripIndex = newIndex
})
: nil
)
As you drag the horizontal stack, the updating function is called. We save the horizontal
drag distance to the dragOffset variable. When the drag ends, we check if the drag
distance exceeds the threshold, which is set to 65% of the screen width, and compute the
new index. Once we have the newIndex computed, we verify if it is within the range of the
sampleTrips array. Lastly, we assign the value of newIndex to currentTripIndex . SwiftUI
will then update the UI and display the corresponding card view automatically.
Please take note that we have a condition for enabling the drag gesture. When the card
view is tapped, there is no gesture recognizer.
To move the stack view during the drag, we have to make one more change. Attach an
additional .offset modifier to the HStack (right after the previous .offset) like this:
Here, we update the horizontal offset of the stack view to the drag offset. Now you are
ready to test the changes. Run the app in a simulator or in the preview canvas. You
should be able to drag the stack view. When your drag exceeds the threshold, the stack
view shows you the next trip.
To:
Other than the card view's height, we also want to set a different opacity value for the
visible and invisible card views. All these changes are not animated yet. Now insert the
following line of code to the outer view's GeometryReader:
SwiftUI will then animate the size and opacity changes of the card views automatically.
Run the app in the preview canvas to test out the changes. This is how we implement a
scroll view with HStack and add paging support.
VStack(alignment: .leading) {
Text("Discover")
.font(.system(.largeTitle, design: .rounded))
.fontWeight(.black)
The code above is self-explanatory, but I would like to highlight two lines of code. Both
.opacity and .offset are optional. The purpose of the .opacity modifier is to hide the
title when the card is tapped. The change to the vertical offset will add a nice touch to the
user experience.
To keep things simple, the rating and description are just dummy data. The same goes for
the Book Now button, which is not functional. This detail view only takes in a destination
like this:
Please take some time to create the detail view. I will walk you through my solution in a
later section.
HStack(spacing: 3) {
ForEach(1...5, id: \.self) { _ in
Image(systemName: "star.fill")
.foregroundStyle(.yellow)
.font(.system(size: 15))
}
Text("5.0")
.font(.system(.headline))
.padding(.leading, 10)
}
}
.padding(.bottom, 30)
Text("Description")
.font(.system(.headline))
.fontWeight(.medium)
Button(action: {
// tap me
}) {
Text("Book Now")
.font(.system(.headline, design: .rounded))
.fontWeight(.heavy)
.foregroundStyle(.white)
.padding()
.frame(minWidth: 0, maxWidth: .infinity)
.background(Color(red: 0.97, green: 0.369, blue: 0
.212))
.cornerRadius(20)
}
}
.padding()
.frame(width: geometry.size.width, height: geometry.size.heigh
t, alignment: .topLeading)
.background(.white)
.cornerRadius(15)
Image(systemName: "bookmark.fill")
.font(.system(size: 40))
.foregroundStyle(Color(red: 0.97, green: 0.369, blue: 0.212
))
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, max
Height: .infinity, alignment: .topTrailing)
.offset(x: -15, y: -5)
}
.offset(y: 15)
}
}
}
}
#Preview {
TripDetailView(destination: "London").background(.black)
}
I also changed the background color to black, so that we can see the rounded corners of
the detail view.
if self.isCardTapped {
TripDetailView(destination: sampleTrips[currentTripIndex].destination)
.offset(y: 200)
.transition(.move(edge: .bottom))
Button(action: {
withAnimation {
self.isCardTapped = false
}
}) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 30))
.foregroundStyle(.black)
.opacity(0.7)
.contentShape(Rectangle())
}
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, a
lignment: .topTrailing)
.padding(.trailing)
The TripDetailView is only brought up when the card view is tapped. It is expected that
the detail view will appear from the bottom of the screen and move upward with an
animation. This is why we attach the .transition modifiers to the detail view. To let
users dismiss the detail view, we also add a close button, which appears at the top-right
corner of the screen. In case you are not sure where to insert the code above, please refer
to Figure 20.
The code won't work yet because we haven't captured the tap gesture. Thus, attach the
.onTapGesture function to the card view like this:
.onTapGesture {
withAnimation(.interpolatingSpring(.bouncy, initialVelocity: 0.3)) {
self.isCardTapped = true
}
}
When someone taps the card view, we simply change the isCardTapped state variable to
true . Run the app and tap any of the card views. The app should bring up the detail
view.
The detail view works! However, the animation doesn't work well. When the detail view
is brought up, the card view grows a little bit bigger, which is achieved by the following
line of code:
To make the animation look better, let's move the image upward when the detail view
appears. Attach the .offset modifier to TripCardView :
I set the vertical offset to 30% of the card view's height. You are free to change the value.
Now run the app again and you should see a more slick animation.
Summary
Great! You have built a custom scroll view with paging support and learned how to bring
up a detail view with an animated transition. This technique is not limited to an image
carousel. In fact, you can modify the code to create a set of onboarding screens. I hope
you have enjoyed what you learned in this chapter and will apply it to your next app
project.
For reference, you can download the complete carousel project here:
Of course, you can build this outline view using your own implementation. Starting from
iOS 14, Apple made it simpler for developers to build this kind of outline view, which
automatically works on iOS, iPadOS, and macOS.
Once the project is created, unzip the image archive and add the images to the asset
catalog.
In the code above, we have a struct that models a menu item. The key to making a nested
list is to include a property that contains an optional array of child menu items (i.e.
subMenuItems ). Note that the children are of the same type (MenuItem) as their parent.
For the top level menu items, we create an array of MenuItem in the same file like this:
For each of the menu item, we specify the array of the sub-menu items. If there are no
sub-menu items, you can omit the subMenuItems parameter or pass it a nil value. We
define the sub-menu items like this:
Text(item.name)
.font(.system(.title3, design: .rounded))
.bold()
}
}
In the closure of the List view, you describe how each row looks. In the code above, we
layout an image and a text description using HStack . If you've added the code in
ContentView correctly, SwiftUI should render the outline view as shown in figure 2.
List {
...
}
.listStyle(.plain)
If you've followed me, the list view should now change to the plain style.
If you understand how to build an expandable list view, the usage of OutlineGroup is very
similar. For example, the following code allows you to build the same expandable list
view as shown in Figure 1:
List {
OutlineGroup(sampleMenuItems, children: \.subMenuItems) { item in
HStack {
Image(item.image)
.resizable()
.scaledToFit()
.frame(width: 50, height: 50)
Text(item.name)
.font(.system(.title3, design: .rounded))
.bold()
}
}
}
Similar to the List view, you just need to pass OutlineGroup the array of items and
specify the key path for the sub menu items (or children).
With OutlineGroup , you have better control on the appearance of the outline view. For
example, we want to display the top-level menu items as the section header. You can
write the code like this:
Section(header:
HStack {
Text(menuItem.name)
.font(.title3)
.fontWeight(.heavy)
Image(menuItem.image)
.resizable()
.scaledToFit()
.frame(width: 30, height: 30)
}
.padding(.vertical)
) {
OutlineGroup(menuItem.subMenuItems ?? [MenuItem](), children: \.subMen
uItems) { item in
HStack {
Image(item.image)
.resizable()
.scaledToFit()
.frame(width: 50, height: 50)
Text(item.name)
.font(.system(.title3, design: .rounded))
.bold()
}
}
}
}
}
In the code above, we use ForEach to loop through the menu items. We present the top-
level items as section headers. For the rest of the submenu items, we rely on
OutlineGroup to create the hierarchy of data. If you have made the change in
ContentView.swift , you should see an outline view like the one shown in Figure 4.
Similarly, if you prefer to use the plain list style, you can attach the listStyle modifier to
the List view:
.listStyle(.plain)
Understanding DisclosureGroup
In the outline view, you can show/hide the submenu items by tapping the disclosure
indicator. Whether you use List or OutlineGroup to implement the expandable list, this
"expand & collapse" feature is supported by a new view called DisclosureGroup .
The disclosure group view is designed to show or hide another content view. While
DisclosureGroup is automatically embedded in OutlineGroup , you can use this view
independently. For example, you can use the following code to show & hide a question
and an answer:
The disclosure group view takes in two parameters: label and content. In the code above,
we specify the question in the label parameter and the answer in the content
By default, the disclosure group view is in hidden mode. To reveal the content view, you
tap the disclosure indicator to switch the disclosure group view to the "expand" state.
Exercise
The DisclosureGroup view allows you to have finer control over the state of the disclosure
indicator. Your exercise is to create a FAQ screen similar to the one shown in figure 7.
Users can tap the disclosure indicator to show or hide an individual question.
Additionally, the app provides a "Show All" button to expand all questions and reveal the
answers at once.
Summary
In this chapter, I have introduced a couple of new features of SwiftUI. As you can see in
the demo, it is effortless to build an outline view or expandable list view. All you need to
do is define a correct data model. The List view handles the rest, traverses the data
structure, and renders the outline view. On top of that, the new update provides
OutlineGroup and DisclosureGroup for you to further customize the outline view.
For reference, you can download the complete expandable list project here:
Demo project
(https://www.appcoda.com/resources/swiftui5/SwiftUIExpandableList.zip)
In this chapter, I will guide you on how to create both horizontal and vertical views using
LazyVGrid and LazyHGrid. Both components are designed to be flexible, allowing
developers to create various types of grid layouts with ease. We will also explore how to
vary the size of grid items to achieve different layouts. Once we have covered the basics,
we will delve into creating more complex layouts, such as the one shown in Figure 1.
1. First, you need to prepare the raw data for presentation in the grid. For example,
here is an array of SF symbols that we are going to present in the demo app:
2. Create an array of type GridItem that describes what the grid will look like.
Including, how many columns the grid should have. Here is a code snippet for
describing a 3-column grid:
3. Next, you layout the grid by using LazyVGrid and ScrollView . Here is an example:
ScrollView {
LazyVGrid(columns: threeColumnGrid) {
// Display the item
}
}
4. Alternatively, if you want to build a horizontal grid, you use LazyHGrid like this:
ScrollView(.horizontal) {
LazyHGrid(rows: threeColumnGrid) {
// Display the item
}
}
We use LazyVGrid to create a vertical grid layout with three columns, and specify a 20-
point space between rows. In the code block, we use a ForEach loop to present a total of
10,000 image views. If you've made the changes correctly, you should see a three-column
grid in the preview.
This is how we create a vertical grid with three columns. The size of each image is fixed at
50 by 50 points using the .frame modifier. If you want to make a grid item wider, you
can modify the frame modifier as follows:
The image's width will expand to take up the column's width like that shown in figure 4.
Note that there is a space between the columns and rows in the current grid layout.
Sometimes you may want to create a grid without any spaces. How can you achieve that?
The space between rows is controlled by the spacing parameter of LazyVGrid , which we
have set to 20 points. To remove the space between rows, simply change the value of the
spacing parameter to 0 .
The spacing between grid items is controlled by the instances of GridItem initialized in
gridItemLayout . You can set the spacing between items by passing a value to the spacing
parameter. Therefore, to remove the spacing between rows in our grid layout, initialize
the gridLayout variable as follows:
For each GridItem , we specify to use a spacing of zero. For simplicity, the code above can
be rewritten like this:
If you've made the changes, your preview canvas should show you a grid view without
any spacing.
.flexible() is just one of the size types for controlling the grid layout. If you want to
place as many items as possible in a row, you can use the adaptive size type:
The adaptive size type requires you to specify the minimum size for a grid item. In the
code above, each grid item has a minimum size of 50 points. If you modify the
gridItemLayout variable as shown above and set the spacing of LazyVGrid back to 20 ,
you should be able to achieve a grid layout similar to the one shown in figure 6.
Note: I used iPhone 13 Pro as the simulator. If you use other iOS simulators with
different screen sizes, you may achieve a different result.
In addition to using .flexible and .adaptive , you can also use .fixed to create fixed-
width columns. For example, if you want to create a grid layout with two columns where
the first column has a width of 100 points and the second one has a width of 150 points,
you can use the following code:
Update the gridItemLayout variable as shown above, this will result in a two-column grid
with different sizes.
You are allowed to mix different size types to create more complex grid layouts. For
example, you can define a fixed size GridItem , followed by a GridItem with an adaptive
size like this:
In this case, LazyVGrid creates a fixed size column of 100 point width. And then, it tries
to fill as many items as possible within the remaining space.
Therefore, to transform a grid view from vertical to horizontal orientation, you can
simply modify a few lines of code as follows:
Run the demo in the preview or test it on a simulator. You should see a horizontal grid.
Create a new project for this demo app. Again, choose the App template and name the
project SwiftUIPhotoGrid. Next, download the image pack at
https://www.appcoda.com/resources/swiftui/coffeeimages.zip. Unzip the images and
add them to the asset catalog.
Before creating the grid view, we will create the data model for the collection of photos.
In the project navigator, right click SwiftUIPhotoGrid and choose New file... to create a
new file. Select the Swift File template and name the file Photo.swift.
Insert the following code in the Photo.swift file to create the Photo struct:
instances. With the data model ready, let's switch over to ContentView.swift to build the
grid.
By default, we want to display the photos in a list view. Instead of using List , you can
use LazyVGrid to build a list view by defining the gridLayout with a single grid item.
When you tell LazyVGrid to use a single-column grid layout, it will arrange the items in a
list view format. Insert the following code in body to create the grid view:
Image(samplePhotos[index].name)
.resizable()
.scaledToFill()
.frame(minWidth: 0, maxWidth: .infinity)
.frame(height: 200)
.cornerRadius(10)
.shadow(color: Color.primary.opacity(0.3), radius: 1)
}
}
.padding(.all, 10)
}
.navigationTitle("Coffee Feed")
}
We use LazyVGrid to create a vertical grid with a spacing of 10 points between rows. The
grid is used to display coffee photos, so we use ForEach to loop through the samplePhotos
array. We embed the grid in a scroll view to make it scrollable and wrap it with a
navigation view. Once you have made the change, you should see a list of photos in the
preview canvas.
Now we need to a button for users to switch between different layouts. We will add the
button to the navigation bar. The SwiftUI framework has a modifier called .toolbar for
you to populate items within the navigation bar. Right after .navigationTitle , insert the
following code to create the bar button:
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
self.gridLayout = Array(repeating: .init(.flexible()), count: self.gri
dLayout.count % 4 + 1)
} label: {
Image(systemName: "square.grid.2x2")
.font(.title)
}
.tint(.primary)
}
}
Figure 12. Adding a bar button for switching the grid layout
You can run the app to have a quick test. Tapping the grid button will switch to another
grid layout.
There are a couple of things we want to improve. First, the height of the grid item should
be adjusted to 100 points for grids with two or more columns. Update the .frame
Second, when you switch from one grid layout to another, SwiftUI simply redraws the
grid view without any animation. Wouldn't it be great if we added a nice transition
between layout changes? To do that, you just add a single line of code. Insert the
following code after .padding(.all, 10) :
This is the power of SwiftUI. By telling SwiftUI that you want to animate changes, the
framework handles the rest and you will see a nice transition between the layout changes.
Let's go back to our Xcode project and create the data model first. The image pack you
downloaded earlier comes a set of cafe photos. So, create a new Swift file and name it
Cafe.swift. In the file, insert the following code:
return cafes
}()
The Cafe struct is self-explanatory, with an image property for storing the cafe photo
and a coffeePhotos property for storing a list of coffee photos. In the code above, we also
create an array of Cafe for demonstration purposes, randomly picking some coffee
photos for each cafe. Feel free to modify the code if you have other images you prefer.
Rather than modifying the ContentView.swift file, let's create a new file to implement this
grid view. Right-click on SwiftUIPhotoGrid and select New File.... Then, choose the
SwiftUI View template and name the file MultiGridView.
Similar to the earlier implementation, let's declare a gridLayout variable to store the
current grid layout:
By default, our grid is initialized with one GridItem . Next, insert the following code in
body to create a vertical grid with a single column:
ForEach(sampleCafes) { cafe in
Image(cafe.image)
.resizable()
.scaledToFill()
.frame(minWidth: 0, maxWidth: .infinity)
.frame(maxHeight: 150)
.cornerRadius(10)
.shadow(color: .primary.opacity(0.3), radius: 1)
}
}
.padding(.all, 10)
.animation(.interactiveSpring(), value: gridLayout.count)
}
.navigationTitle("Coffee Feed")
}
I don't think we need to go through the code again because it's almost the same as the
code we wrote earlier. If your code works properly, you should see a list view that shows
the collection of cafe photos.
If you prefer to arrange the cafe and coffee photos side by side, you can modify the
gridLayout variable like this:
As soon as you change the gridLayout variable, your preview will be updated to display
the cafe and coffee photos side by side.
Alternatively, you can run the app on a simulator and test the landscape mode. But
before you run the app, you will need to perform a simple modification in
SwiftUIPhotoGridApp.swift . Since we have created a new file for implementing this multi-
grid, modify the view in WindowGroup from ContentView() to MultiGridView() like below:
Now you're ready to test the app on an iPhone simulator. You rotate the simulator
sideways by pressing command-left (or right)
To fix the issue, we can adjust the minimum width of the adaptive grid item and make it a
bit wider when the device is in landscape orientation. The question is how can you detect
the orientation changes?
In SwiftUI, every view comes with a set of environment variables. You can find out the
current device orientation by accessing both the horizontal and vertical size class
variables like this:
The @Environment property wrapper allows you to access the environment values. In the
code above, we tell SwiftUI that we want to read both the horizontal and vertical size
classes, and subscribe to their changes. In other words, we will be notified whenever the
device's orientation changes.
If you haven't done so, please make sure you insert the code above in MultiGridView .
The next question is how do we capture the notification and respond to the changes? You
use a modifier called .onChange() . You can attach this modifier to any views to monitor
any state changes. In this case, we can attach the modifier to NavigationStack like this:
.onChange(of: verticalSizeClass) {
self.gridLayout = [ GridItem(.adaptive(minimum: verticalSizeClass == .compact
? 100 : 250)), GridItem(.flexible()) ]
}
Now run the app on an iPhone simulator again. When you turn the device sideways, it
now shows the cafe photo and coffee photos side by side.
Exercise
I have a couple of exercises for you. First, the app UI doesn't look good on iPad. Modify
the code and fix the issue such that it only shows two columns: one for the cafe photo and
the other for the coffee photos.
1. Different default grid layout for iPhone and iPad - When the app is first
loaded up, it displays a single column grid for iPhone in portrait mode. For iPad and
iPhone landscape, the app shows the cafe photos in a 2-column grid.
2. Show/hide button for the coffee photos - Add a new button in the navigation
bar for toggling the display of coffee photos. By default, the app only shows the list of
cafe photos. When this button is tapped, it shows the coffee photo grid.
3. Another button for switching grid layout - Add another bar button for toggling
the grid layout between one and two columns.
To help you better understand what the final deliverable looks like, please check out this
video demo at https://link.appcoda.com/multigrid-demo.
Summary
The missing collection view in the first release of SwiftUI is finally available. The
introduction of LazyVGrid and LazyHGrid in SwiftUI allows developers to create various
types of grid layouts with just a few lines of code. This chapter provides a quick overview
of these two new UI components. I encourage you to experiment with different
configurations of GridItem to see what grid layouts you can achieve.
For reference, you can download the complete grid project and the solution to the
exercise at:
https://www.appcoda.com/resources/swiftui5/SwiftUIGridLayout.zip
https://www.appcoda.com/resources/swiftui5/SwiftUIPhotoGrid.zip
To organize our code better, create a new file using the SwiftUI view template and name
it ProgressRingView.swift . Once created, Xcode should generate the file with the following
code:
#Preview {
ProgressRingView()
}
For example, if you tell the bar view to display 60% progress in red and set its width to
250 points, the circular progress view should show something like this:
By building a flexible circular progress bar view, it becomes very easy to create an activity
ring. For example, we can overlay another circular progress bar with a bigger size and
different color on top of the one shown in Figure 4 to create an activity ring.
That's how we are going to build the activity ring. Now let's begin to implement the
circular progress bar.
extension Color {
public init(red: Int, green: Int, blue: Int, opacity: Double = 1.0) {
let redValue = Double(red) / 255.0
let greenValue = Double(green) / 255.0
let blueValue = Double(blue) / 255.0
public static let lightRed = Color(red: 231, green: 76, blue: 60)
public static let darkRed = Color(red: 192, green: 57, blue: 43)
public static let lightGreen = Color(red: 46, green: 204, blue: 113)
public static let darkGreen = Color(red: 39, green: 174, blue: 96)
public static let lightPurple = Color(red: 155, green: 89, blue: 182)
public static let darkPurple = Color(red: 142, green: 68, blue: 173)
public static let lightBlue = Color(red: 52, green: 152, blue: 219)
public static let darkBlue = Color(red: 41, green: 128, blue: 185)
public static let lightYellow = Color(red: 241, green: 196, blue: 15)
public static let darkYellow = Color(red: 243, green: 156, blue: 18)
public static let lightOrange = Color(red: 230, green: 126, blue: 34)
public static let darkOrange = Color(red: 211, green: 84, blue: 0)
public static let purpleBg = Color(red: 69, green: 51, blue: 201)
}
In the code above, we create an init method which takes in the values of red , green ,
and blue . This makes it easier to initialize an instance of Color with an RGB color code.
All the colors are derived from the flat color palette
(https://flatuicolors.com/palette/defo). If you prefer to use other colors, you can simply
modify the color values or create your own color constants.
Since this circular progress bar should support various sizes, we declare the variables
above with default values. As the name suggests, the thickness variable controls the
thickness of the progress bar, while the width variable stores the diameter of the circle.
You can create the circle view using the built-in Circle shape like this:
We use the stroke modifier to draw the outline of the circle in gray. As shown in the
figure, the thickness property controls the width of the outline, while the width
property represents the diameter of the circle. I intentionally highlight the frame so that
you can see the thickness and width.
We create a RingShape struct by adopting the Shape protocol. We declare two properties
in the struct: the progress property, which allows the user to specify the percentage of
progress, and the thickness property, which, similar to that in ProgressRingView , lets you
control the width of the ring.
To draw the ring, we use the addArc method, followed by strokedPath . The radius of the
arc can be calculated by dividing the frame's width (or height) by 2. The starting angle is
currently set to zero degrees. We calculate the ending angle by multiplying 360 with the
progress value. For example, if we set the progress to 0.5, we draw a half ring (from 0 to
180 degrees).
To use the RingShape , you can update the body variable like this:
Once you make the changes, you should see a partial ring overlay on top of the gray
circle. Note that it has round cap at both ends since we set the lineCap parameter of
strokedPath to .round .
Other than the ring's color, you may also notice something that we need to tweak. The
start point of the arc is not the same as that in figure 4. To fix the issue, you need change
the startAngle from zero to -90.
We change the startAngle parameter to -90 degree. we also need to alter the endAngle
parameter, because the starting angle is changed. With the modification, the arc now
rotates by 90 degrees anticlockwise.
Adding a Gradient
Now that we have a ring shape that we can adjust by passing different progress values to
it, wouldn't it be great if we could add a gradient color to the bar? SwiftUI provides three
types of gradients, including linear gradient, angular gradient, and radial gradient. Apple
uses the angular gradient to fill the progress bar.
The angular gradient applies the gradient color as the angle changes. In the code above,
we render the gradient from 0 degrees to 180 degrees. Figure 9 shows you the result of
two different angular gradients.
Since the starting angle of the ring shape is set to -90 degrees, we will apply the angular
gradient like this (assuming the progress is set to 0.5):
Now let's modify the code to apply the gradient to the RingShape . First, declare the
following properties in ProgressRingView :
Then fill the RingShape with the angular gradient by attaching the .fill modifier like
below:
As soon as you complete the modification, the circular progress bar should be filled with
the specified gradient.
Varying Progress
The percentage of progress is now fixed at 0.5. Obviously, we need to create a variable for
that to make it adjustable. In ProgressRingView , declare a variable named progress like
this:
We are developing a flexible ProgressRingView and want to let the caller control the
percentage of progress. Therefore, the source of truth (i.e. progress) should be provided
by the caller. This is the reason why progress is marked as a binding variable.
With the variable, we can update the following line of code accordingly:
#Preview("ProgressRingView (50%)") {
ProgressRingView(progress: .constant(0.5))
}
#Preview("ProgressRingView (90%)") {
ProgressRingView(progress: .constant(0.9))
}
I want to see the end result of two different values of progress, so we create two instances
of ProgressRingView in the preview. Now you should be able to see two previews in the
preview pane.
Now let's switch over to ContentView.swift to create this demo. First, declare a state
variable to keep track of the progress like this:
Then insert the following code in the body variable to create the UI:
HStack {
Group {
Text("0%")
.font(.system(.headline, design: .rounded))
.onTapGesture {
self.progress = 0.0
}
Text("50%")
.font(.system(.headline, design: .rounded))
.onTapGesture {
self.progress = 0.5
}
Text("100%")
.font(.system(.headline, design: .rounded))
.onTapGesture {
self.progress = 1.0
}
}
.padding()
.background(Color(.systemGray6))
.clipShape(RoundedRectangle(cornerRadius: 15.0, style: .continuous))
.padding()
}
.padding()
}
In your preview canvas, you should have something like below. The progress bar only
shows the gray circle underneath because the progress is defaulted to zero. Click the Play
button to run the demo. Try tapping different buttons to see how the progress bar
responds.
Does it work up to your expections? I think not. When you tap the 50% button, the
progress bar instantly fills half of the ring without any animation. This isn't what we
expect.
I guess you may know why the view is not animated. We haven't attached an .animation
modifier to the ring shape. Switch back to ProgressRingView.swift and attach the
.animation modifier to the ZStack of ProgressRingView . You can insert the code after the
.frame modifier:
Okay, it seems like we've figured out the solution. Let's go back to ContentView.swift and
test the demo again. Run the demo and tap any of the buttons to try it out.
Unfortunately, the ring still doesn't animate the progress change, but it does animate the
gradient change.
SwiftUI comes with a protocol called Animatable . For a view that supports animation,
you can adopt the protocol and provide the animatableData property. This property tells
SwiftUI what data the view can animate.
In Chapter 9, I introduced you the basics of SwiftUI animation. You can easily animate
the size change of a view using .scaleEffect or the position change by using .offset . It
may seem to you that all these animations work automatically. Behind the scenes, Apple's
engineers actually adopted the protocol and provided the animatable data for CGSize
and CGPoint .
The RingShape struct conforms to the Shape protocol. If you look at its documentation,
Shape adopts the Animatable protocol and provides the default implementation.
However, the default implementation of the animatableData property returns an instance
of EmptyAnimatableData , which means no animatable data. This is why ProgressRingView
To fix the issue and make the progress animatable, all you need to do is to override the
default implementation and provide the animatable values. In the RingShape struct,
insert the following code before the path function:
The implementation is very simple. We just tell SwiftUI to animate the progress value.
That's it!
To resolve the issue, my idea is to overlay a small circle, the size of which is based on the
thickness of the ring, at the end of the arc. Additionally, we will add a drop shadow to
that small circle. Figure 16 illustrates this solution. Please note that, for the final
solution, the circle should have the same color as the arc's end. I have highlighted it in
red for illustrative purposes only.
The question is how do you calculate the position of this little circle or the end position of
the arc? This requires some mathematical knowledge. Figure 17 shows you how we
calculate the position of the little circle.
Now, let's dive into the implementation and create the little circle. Let's call it RingTip
return path
}
The RingTip struct takes in three parameters: progress , startAngle , and ringRadius for
the calculation of the circle's position. Once we figure out the position, we can draw the
path of the circle by using addRoundedRect .
Next, create RingTip by inserting the following code after RingShape in the ZStack :
We instantiate RingTip by passing the current progress, start angle, and the radius of the
ring. The foreground color is set to the ending gradient color. You may wonder why we
only display the gradient color when the progress is greater than 0.96. Take a look at
Figure 18, and you will understand why I came up with this decision.
Figure 18. Need to overlay the circle only when the progress is greater than 0.96
After adding the instance of RingTip in the ZStack , run the program in the preview.
Click the 100% button. The progress bar should now have a round cap.
You've already built a pretty nice circular progress bar, but we can make it even better by
adding a drop shadow at the end of the arc. In SwiftUI, you can simply attach the
.shadow modifier to add a drop shadow. In this case, we can attach the modifier to
RingTip . The hard part is figuring out where to add the drop shadow.
The calculation of the shadow position is very similar to that of the ring tip. So, in
ProgressRingView.swift , let's insert a function for computing the position of the ring tip:
Then add a new computed property for calculating the shadow offset of the ring tip like
this:
By adding 0.01 to the current progress, we can compute the shadow position. This is my
solution for finding the shadow position. You may come up with an alternative solution.
With the shadow offset, we can attach the .shadow modifier to RingTip :
I just want to add a light shadow, so the opacity is set to 0.15. If you prefer a darker
shadow, increase the opacity value (say, 1.0). After making the code change, you should
see a drop shadow at the end of the ring, provided that the progress is greater than 0.96.
You can also try setting the progress value to a value larger than 1.0 and see how the
progress bar looks.
Exercise
Now that you've created a flexible circular progress bar, it's time to have an exercise. Your
task is to make use of what you've built and create an activity ring. The app should also
provide four buttons for adjusting the activity ring, as seen in Figure 21.
Summary
By building an activity ring, we have covered a number of SwiftUI features in this
chapter. You should now have a better idea of how to implement your custom shape and
how to animate a shape using the Animatable protocol.
Demo project
(https://www.appcoda.com/resources/swiftui5/SwiftUIProgressRing.zip)
Solution
(https://www.appcoda.com/resources/swiftui5/SwiftUIProgressRingExercise.zip)
So, what are we going to animate? We will build upon what we implemented in the
previous chapter and add a text label at the center of the progress ring. This label will
display the current percentage of progress. As the progress bar moves, the label will
update accordingly. Figure 2 provides a visual representation of the label's appearance.
Before we delve into the AnimatableModifier protocol, let me ask you: How do you plan to
layout the progress label and animate its changes? In fact, we built a similar progress
indicator in chapter 9. Based on what you've learned, you can layout the progress label
(in the ProgressRingView.swift file) as follows:
ZStack {
Circle()
.stroke(Color(.systemGray6), lineWidth: thickness)
Text(progressText)
.font(.system(.largeTitle, design: .rounded))
.fontWeight(.bold)
.foregroundStyle(.black)
...
}
You add a Text view in the ZStack and display the current progress in a formatted text
using the below conversion:
Since the progress variable is a state variable, the progressText will be automatically
updated whenever the value of progress changes. This implementation is
straightforward. However, there is an issue with the solution: the text animation doesn't
work well.
This is not what we expect. The progress label shouldn't jump directly from one value
(e.g., 100%) to another value (e.g., 50%). Instead, we expect the progress label to follow
the animation of the progress bar and update its value step by step, as shown in the
following example:
100 -> 99 -> 98 -> 97 -> 96 ... ... ... ... ... ... ... ... ... ... 53 -> 52 -> 51 -> 50
The current implementation doesn't allow you to animate the text change. This is why I
have to introduce you the AnimatableModifier protocol.
To animate the progress text, we will create a new struct called ProgressTextModifier ,
which adopts AnimatableModifier , in ProgressRingView.swift :
Does the code look familiar to you? As mentioned earlier, the AnimatableModifier
This is how we animate the text using AnimatableModifier . For convenience, insert the
following code at the end of ProgressRingView to create an extension for applying the
ProgressTextModifier :
Now you can attach the animatableProgressText modifier to RingShape like this:
Once you have made the change, you should see the progress label in the preview canvas.
To test the animation, run the app on an iPhone simulator or test it in the preview of
ContentView.swift . When you change the progress, the progress text now animates.
Xcode enables developers to include custom views in the library by utilizing a protocol
called LibraryContentProvider . To add a custom view to the View library, you simply need
to create a new struct that conforms to the LibraryContentProvider protocol.
For example, to share the progress ring view to the View library, we can create a struct
called ProgressBar_Library in ProgressRingView.swift like this:
The way to add a view to the View library is very simple. You need to create a struct that
conforms to the LibraryContentProvider protocol and override the views property to
return an array of custom views. In the provided code, we return the progress ring view
with default values, name it "Progress Ring," and categorize it under the control category.
Optionally, if you want to add more than one library item, you can write the code like
this:
As a side note, there are four possible values that can be given to item's category,
depending on what the library item is supposed to represent:
control
effect
layout
other
Once you create the struct, Xcode automatically detects the implementation of the
LibraryContentProvider protocol in your project and includes the progress ring view in the
View library. You can now easily add the progress ring view to your user interface using
drag and drop. Please note that as of the time of this writing, it is not possible to add
documentation for custom controls.
@LibraryContentBuilder
func modifiers(base: Circle) -> [LibraryItem] {
LibraryItem(base.animatableProgressText(progress: 1.0), title: "Progress I
ndicator", category: .control)
}
}
The base parameter lets you specify the type of control that can be modified by the
modifier. In the code above, it's the Circle view. Again, once you insert the code
in ProgressBar_Library , Xcode will scan the library item and add it to the Modifier library.
Summary
The AnimatableModifier protocol is a powerful tool for animating changes in any view. In
this chapter, we demonstrated how to animate text changes in a label. This technique can
be applied to animate other values such as color and size.
Demo project
(https://www.appcoda.com/resources/swiftui5/SwiftUIProgressRingLibrary.zip)
Exercise
(https://www.appcoda.com/resources/swiftui5/SwiftUITaskAnimation.zip)
In this chapter, we will demonstrate how to utilize both TextEditor and TextField for
handling multiline input in your SwiftUI apps.
Using TextEditor
It is very easy to use TextEditor . You simply need to have a state variable to hold the
input text. Then, create a TextEditor instance in the body of your view, like this:
To instantiate the text editor, you pass the binding of inputText so that the state variable
can store the user input.
TextEditor(text: $inputText)
.font(.title)
.lineSpacing(20)
.autocapitalization(.words)
.disableAutocorrection(true)
.padding()
Text("\(wordCount) words")
.font(.headline)
.foregroundColor(.secondary)
.padding(.trailing)
}
}
}
In the code above, we declare a state property to store the word count. And, we specify in
the onChange() modifier to monitor the change of inputText . Whenever a user types a
character, the code inside the onChange() modifier wilsl be invoked. In the closure, we
compute the total number of words in inputText and update the wordCount variable
accordingly.
If you test the code in Xcode preview or a simulator, you should see a plain text editor
that also displays the word count in real time.
For example, the text field initially displays a single-line input. When the user keys in the
text, the text field expands automatically to support multiline input. Here is the sample
code snippet for the implementation:
}
}
The axis parameter can either have a value of .vertical or .horizontal . In the code
above, we set the value to .vertical . In this case, the text field expands vertically to
support multiline input. If it's set to .horizontal , the text field will expand horizontally
and keep itself as a single line text field.
By pairing the lineLimit modifier, you can change the initial size of the text field. Let's
say, if you want to display a three-line text field, you can attach the lineLimit modifier
and set the value to 3 :
You can also provide a range for the line limit. Here is an example:
In this case, the text field will not expand more than 5 lines.
Summary
Since the initial release of SwiftUI, TextEditor has been one of the most anticipated UI
components. You can now use this native component to handle multiline input. With the
release of iOS 16, you can also use TextField to get user input that may need more space
to type. The auto-expand feature allows you to easily create a text field that is flexible
enough to take a single line or multiline input.
For reference, you can download the complete text editor project here:
Demo project
(https://www.appcoda.com/resources/swiftui5/SwiftUITextEditor.zip)
For any mobile app, it's common to move from one view to another. Creating a delightful
transition between views will enhance the user experience. With the
matchedGeometryEffect modifier, you describe the appearance of two views. The modifier
then computes the difference between those views and automatically animates the size
and position changes.
Feeling confused? No worries. You will understand what I mean after going through the
demo apps.
However, with the matchedGeometryEffect modifier, you no longer need to figure out these
differences. All you need to do is describe two views: one that represents the start state
and the other for the final state. matchedGeometryEffect automatically interpolates the size
and position between the views.
// Final State
Circle()
.fill(.green)
.matchedGeometryEffect(id: "circle", in: shapeTransition)
.frame(width: 300, height: 300)
.offset(y: -200)
.onTapGesture {
withAnimation(.easeIn) {
expand.toggle()
}
}
} else {
// Start State
Circle()
.fill(.green)
.matchedGeometryEffect(id: "circle", in: shapeTransition)
.frame(width: 150, height: 150)
.offset(y: 0)
.onTapGesture {
withAnimation(.easeIn) {
expand.toggle()
}
}
}
}
For both Circle views, we attach the matchedGeometryEffect modifier and specify the
same ID and namespace. By doing so, SwiftUI computes the size and position difference
between the views and interpolates the transition. Along with the withAnimation
The ID and namespace are used to identify which views are part of the same transition.
That's why both Circle views use the same ID and namespace.
This is how you use matchedGeometryEffect to animate transitions between two views. If
you've used Magic Move in Keynote before, this new modifier is very much like Magic
Move. To test the animation, simply tap the circle in the preview canvas.
Using the same technique you just learned, you need to prepare two views: the circle view
and the rounded rectangle view. The matchedGeometryEffect modifier will handle the
transformation. Replace the body variable of the ContentView struct with the following
code:
// Rounded Rectangle
Spacer()
RoundedRectangle(cornerRadius: 50.0)
.matchedGeometryEffect(id: "circle", in: shapeTransition)
.frame(minWidth: 0, maxWidth: .infinity, maxHeight: 300)
.padding()
.foregroundColor(Color(.systemGreen))
.onTapGesture {
withAnimation {
expand.toggle()
}
}
} else {
// Circle
RoundedRectangle(cornerRadius: 50.0)
.matchedGeometryEffect(id: "circle", in: shapeTransition)
.frame(width: 100, height: 100)
.foregroundColor(Color(.systemOrange))
.onTapGesture {
withAnimation {
expand.toggle()
}
}
Spacer()
}
}
We still use the expand state variable to toggle between the circle view and the rounded
rectangle view. The code is very similar to the previous example, except that we use a
VStack and a Spacer to position the views. You may wonder why we used
RoundedRectangle to create the circle. The main reason is that it gives a smoother
transition.
However, you may notice that the modifier doesn't animate the color change. This is
because matchedGeometryEffect only handles position and size changes.
Exercise #1
Let's have a simple exercise to test your understanding of matchedGeometryEffect . Your
task is to create the animated transition shown in Figure 5. It starts with an orange circle
view. When the circle is tapped, it transforms into a full-screen background. You can find
We will use a state variable to store the state of the swap and create a namespace variable
for matchedGeometryEffect . Declare the following variable in ContentView :
By default, the orange circle is on the left side of the screen, while the green circle is
positioned on the right. When the user taps either circle, it triggers the swap. You don't
need to figure out how the swap is done when using matchedGeometryEffect . To create the
transition, all you need to do is:
1. Create the layout of the orange and green circles before the swap
2. Create the layout of the two circles after the swap
To translate the layout into code, you write the body variable like this:
if swap {
HStack {
Circle()
.fill(.green)
.frame(width: 30, height: 30)
.matchedGeometryEffect(id: "greenCircle", in: dotTransition)
Spacer()
Circle()
.fill(.orange)
.frame(width: 30, height: 30)
.matchedGeometryEffect(id: "orangeCircle", in: dotTransition)
}
.frame(width: 100)
.onTapGesture {
withAnimation {
swap.toggle()
}
}
} else {
// Start state
// Orange dot on the left, Green dot on the right
HStack {
Circle()
.fill(.orange)
.frame(width: 30, height: 30)
.matchedGeometryEffect(id: "orangeCircle", in: dotTransition)
Spacer()
Circle()
.fill(.green)
.frame(width: 30, height: 30)
.matchedGeometryEffect(id: "greenCircle", in: dotTransition)
}
.frame(width: 100)
We use a HStack to layout the two circles horizontally and have a Spacer in between
them to create some separation. When the swap variable is set to true , the green circle
is placed to the left of the orange circle. When false , the green circle is positioned to the
right of the orange circle.
As you can see, we just describe the layout of the circle views in difference states and let
matchedGeometryEffect handle the rest. We attach the modifier to each of the Circle
views. However, this time is a bit different. Since we have two different Circle views to
match, we use two distinct IDs for the matchedGeometryEffect modifier. For the orange
circles, we set the identifier to orangeCircle , while the green circles uses the identifier
greenCircle .
Run the app on a simulator or in the preview canvas, you should see the swap animation
when you tap any of the circles.
Exercise #2
Earlier, we used matchedGeometryEffect on two circles and swapped their positions. Your
exercise is to apply the same technique but on two images. Figure 6 shows you the
sample UI. When the swap button is tapped, the app swaps the two photos with a nice
animation.
You are free to use your own photos. For my demo, I used these free photos from
Unsplash.com:
https://unsplash.com/photos/pMW4jzELQCw
https://unsplash.com/photos/PM4Vu1B0gxk
1. One is the view showing a smaller image and an excerpt for the article.
2. The other is the view expanded into full screen showing a featured photo and the full
article.
To begin, first declare a state variable to control the status of the view mode:
When showDetail is set to false, the article view with a smaller image is displayed. when
true, a full screen article view will be shown. Again, to use the matchedGeometryEffect
VStack {
Image("latte")
.resizable()
.scaledToFill()
.frame(minWidth: 0, maxWidth: .infinity)
.frame(height: 200)
.matchedGeometryEffect(id: "image", in: articleTransition)
.cornerRadius(10)
.padding()
.onTapGesture {
withAnimation(.interactiveSpring(response: 0.5, dampingFractio
n: 0.8, blendDuration: 0.2)) {
showDetail.toggle()
}
}
ScrollView {
VStack {
Image("latte")
.resizable()
.scaledToFill()
.frame(minWidth: 0, maxWidth: .infinity)
.frame(height: 400)
Spacer()
}
}
.edgesIgnoringSafeArea(.all)
}
In the code above, we layout the views in different states. When showDetail is set to
false , we use a VStack to layout the article image and the excerpt. The height of the
image is set to 200 points to make it smaller.
Since we have two different views in the transition, we use two different IDs for the
matchedGeometryEffect modifier. For the image, we set the ID to image :
Furthermore, we use two different animations for the text and image views. We apply the
.interactiveSpring animation for the image view, while for the text view, we use the
.easeOut animation.
The implementation is very straightforward, similar to what we have done in the earlier
examples. Run the app on a simulator or in the preview canvas to try it out. When you
tap the image view, the app renders a nice animation and shows the article in full screen.
First, hold the control key and click on the VStack keyword of the first stack view.
Choose Extract Subview from the context menu and name the subview
ArticleExcerptView .
You should see quite a number of errors in the ArticleExcerptView struct, complaining
about the missing of the namespace and the showDetail variable. To fix the error of the
showDetail variable, you can declare a binding in ArticleExcerptView like this:
To accept a namespace from another view, the trick is to declare a variable with the type
Namespace.ID like this:
This should now fix all the errors in ArticleExcerptView . Now go back to ContentView and
replace ArticleExcerptView() with:
We pass the binding to showDetail and the namespace variable to the subview. This is
how you share a namespace across different views. Repeat the same procedure to extract
the ScrollView into another subview. Name the subview ArticleDetailView .
After all these changes, the ContentView struct is now simplified like this:
}
}
Everything works the same but the code is now more readable and organized.
For reference, you can download the complete matched geometry project, with the
solutions to the exercises, here:
Demo project
(https://www.appcoda.com/resources/swiftui5/SwiftUIMatchedGeometry.zip)
One way to present item selection is to have a dock at the bottom of the screen. When an
item is selected, it is removed from the grid and added to the dock. As you select more
items, the dock will hold more items. You can swipe horizontally to navigate through the
items in the dock. If you tap an item in the dock, that item will be removed and added
back to the grid. Figure 1 illustrates how insertion and removal of an item works.
We will implement the grid view and the item selection. We will use the
matchedGeometryEffect modifier to animate the selection. To get started, please first
download the starter project at
https://www.appcoda.com/resources/swiftui5/SwiftUIGridViewAnimationStarter.zip.
This project includes sample data and images.
The samplePhotos constant is predefined in the starter project and stores the array of
photos. The reason why photoSet is declared as a state variable is that we will change its
content for photo selection.
VStack {
ScrollView {
HStack {
Text("Photos")
.font(.system(.title, design: .rounded))
.fontWeight(.heavy)
Spacer()
}
ForEach(photoSet) { photo in
Image(photo.name)
.resizable()
.scaledToFill()
.frame(minWidth: 0, maxWidth: .infinity)
.frame(height: 60)
.cornerRadius(3.0)
}
}
}
}
.padding()
Assuming you have read the earlier chapter on grid views, the code is self-explanatory.
We use the adaptive layout to arrange the set of photos in a grid.
}
.frame(height: 100)
.padding()
.background(Color(.systemGray6))
.cornerRadius(5)
This creates a scrollable rectangle area for holding the selected photos. Right now, it's
just blank.
Each photo in the photoSet has its own ID of the type UUID . To store the current
selected photo, declare another state variable of the type UUID :
To handle the photo selection, attach a onTapGesture function to the Image component
of LazyVGrid like this:
In the block onTapGesture , we add the selected photo to the selectedPhotos array and
update the selectedPhotoId . Additionally, we remove the selected photo from photoSet .
Since photoSet is a state variable, the selected photo will be removed from the grid once
it's removed from the array.
The selected photo should be added to the dock. So, update the empty ScrollView of the
dock like this:
We create a horizontal grid to present the selected photos. For each photo, we attach the
onTapGesture function to it. When someone taps a photo in the dock, it will be added
back to the photo grid and removed from selectedPhotos . In other words, the photo will
be deleted from the dock.
If you test the app in the preview canvas, you should be able to select any of the photos in
the grid. When you tap a photo, it will be automatically added to the dock and that photo
will be removed from the grid. Conversely, you can tap a photo in the dock to move it
back to the photo grid.
The trick here is to assign each image a distinct ID, so that the app will only animate the
change of the selected photo.
To enable the animation, attach the .animation modifier to the VStack and insert the
following line of code under .padding() :
This is the code you need to create the animated transtion. Run the app on a simulator or
in the preview canvas. When you tap a photo in the grid, you can see a beautiful
transition before it's added to the dock.
Figure 5. The selected photos are added to the dock with animation
How can we fix this bug? SwiftUI offers a component called ScrollViewReader . As its
name suggests, this reader is designed to work with ScrollView . It allows developers to
programmatically move a scroll view to a specific location. To use ScrollViewReader , wrap
it around a ScrollView . Each child view should be given its own identifier. Then you call
the scrollTo function of the ScrollViewProxy with the specific ID to move the scroll view
to that particular location.
Now let's return to our demo app. To programmatically scroll the dock's ScrollView , we
first need to give each photo an identifier. The scrollTo function requires an identifier of
the view to scroll to. Since each photo already has its unique identifier, we can use the
photo ID as the view's identifier.
To set the identifier of the Image views in the dock, attach the .id modifier to it:
Once we assign each Image view an identifier, wrap the horizontal ScrollView in a
ScrollViewReader like this:
ScrollViewReader { scrollProxy in
ScrollView(.horizontal, showsIndicators: false) {
LazyHGrid(rows: [ GridItem() ]) {
ForEach(selectedPhotos) { photo in
Image(photo.name)
.resizable()
.scaledToFill()
.frame(minWidth: 0, maxWidth: .infinity)
.frame(height: 100)
.cornerRadius(3.0)
.id(photo.id)
.matchedGeometryEffect(id: photo.id, in: photoTransition)
.onTapGesture {
photoSet.append(photo)
if let index = selectedPhotos.firstIndex(where: { $0.id ==
photo.id }) {
selectedPhotos.remove(at: index)
}
}
}
}
}
.frame(height: 100)
.padding()
.background(Color(.systemGray6))
.cornerRadius(5)
}
Finally, attach the .onChange function to the ScrollView of the dock like this:
We use .onChange to listen for updates to the selectedPhotoId . Whenever the selected
photo ID changes, we call scrollTo with that photo ID to scroll the scroll view to that
location. This ensures the dock always shows the most recently selected photo. You can
run the app again to try it out.
Summary
In this chapter, we continue exploring the usage of matchedGeometryEffect and use this
modifier to create an amazing view transition. The modifier opens up many opportunities
for developers to improve the user experience of their iOS apps. Additionally, we
experimented with the new ScrollViewReader to learn how to use it to scroll a scroll view
programmatically.
Demo project
(https://www.appcoda.com/resources/swiftui5/SwiftUIGridViewAnimation.zip)
In this chapter, we will show you how to create a tab bar interface using TabView , handle
the tab selection, and customize the appearance of the tab bar.
To embed this text view in a tab bar, all you need to do is wrap it with the TabView
component and set the tab item description by attaching the .tabItem modifier like this:
This creates a tab bar with a single tab item. In the sample code, the tab item has both
image and text, but you are free to remove either one of the those.
To display more tabs, you just need to add child views inside the TabView like this:
Text("Bookmark Tab")
.font(.system(size: 30, weight: .bold, design: .rounded))
.tabItem {
Image(systemName: "bookmark.circle.fill")
Text("Bookmark")
}
Text("Video Tab")
.font(.system(size: 30, weight: .bold, design: .rounded))
.tabItem {
Image(systemName: "video.circle.fill")
Text("Video")
}
Text("Profile Tab")
.font(.system(size: 30, weight: .bold, design: .rounded))
.tabItem {
Image(systemName: "person.crop.circle")
Text("Profile")
}
}
TabView {
}
.tint(.red)
If you attach the modifier to TabView , the icon and text of the tab bar should be changed
to red.
TabView(selection: $selection)
Here, we initialize the selection variable with a value of 0 , which corresponds to the
tag value of the first tab item. To complete the setup, we need to define the tag values for
each of the tab items. Let's update the code as follows and attach the tag modifier for
each of the tab items:
Text("Bookmark Tab")
.font(.system(size: 30, weight: .bold, design: .rounded))
.tabItem {
Image(systemName: "bookmark.circle.fill")
Text("Bookmark")
}
.tag(1)
Text("Video Tab")
.font(.system(size: 30, weight: .bold, design: .rounded))
.tabItem {
Image(systemName: "video.circle.fill")
Text("Video")
}
.tag(2)
Text("Profile Tab")
.font(.system(size: 30, weight: .bold, design: .rounded))
.tabItem {
Image(systemName: "person.crop.circle")
Text("Profile")
}
.tag(3)
}
We assign a unique index to each tab item using the tag modifier. The TabView is also
bound to the selection value. To programmatically switch to your preferred tab, simply
update the value of the selection variable.
You can create a Next button that switches to the next tab like this:
Button {
selection = (selection + 1) % 4
} label: {
Text("Next")
.font(.system(.headline, design: .rounded))
.padding()
.foregroundStyle(.white)
.background(.red)
.cornerRadius(10.0)
.padding()
}
}
After making the changes, test the app in the preview canvas. You should be able step
through the tabs by tapping the Next button.
NavigationStack {
TabView(selection: $selection) {
.
.
.
}
.navigationTitle("TabView Demo")
}
NavigationStack {
}
.listStyle(.plain)
.tabItem {
Image(systemName: "house.fill")
Text("Home")
}
.tag(0)
Text("Bookmark Tab")
.font(.system(size: 30, weight: .bold, design: .rounded))
.tabItem {
Image(systemName: "bookmark.circle.fill")
Text("Bookmark")
}
.tag(1)
Text("Video Tab")
.font(.system(size: 30, weight: .bold, design: .rounded))
.tabItem {
Image(systemName: "video.circle.fill")
Text("Video")
}
.tag(2)
Text("Profile Tab")
.font(.system(size: 30, weight: .bold, design: .rounded))
.tabItem {
Image(systemName: "person.crop.circle")
Text("Profile")
}
.tag(3)
}
.tint(.red)
We have just updated the code of the Home tab to display a list of items. Each list item is
now wrapped with a NavigationLink , allowing it to navigate to the detail view when
tapped. When you run the app in the simulator or preview canvas, you may notice that
the tab bar is hidden when navigating to the detail view.
For certain scenarios, you may not want the tab bar to be hidden when navigating to a
detail view. In such cases, you can create the navigation interface in the reverse order.
Instead of wrapping the tab view in a navigation view, you embed the navigation view in
a tab view. The updated code would look like this:
.navigationTitle("TabView Demo")
}
.tabItem {
Image(systemName: "house.fill")
Text("Home")
}
.tag(0)
.
.
.
}
Now when you navigate to the detail view of the item, the tab bar is still there.
Summary
In this chapter, we walked you through the basics of TabView , which is the UI component
in SwiftUI for building a tab view interface. We only show you how to work with the
built-in tab bar. However, keep in mind that you have the flexibility to create your own
custom tab bar if you require full customization. We will explore this topic in future
chapters.
AsyncImage is a built-in view for loading and displaying remote images asynchronously.
All you need is to tell it what the image URL is, and AsyncImage will then do the heavy
lifting to grab the remote image and show it on the screen.
In this chapter, I will show you how to work with AsyncImage in SwiftUI projects.
AsyncImage then connects to the given URL and downloads the remote image
asynchronously. It also automatically renders a placeholder in gray while the image is not
yet ready for display. Once the image is completely downloaded, AsyncImage displays the
image in its intrinsic size.
Assuming that you've created a new SwiftUI project in Xcode, you can replace the code in
ContentView like this to have a try:
In the preview canvas, you should immediately see a placeholder in gray, followed by the
image. It takes a few seconds for the image to download. Thus, iOS displays the
placeholder.
If you want to make the image smaller or larger, you can pass a scaling value to the
scale parameter like this:
A value greater than 1.0 will scale down the image. Conversely, a value less than 1 will
make the image bigger.
init<I, P>(url: URL?, scale: CGFloat, content: (Image) -> I, placeholder: () -> P)
By initializing AsyncImage using the init above, we can resize and scale the downloaded
image to the preferred size. On top of that, we can provide our own implementation for
the placeholder. Here is a sample code snippet:
In the code above, AsyncImage provides the resulting image for manipulation. We then
apply the resizable() and scaledToFill() modifier to resize the image. For the
AsyncImage view, we limit its size to 300×500 points.
The placeholder parameter allows us to create our own placeholder instead of using the
default one. Here, we display a placeholder in light purple.
AsyncImagePhase is an enum that keeps track of the current phase of the download
operation. You can provide detailed implementation for each of the phases including
empty, failure, and success.
The empty state indicates that the image is not loaded. In this case, we display a
placeholder. For the success state, we apply a couple of modifiers and display it on
screen. The failure state allows you to provide an alternate view if there is any errors. In
the code above, we simply display a system image. If you updated the imageURL to an
invalid URL, the app now displays an exclamation mark image.
case .failure(_):
Image(systemName: "exclamationmark.icloud")
.resizable()
.scaledToFit()
@unknown default:
Image(systemName: "exclamationmark.icloud")
}
}
.frame(width: 300, height: 500)
.cornerRadius(20)
By doing so, you will see a fade-in animation when the image is downloaded. If you test
the code in the preview pane, it won’t work. Please make sure you test the code in a
simulator to see the animation.
You can also attach the transition modifier to the image view like this:
Summary
For reference, you can download the complete demo project here:
Demo project
(https://www.appcoda.com/resources/swiftui5/SwiftUIAsyncImage.zip)
views. Developers had to create their own solutions. In earlier chapters, I showed you
how to implement a search bar in SwiftUI using TextField and display search results.
With the release of iOS 15, the SwiftUI framework introduced a new modifier named
searchable for List views.
In this chapter, we will look into this modifier and see easily it is to implement search for
a list.
The starter project already includes a list view that displays a set of articles. What I want
to do is provide an enhancement by adding a search bar for filtering the articles. To add a
search bar to the list view, all you need to do is declare a state variable (e.g., searchText )
to hold the search text and attach a searchable modifier to the NavigationStack , like this:
By default, it displays the word Search as a placeholder. In case if you want to change it,
you can write the .searchable modifier like this and use your own placeholder value:
If you want to permanently display the search field, you can change the .searchable
So far, we attach the .searchable modifier to the navigation view. You can actually attach
it to the List view and achieve the same result on iPhone.
Having that said, the placement of the .searchable modifier affects the position of the
search field when using split view on iPadOS. Let's change the code to use
NavigationSplitView :
NavigationSplitView {
List {
ForEach(articles) { article in
ArticleRow(article: article)
}
.listRowSeparator(.hidden)
}
.listStyle(.plain)
.navigationTitle("AppCoda")
} detail: {
Text("Article details")
}
.searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .alway
s))
As usual, we attach the .searchable modifier to the navigation stack. If you run the app
on iPad, the search bar is displayed on the sidebar of the split view.
What if you want to place the search field in the detail view? You can try to insert the
following code right above the .navigationTitle modifier:
Text("Article details")
.searchable(text: $searchText)
iPadOS will then render an additional search bar at the top right corner of the detail view.
Again, you can further change the placement of the search bar by adjusting the value of
the placement parameter. Here is an example:
Text("Article details")
.searchable(text: $searchText, placement: .navigationBarDrawer)
.listRowSeparator(.hidden)
}
.listStyle(.plain)
.navigationTitle("AppCoda")
} detail: {
Text("Article details")
}
.searchable(text: $searchText)
.onChange(of: searchText) { oldValue, newValue in
if !newValue.isEmpty {
articles = sampleArticles.filter { $0.title.contains(newValue) }
} else {
articles = sampleArticles
}
}
The .onChange modifier is called whenever the user types in the search field. We then
perform the search in real-time by using the filter method. The Xcode preview doesn't
work properly for search, so please test the search feature on simulators.
.searchable(text: $searchText) {
Text("SwiftUI").searchCompletion("SwiftUI")
Text("iOS 15").searchCompletion("iOS 15")
}
This displays a search suggestion with two tappable search terms. Users can either type
the search keyword or tap the search suggestion to perform the search.
.searchable(text: $searchText)
.searchSuggestions {
Text("SwiftUI").searchCompletion("SwiftUI")
Text("iOS 15").searchCompletion("iOS 15")
}
Summary
The .searchable modifier simplifies the implementation of a search bar and saves us
time from creating our own solution. The downside is that this feature is only available
on iOS 15 (or later). If you're building an app that needs to support older versions of iOS,
you still need to build your own search bar.
For reference, you can download the complete demo project here:
Demo project
(https://www.appcoda.com/resources/swiftui5/SwiftUISearchable.zip)
import SwiftUI
import Charts
BarMark(
x: .value("Day", "Tuesday"),
y: .value("Steps", 7200)
)
}
}
}
value is used for defining the chart data for the x-axis. In the code above, the label of the
x-axis is set to "Day". The y-axis shows the total number of steps.
If you enter the code in Xcode, the preview automatically displays the bar chart with two
vertical bars.
The code above shows you the simplest way to create a bar chart. However, instead of
hardcoding the chart data, you usually use the Charts API with a collection of data. Here
is an example:
We created two arrays ( weekdays and steps ) for the chart data. In the Chart view, we
loop through the weekdays array and present the chart data. If you have entered the code
in your Xcode project, the preview should render the bar chart as shown in Figure 2.
By default, the Charts API renders the bars in the same color. To display a different color
for each of the bars, you can attach the foregroundStyle modifier to the BarMark view:
To add an annotation to each bar, you use the annotation modifier like this:
.annotation {
Text("\(steps[index])")
}
By making these changes, the bar chart becomes more visually appealing.
To create a horizontal bar chart, you can simply swap the values of x and y parameter
of the BarMark view.
To store the weather data, we created a WeatherData struct. In your Xcode project, create
a new file named WeatherData using the Swift File template. Insert the following code in
the file:
let londonWeatherData = [
WeatherData(year: 2021, month: 7, day: 1, temperature: 19.0),
WeatherData(year: 2021, month: 8, day: 1, temperature: 17.0),
WeatherData(year: 2021, month: 9, day: 1, temperature: 17.0),
WeatherData(year: 2021, month: 10, day: 1, temperature: 13.0),
WeatherData(year: 2021, month: 11, day: 1, temperature: 8.0),
WeatherData(year: 2021, month: 12, day: 1, temperature: 8.0),
WeatherData(year: 2022, month: 1, day: 1, temperature: 5.0),
WeatherData(year: 2022, month: 2, day: 1, temperature: 8.0),
WeatherData(year: 2022, month: 3, day: 1, temperature: 9.0),
WeatherData(year: 2022, month: 4, day: 1, temperature: 11.0),
WeatherData(year: 2022, month: 5, day: 1, temperature: 15.0),
WeatherData(year: 2022, month: 6, day: 1, temperature: 18.0)
]
let taipeiWeatherData = [
WeatherData(year: 2021, month: 7, day: 1, temperature: 31.0),
WeatherData(year: 2021, month: 8, day: 1, temperature: 30.0),
WeatherData(year: 2021, month: 9, day: 1, temperature: 30.0),
WeatherData(year: 2021, month: 10, day: 1, temperature: 26.0),
WeatherData(year: 2021, month: 11, day: 1, temperature: 22.0),
WeatherData(year: 2021, month: 12, day: 1, temperature: 19.0),
WeatherData(year: 2022, month: 1, day: 1, temperature: 17.0),
WeatherData(year: 2022, month: 2, day: 1, temperature: 17.0),
WeatherData(year: 2022, month: 3, day: 1, temperature: 21.0),
WeatherData(year: 2022, month: 4, day: 1, temperature: 23.0),
WeatherData(year: 2022, month: 5, day: 1, temperature: 24.0),
The Chart initializer takes in a list of Identifiable objects. This is why we make the
WeatherData conform to the Identifiable protocol. For each city, we create an array to
store the sample weather data.
In the project navigator, create a new file named SimpleLineChartView using the SwiftUI
View template. To create any type of chart using the Charts framework, you first need to
import the Charts framework:
import Charts
Declare a variable to store the sample weather data for the cities:
In the body variable, update the code like this to create the line chart:
VStack {
Chart {
ForEach(hkWeatherData) { item in
LineMark(
x: .value("Month", item.date),
y: .value("Temp", item.temperature)
)
}
}
.frame(height: 300)
}
The code above plots a line chart displaying the average temperature of Hong Kong. The
ForEach statement loops through all the items stored in hkWeatherData . For each item,
we create a LineMark object where the x axis is set to the date and the y axis is set to
Optionally, you can resize the chart using the frame modifier. If you preview the code in
the Xcode preview, you should see the line chart in Figure 5.
.chartXAxis {
AxisMarks(values: .stride(by: .month)) { value in
AxisGridLine()
AxisValueLabel(format: .dateTime.month(.defaultDigits))
}
}
.dateTime.month(.defaultDigits)
For the y-axis, instead of display the axis on the trailing (or right) side, we want to switch
it to the leading (or left) side. To do that, attach the chartYAxis modifier like this:
.chartYAxis {
AxisMarks(position: .leading)
}
If you've made the change, Xcode preview should update the chart like figure 6. The y-
axis is moved to the left side and the format of month is changed. Plus, you should see
some grid lines.
.chartPlotStyle { plotArea in
plotArea
.background(.blue.opacity(0.1))
}
We can then change the plot area using the background modifier. As an example, we
change the plot area to light blue.
We use another ForEach loop to iterate through all the cities in the chart data. Here, we
use the foregroundStyle modifier to apply a different color for each line. You don't have
to specify the color; SwiftUI will automatically pick the color for you.
Right now, all the cities share the same symbol. If you want to use a distinct symbol,
place the following line of code after foregroundStyle :
Now each city has its own symbol in the line chart.
.interpolationMethod(.stepStart)
If you change the interpolation method to .stepStart , the line chart now looks like that
displayed in figure 10.
Other than .stepStart , you can try out the following options:
cardinal
catmullRom
linear
monotone
stepCenter
stepEnd
Summary
The Charts framework is a valuable addition to SwiftUI. Even if you're new to SwiftUI,
you can create attractive charts with just a few lines of code. Although this chapter
concentrates on line charts and bar charts, the Charts API simplifies the process of
converting your chart into other formats, such as pie and donut charts. For more
information, refer to the Swift Charts documentation. Additionally, we will explain how
to create pie charts and donut charts in a later chapter.
Live Text is built into the Camera app and Photos app. If you haven't tried out this
feature, simply open the Camera app. When you point the device's camera at an image of
text, you will find a Live Text button at the lower-right corner. By tapping the button, iOS
automatically captures the text for you. You can then copy and paste it into other
applications (e.g., Notes).
This is a very powerful and convenient feature for most users. As a developer, wouldn't it
be great if you could incorporate this Live Text feature into your own app? In iOS 16,
Apple released the Live Text API for developers to power their apps with Live Text. In
this chapter, let's see how to use the Live Text API with SwiftUI.
Using DataScannerViewController
In the WWDC session about Capturing Machine-readable Codes and Text with
VisionKit, Apple's engineer showed the following diagram:
Text recognition is not a new feature in iOS 16. In older versions of iOS, you could use
APIs from the AVFoundation and Vision framework to detect and recognize text.
However, the implementation was quite complicated, especially for those who were new
to iOS development.
In iOS 16, all of the above is simplified with a new class called DataScannerViewController
in VisionKit. By using this view controller, your app can automatically display a camera
UI with Live Text capability.
To use the class, you first import the VisionKit framework and then check if the device
supports the data scanner feature:
DataScannerViewController.isSupported
The Live Text API only supports devices released in 2018 or newer with Neural engine.
On top of that, you also need to check the availability to see if the user approves the use of
data scanner:
DataScannerViewController.isAvailable
Working with
DataScannerViewController in SwiftUI
The DataScannerViewController class now only supports UIKit. For SwiftUI, it requires a
bit of work. We have to adopt the UIViewControllerRepresentable protocol to use the class
in SwiftUI projects. Assuming you've created a new Xcode project, you can now create a
new file using the Swift template and name it DataScanner . To port the
DataScannerViewController to SwiftUI, create a new struct named DataScanner and
implement the UIViewControllerRepresentable protocol like this:
import SwiftUI
import VisionKit
return controller
}
if startScanning {
try? uiViewController.startScanning()
} else {
uiViewController.stopScanning()
}
}
The method is called when the user taps the detected text, so we will implement it like
this:
We check the recognized item and store the scanned text if any text is recognized. Lastly,
insert this line of code in the makeUIViewController method to configure the delegate:
controller.delegate = context.coordinator
Assuming you've created a standard SwiftUI project, open ContentView.swift and the
VisionKit framework.
Next, declare a couple of state variables to control the operation of the data scanner and
the scanned text.
For the body part, let's update the code like this:
VStack(spacing: 0) {
DataScanner(startScanning: $startScanning, scanText: $scanText)
.frame(height: 400)
Text(scanText)
.frame(minWidth: 0, maxWidth: .infinity, maxHeight: .infinity)
.background(in: Rectangle())
.backgroundStyle(Color(uiColor: .systemGray6))
}
.task {
if DataScannerViewController.isSupported && DataScannerViewController.isAvaila
ble {
startScanning.toggle()
}
}
We start the data scanner when the app launches. But before that, we call
DataScannerViewController.isSupported and DataScannerViewController.isAvailable to
ensure that Live Text is supported on the device.
The demo app is almost ready to test. Since Live Text requires camera access, please
remember to go to the project configuration and add the key Privacy - Camera Usage
Description in the Info.plist file. Specify the reason why your app needs to access the
device's camera.
After the changes, you can deploy the app to a real iOS device and test the Live Text
function.
Other than English, Live Text also supports French, Italian, German, Spanish, Chinese,
Portuguese, Japanese, and Korean.
Summary
It's great to see Apple open up the Live Text feature to iOS developers. Although the new
DataScannerViewController is not specifically designed for SwiftUI, we can easily port it to
our Swift project and incorporate the Live Text feature into our own apps. If you are
going to publish your next app update, consider bundling this great feature. It will
definitely improve the user experience.
The ShareLink view is designed to share any type of data. In this chapter, we will show
you how to use ShareLink to let users share text, URLs, and images.
When tapped, iOS brings up a share sheet for users to perform further actions such as
copy and adding the link to Reminders.
To share text, instead of URL, you can simply pass the a string to the item parameter.
In this case, SwiftUI only displays the share icon for the link.
Alternatively, you can present a label with a system image or custom image:
ShareLink(item: url) {
Label("Tap me to share", systemImage: "square.and.arrow.up")
}
When initializing the ShareLink instance, you can include two additional parameters to
provide extra information about the shared item:
ShareLink(item: url, subject: Text("Check out this link"), message: Text("If you w
ant to learn Swift, take a look at this website.")) {
Image(systemName: "square.and.arrow.up")
}
The subject parameter allows you to include a title for the URL or any item you want to
share. The message parameter allows you to specify the description of the item.
Depending on the activities that the user shares to, iOS will present the subject, message,
Sharing Images
Other than URLs, you can share images using ShareLink . Here is a sample code snippet:
}
.padding(.horizontal)
}
}
For the item parameter, you specify the image to share, and you provide a preview of the
image by passing an instance of SharePreview . In the preview, you specify the title of the
image and the thumbnail. When you tap the Share button, iOS displays a share sheet
with the image preview.
String
Data
URL
Attributed String
Image
Transferable is a protocol that describes how a type interacts with transport APIs
such as drag and drop or copy and paste.
So what if you have a custom object, how can you make it transferable? Let's say, you
create the following Photo structure:
To let ShareLink share this object, you have to adopt the Transferable protocol for
Photo and implement the transferRepresentation property:
conformance.
Since Photo now conforms to Transferable , you can pass the Photo instance to
ShareLink :
When users tap the Share button, the app brings up the share sheet for sharing the
photo.
Summary
This chapter shows you how to use ShareLink for sharing text, URLs, and images. In fact,
this new view allows you to share any type of data as long as the type conforms to the
Transferable protocol.
You can then access the cgImage or uiImage property to retrieve the generated image.
As always, I love to demonstrate the usage of an API with an example. In Chapter 38, we
built a line chart using the new Charts framework. Let's see how to let users save the
chart as an image in the photo album and share it using ShareLink .
First, let's revisit the code of the ChartView example. We used the new API of the Charts
framework to create a line chart and display the weather data. Here is the code snippet:
}
}
.chartPlotStyle { plotArea in
plotArea
.background(.blue.opacity(0.1))
}
.chartYAxis {
AxisMarks(position: .leading)
}
.frame(width: 350, height: 300)
.padding(.horizontal)
}
}
To follow this tutorial, you can first download the starter project from
https://www.appcoda.com/resources/swiftui5/SwiftUIImageRendererStarter.zip. Before
using ImageRenderer , let's refactor the code above in ContentView.swift into a separate
view like this:
}
.chartPlotStyle { plotArea in
plotArea
.background(.blue.opacity(0.1))
}
.chartYAxis {
AxisMarks(position: .leading)
}
.frame(width: 350, height: 300)
.padding(.horizontal)
}
}
VStack(spacing: 20) {
chartView
HStack {
Button {
let renderer = ImageRenderer(content: chartView)
Note: You need to add a key named Privacy - Photo Library Usage Description in the
info.plist before the app can properly save an image to the built-in photo album.
In the previous chapter, you learned how to use ShareLink to present a share sheet for
content sharing. With ImageRenderer , you can now easily build a function for users to
share the chart view as an image.
For convenience purpose, let's refactor the code for image rendering into a separate
method:
Note: If you are new to @MainActor , you can check out this article.
With this helper method, we can create a ShareLink like this in the VStack view:
Now when you tap the Share button, the app captures the line chart and lets you share it
as an image.
class has a property named scale that you can adjust to change the scale of the rendered
image. By default, its value is set to 1.0. To generate an image with a higher resolution,
you can set it to 2.0 or 3.0 . Alternatively, you can set the value to the scale of the
screen:
renderer.scale = UIScreen.main.scale
Summary
The ImageRenderer class has made it very easy to convert any SwiftUI views into an
image. If your app supports iOS 16 or later, you can use this new API to create convenient
features for your users. Besides rendering images, ImageRenderer also allows you to
render a PDF document. For further details, you can refer to the official documentation.
Demo project
(https://www.appcoda.com/resources/swiftui5/SwiftUIImageRenderer.zip)
In this chapter, we will build on top of the previous demo and add the Save to PDF
function.
To follow this chapter, you can first download the starter project from
https://www.appcoda.com/resources/swiftui5/SwiftUIImageRendererPDFStarter.zip.
I have made some modifications to the demo app by adding a heading and a caption for
the line chart. You can refer to the code of the ChartView struct below:
Chart {
}
.chartPlotStyle { plotArea in
plotArea
.background(.blue.opacity(0.1))
}
.chartYAxis {
AxisMarks(position: .leading)
}
.frame(width: 350, height: 300)
.padding(.horizontal)
}
}
}
The demo app now also comes with a PDF button for saving the chart view in a PDF
document.
For image conversion, you can access the uiImage property to get the rendered image. To
draw the chart into a PDF, we will use the render method of ImageRenderer . Here is what
we are going to implement:
Look for the document directory and prepare the rendered path for the PDF file (e.g.
linechart.pdf).
Prepare an instance of CGContext for drawing.
Call the render method of the renderer to render the PDF document.
pdfContext.beginPDFPage(options as CFDictionary)
renderer(pdfContext)
pdfContext.endPDFPage()
pdfContext.closePDF()
}
}
The first two lines of the code retrieves the document directory of the user and set up the
file path of the PDF file (i.e. line chart.pdf ). We then create the instance of CGContext .
The mediaBox parameter is set to nil. In this case, Core Graphics uses a default page size
of 8.5 by 11 inches (612 by 792 points).
The renderer closure receives two parameters: the current size of the view and a
function that renders the view to the CGContext . To begin the PDF page, we call the
context's beginPDFPage method. The renderer method draws the chart view. Remember
that you need to close the PDF document to complete the whole operation.
You can run the app in a simulator to have a test. After you tap the PDF button, you
should see the following message in the console:
If you open the file in Finder (choose Go > Go to Folder...), you should see a PDF
document like below.
pdfContext.translateBy(x: 0, y: 200)
This will move the chart to the upper part of the document.
Set the value of the keys to Yes. Once you enable both options, run the app on the
simulator again. Open the Files app and navigate to the On My iPhone location. You
should see the app's folder. Inside the folder, you will find the PDF document.
Summary
Not only can you create an image from a view, but ImageRenderer also allows developers
to turn a view into a PDF document. With this API, introduced in iOS 16, you can easily
add some PDF-related features to your iOS apps.
Demo project
(https://www.appcoda.com/resources/swiftui5/SwiftUIImageRendererPDF.zip)
A gauge is a view that shows a current level of a value in relation to a specified finite
capacity, very much like a fuel gauge in an automobile. Gauge displays are
configurable; they can show any combination of the gauge’s current value, the
range the gauge can display, and a label describing the purpose of the gauge itself.
In the most basic form, a gauge has a default range from 0 to 1. If we set the value
parameter to 0.5 , SwiftUI renders a progress bar indicating the task is 50% complete.
Optionally, you can provide labels for the current, minimum, and maximum values:
Gauge(value: progress) {
Text("Upload Status")
} currentValueLabel: {
Text(progress.formatted(.percent))
} minimumValueLabel: {
Text(0.formatted(.percent))
} maximumValueLabel: {
Text(100.formatted(.percent))
}
In the code above, we set the range to 0...200 . If you have already added the
SpeedometerView to the #Preview code block, your preview should show the progress bar
filled halfway, as we set the current speed to 100km/h.
We changed the text label of the gauge to a system image. For the current value label, we
created a stack to arrange the image and text. Your preview should display the gauge as
shown in Figure 3.
The default color of the Gauge view is blue. To customize its color, attach the tint
modifier and set the value to your preferred color like this:
The look and feel of the Gauge view is very similar to that of ProgressView . You can
optionally customize the Gauge view using the gaugeStyle modifier, which supports
several built-in styles.
linearCapacity
accessoryLinear
This style displays a bar with a point marker to indicate the current value.
accessoryLinearCapacity
For this style, the gauge is still displayed as a progress bar but it's more compact.
accessoryCircular
Instead of displaying a bar, this style displays an open ring with a point marker to
indicate the current value.
accessoryCircularCapacity
To create a custom gauge style, you need to adopt the GaugeStyle protocol and provide
your own implementation. Here is our implementation of the custom style:
Circle()
.foregroundStyle(Color(.systemGray6))
Circle()
.trim(from: 0, to: 0.75 * configuration.value)
.stroke(purpleGradient, lineWidth: 20)
.rotationEffect(.degrees(135))
Circle()
.trim(from: 0, to: 0.75)
.stroke(Color.black, style: StrokeStyle(lineWidth: 10, lineCap: .b
utt, lineJoin: .round, dash: [1, 34], dashPhase: 0.0))
.rotationEffect(.degrees(135))
VStack {
configuration.currentValueLabel
.font(.system(size: 80, weight: .bold, design: .rounded))
.foregroundStyle(.gray)
Text("KM/H")
.font(.system(.body, design: .rounded))
.bold()
.foregroundStyle(.gray)
}
}
.frame(width: 300, height: 300)
Once we have implemented our custom gauge style, we can apply it by attaching the
gaugeStyle modifier as follows:
}
.gaugeStyle(SpeedometerGaugeStyle())
}
}
I created a separate view for the demo. To preview the CustomGaugeView , you need to add
another #Preview code block:
#Preview("CustomGauge") {
CustomGaugeView()
}
That's it. If you've made the changes, your preview should show a custom gauge.
Summary
In this chapter, you learned how to use the new Gauge view to build a speedometer.
Other than a speedometer, you can use Gauge to display progress or any other
measurements. The Gauge view is highly customizable; you just need to adopt the
GaugeStyle protocol and create your own Gauge style.
The Basics
Let's start with a simple grid. To create a 2x2 grid, you write the code like below:
GridRow {
Color.green
Color.yellow
}
}
}
}
Assuming you've created a SwiftUI project in Xcode, you should see a 2x2 grid, filled with
different colors.
To create a 3x3 grid, you just need to add another GridRow . And, for each GridRow , you
insert one more child view.
GridRow {
Color.green
Color.yellow
}
}
}
}
This time, the first row displays two system images and the second row shows the color
views. Your preview should show a grid like below.
To build the same grid layout using nested VStack and HStack , we can write the code
like this:
VStack {
HStack {
Image(systemName: "trash")
.font(.system(size: 100))
.frame(minWidth: 0, maxWidth: .infinity)
Image(systemName: "trash")
.font(.system(size: 50))
.frame(minWidth: 0, maxWidth: .infinity)
}
HStack {
Color.green
Color.yellow
}
}
The Grid view makes it easier to create grid views and provides several modifiers to
customize the grid layout.
Grid {
GridRow {
Image(systemName: "trash")
.font(.system(size: 100))
Image(systemName: "trash")
.font(.system(size: 50))
}
GridRow {
Color.purple
.overlay {
Image(systemName: "magazine.fill")
.font(.system(size: 100))
.foregroundColor(.white)
}
.gridCellColumns(2)
}
}
The second row only has one cell. We attach the gridCellColumn modifier and set its value
to 2 to merge the cells. If you do not use the modifier, you'll see a blank cell.
GridRow {
IconView(name: "cloud")
IconView(name: "cloud")
IconView(name: "cloud")
}
GridRow {
IconView(name: "cloud")
IconView(name: "cloud")
IconView(name: "cloud")
}
}
}
}
What if we want to display a blank view for the cell at the center of the grid? To make this
happen, you can use the following code to add a blank cell:
If you replace the center cell with the code above, you will see a blank cell at the center of
the grid.
The gridCellUnsizedAxes modifier prevents the blank cell from taking up more space than
the other cells in the row or column need?. If you omit the modifier, you will achieve a
grid layout like figure 7.
Grid(horizontalSpacing: 0, verticalSpacing: 0) {
.
.
.
}
If you need to add some spacing between rows, you can attach the padding modifier to
GridRow . Figure 8 shows you an example.
If you've changed the code, the black box should align to the bottom of the cell.
To override the default alignment setting, the cell itself can attach the gridCellAnchor
Summary
In this chapter, you will learn how to use AnyLayout to switch between vertical and
horizontal layout.
Using AnyLayout
First, create a new Xcode project using the App template. Name the project
SwiftUIAnyLayout or any other name you prefer. What we are going to build is a simple
demo app that switches the UI layout when you tap the stack view. Figure 1 shows the UI
layout for different orientations.
The app initially arranges three images vertically using VStack . When a user taps the
stack view, it changes to a horizontal stack. With AnyLayout , you can implement the
layout like this:
layout {
Image(systemName: "bus")
.font(.system(size: 80))
.frame(width: 120, height: 120)
.background(in: RoundedRectangle(cornerRadius: 5.0))
.backgroundStyle(.green)
.foregroundStyle(.white)
Image(systemName: "ferry")
.font(.system(size: 80))
.frame(width: 120, height: 120)
.background(in: RoundedRectangle(cornerRadius: 5.0))
.backgroundStyle(.yellow)
.foregroundStyle(.white)
Image(systemName: "scooter")
.font(.system(size: 80))
.frame(width: 120, height: 120)
.background(in: RoundedRectangle(cornerRadius: 5.0))
.backgroundStyle(.indigo)
.foregroundStyle(.white)
}
.animation(.default, value: changeLayout)
.onTapGesture {
changeLayout.toggle()
}
}
}
By attaching the animation to the layout, the layout change can be animated. Now when
you tap the stack view, it switches between vertical and horizontal layouts.
Say, for example, you rotate an iPhone 14 Pro Max to landscape, the layout changes to
horizontally stack view.
In most cases, we use SwiftUI's built-in layout containers like HStackLayout and
VStackLayout to compose layouts. What if those layout containers are not suitable for
arranging the type of layouts you need? The Layout protocol allows you to define your
custom layout. All you need to do is define a custom layout container by creating a type
that conforms to the Layout protocol and implementing its required methods:
Summary
The introduction of AnyLayout allows us to customize and change the UI layout with just
a couple of lines of code. This undoubtedly helps us build more elegant and engaging UIs.
In the earlier demo, I showed you how to switch layouts based on the screen orientation.
In fact, you can apply the same technique to other scenarios, like the size of the Dynamic
Type.
import SwiftUI
import MapKit
Before using the Map view, you have to import the MapKit framework. Then, to create a
map, simply instantiate a Map view. If you've opened the Preview canvas in Xcode, you
should see a full screen map in the simulator.
init(
initialPosition: MapCameraPosition,
bounds: MapCameraBounds? = nil,
interactionModes: MapInteractionModes = .all,
scope: Namespace.ID? = nil
) where Content == MapContentView<Never, EmptyMapContent>
automatic
For instance, you can instruct the map to display a specific region by using
.region(MKCoordinateRegion) :
The coordinates in the above sample is the GPS coordinates of Times Square in New
York. The value of span is used to define your desired zoom level of the map. The
smaller the value, the higher is the zoom level.
extension CLLocationCoordinate2D {
static let bigBen = CLLocationCoordinate2D(latitude: 51.500685, longitude: -0.
124570)
}
Map(position: $position) {
.
.
.
}
For example, if you want to add two buttons for users to switch between two locations,
you can write the code like this:
extension CLLocationCoordinate2D {
static let bigBen = CLLocationCoordinate2D(latitude: 51.500685, longitude: -0.
124570)
static let towerBridge = CLLocationCoordinate2D(latitude: 51.505507, longitude
Button(action: {
withAnimation {
position = .item(MKMapItem(placemark: .init(coordinate
: .towerBridge)))
}
}) {
Text("Tower Bridge")
}
.tint(.black)
.buttonStyle(.borderedProminent)
}
}
}
}
This animation works even better when you provide a MapCamera with a pitch angle to
create a 3D perspective. To see what happens, you can try changing the position of Big
Ben in the following line of code:
position = .camera(MapCamera(
centerCoordinate: .bigBen,
distance: 800,
heading: 90,
pitch: 50))
When you preview the map view, the camera angle adjusts to show a 3D perspective of
the region.
To add a marker, you can create the Marker view in the map content builder closure like
this:
Map(position: $position) {
Marker("Pickup here", coordinate: .pickupLocation)
}
Optionally, you can customize the Marker object with a system image. To change the
color of the marker, use the tint modifier:
Marker("Pickup here",
systemImage: "car.front.waves.up",
coordinate: .pickupLocation)
.tint(.purple)
In addition to Marker , SwiftUI now includes an Annotation view in iOS 17 for indicating
a location on a map. It functions similarly to Marker , but offers greater flexibility for
customization.
To add an annotation, you create an Annotation view in the map content closure. Here is
a sample code snippet for adding a simple annotation:
Map(position: $position) {
Annotation("Pick up", coordinate: .pickupLocation, anchor: .bottom) {
Image(systemName: "car.front.waves.up")
}
}
Image(systemName: "car.front.waves.up")
.symbolEffect(.variableColor)
.padding()
.foregroundStyle(.white)
.background(Color.indigo)
.clipShape(Circle())
}
}
Map {
}
.mapStyle(.imagery(elevation: .realistic))
This creates a map style based on satellite imagery. By specifying a realistic elevation,
the map view renders a 3D map with a realistic appearance.
Optionally, you can also change the map style to hybrid like this:
.mapStyle(.hybrid)
Points of Interest
Task {
let search = MKLocalSearch(request: request)
let response = try? await search.start()
searchResults = response?.mapItems ?? []
}
}
This search function takes a location variable and a string query. For example, if you
want to search for restaurants around Tower Bridge, you can initiate the call like this:
We utilize MKLocalSearch to generate a search query for "restaurants" and specify the
result types as .pointOfInterest . The region property provides a location hint for the
search. The returned result is an array of MKMapItem , which we store in a state variable
that hasn't been declared yet. Therefore, please insert this line of code at the beginning of
ContentView :
To initiate the search, insert this line of code in the action closure of the Big Ben button:
And, insert another line of code in the action closure of the Tower Bridge button:
When a user taps either of the buttons to switch the location, the app will also search and
display the points of interest (POI) around that location.
Lastly, to pinpoint the POI on the map, we will use Marker to map the locations. Insert
these lines of code in the Map closure:
Once you have made the necessary changes, you can test the app in the preview canvas.
The app should display the restaurants located around Tower Bridge or Big Ben,
depending on the button you click.
Summary
This tutorial covers how to work with maps and annotations in SwiftUI, using the MapKit
framework. The latest version of SwiftUI offers additional APIs and views for developers
to further customize the map view. By now, you should know how to embed a map in
your app and add an annotation to highlight a location on the map.
Demo project
(https://www.appcoda.com/resources/swiftui5/SwiftUIMapDemo.zip)
With the #Preview macros, you tell Xcode to create a preview like this:
As you can see, #Preview simplifies the way we define previews. Optionally, you can pass
the #Preview macro a name to configure the preview's name.
You can use this to set up a preview for any view as needed. Xcode will then render the
preview, which will appear directly in the canvas.
When using PreviewProvider , you can embed several views to preview using Group .
ArticleView()
.previewDisplayName("Article View")
}
}
}
If you use the #Preview macro, you can define multiple blocks of #Preview sections. For
example, to preview both ArticleListView and ArticleView , you can create two #Preview
#Preview("Article View") {
ArticleView()
}
The #Preview macro has an optional traits parameter that allows developers to
customize the orientation of the simulator. To preview views in landscape mode, you can
pass .landscapeLeft or .landscapeRight to the traits parameter. Here is an example:
On top of .sizeThatFitsLayout , you can also use .fixedLayout to preview the view in a
specific size.
return controller
}
If your view controller is designed in the storyboard, you can also preview it using the
macro. Here is the sample code snippet:
#Preview("From Storyboard") {
let storyboard = UIStoryboard(name: "Main", bundle: nil)
return controller
}
Assuming you have assigned a storyboard ID for the view controller, you can create the
view controller using the instantiateViewController method. This is how you use
#Preview to preview a UIKit view controller.
Summary
The Preview feature in SwiftUI allows developers to see what their app will look like in
real-time, without having to run the app on a device or simulator. The #Preview macro
introduced in iOS 17 makes the preview code cleaner and simpler. It has become even
more powerful and versatile, allowing you to preview views developed in UIKit.
In this chapter, we will guide you through the process of building pie charts and donut
charts using SwiftUI. On top of that, you’ll also learn how to add interactivity to the
charts.
First, import the Charts framework and replace the body part with the following code:
VStack {
Chart {
ForEach(coffeeSales, id: \.name) { coffee in
BarMark(
x: .value("Type", coffee.name),
y: .value("Cup", coffee.count)
)
.foregroundStyle(by: .value("Type", coffee.name))
}
}
}
.padding()
Whether you're creating a bar chart or a pie chart, it all starts with the Chart view.
Within this view, we define a set of BarMark for rendering a vertical bar chart that plots
coffee types on the x-axis and counts on the y-axis. The foregroundStyle modifier
automatically assigns a unique color for each of the bars.
You can easily create a different type of bar chart by altering some of the BarMark
parameters.
VStack {
Chart {
ForEach(coffeeSales, id: \.name) { coffee in
BarMark(
x: .value("Cup", coffee.count)
)
.foregroundStyle(by: .value("Type", coffee.name))
}
}
.frame(height: 100)
}
.padding()
By default, it shows the accumulated count in the x-axis. If you want to normalized the
values, simple specify the stacking parameter for BarMark like this:
BarMark(
x: .value("Cup", coffee.count),
stacking: .normalized
)
.foregroundStyle(by: .value("Type", coffee.name))
The SectorMark , as the name suggests, represents a sector of the pie chart that
corresponds to a specific category. Each SectorMark is defined by the value it represents.
By using SectorMark , developers can easily create various types of pie (or donut charts)
without having to build them from scratch using components like Path and Arc .
Chart {
ForEach(coffeeSales, id: \.name) { coffee in
SectorMark(
angle: .value("Cup", coffee.count)
)
.foregroundStyle(by: .value("Type", coffee.name))
}
}
.frame(height: 500)
Instead of specifying the value of x-axis, you pass the values to the angle parameter.
SwiftUI will automatically compute the angular size of the sector and generate the pie
chart.
You can control the size of the sectors by specifying a value for the outerRadius
parameter. For example, if you want to highlight the Latte sector by making it a bit
larger, you can add the outerRadius parameter.
.annotation(position: .overlay) {
Text("\(coffee.count)")
.font(.headline)
.foregroundStyle(.white)
}
Here, we simply overlay a text label on each sector to display the count.
To create a donut chart, simply specify the innerRadius parameter of the sector mark and
pass it your preferred value:
The value of innerRadius is either a size in points, or a .ratio or .inset relative to the
outer radius. By having a value greater than zero, you create a hole in the pie and turn the
chart into a donut chart.
Optionally, you can attach a cornerRadius modifier to the sector marks to round the
corners of the sector.
You can also add a view to the chart’s background by attaching the chartBackground
Chart {
.
.
.
}
.chartAngleSelection(value: $selectedCount)
The chartAngleSelection modifier takes in a binding to a plottable value. Since all our
plottable values are in integer, we declare a state variable of the type Int . With the
implementation, the chart now can detect user’s touch and capture the selected count of
the donut (or pie) chart.
You may attach the onChange modifier to the chart to reveal the selected value.
The value captured doesn’t directly tell you the exact sector the user touched. Instead, it
gives a value of the selected coffee count. For example, if the user taps the trailing edge of
the green sector, SwiftUI returns you a value of 354 .
To figure out the sector from the given value, we need to create a new function. This
function takes the selected value and returns the name of the corresponding sector.
var accumulatedCount = 0
return coffee?.name
}
With the implementation above, we can declare a state variable to hold the selected
sector and make some interesting changes to the donut chart.
When a sector of the chart is selected, we will dim the remaining sectors to highlight the
selected sector. Update the onChange modifier like this:
...
}
.opacity(selectedSector == nil ? 1.0 : (selectedSector == coffee.name ? 1.0 : 0.5)
)
We keep the original opacity when there is no selected sector. Once a user touches a
specific sector, we change the opacity of those unselected sectors. Below shows the
appearance of the donut chart when the Latte sector is selected.
Summary
We also discussed with you how to add interactivity to the charts. This is another new
feature of the SwiftUI Charts framework. With a few lines of code, you can detect users’
touches and highlight a certain part of the chart. I hope you enjoy reading this chapter
and start building great charts with all the new functionalities provided in iOS 17.
Demo project
(https://www.appcoda.com/resources/swiftui5/SwiftUIPieDonutChart.zip)
In this chapter, we will take a closer look at this new modifier and examine how it can
assist you in detecting scroll positions in scroll views.
bgColors[index % 5]
.frame(height: 100)
.overlay {
Text("\(index)")
.foregroundStyle(.white)
.font(.system(.title, weight: .bold))
}
}
}
}
.contentMargins(.horizontal, 10.0, for: .scrollContent)
}
}
The code is quite straightforward if you are familiar with implementing a scroll view in
SwiftUI. We utilize a ForEach loop to present 50 color items, which are then embedded
within a vertical scroll view. If you add this code to a SwiftUI project, you should be able
to preview it and see something similar to Figure 1.
To keep track of the current scroll position or item, you should first declare a state
variable to hold the position:
Next, attach the scrollPosition modifier to the scroll view. This modifier takes a binding
to scrollID , which stores the scroll position:
ScrollView {
...
}
.scrollPosition(id: $scrollID)
As the scroll view scrolls, the binding will be updated with the index of the color view.
LazyVStack(spacing: 10) {
...
}
.scrollTargetLayout()
Without the scrollTargetLayout() modifier, the scrollPosition modifier will not work
correctly. The scrollPosition modifier relies on the scrollTargetLayout() modifier to
configure which the layout that contains your scroll targets.
To observe the changes of the scroll position, you can attach a onChange modifier to the
scroll view and print the scroll ID to the console:
As you scroll through the list, the current scroll position should be displayed in the
console.
This can be achieved by adding a onTapGesture modifier to each color view, and passing a
closure that sets the scrollID to 0 within it. When the user taps on any of the color
views, the scrollID will be updated to 0 , which will cause the list to scroll back to the
top.
We have already used this modifier in the sample code that sets the horizontal margin to
10 points.
The .scrollContent parameter value indicates that the content margin should only be
applied to the scroll content, rather than the entire scroll view. If you do not specify the
edges and placement parameters, the contentMargins modifier will apply the margins to
all edges of the scroll view. For example, in the code below, it insets the entire scroll view
by 50 points, including the scroll bar.
In case if you want to keep the scroll bar at its original position, you can set the
placement parameter to .scrollContent .
With these new features in SwiftUI, developers can now effortlessly create more
customized and visually appealing layouts for their apps.
Demo project
(https://www.appcoda.com/resources/swiftui5/SwiftUIScrollPosition.zip)
Previously, we built a basic scroll view. Let's continue using it as an example. For
reference, here is the code for creating a scroll view:
ScrollView {
LazyVStack(spacing: 10) {
ForEach(0...50, id: \.self) { index in
bgColors[index % 5]
.frame(height: 100)
.overlay {
Text("\(index)")
.foregroundStyle(.white)
.font(.system(.title, weight: .bold))
}
.onTapGesture {
withAnimation {
scrollID = 0
}
}
}
}
.scrollTargetLayout()
}
.contentMargins(10.0, for: .scrollContent)
.scrollPosition(id: $scrollID)
To demonstrate how it works, let's modify the code from the previous section by adding
the scrollTransition modifier to the color view. Here is the updated code:
We apply a subtle animation by changing the opacity and size of child views. The
scrollTransition closure passes two parameters: the child view and the transition phase.
There are three possible values for transition phases: .identity , .topLeading , and
.bottomTrailing . Based on the phase, we can apply different visual effects.
The .identity value indicates that the child view is fully visible in the scroll view's visible
region. The .topLeading value indicates that the view is about to move into the visible
area at the top edge of the scroll view, while .bottomTrailing indicates that the view is
about to move into the visible area at the bottom edge of the scroll view.
During the identity phase, scroll transitions should not typically result in any visual
changes to the view. Therefore, in the code above, we reset both opacity and size to their
original state when the view is in the identity phase. For other phases, we make the
view smaller and more transparent. This is how we animate views during the scroll
transition.
Other than the default configuration, you also have the option to use .animated to
smoothly animate the transition when the view is displayed. You can replace the
.scrollTransition modifier like this to achieve a slightly different animated effect:
content
.opacity(phase.isIdentity ? 1.0 : 0.3)
.scaleEffect(phase.isIdentity ? 1.0 : 0.3)
bgColors[index % 5]
.frame(height: 300)
After making the change, you should notice that the third item in the scroll view is
minimized and has already been applied with the transparent effect.
This is not the desired UI layout. The expected behavior is for the third item to be fully
visible and not in a transitional state. In this case, we can alter the threshold to define
when the animation takes place.
The threshold determines when the view is considered visible (i.e. the identity phase)
based on how much of the view intersects with the scroll view. To change the threshold,
you can update the scrollTransition modifier like this:
.
.
.
The code above sets a threshold where the view is considered fully visible when it is 30%
visible within the scrolling area. As soon as you update the code, the third item of the
scroll view will be fully displayed. The animation will only occur when the item is less
than 30% visible.
Figure 4. The animation occurs when the item is less than 30% visible
For example, we can utilize the phase value to apply a 3-dimensional rotation effect:
content
.opacity(phase.isIdentity ? 1.0 : 0.3)
.scaleEffect(phase.isIdentity ? 1.0 : 0.3)
.rotation3DEffect(.radians(phase.value), axis: (1, 1, 1))
Summary
In this tutorial, we explain how to use the scrollTransition modifier in SwiftUI to
animate the transition of views in a ScrollView. The modifier allows developers to apply
various visual and animated effects to child views based on the transition phase.
By mastering this modifier, you can take their app's user experience to the next level.
Demo project
(https://www.appcoda.com/resources/swiftui5/SwiftUIScrollViewAnimation.zip)
Rectangle()
.cornerRadius(10.0)
RoundedRectangle(cornerRadius: 25.0)
Unfortunately, both the cornerRadius modifier and the RoundedRectangle view can only
apply the same corner radius to all corners of the shape.
In iOS 17, the SwiftUI framework introduces a new view called UnevenRoundedRectangle .
What sets this view apart is the ability to specify a distinct radius value for each corner,
allowing developers to create highly customizable shapes.
Optionally, you can indicate the style of the corners. A continuous corner style will give
the corners a smoother look. If you’ve put the code above in Xcode 15, you can create a
rectangular shape like below.
You can use this shape and transform it into a button by using the background modifier.
Here is a sample code snippet:
}) {
Text("Register")
.font(.title)
}
.tint(.white)
.frame(width: 300, height: 100)
.background {
UnevenRoundedRectangle(cornerRadii: .init(
topLeading: 50.0,
bottomLeading: 10.0,
bottomTrailing: 50.0,
topTrailing: 30.0),
style: .continuous)
.foregroundStyle(.indigo)
}
UnevenRoundedRectangle(cornerRadii: .init(
topLeading: animate ? 10.0 : 80.0,
bottomLeading: animate ? 80.0 : 10.0,
bottomTrailing: animate ? 80.0 : 10.0,
topTrailing: animate ? 10.0 : 80.0))
.foregroundStyle(.indigo)
.frame(height: 200)
.padding()
.onTapGesture {
withAnimation {
animate.toggle()
}
}
}
}
In this example, tapping the rectangle will toggle the animate variable, which controls
the corner radii of the rectangle. The withAnimation function will animate the transition
between the two sets of corner radii.
ZStack {
ForEach(0..<18, id: \.self) { index in
UnevenRoundedRectangle(cornerRadii: .init(topLeading: 20.0, bottomLeading:
5.0, bottomTrailing: 20.0, topTrailing: 10.0), style: .continuous)
.foregroundStyle(.indigo)
.frame(width: 300, height: 30)
.rotationEffect(.degrees(Double(10 * index)))
}
}
.overlay {
Image(systemName: "briefcase")
.foregroundStyle(.white)
.font(.system(size: 100))
}
ZStack {
ForEach(0..<18, id: \.self) { index in
UnevenRoundedRectangle(cornerRadii: .init(topLeading: 20.0, bottomLeading:
5.0, bottomTrailing: 20.0, topTrailing: 10.0), style: .continuous)
.foregroundStyle(.indigo)
.frame(width: 300, height: 30)
.opacity(animate ? 0.6 : 1.0)
.rotationEffect(.degrees(Double(10 * index)))
.animation(.easeInOut.delay(Double(index) * 0.02), value: animate)
}
}
.overlay {
Image(systemName: "briefcase")
.foregroundStyle(.white)
.font(.system(size: 100))
}
.onTapGesture {
animate.toggle()
}
Implementing this modification will result in a visually captivating effect that adds an
extra level of interest to your design.
Summary
The addition of the UnevenRoundedRectangle view in SwiftUI provides developers with a
convenient solution for rounding specific corners of rectangular views. It also gives you
the flexibility and options to achieve the look you want. With the UnevenRoundedRectangle
shape, you can seamlessly incorporate unique and eye-catching shapes into your app,
enhancing its overall design and user experience.
Demo project
(https://www.appcoda.com/resources/swiftui5/SwiftUIUnevenRoundedRect.zip)
What’s SwiftData
First and foremost, it's important to note that the SwiftData framework should not be
confused with a database. Built on top of Core Data, SwiftData is actually a framework
designed to help developers manage and interact with data on a persistent store. While
the default persistent store for iOS is typically the SQLite database, it's worth noting that
persistent stores can take other forms as well. For example, Core Data can also be used to
manage data in a local file, such as an XML file.
Regardless of whether you're using Core Data or the SwiftData framework, both tools
serve to shield developers from the complexities of the underlying persistent store.
Consider the SQLite database, for instance. With SwiftData, there's no need to worry
about connecting to the database or understanding SQL in order to retrieve data records.
Instead, developers can focus on working with APIs and Swift Macros, such as @Query
If you have used Core Data before, you may remember that you have to create a data
model (with a file extension .xcdatamodeld) using a data model editor for data
persistence. With the release of SwiftData, you no longer need to do that. SwiftData
streamlines the whole process with macros, another new Swift feature in iOS 17. Say, for
example, you already define a model class for Song as follows:
class Song {
var title: String
var artist: String
var album: String
var genre: String
var rating: Double
}
To use SwiftData, the new @Model macro is the key for storing persistent data using
SwiftUI. Instead of building the data model with model editor, SwiftData just requires
you to annotate the model class with the @Model macro like this:
This is how you define the schema of the data model in code. With this simple keyword,
SwiftData automatically enables persistence for the data class and offers other data
management functionalities such as iCloud sync. Attributes are inferred from properties
and it supports basic value types such as Int and String.
SwiftData allows you to customize how your schema is built using property metadata.
You can add uniqueness constraints by using the @Attribute annotation, and delete
propagation rules with the @Relationship annotation. If there are certain properties you
do not want included, you can use the @Transient macro to tell SwiftData to exclude
them. Here is an example:
To drive the data persistent operations, there are two key objects of SwiftData that you
should be familiar with: ModelContainer and ModelContext . The ModelContainer serves as
the persistent backend for your model types. To create a ModelContaine r, you simply need
to instantiate an instance of it.
// With configuration
let container = try ModelContainer(for: [Song.self, Album.self],
configurations: ModelConfiguration(url: URL("p
ath"))))
In SwiftUI, you can set up the model container at the root of the application:
import SwiftData
import SwiftUI
@main
struct MusicApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer (for: [Song.self, Album.self]))
}
}
Once you have set up the model container, you can begin using the model context to fetch
and save data. The context serves as your interface for tracking updates, fetching data,
saving changes, and even undoing those changes. When working with SwiftUI, you can
typically obtain the model context from your view's environment:
With the context, you are ready to fetch data. The simplest way is to use the @Query
property wrapper. You can easily load and filter anything stored in your database with a
single line of code.
To insert item in the persistent store, you can call the insert method of the model context
and pass it the model objects to insert.
modelContext.insert(song)
Similarly, you can delete the item via the model context like this:
modelContext.delete(song)
This is a brief introduction of SwiftData. If you're still feeling confused about how to use
SwiftData? No worries. You will understand its usage after building a ToDO app.
Assuming you’ve created a SwiftUI project in Xcode, let’s first create the data model of
the app. Create a new file named ToDoItem and update the content like this:
As discussed earlier, SwiftData simplifies the process of defining a schema using code. All
you need to do is annotate the model class with the @Model macro. SwiftData will then
automatically enable persistence for the data class.
Before we move onto building the UI of the app and handling the data persistent, let’s
create a helper function for generating a random to-do item:
Next, let’s build the main UI of the to-do app. In the ContentView.swift file, update the
code like this:
Spacer()
if todoItem.isComplete {
Image(systemName: "checkmark")
}
}
}
}
.navigationTitle("To Do List")
}
}
}
We mark the todoItems array with the @Query property wrapper. This @Query property
automatically fetches the required data for you. In the provided code, we specify to fetch
the ToDoItem instances. Once we retrieve the to-do items, we utilize the List view to
display the items.
If you preview the ContentView , the list view is empty. Obviously, we haven’t stored any
to-do items in the database. Now, let’s add a “Add item” button to insert a random to-do
item into the database.
After obtaining the model context, we can easily insert data into the database. We’ll add a
toolbar button for adding a random to-do item. Insert the following code inside the
NavigationStack view (place it after navigationTitle ):
.toolbar {
Button("", systemImage: "plus") {
modelContext.insert(generateRandomTodoItem())
}
}
To store an item into database, you simply call the insert method of the model context.
Now you're ready to test the app in the simulator. However, if you intend to test it in the
preview canvas, you need to make one additional modification by adding the model
container within the #Preview block:
#Preview {
ContentView()
.modelContainer(for: ToDoItem.self)
}
When you tap the "+" button, the app instantly stores the to-do item. Simultaneously, it
retrieves the new item from the database and displays it in the list view.
In the ContentView struct, attach the onDelete modifier to the ForEach loop:
.onDelete(perform: { indexSet in
for index in indexSet {
let itemToDelete = todoItems[index]
modelContext.delete(itemToDelete)
}
})
This closure takes an index set that stores the indices of the items to be deleted. To
remove an item from the persistent store, simply call the delete function of the model
context and specify the item to be deleted.
The onDelete modifier automatically enables the swipe-to-delete feature in the list view.
To try this out, simply run the app and swipe to delete an item. This will completely
remove the item from the database.
for _ in 1...10 {
container.mainContext.insert(generateRandomTodoItem())
}
return container
} catch {
fatalError("Failed to create container")
}
}()
In the code above, we set up an in-memory database that is populated with 10 random
to-do items. Once the preview container is ready, you can attach it to the ContentView in
the #Preview block:
#Preview {
ContentView()
.modelContainer(previewContainer)
}
Summary
I hope that you now have a better understanding of how to integrate SwiftData into a
SwiftUI project and how to perform all basic CRUD (create, read, update & delete)
operations. Apple has put a lot of efforts to make persistent data management and data
modeling easier for Swift developers and new comers.
While Core Data remains an option for backward compatibility, it’s time to learn the
SwiftData framework, especially if you are developing an app exclusively for iOS 17 or
later. Embrace this new framework to leverage the enhanced capabilities and benefits
SwiftData offers.
When presenting the PhotosPicker view, it showcases the photo album in a separate
sheet, rendered atop your app’s interface. In earlier versions of iOS, you couldn’t
customize or change the appearance of the photos picker view to align with your app’s
layout. However, Apple has introduced enhancements to the PhotosPicker view in iOS 17,
enabling developers to seamlessly embed it inline within the app. Additionally, you have
the option to modify its size and layout using standard SwiftUI modifiers such as .frame
and .padding .
In this tutorial, I will show you how to implement an inline photo picker with the
improved PhotosPicker view.
Revisiting PhotosPicker
To use the PhotosPicker view, you can first declare a state variable to store the photo
selection and then instantiate a PhotosPicker view by passing the binding to the state
variable. Here is an example:
The matching parameter allows you to specify the asset type to display. Here, we just
choose to display images only. In the closure, we create a simple button with the Label
view.
Upon selecting a photo, the photo picker automatically dismisses itself, and the chosen
photo item is stored in the selectedItem variable, which is of type PhotosPickerItem . To
load the image from the item, you can use loadTransferable(type:completionHandler:) . You
can attach the onChange modifier to listen to the update of the selectedItem variable.
Whenever there is a change, you call the loadTransferable method to load the asset data
like this:
.
.
.
When using loadTransferable , it is necessary to specify the asset type for retrieval. In this
case, we employ the Image type to directly load the image. If the operation is successful,
the method will return an Image view, which can be used to directly render the photo on
the screen.
if let selectedImage {
selectedImage
.resizable()
.scaledToFit()
.padding(.horizontal, 10)
}
.photosPickerStyle(.inline)
By default, the top accessory of the picker is the navigation bar and the bottom accessory
is the toolbar. To disable both bars, you can apply the photosPickerAccessoryVisibility
modifier:
.photosPickerAccessoryVisibility(.hidden)
If you wish to restrict the number of items available for selection, you can specify the
maximum count using the maxSelectionCount parameter.
Once the user has selected a set of photos, they are stored in the selectedItems array. The
selectedItems array has been modified to accommodate multiple items and is now of
type PhotosPickerItem .
To load the selected photos, you can update the onChange closure like this:
selectedImages.removeAll()
newItems.forEach { newItem in
Task {
if let image = try? await newItem.loadTransferable(type: Image.self) {
selectedImages.append(image)
}
}
}
}
To display the chosen images, you may use a horizontal scroll view. Here is the sample
code that can be placed at the beginning of the VStack view:
ScrollView(.horizontal) {
LazyHStack {
ForEach(0..<selectedImages.count, id: \.self) { index in
selectedImages[index]
.resizable()
.scaledToFill()
.frame(height: 250)
.clipShape(RoundedRectangle(cornerRadius: 25.0))
.padding(.horizontal, 20)
.containerRelativeFrame(.horizontal)
}
}
}
.frame(height: 300)
}
If you'd like to learn more about how to create image carousels, you can check out this
tutorial. In iOS 17, a new view called ContentUnavailableView is introduced. This view is
recommended for use in scenarios where the content of a view cannot be displayed. So,
when no photo is selected, we use the ContentUnavailableView to present a concise and
informative message.
Summary
In iOS 17, Apple made improvements to the native Photos picker. Now, you can easily
include it within your app instead of using a separate sheet. This tutorial explains the
new modifiers that come with the updated PhotosPicker view and shows you how to
create an inline photo picker.
Demo project
(https://www.appcoda.com/resources/swiftui5/SwiftUIPhotosPicker.zip)
In this tutorial, we will explore the capabilities of PhaseAnimator and learn how to utilize
it to create multi-step animations.
Let me give a simple example, so you will understand how to work the phase animator.
We will animate the transformation of a rounded rectangle. It begins as a blue rectangle,
then scales up, changes color to indigo, and incorporates a 3D rotation animation.
We can use the RoundedRectangle view to create the rounded rectangle and attach the
phaseAnimator modifier to the rectangle like this:
When the view initially appears, the first phase (i.e. false ) is active. We set the scale to
50% of the original size and the foreground color to blue. In the second phase, the
rectangle scales back to its original size and the color transitions to indigo.
The phase animator automatically animates the change between these two phases.
To create the 3D rotation animation, you can attach the rotation3DEffect modifier to the
content view like below:
.rotation3DEffect(
phase ? .degrees(720) : .zero,
axis: (x: 0.0, y: 1.0, z: 0.0)
)
If you want to customize the animation, phaseAnimator also provides the animation
parameter for defining your preferred animation. Based on the given phase, you can
specify the animation to be used when moving from one phase to another. Here is an
Let's consider an example of animating an emoji icon with the following steps:
With these steps, we can create a dynamic animation for the emoji icon.
In this enum, we have four cases that represent different steps of the animation. During
each phase, we perform scaling, rotation, or movement on the emoji icon. To accomplish
this, we define three computed properties for each action. Within each property, we
specify the values for the particular animation phase or step.
With the Phase enum, we can now easily animate the emoji with the phase animator like
below:
Text(" ")
.font(.system(size: 100))
.phaseAnimator(Phase.allCases) { content, phase in
content
.scaleEffect(phase.scale)
.rotationEffect(phase.angle)
.offset(y: phase.offset)
} animation: { phase in
switch phase {
case .initial: .bouncy
case .rotate: .smooth
case .jump: .snappy
case .fall: .interactiveSpring
}
}
The Phase.allCases automatically informs the phase animator about the available
phases. Depending on the given phase, the emoji icon is scaled, rotated, and moved
according to the computed values.
To customize the animation, we can specify a particular animation, such as snappy , for
different phases instead of using the default animation.
Using Triggers
Currently, the phase animator initiates the animation automatically and repeats it
indefinitely. However, there may be situations where you prefer to trigger the animation
manually. In such cases, you can define your criteria by specifying the desired conditions
in the trigger parameter of the phase animator.
Next, you update the phaseAnimator modifier by adding the trigger parameter:
After making the code changes, the animation will only be triggered when the value of
startAnimation is switched from false to true . To achieve this, attach the
onTapGesture modifier to the Text view.
.onTapGesture {
startAnimation.toggle()
}
When a user taps the emoji, we toggle the value of startAnimation . This triggers the
multi-step animation.
Summary
Demo project
(https://www.appcoda.com/resources/swiftui5/SwiftUIPhaseAnimator.zip)
The PhaseAnimator view (or modifier), which we discussed in the previous tutorial,
provides developers with the ability to create multi-step animations over a sequence of
phases. By specifying the desired animations for each phase, the PhaseAnimator
automatically animates the content whenever the phase changes. It simplifies the process
of building complex animations by handling the transitions between phases for you.
Let’s try to animate an emoji icon (as illustrated above) and you will understand how we
can use keyframe animator.
The initial values define the initial state of the emoji icon. Later, we will change each of
the properties to scale, stretch, and move the emoji icon.
Text(" ")
.font(.system(size: 100))
.keyframeAnimator(initialValue: AnimationValues()) {
content, value in
content
.scaleEffect(value.scale)
.scaleEffect(y: value.verticalStretch)
.offset(value.translation)
.opacity(value.opacity)
} keyframes: { _ in
KeyframeTrack(\.scale) {
CubicKeyframe(0.8, duration: 0.2)
}
}
As usual, we use a Text view to display the emoji icon. To create a keyframe-based
animation, we attach the keyframeAnimator modifier to the text view.
We apply the desired animation effects to the content view by adjusting its scale, offset,
and opacity. Lastly, it’s the keyframes parameter. This is where we define the value
changes that occur over time. These defined keyframes will be responsible for applying
the corresponding animations to the specified value.
Keyframes are arranged into tracks, with each track governing a distinct property of the
animated type. In the provided code snippet, we specifically designated the keyframe
track for the scale property using the CubicKeyframe type. We adjust the size of the emoji,
reducing it to 80% of its original size.
Once you've made the code changes, you should be able to see the animation instantly in
the preview canvas. The keyframe animator animates the size change and repeats it
continuously.
Keyframe Types
Try to explore and test different keyframe types and durations to see their behaviors in
action. By experimenting with various keyframe types and adjusting the duration, you
can gain a deeper understanding of how they impact and shape your animations.
Currently, we only apply a single change for the scale property. You are free to define
other value changes over time. Here is an example:
KeyframeTrack(\.scale) {
CubicKeyframe(0.8, duration: 0.2)
CubicKeyframe(0.6, duration: 0.3)
CubicKeyframe(1.0, duration: 0.3)
CubicKeyframe(0.8, duration: 0.2)
CubicKeyframe(0.6, duration: 0.3)
CubicKeyframe(1.0, duration: 0.3)
}
The code describes the scale factor at specific times within the animation. In the preview
canvas, you'll notice a smoother and more fluid animation for the emoji icon.
In the same demo, we can define separate keyframe tracks for the vertical stretch,
translation, and opacity properties. Here is the sample code:
Text(" ")
.font(.system(size: 100))
.keyframeAnimator(initialValue: AnimationValues()) { content, value in
content
.scaleEffect(value.scale)
.scaleEffect(y: value.verticalStretch)
.offset(value.translation)
.opacity(value.opacity)
} keyframes: { _ in
KeyframeTrack(\.verticalStretch) {
LinearKeyframe(1.2, duration: 0.1)
KeyframeTrack(\.scale) {
CubicKeyframe(0.8, duration: 0.2)
CubicKeyframe(0.6, duration: 0.3)
CubicKeyframe(1.0, duration: 0.3)
CubicKeyframe(0.8, duration: 0.2)
CubicKeyframe(0.6, duration: 0.3)
CubicKeyframe(1.0, duration: 0.3)
}
KeyframeTrack(\.translation) {
SpringKeyframe(CGSize(width: 100, height: 100), duration: 0.4)
SpringKeyframe(CGSize(width: -50, height: -300), duration: 0.4)
SpringKeyframe(.zero, duration: 0.2)
SpringKeyframe(CGSize(width: -50, height: 200), duration: 0.3)
SpringKeyframe(CGSize(width: -90, height: 300), duration: 0.3)
SpringKeyframe(.zero, duration: 0.4)
}
KeyframeTrack(\.opacity) {
LinearKeyframe(0.5, duration: 0.2)
LinearKeyframe(1.0, duration: 0.23)
LinearKeyframe(0.7, duration: 0.25)
LinearKeyframe(1.0, duration: 0.33)
LinearKeyframe(0.8, duration: 0.2)
LinearKeyframe(1.0, duration: 0.23)
}
}
By default, keyframe animator keeps running the animation continuously. If you want to
stop the animation, you can set the repeat parameter to false .
You can use ZStack and overlay another emoji icon to create animation as shown below.
I'll leave this as an exercise for you to explore and implement.
By leveraging keyframes, developers can define specific points in time and precisely
manipulate the properties of their animations. This enhanced control allows for the
creation of intricate and dynamic visual effects, resulting in a more fluid animations.
Demo project
(https://www.appcoda.com/resources/swiftui5/SwiftUIKeyframeAnimator.zip)
In this tutorial, we will explore the TipKit framework and see how to create tips for a
demo app.
import TipKit
For example, to setup the “Save as favorite” tip, you can create a struct that conforms to
the Tip protocol like this:
If you want to add an image to the tip, you can define the image property:
To show the tip as an inline view, you can create an instance of TipView and pass it the
tip to display. Here is an example:
TipView(getStartedTip)
.
.
.
}
If you want to display a tip as a popover view, you can attach the modifier popoverTip to
the button or other UI elements:
Image(systemName: "heart")
.font(.system(size: 50))
.foregroundStyle(.white)
.padding()
.popoverTip(favoriteTip, arrowEdge: .top)
To enable the appearance of tips within your apps, the final step is to configure the Tips
center. Assuming your Xcode project is named TipKitDemo , you can switch over to
TipKitDemoApp and update the struct like this:
We can customize the display frequency and the data store location by utilizing the
configure method of the Tips center. In the code snippet above, the display frequency
is set to immediate , which means the tips will be shown right away. If you prefer the tips
to appear once every 24 hours, you can use the .daily option. Moreover, you have the
flexibility to customize the display frequency to any desired time interval, as
demonstrated in the following example:
Tips.configure([
.displayFrequency(threeDays),
.dataStoreLocation(.applicationDefault)
])
With the Tips center configured, you should be able to see the tips when running the
app in the simulator.
If you want to preview the tips in the preview canvas, you also need to set up the Tips
#Preview {
ContentView()
.task {
try? Tips.resetDatastore()
try? Tips.configure([
.displayFrequency(.immediate),
.datastoreLocation(.applicationDefault)
])
}
}
An important point to note is the inclusion of an extra line of code for resetting the data
store. Once a tip is dismissed, it won't be displayed again in the app. However, when it
comes to previewing the app and ensuring that the tips are consistently shown, it is
recommended to reset the data store.
getStartedTip.invalidate(reason: .actionPerformed)
The reason actionPerformed means that the user performed the action that the tip
describes.
For instance, the favorite tip should only be displayed after the “Getting Started” tip. We
can set up the parameter-based rule like this:
@Parameter
static var hasViewedGetStartedTip: Bool = false
}
When the image is tapped, the “Getting Started” tip is dismissed. In the same closure, we
can set the value of hasViewedGetStartedTip to true .
.onTapGesture {
withAnimation {
showDetail.toggle()
}
getStartedTip.invalidate(reason: .actionPerformed)
FavoriteTip.hasViewedGetStartedTip = true
}
Upon launching the app, only the "Getting Started" tip is displayed. However, once you
tap the image to dismiss the tip, the app then presents the "Favorite" tip.
You can also define event-based rules to achieve the same result. Instead of using
@Parameter to define the hasViewedGetStartedTip variable, you configure the variable as a
Tips.Event like this:
When employing event-based rules, you verify if the donation count meets your specified
criteria. In this case, we examine whether the hasViewedGetStartedTip action has taken
place at least once within your app.
To increase the event count, you use donate() to donate to the event when the action
occurs. For the demo, I'll donate the event when the "Get Started" tip is dismissed. As a
result, we can update the onTapGesture closure like this:
.onTapGesture {
withAnimation {
showDetail.toggle()
}
getStartedTip.invalidate(reason: .actionPerformed)
Task {
await FavoriteTip.hasViewedGetStartedTip.donate()
}
By invoking the donate() method, the donation count for hasViewedGetStartedTip will
increment by one. This, in turn, will trigger the display of the favorite tip.
Summary
In this tutorial, we covered the TipKit framework available on iOS 17. It's a handy tool for
showcasing hidden app features and teaching users how to effectively utilize them. With
TipKit, you can effortlessly create and display tips to enhance the user experience. If you
find TipKit useful, consider integrating it into your next app update for added benefits.