Mastering SwiftUI For IOS 16 and Xcode 14 - Simon NG
Mastering SwiftUI For IOS 16 and Xcode 14 - Simon NG
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: 30/9/2022 | AppCoda © 2022
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 three years. Apple has packed even more features
and brought more UI components to the SwiftUI framework, which comes alongside with
Xcode 14. 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 14, 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 14. 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/)
Developers have been debating for a long time whether we should use Storyboards or
build the app UI programmatically. The introduction of SwiftUI is Apple's answer. With
this brand new framework, Apple offers developers a new way to create user interfaces.
Take a look at the figure below and have a glance at the code.
With the release of SwiftUI, you can now develop the app's UI with a declarative Swift
syntax in Xcode. What that means to you is that the UI code is easier and more natural to
write. Compared with the existing UI frameworks like UIKit, you can create the same UI
with way less code.
The preview function has always been a weak point of Xcode. While you can preview
simple layouts in Interface Builder, you usually can't preview the complete UI until the
app is loaded onto the simulators. With SwiftUI, you get immediate feedback of the UI
you are coding. For example, 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 simply makes UI
development a breeze and iteration much faster.
Not only does it allow you to preview the UI, the new canvas also lets you design the user
interface visually using drag and drop. What's great is that Xcode automatically generates
the SwiftUI code as you add the UI component visually. The code and the UI are always
In this book, you will dive deep into SwiftUI, learn how to layout the built-in
components, and create complex UIs with the framework. I know some of you may
already have experience in iOS development. Let me first walk you through the major
differences between the existing framework that you're using (e.g. UIKit) and SwiftUI. If
you are completely new to iOS development or even have no programming experience,
you can use the information as a reference or even skip the following sections. I don't
want to scare you away from learning SwiftUI, it is an awesome framework for beginners.
If you are new to programming, you probably don't need to care about the difference
because everything is new to you. However, if you have some experience in Object-
oriented programming or have developed with UIKit before, this paradigm shift affects
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 go to
Wikipedia and search for the terms, you will find these definitions:
Instead of focusing on programming, let's talk about cooking a pizza (or any dishes you
like). Let’s assume you are instructing someone else (a helper) to prepare the pizza, you
can either do it imperatively or declaratively. To cook the pizza imperatively, you tell
your helper each of the instructions clearly like a recipe:
On the other hand, if you cook it in a declarative way, you do not need to specify the step
by step instructions but just describe how you would like the pizza cooked. Thick or thin
crust? Pepperoni and bacon, or just a classic Margherita with tomato sauce? 10-inch or
16-inch? The helper will figure out the rest and cook the pizza for you.
That's the core difference between the term imperative and declarative. Now back to UI
programming. Imperative UI programming requires developers to write detailed
instructions to layout the UI and control its states. Conversely, declarative UI
programming lets developers describe what the UI looks like and what you want to
respond when a state changes.
The declarative way of coding would make the code much easier to read and understand.
Most importantly, the SwiftUI framework allows you to write way less code to create a
user interface. Say, for example, you are going to build a heart button in an app. This
button should be positioned at the center of the screen and is able to detect touches. If a
user taps the heart button, its color is changed from red to yellow. When a user taps and
holds the heart, it scales up with an animation.
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 one of the hard topics when learning iOS development. With
SwiftUI, you no longer need to learn how to define layout constraints and resolve the
conflicts. Now you compose the desired UI by using stacks, spacers, and padding. We will
discuss this concept in detail in later chapters.
With SwiftUI, Apple offers developers a unified UI framework for building user interfaces
on all types of Apple devices. The UI code written for iOS can be easily ported to your
watchOS/macOS/watchOS app without modifications or with very minimal
modifications. This is made possible thanks to the declarative UI framework.
Your code describes how the user interface looks. Depending on the platform, the same
piece of code in SwiftUI can result in different UI controls. For example, the code below
declares 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 making any changes. SwiftUI does the heavy lifting to render the
corresponding controls and layout.
However, don't consider SwiftUI as a "Write once, run anywhere" solution. As Apple
stressed in a WWDC talk, that's not the goal of SwiftUI. So, don't expect you can turn a
beautiful app 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.
Though SwiftUI is still new to most developers, now is the right time to learn and
incorporate the framework into your new project. Along with the release of Xcode 14,
Apple has made the SwiftUI framework more stable and feature-rich. If you have some
personal projects or side projects for personal use or at work, there is no reason why you
shouldn't try out SwiftUI.
Having said that, you need to consider carefully whether you should apply SwiftUI to
your commercial projects. One major drawback of SwiftUI is that the device must run at
a minimum on iOS 13, macOS 10.15, tvOS 13, or watchOS 6. If your app requires support
for lower versions of the platform (e.g. iOS 12), you may need to wait a little bit longer
before adopting SwiftUI.
SwiftUI is still new. It will take time to grow into a mature framework, but what's clear is
that SwiftUI is the future of UI development for Apple platforms. Even though it may not
yet be applicable to your production projects, I recommended you start a side project and
explore the framework. Once you try out SwiftUI and understand its benefits, you will
enjoy developing UIs in a declarative way.
control is non-editable but is useful for presenting read-only information on screen. For
example, you want to present an on-screen message, you can use Text to implement it.
In this chapter, I'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.
Choose Next to proceed to the next screen and type the name of the project. I set it to
SwiftUIText but you're free to use any other name. For the organization name, you can
set it to your company or organization. The organization identifier is a unique identifier
of your app. Here I use com.appcoda but you should set it to your own value. If you have
a website, set it to 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 14, the
preview canvas should automatically render the app preview in a simulator that you
choose in the simulator selection (e.g. iPhone 13 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 Stay Hungry. Stay Foolish. on screen. This is the
basic syntax for creating a text view. You're free to change the text to whatever value you
want and the canvas should show you the change instantaneously.
You access the modifier by using the dot syntax. Whenever you type a dot, Xcode will
show you the possible modifiers or values you can use. For example, you will see various
font weight options when you type a dot in the fontWeight modifier. You can choose
bold to bold the text. If you want to make it even bolder, use heavy or black .
By calling fontWeight with the value .bold , it actually returns to you a new view that has
the bolded text. What is interesting in SwiftUI is that you can further chain this new view
with other modifiers. Say, you want to make the bolded text a little bit bigger, you 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 you'll find the code above more easy to read.
We will continue to use this coding convention for the rest of this book.
The font modifier lets you change the font properties. In the code above, we specify the
title font type in order to enlarge the text. SwiftUI comes with several built-in text styles
including title, largeTitle, body, etc. If you want to further increase the font size, replace
You can also use the font modifier to specify the font design. Let's say, 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 a feature of iOS that automatically adjusts the font size in reference to
the user's setting (Settings > Display & Brightness > Text Size). In other words, when you
use text styles (e.g. .title ), the font size will be varied and your app will scale the text
automatically, depending on the user's preference.
.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 foregroundColor modifier like this:
.foregroundColor(.green)
The foregroundColor 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.
While I prefer to customize the properties of a control by writing code, you can also use
the design canvas to edit them. By default, the preview is running in the Live mode. To
edit the view's properties, you have to first switch to the Selectable mode. Then hold the
command key and click the text to bring up a pop-over menu. Choose Show SwiftUI
Inspector and then you can edit the text/font properties. What is great is that the code
will update automatically when you make changes to the font properties.
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)
.foregroundColor(.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 it to your Xcode project.
You can simply drag the font files to the project navigator and insert them under the
SwiftUIText folder. For this demo, I just add the regular font file (i.e. Nunito-
Regular.ttf). If you need to use the bold or italic font, please add the corresponding font
files.
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, you still can't use the font directly. Xcode requires developers
to register the font in the project configuration. In the project navigator, select
SwiftUIText and then click SwiftUIText under Targets. Choose the Info tab, which
displays the project configuration.
You can right Bundle name and choose Add row. Set the key name to Fonts provided by
application. Next, click the disclosure indicator to expand the entry. For item 0, set the
value to Nunito-Regular.ttf , which is the font file you've just added. If you have multiple
font files, you can click the + button to add another item.
Now you can go back to ContentView.swift . To use the custom font, you can replace the
following line of code:
.font(.title)
With:
Instead of using the system font style, the code above uses .custom and specifies the
preferred font name. Font names can be found in the application "Font Book". You can
open Finder > Application and click Font Book to launch the app.
SwiftUI has built-in support for rendering Markdown. If you don't know what Markdown
is, it allows you to style plain text using an easy to read format. To learn more about
Markdown, you can check out this guide (https://www.markdownguide.org/getting-
started/).
To use Markdown for rending text, all you need to do is specify the text in Markdown.
The Text view automatically renders 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 ContentView , you will see how the given text is rendered. To test
the hyperlink, you have to run the app in simulators. When you tap the link, iOS will
redirect to mobile Safari and open the URL.
Summary
Do you enjoy creating user interfaces with SwiftUI? I hope so. 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 text in 3D style.
For reference, you can download the complete text project here:
In addition to text, images are another basic element that you'll use in iOS app
development. SwiftUI provides a view called Image for developers to render and draw
images on screen. Similar to what we've done in the previous chapter, I'll show you how
to work with Image by building a simple demo. In brief, this chapter covers the following
topics:
Once you save the project, Xcode should load the ContentView.swift file and display a
design/preview canvas.
Understanding SF Symbols
With over 4,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
labels. They can be exported and edited using vector graphics editing tools to create
custom symbols with shared design characteristics and accessibility features. SF
Symbols 4 features over 700 new symbols, variable color, automatic rendering, and
new unified layer annotation.
Before I show you how to display an image on screen, let's first talk about where the
images I use come from. Needless to say, you can provide your own images for use in the
app. Starting from iOS 13, Apple introduced a large set of system images called SF
Symbols that allow developers to use them in any app. Along with the release of iOS 16,
Apple further improved the image set by releasing SF Symbols 4. It features over 700
new symbols and supports variable colors.
To use the symbols, all you need is the name of the symbol. With over 4,000 symbols
available for your use, Apple has released an app called SF Symbols
(https://developer.apple.com/sf-symbols/), so that you can easily explore the symbols
and locate the one that fits your need. I highly recommend 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 this system image is actually a font, you can apply modifiers such as
foregroundColor , that you learned in the previous chapter, to change its appearance.
For example, to change the symbol's color to blue, you write the code like this:
Image(systemName: "cloud.heavyrain")
.font(.system(size: 100))
.foregroundColor(.blue)
Image(systemName: "cloud.heavyrain")
.font(.system(size: 100))
.foregroundColor(.blue)
.shadow(color: .gray, radius: 10, x: 0, y: 10)
Before you can use an image in your project, the first step is to import the images into the
asset catalog ( Assets ). Assuming you already prepared the image ( paris.jpg ), press
command+0 in Xcode to reveal the project navigator and then choose Assets . Open
Finder and drag the image to the outline view.
If you're new to iOS app development, this asset catalog is where you store application
resources like images, color, and data. Once you put the image in 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 screen, you write the code like this (see figure 6):
Image("paris")
All you need to do is specify the name of the image and you should see the image in the
preview canvas. However, since the image is a high resolution image (4437x6656 pixels),
you only see a part of the image.
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
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 our 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 like 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())
Here we specify to clip the image into a circular shape. You can pass different parameters
to create an image with a different shape. Figure 13 shows you some examples.
For example, if you apply the opacity modifier to the image view and set its value to 0.5,
the image will become 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))
.foregroundColor(.white)
.padding()
.background(Color.black)
.cornerRadius(10)
.opacity(0.8)
.padding(),
alignment: .top
We draw a Rectangle over the image and set its foreground color to black. In order to
apply a darkening effect, we set the opacity to 0.4, giving it a 40% opacity. The image
should now be darkened.
Alternatively, you may rewrite the code like this to achieve the same effect:
Image("paris")
.resizable()
.aspectRatio(contentMode: .fit)
.overlay(
Color.black
.opacity(0.4)
)
In SwiftUI, Color is also a view. This is why we can use Color.black as the top layer to
darken the image underneath.
This technique is very useful if you want to overlay some light-colored text on a bright
image to make the text more legible. Replace the Image code like this:
As mentioned before, the overlay modifier is not limited to Image . You can apply it to
any other view. In the code above, we use Color.black to darken the image. On top of
that, we apply an overlay and place a Text over it. If you've made the change correctly,
you should see the word "Paris" in bold white, placed 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: "cloud.sun.rain")
.symbolRenderingMode(.palette)
.foregroundStyle(.indigo, .yellow, .gray)
We specify in the code to use the palette mode and then apply the colors by using the
foregroundStyle modifier.
Variable Colors
In iOS 16, SF Symbols add a new 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 can be used with every single rendering mode available for SF Symbols.
You can change to other Rendering modes to see the effects.
To set the percentage value in code, you can instantiate the Image view with an
additional variableValue parameter and pass it with the percentage value:
Wrap Up
For reference, you can download the complete images project here:
In this chapter, I will walk you through all types of stacks and build a grid layout using
stacks. So, what project will you work on? Take a look at the figure below. We'll lay out a
simple grid interfaces step by step. After going over this chapter, you will be able to
combine views with stacks and build the UI you want.
The figure below shows you 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. If the preview is not displayed, click the Resume button in
the canvas.
Using VStack
We're going to build the UI as displayed in figure 1, but first, let's break down the UI into
small parts. We'll begin with the heading as shown below.
Presently, 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 aligned in center position. To align both
views to the left, you can specify the alignment parameter and set its value to .leading
like this:
Additionally, you can adjust the space of the embedded views by using the spacing
parameter. The code above added the parameter spacing to the VStack and set its value
to 2 . The figure below shows the resulting view.
Using HStack
Both the Basic and Pro components are arranged side by side. By using HStack , you can
lay out views horizontally. Stacks can be nested meaning that you can nest stack views
within other stack views. Since the pricing plan block sits right below the heading view,
which is a VStack , we will 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 some basic ideas about how we're going to use VStack and HStack
for implementing the UI, let's jump right into the code.
To embed the existing VStack in another VStack , hold the command key and then 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 required code to embed the stack. Your code should look
like the following:
Extracting a View
Xcode has a built-in feature to refactor the SwiftUI code. Hold the command key and
click the VStack that holds the text views (i.e. line 13). Select Extract Subview to extract
the code.
Xcode extracts the code block and creates a default struct named ExtractedView . Rename
ExtractedView to HeaderView to give it a more meaningful name (see the figure below for
details).
The UI is still the same. However, look at the code block in ContentView . It's now much
cleaner and easier to read.
Let's continue to implement the UI of the pricing plans. We'll first create the UI for the
Basic plan. Update ContentView like this:
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 is used to hold three text
views for showing the Basic plan. I'll not go into the details of padding , background , and
cornerRadius because we have already discussed these modifiers in earlier chapters.
Next, we're going to implement the UI of the Pro plan. This Pro plan should be placed
right next the Basic plan. In order to do that, you need to embed the VStack of the Basic
plan in a HStack . Hold the command key and click the VStack keyword. Choose Embed
in HStack.
Xcode should insert the code for 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:
The .frame modifier allows you to define the frame size. You can specify the size as a
fixed value. For example, in the code above, we set the minHeight to 100 points. When
you set the maxWidth to .infinity , the view will adjust itself to fill the maximum width.
For example, if there is only one pricing block, it will take up the whole screen width.
For two pricing blocks, iOS will fill the block equally when maxWidth is set to .infinity .
Now insert the above line of code into each of the pricing blocks. Your result should look
like figure 17.
To give the horizontal stack some spacing, you can add a .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 the command key and click the VStack of the Basic
plan. Once Xcode extracts the code, rename the subview from ExtractedView to
PricingView .
We added variables for the title, price, text, and background color of the pricing block.
Furthermore, we make use of 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 telling you that there are some missing
arguments for the PricingView .
Earlier, we introduced four variables in the view. When calling PricingView , we must
provide the values of these parameters. So, update the initialization of PricingView() and
add the parameters like this:
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. We want to overlay a message in yellow on the pricing
block. To do that, we 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 determine how the views are overlaid
with each other. For the code above, the Text view will overlay on top of the pricing
view. In 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 if you want to re-position it.
Optionally, if you want to adjust the spacing between the Basic and Pro pricing block,
you can specify the spacing parameter within a HStack like this:
HStack(spacing: 15) {
...
}
Exercise #1
Please don't look at the solution, 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
plan, we declare an optional in PricingView :
If you're new to Swift, an optional means that the variable may or may not have a value.
In the example above, we define a variable named icon of the type 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 option. 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 make 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, do you see any difference? There
are a couple of differences you may notice:
In UIKit, you would define auto layout constraints to position the views. 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 a Spacer within a HStack . By using a Spacer ,
we push the VStack to the left. Figure 26 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()
}
}
}
By using stacks, image, and text views, you should be able to create the UI. While I will go
through the implementation step by step with you later, please take some time to work
on the exercise and figure out 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 code there. That's completely fine, but I want to show you 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.
The code in CardView.swift looks very similar to that of ContentView.swift . Similarly, you
can preview the UI in the canvas.
Let's start with the image. 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 actually have three text views in
the description, that are vertically aligned, we use a VStack to embed them. For the
VStack , we specify the alignment as .leading . This will align the text view to the left of
the stack view.
The modifiers of Text are all discussed in the chapter about the Text object. You can
refer to it if you find any of the modifiers are confusing. But one topic about the .primary
While you can specify a standard color like .black and .purple in the foregroundColor
modifier, iOS provides a set of system colors that contain primary, secondary, and
tertiary variants. By using these color variants, your app can easily support both light and
dark modes. For example, the primary color of the text view is set to black in light mode
by default. When the app is switched over to dark mode, the primary color will be
adjusted to white. This is automatically arranged by iOS, so you don't have to write extra
code to support the dark mode. We will discuss dark mode in depth in a later chapter.
To arrange the image and these text views vertically, we use a VStack to embed them.
The current layout is shown in the figure below.
We are not done yet! There are still a couple of things we need to implement. First, the
text description block should be 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've changed the code to match the one shown in figure 8, the VStack of the text
views are aligned to the left 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 the CardView_Previews struct. This is
because we've introduced some variables in CardView . We have to specify the parameters
when using it.
This should fix the error. Great! You have built a flexible CardView that accepts different
images and text.
Introducing ScrollView
Take a look at figure 2 again. That's the user interface we're going to implement. At first,
you may think we can embed four card views using a VStack . You can switch over to
ContentView.swift and insert the following code:
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 embedded in a ScrollView , it becomes scrollable. What you need to do is to
enclose the VStack within a ScrollView to make the views scrollable. In the preview
canvas, you can drag the views to scroll 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)
}
}
}
}
value.
2. Since we use 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 is required because
the image is too wide to display.
After changing the code, you'll see the card views are arranged horizontally and they are
scrollable.
For example, you can rewrite the code in HStack like this to achieve the same result:
.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 to the final exercise. Modify the current code and re-arrange it like that
shown in figure 16. Please note that the title and the date should be visible to users when
he/she scrolls through the card views.
For reference, you can download the complete scrollview project here:
Demo project
(https://www.appcoda.com/resources/swiftui4/SwiftUIScrollView.zip)
I don't think I need to explain what a button is. It's a very basic UI control that you can
find in all apps and has the ability to handle users' touch, and trigger a certain action. If
you have learned iOS programming before, Button in SwiftUI is very similar to UIButton
in UIKit. It's just more flexible and customizable. You will understand what I mean in a
while. In this chapter, I will go through this SwiftUI control and you will learn the
following techniques:
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.
Both pieces of code are exactly the same. It's just a matter of coding style. In this book,
we prefer to use the first approach.
Once you implement a button, the Hello World text becomes a tappable button as you see
it in the canvas.
The print statement outputs the message to the console. In order to test it, you have to
run the app on a simulator. Click the Play button to launch the simulator. You should see
the Hello World tapped message on the console when you tap the button. If you can't see
the console, go up to the Xcode menu and choose View > Debug Area > Activate
Console.
Text("Hello World")
.background(Color.purple)
.foregroundColor(.white)
If you want to change the font type, you use the font modifier and specify the font type
(e.g. .title ) like this:
Text("Hello World")
.background(Color.purple)
.foregroundColor(.white)
.font(.title)
After the change, your button should look like the figure below.
As you can see, the button doesn't look very good. Wouldn't it be great to add some space
around the text? To do that, you can use the padding modifier like this:
After you make the change, the canvas will update the button accordingly. The button
should now look much better.
If you place the padding modifier after the background modifier, you can still add some
padding to the button but without the preferred background color. If you wonder why,
the modifiers like this:
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)
Conversely, the modifiers will work like this if the padding modifier is placed before the
background modifier:
You can change the code of the Text control like below:
Text("Hello World")
.foregroundColor(.purple)
.font(.title)
.padding()
.border(Color.purple, width: 5)
Let me give you another example. Let's say, a designer shows you the following button
design. How are you going to implement it with what you've learned? Before you read the
next paragraph, take a few minutes to figure out the solution.
Text("Hello World")
.fontWeight(.bold)
.font(.title)
.padding()
.background(Color.purple)
.foregroundColor(.white)
.padding(10)
.border(Color.purple, width: 5)
We use two padding modifiers to create the button design. The first padding , together
with the background modifier, is for creating a button with padding and a purple
background. The padding(10) modifier adds extra padding around the button and the
Let's look at a more complex example. What if you wanted a button with rounded borders
like this?
SwiftUI comes with a modifier named cornerRadius that lets you easily create rounded
corners. To render the button's background with rounded corners, you simply use the
modifier and specify the corner radius:
.cornerRadius(40)
For the border with rounded corners, it'll take a little bit of work since the border
modifier doesn't allow you to create rounded corners. What we need to do is to draw a
border and overlay it on the button. Here is the final code:
The overlay modifier lets you overlay another view on top of the current view. In the
code, we draw a border with rounded corners using the stroke modifier of the
RoundedRectangle object. The stroke modifier allows you to configure the color and line
width of the stroke.
Button(action: {
print("Delete button tapped!")
}) {
Image(systemName: "trash")
.font(.largeTitle)
.foregroundColor(.red)
}
For convenience, we use the built-in SF Symbols (i.e. trash) to create the image button.
We specify .largeTitle in the font modifier to make the image a bit larger. Your button
should look like this:
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:
Here we embed both the image and the text control in a horizontal stack. This will lay out
the trash icon and the Delete text side by side. The modifiers applied to the HStack set
the background color, padding, and round the button's corners. Figure 13 shows the
resulting button.
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:
You're free to use your own colors to render the 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)
Button(action: {
print("Delete tapped!")
}) {
HStack {
Image(systemName: "trash")
.font(.title)
Text("Delete")
.fontWeight(.semibold)
.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)
}
This is very similar to the code we just wrote, except that we added the frame modifier
before padding . Here we define a flexible width for the button. We set the maxWidth
parameter to .infinity . This will result in the button filling the width of the container
view. You should now see a full-width button in the canvas.
.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 struct ContentPreview_Previews :
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:
So, what the line of code does is scale down the button (i.e. 0.9 ) when the button is
pressed and scales back to its original size (i.e. 1.0 ) when the user lifts their finger. Run
the app, you should see a nice animation when the button is scaled 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, 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
For reference, you can download the complete button project here:
SwiftUI comes with a few built-in features for state management. In particular, it
introduces a property wrapper named @State . When you annotate a property with
@State , SwiftUI automatically stores it somewhere in your application. What's more,
views that make use of that property automatically listen to the value change of the
property. When the state changes, SwiftUI will recompute those views and update the
application's appearance.
Doesn't it sound great? Or are you a bit confused with state management?
Button {
// Switch between the play and stop button
} label: {
Image(systemName: "play.circle.fill")
.font(.system(size: 150))
.foregroundColor(.green)
}
We make use of the system image play.circle.fill and color the button green.
We change the image's name and color by referring the value of the isPlaying variable.
If you update the code in your project, you should see a Play button in the preview
canvas. However, if you set the default value of isPlaying to true , you would see a Stop
button.
Now the question is how can the app monitor the change of the state (i.e. isPlaying ) and
update the button automatically? With SwiftUI, all you need to do is prefix the isPlaying
Once we declare the property as a state variable, SwiftUI manages the storage of
isPlaying and monitors its value change. When the value of isPlaying changes, SwiftUI
automatically recomputes the views that are referencing the isPlaying state.
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 from
false to true or from true to false . In the preview canvas, click the play icon to
toggle between the Play and Stop button.
Did you notice that SwiftUI renders a fade animation when you toggle between the
buttons? This animation is built-in and automatically generated for you. We will talk
more about animations in later chapters of the book, but as you can see, SwiftUI makes
UI animation more approachable for all developers.
Exercise #1
Your exercise is to create a counter button which shows the number of taps. When a user
taps the button, the counter will increase by one and display the total number of taps.
Now we will further modify the code to display three counter buttons (see figure 7). All
three buttons share the same counter. No matter which button is tapped, the counter will
increase by 1 and all the buttons will be invalidated to display the updated count.
As you can see, all the buttons share the same look & feel. As I've explained in earlier
chapters, rather than duplicating the code, it's always a good practice to extract a
common view into a reusable subview. We can extract the Button view to create an
independent subview like this:
The CounterButton view accepts two parameters: counter and color. You can create a
button in red like this:
You should notice that the counter variable is annotated with @Binding . When you
create a CounterButton instance, the value of the counter parameter is prefixed by a $
sign.
After we extract the button into a separate view, CounterButton becomes a subview of
ContentView . The counter increment is now performed in the CounterButton view instead
of the ContentView . The CounterButton must have a way to manage the state variable in
the ContentView .
So, what's the $ sign? In SwiftUI, you use the $ prefix operator to get the binding from a
state variable.
Now that you understand how binding works, you can continue to create the other two
buttons and align them vertically using VStack like this:
After the changes, run the app to test it. Tapping any of the buttons will increase the
count by one.
Exercise #2
Summary
The support of State in SwiftUI simplifies state management in application development.
It's important you understand what @State and @Binding mean because they play a big
part in SwiftUI for managing states and UI updates. This chapter kicks off the basics of
For reference, you can download the sample state project below:
In this chapter, you will learn how to draw lines, arcs, pie charts, and donut charts using
Path and the built-in Shape such as Circle and RoundedRectangle . Here are the topics
we'll cover:
Figure 1 shows you some of the shapes and charts that we will create in this chapter.
Understanding Path
In SwiftUI, you draw lines and shapes using Path . If you refer to Apple's documentation
(https://developer.apple.com/documentation/swiftui/path), Path is a struct containing
the outline of a 2D shape. Basically, a path is the setting of a point of origin, then drawing
lines from point to point. Let me give you an example. Take a look at figure 2. We will
walk thorugh how this rectangle is drawn.
If you were to verbally tell me how you would draw the rectangle step by step, you would
probably provide the following description:
That's how Path is works! Let's write your verbal description in code:
Path() { path in
path.move(to: CGPoint(x: 20, y: 20))
path.addLine(to: CGPoint(x: 300, y: 20))
path.addLine(to: CGPoint(x: 300, y: 200))
path.addLine(to: CGPoint(x: 20, y: 200))
}
.fill(.green)
Test the code by creating a new project using the App template. Name the project
SwiftUIShape (or whatever name you like) and then type the above code snippet in the
body . The preview canvas should display a rectangle in green.
Because we didn't specify a step to draw the line to the point of origin, it shows an open-
ended path. To close the path, you can call the closeSubpath() method at the end of the
Path closure, that will automatically connect the current point with the point of origin.
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 lets you draw a curve by defining a control point. Referring to
figure 6, (40, 60) and (210, 60) are known as anchor points. (125, 0) is the control point,
which is calculated to create the dome shape. I'm not going to discuss the mathematics
ZStack {
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)
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)
}
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 name of the startAngle and endAngle parameters, you might be a
bit confused with their meaning. Figure 9 will give you a better idea of how these
parameters work.
By using addArc , you can easily create a pie chart with different colored segments. All
you need to do is overlay different pie segments with 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 will render a pie chart with 4 segments. If you want to have more segments, just
create additional path objects with different angle values. As a side note, the color I used
comes from the standard color objects provided in iOS. You can check out 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()
}
.stroke(Color(red: 52/255, green: 52/255, blue: 122/255), lineWidth: 10)
.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, however; it only
draws the border and adds a text view in order 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 find another function called
addRect , which lets you 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 say you want to
create a button with the dome shape but flexible size. Is it possible to reuse the Path that
you have just created?
Take a look at the code above again. You created a path with absolute coordinates and
size. In order to create the same shape but with variable size, you can create a struct to
adopt the Shape protocol and implement the path(in:) function. When the path(in:)
function is called by the framework, you will be given the rect size. You can then draw
the path within that rect .
In the code that follows we create the Dome shape using a path(in:) function.
return path
}
}
By adopting the protocol, we are given the rectangular area for drawing the path. From
the rect , we get the width and height of the rectangular area to compute the control
point and draw the rectangle base.
With the dynamic shape, you can create various SwiftUI controls. For example, you can
create a button with the Dome shape like this:
We apply the Dome shape as the background of the button. Its width and height are
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 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 may adjust the
value of the lineWidth parameter if you prefer thicker (or thinner) lines. The
purpleGradient property defines the purple gradient that we will use later in drawing the
open circle.
Now, insert the following code in ZStack to create the open circle:
To create an open circle, add the trim modifier. You specify a from value and a to
value to indicate which segment of the circle should be shown. In this case, we want to
show 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 20% of the circle, but
note that the from value is 0.4 instead of 0. This starts the second segment at the end of
the first segment.
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 don't like that, you can change the value of
lineWidth from 90 to 80 .
Summary
I hope you enjoyed reading this chapter and coding the demo projects. With these
drawing APIs, provided by the framework, you can easily create custom shapes for your
application. There is a lot you can do with Path and Shape . I have covered just a few of
these in this chapter, try to apply what you've learned and further explore these powerful
APIs, they are magical!
For reference, you can download the shapes project files below:
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 technique along the way.
Explicit animations offer more finite 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.
A bit confused? That's fine. You will have a better idea after going through a couple of
examples.
Take a look at figure 1. It's a simple tappable view that is composed of a red circle and a
heart. When a user taps the heart or circle, the circle's color will be changed to light gray
and the heart's color to red. At the same time, the size of the heart icon grows bigger. 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)
.foregroundColor(circleColorChanged ? Color(.systemGray5) : .red)
Image(systemName: "heart.fill")
.foregroundColor(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 model the states of the circle color, heart color and
heart size, with the initial value set to false. To create the circle and heart, we use ZStack
to overlay the heart image on top of the circle. SwiftUI comes with the onTapGesture
modifier to detect the tap gesture. You can attach it to any view to make it tappable. In
the onTapGesture closure, we toggle the states to change the view's appearance.
In the preview canvas, tap the heart view. The color of the circle and heart icon should
change accordingly. However, these changes are not animated.
To animate the changes, you need to attach the animation modifier to both Circle and
Image views:
Circle()
.frame(width: 200, height: 200)
.foregroundColor(circleColorChanged ? Color(.systemGray5) : .red)
.animation(.default, value: circleColorChanged)
Image(systemName: "heart.fill")
.foregroundColor(heartColorChanged ? .red : .white)
.font(.system(size: 100))
.scaleEffect(heartSizeChanged ? 1.0 : 0.5)
.animation(.default, value: heartSizeChanged)
Not only can you apply the animation modifier to a single view, it is applicable to a group
of views. For example, you can rewrite the code above by attaching the animation
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()
}
It works exactly same. SwiftUI looks for all the state changes embedded in ZStack and
creates the animations.
In the example, we use the default animation. SwiftUI provides a number of built-in
animations for you to choose including linear , easeIn , easeOut , easeInOut , and
spring . The linear animation animates the changes in linear speed, while other easing
animations have various speed. For details, you can check out www.easings.net to see the
difference between each of the easing functions.
This renders a spring-based animation that gives the heart a bumpy effect. You can
adjust the damping and blend values to achieve a different effect.
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()
}
}
Of course, you can change it to spring animation by updating withAnimation like this:
With explicit animation, you can easily control which state you want to animate. For
example, if you don't want to animate the size change of the heart icon, you can exclude
that line of code from withAnimation like this:
.onTapGesture {
withAnimation(.spring(response: 0.3, dampingFraction: 0.3, blendDuration: 0.3)
) {
self.circleColorChanged.toggle()
self.heartColorChanged.toggle()
}
self.heartSizeChanged.toggle()
}
In this case, SwiftUI only animates the color change of both circle and heart. You no
longer see the animated growing effect of the heart icon.
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:
Image(systemName: "heart.fill")
.foregroundColor(heartColorChanged ? .red : .white)
.font(.system(size: 100))
.animation(.spring(response: 0.3, dampingFraction: 0.3, blendDuration: 0.3
), 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
cancel the animation. The state change of the scaleEffect modifier will not be animated.
While you can create 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 like that shown in figure
3, we 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 make use of the rotationEffect and animation
modifiers. The trick is to keep rotating the circle by 360 degrees. Here is the code:
The rotationEffect modifier takes in the rotation degree (360 degrees). In the code
above, we have a state variable to control the loading status. When it's set to true, the
rotation degree will be set to 360 to rotate the circle. In the animation modifier, we
specify to use the .default animation, but there is a difference. We tell SwiftUI to repeat
the same animation again and again. This is the trick that creates the loading animation.
*Note: If you can't see the animation in the preview canvas, run the app in the
simulator.*
If you want to change the speed of the animation, you can use the linear animation and
specify a duration like this:
The greater the duration value the slower the animation (rotation).
The onAppear modifier may be new to you. If you have some knowledge of UIKit, this
modifier is very similar to viewDidAppear . It's automatically called when the view appears
on screen. In the code, we change the loading status to true in order to start the
Once you manage this technique, you can tweak the design and develop various versions
of loading indicator. For example, you can overlay an arc on a circle to create a fancy
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 need to be circular. You can also use Rectangle or
RoundedRectangle to create the indicator. Instead of changing the rotation angle, you
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()
}
}
}
}
}
Instead of a boolean state variable, we use a floating point number to store the status. To
display progress, we set the trim modifier with the progress value. In a real world
application, you can update the value of the progress value to show the actual progress
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 delay the start of the animation by 1 second. The delay function is applicable to
other animations.
By mixing and matching the values of duration and delay, you can achieve some
interesting animations like the dot loading indicator below.
This indicator is composed of five dots. Each dot is animated to scale up and down, but
with different time delays. Here is how it's implemented in code.
We first use a HStack to layout 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's set to 1, which is its original size. When the
loading starts, the value is updated to 0. This will minimize the dot.
The line of code for rendering the animation looks a bit complicated. Let's break it down
and look at it step by step:
The first part creates a linear animation with a duration of 0.6 seconds. This animation is
expected to run repeatedly, so we call the repeatForever function.
If you run the animation without calling the delay function, all the dots scales up and
down simultaneously. However, this is not what we want. Instead of scaling up/down all
at once, each dot should resize itself independently. This is why we call the delay
You may vary the value of duration and delay to tweak the animation.
The trick of morphing a rectangle into a circle is to use the RoundedRectangle shape and
animate the change of the corner radius. Assuming the width and height of the rectangle
are the same, it becomes a circle when its corner radius is set to half of its width. 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))
.foregroundColor(.white)
.scaleEffect(recording ? 0.7 : 1)
)
}
.onTapGesture {
withAnimation(Animation.spring()) {
self.recordBegin.toggle()
}
withAnimation(Animation.spring().repeatForever().delay(0.5)) {
self.recording.toggle()
}
}
}
}
We have two state variables here: recordBegin and recording to control two separate
animations. The first variable controls the morphing of the button. As explained before,
we make use of the corner radius for the transformation. The width of the rectangle is
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 mic image. We change the scaling
ratio from 1 to 0.7 when it's in the recording state. By running the same animation
repeatedly, it creates the pulsing animation.
Note that the code above uses the explicit approach to animate the views. This is 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 animate the view's size by scaling it up and down.
SwiftUI allows developers to do more than that. You can define how a view is inserted or
removed from the view hierarchy. In SwiftUI, this is known as transition. By default, the
framework uses fade in and fade out transition. However, it comes with several ready-to-
use transitions such as slide, move, opacity, etc. Of course, you are allowed to develop
your own or simply mix and match various types of transitions together to create your
desired transition.
RoundedRectangle(cornerRadius: 10)
.frame(width: 300, height: 300)
.foregroundColor(.purple)
.overlay(
Text("Well, here is the details")
.font(.system(.largeTitle, design: .rounded))
.bold()
.foregroundColor(.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)
.foregroundColor(.purple)
.overlay(
Text("Well, here is the details")
.font(.system(.largeTitle, design: .rounded))
.bold()
.foregroundColor(.white)
)
}
.onTapGesture {
withAnimation(.spring()) {
self.show.toggle()
}
}
Once a user taps the stack, we toggle the show variable to display the purple square. If
you run the app in the simulator or the preview canvas, you should only see the green
square. Tapping it will display the purple rectangle with a smooth fade in/out transition.
As mentioned, if you do not specify the transition you want to use, SwiftUI renders the
fade in and out transition. To use an alternative transition, attach the transition
The transition modifier takes in a parameter of the type AnyTransition . Here we use the
scale transition with the anchor set to .bottom . That's all you need to do to modify the
transition. Run the app in a simulator. You should see a pop animation when the app
reveals the purple square. You should test the animations using the built-in simulator
instead of running the app in preview because the preview canvas may not 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:
Run the app and test the transition again. Does it look great?
Asymmetric Transitions
The transitions that 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 in the view hierarchy. When it's
removed, the framework scales it back down to the original size.
So, 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 Assymetric Transitions in
SwiftUI. It's very simple to use this type of transition. You just need to call the
.assymetric method and specify both the insertion & 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 ContentView_Previews block.
Run the app using the built-in simulator. 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
transition. 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 use the following
code to simulate an operation.
Summary
Animation has a special role in mobile UI design. Well thought out animation improves
user experience and brings meaning to UI interaction. A smooth and effortless transition
between two views will delight and impress your users. With more than 2 million apps on
the App Store, it's not easy to make your app stand out. However, a well-designed UI
with animation will definitely make a difference!
Even for experienced developers, it's not an easy task to code slick animations.
Fortunately, the SwiftUI framework has simplified the development of UI animation and
transition. You tell the framework how the view should look at the beginning and the
end. SwiftUI figures out the rest, rendering a smooth and nice animation.
In this chapter, I've walked you through the basics. But as you can see, you've already
built some delightful animations and transitions. Most importantly, it needed just a few
lines of code.
I hope you enjoyed reading this chapter and find the techniques useful. For reference,
you can download the sample projects and solutions to exercises below:
Demo project
(https://www.appcoda.com/resources/swiftui4/SwiftUIAnimation.zip)
Exercise #1
(https://www.appcoda.com/resources/swiftui4/SwiftUIAnimationExercise1.zip)
Exercise #2
(https://www.appcoda.com/resources/swiftui4/SwiftUICardAnimation.zip)
Instead of using UITableView , we use List in Swift UI to present rows of data. If you've
built a table view with UIKit before, you know it'll take you a bit of work to implement a
simple table view. It'll take even more effort to build a table view with custom cell layout.
In this chapter, we will start with a simple list. Once you understand 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. But one thing you have to
take note of is that you need to tell ForEach how to identify each of the items in the
collection. The parameter id is for this purpose. Why does ForEach need to identify the
items uniquely? SwiftUI is powerful enough to update the UI automatically when
some/all items in the collection are changed. To make this possible, it needs an identifier
to uniquely identify the item when it's updated or removed.
In the code above, we pass ForEach a range of values to loop through. The identifier is set
to the value itself (i.e. 1, 2, 3, or 4). The index parameter stores the current value of the
loop. Say, for example, it starts with the value of 1. The index parameter will have a
value of 1.
Within the closure, it is the code you need to render the views. Here, we create the text
view. Its description will change depending on the value of index in the loop. That's how
you create 4 items in the list with different titles.
Let me show you one more technique. The same code snippet can be further rewritten
like this:
You can omit the index parameter and use the shorthand $0 , which refers the first
parameter of the closure.
Let's further rewrite the code to make it even more simple. You can pass the collection of
data to the List view directly. Here is the 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 with SwiftUI.
To build the table using UIKit, you'll need to create a table view or table view controller
and then customize the prototype cell. Furthermore, you'll have to code the table view
data source to provide the data. That's quite a lot of steps to build a table UI. Let's 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 are for storing restaurant names and images. Here is the
complete code:
Both arrays have the same number of items. The restaurantNames array stores the name
of the restaurants, the restaurantImages array stores the name of the images you just
imported. To create a list view like that shown in figure 4, all you need to do is update the
body variable 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 only
works when both arrays are of the same size as the index of one is used as an index for
the other array.
In the closure, the code was updated to create the row layout. I'll not go into the details as
the code is similar to previous stack views we've created. To change the style of the List
view, we attached 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 ContentView :
If you're new to Swift, each item of the array represents restaurant object containing both
the name and image for each restaruant. Once you have replaced the array, you'll see an
error in Xcode, complaining that the restaurantNames variable is missing. That's expected
because we've just removed it.
Take a look at the parameters we pass into List . Instead of passing the range, we pass
the restaurants array and tell the List to use its name property as the identifier. The
List will loop through the array and let us know the current restaurant it's handling in
the closure. So, in the closure, we tell the list how we want to present the restaurant row.
Here, we simply present both the restaurant image and name in a HStack .
The resulting UI is still the same but the underlying code was modified to utilize List
Take note that we are only changing the value of the name property and keeping the
image to upstate . Check the preview pane again and see what you get.
Do you see the issue (in figure 8)? We now have two records with the name Homei. You
might expect the second Homei record to show the upstate image, but iOS renders two
records with the same text and image. In the code, we told the List to use the
restaurant's name as the unique identifier. When two restaurants have the same name,
iOS considers both restaurants to be the same restaurant. Thus, it reuses the same view
and renders the same image.
That's pretty easy. Instead of using the name as the identifier (ID), you should give each
restaurant a unique identifier. Update the Restaurant struct like this:
struct Restaurant {
var id = UUID()
var name: String
var image: String
}
Now each restaurant has a unique ID, but we still have to make one more change for
things to work. For the List , change the value of the id parameter from \.name to
\.id :
This tells the List view to use the id property of the restaurants as the unique
identifier. Take a look at the preview, the second Homei record now 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, that the type
implementing the protocol should have some sort of id as a unique identifier. Update
Since Restaurant already provides a unique id property, this conforms to the protocol
requirement.
What's the purpose of implementing the Identifiable protocol here? With the
Restaurant struct conforming to the Identifiable protocol, you can initialize the List
without the id parameter. You just simplified the code! Here is the updated code for the
list view:
List(restaurants) { restaurant in
HStack {
Image(restaurant.image)
.resizable()
.frame(width: 40, height: 40)
.cornerRadius(5)
Text(restaurant.name)
}
.listStyle(.plain)
}
Xcode immediately shows you an error once you made the change. Since the extracted
subview doesn't have a restaurant property, update the BasicImageRow struct like this to
declare the restaurant property:
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 change.
Let's say, you create another layout for the row like this:
Text(restaurant.name)
.font(.system(.title, design: .rounded))
.fontWeight(.black)
.foregroundColor(.white)
}
}
}
This row layout is designed to show a larger image with the restaurant name overlayed 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
List :
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
is to use FullImageRow for the first two rows of data and the rest of the rows will utilize
the BasicImageRow . To do this, you update 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 a finer control on the line separators, you can use an alternate version
of .listRowSeparator by specifying the edges parameter. Say, for example, if you want to
keep the separator at the top of the list view, you can write the code like this:
In iOS 16, you can customize the color of the scrollable area of the list view. Simply attach
the scrollContentBackground modifier to the List view and set it to your preferred color.
Here is an example:
Other than using a solid color, you can use an image as the background. Update the code
like this to have a try:
List(restaurants) { restaurant in
.
.
.
}
.background {
Image("homei")
.resizable()
.scaledToFill()
.clipped()
}
.scrollContentBackground(Color.clear)
We use the background modifier to set the background image. Then we set the
scrollContentBackground modifier to Color.clear 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 looks complicated but if you fully understand 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!
To save you time finding your own images, you can download the image pack for this
exercise from https://www.appcoda.com/resources/swiftui/SwiftUIArticleImages.zip.
For reference, you can download the complete list project and solution to the exercise
here:
NavigationView {
List {
ForEach(restaurants) { restaurant in
BasicImageRow(restaurant: restaurant)
}
}
.listStyle(.plain)
}
To create a navigation view using NavigationStack , you can write the same piece of code
like this:
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 just like other SwiftUI views of the type View . Its layout is very simple
that it only displays the restaurant image and name. The RestaurantDetailView struct also
takes in a Restaurant object in order to retrieve the image and name of the restaurant.
With the detail view now ready, the question is how you can pass the selected restaurant
in the content view to this detail view?
SwiftUI provides a special button called NavigationLink , which is able to detect users'
touches and triggers the navigation presentation. The basic usage of NavigationLink is
like this:
NavigationLink(destination: DetailView()) {
Text("Press me for details")
}
You specify the destination view in the destination parameter and implement its
appearance in the closure. For the demo app, it should navigate to the
RestaurantDetailView when any of the restaurants is tapped. In this case, we can apply
NavigationLink to each of the rows. Update the List view like this:
In the canvas, you should notice that each row of data has been added with a disclosure
icon. In the preview canvas, you should be able to navigate to the detail view after
selecting one of the restaurants. Furthermore, you can navigate back to content view by
clicking the back button. The whole navigation is automatically rendered by
NavigationStack .
If you want to keep the navigation bar compact and disable the use of the large title, you
can add the navigationBarTitleDisplayMode modifier right below navigationTitle :
.navigationBarTitleDisplayMode(.inline)
The parameter specifies the appearance of the navigation bar, whether it should appear
as a large title bar or compact title. By default, it's set to .automatic , which means large
title is used. In the code above, we set it to .inline . This instructs iOS to use a compact
bar.
Figure 6. Setting the display mode to .inline to use the compact bar
.navigationBarTitleDisplayMode(.automatic)
Say, we want to change the title color to red and the font to Arial Rounded MT Bold. We
create a UINavigationBarAppearance object in the init() function and configure the
attributes accordingly. 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
}
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 set it to the system
image arrow.turn.up.left .
For the back button color, you can change it by setting the accentColor property like this:
NavigationStack {
.
.
.
}
.accentColor(.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 toolbar , we create an ToolbarItem object with the placement set to
.navigationBarLeading . This tells iOS to place the button in 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 retrieve the environment value using the .dismiss
key. Then you can call the dismiss() function to dismiss the current view. Please note
that the .dismiss environment key is only available on iOS 15 (or up). If you need to
support an older version of iOS, you can use the environment key .presentationMode :
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 make sure understand how to build a navigation UI, here is an exercise for you. First,
download this starter project from
https://www.appcoda.com/resources/swiftui4/SwiftUINavigationStarter.zip. Open the
project and you will see a demo app showing a list of articles.
This project is very similar to the one you've built before. The main difference is the
introduction of Article.swift . This file stores the articles array, which contains sample
data. If you look at the Article struct closely, it now has the content property for
storing a full article.
Your task is to embed the list in a navigation view and create the detail view. When a user
taps one of the articles in the content view, it'll navigate to the detail view showing the
full article. I'll present the solution to you in the next section, but please try your best to
figure out your own solution.
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 to enable scrollable content. I'll not go over the
code line by line as you understand how Text , Image , and VStack work. But one
modifier that I want to highlight is Group . This modifier allows you to group multiple
Now that we have completed the layout of the detail view, you will see an error in Xcode
complaining about the ArticleDetailView_Previews . The preview doesn't work because
we've added the property article in ArticleDetailView . Therefore, you need to pass a
sample article in the preview. Update ArticleDetailView_Previews like this to fix the error:
Here we simply pick the first article of the articles array for preview. You can change it
to a different value if you want to preview other articles. Once you have made this
change, the preview canvas should render the detail view properly.
.navigationTitle("Article")
}
}
}
By updating the code, you will see a blank 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")
}
}
}
NavigationStack {
List(articles) { article in
ZStack {
ArticleRow(article: article)
.listRowSeparator(.hidden)
}
}
.listStyle(.plain)
.navigationTitle("Your Reading")
}
The lower layer is the article row, while the upper layer is an empty view. The
NavigationLink now applies to the empty view, preventing iOS from rendering the
disclosure button. Once you have made the change, the disclosure indicator vanishes but
you can still navigate to the detail view.
The reason why we have that empty space right above the image is due to the navigation
bar. This empty space is actually a large-size navigation bar with a blank title. When the
app navigates from the content view to the detail view, the navigation bar becomes a
standard-size bar. So, to fix the issue, all we need to do is explicitly specify to use the
standard-size navigation bar.
.navigationBarTitleDisplayMode(.inline)
By setting the navigation bar to the inline mode, the navigation bar will be minimized.
You can now go back to ContentView.swift and test the app again. The detail view now
looks much better.
In this last section, I want to show you how to build an even more elegant detailed view
by hiding the navigation bar and building your own back button. First, let's check out the
final design displayed in figure 14. Doesn't it look great?
To place content that extends outside the safe areas, you use a modifier named
ignoresSafeArea . For our project, we want the scroll view to go beyond the top edge of the
safe area, To accomplish this, we write the modifier like this:
parameter. If you want to ignore the whole safe area, you can just call
.ignoresSafeArea() . By attaching this modifier to the ScrollView , we can hide the
navigation bar and achieve a visually pleasing detail view.
Now it comes to the second issue of creating our own back button. This issue is trickier
than the first one. Here is what we're going to implement:
.navigationBarBackButtonHidden(true)
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
navigation bar. Here is the code:
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button(action: {
// Navigate to the previous screen
}) {
Image(systemName: "chevron.left.circle.fill")
.font(.largeTitle)
.foregroundColor(.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.
The original back button rendered by NavigationView can automatically navigate back to
the previous screen. We need to programmatically navigate back. Thanks to the
environment values built into the SwiftUI framework. You can refer to an environment
binding named dismiss for dismissing the current view.
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 be able to navigate between the content
view and the detail view.
Summary
Navigation UI is very common in mobile apps. It's crucial you understand this key
concept. With this understanding, you are capable of building a simple content-based
app, although the data is static. x
To further study navigation view, you can also refer to the documentation provided by
Apple:
For iPhone users, you should be very familiar with modal views. One common use of
modal views is for presenting a form for input. For example, the Calendar app presents a
modal view for users to create a new event. The built-in Reminders and Contact apps also
use modal views to ask for user input.
From the user experience point of view, a modal view is usually triggered by tapping a
button. Again, the transition animation of the modal view is handled by iOS. When
presenting a full-screen modal view, it slides up fluidly from the bottom of the screen.
If you're a long-time iOS user, you may find the look & feel of the modal views displayed
in figure 1 are not the same as the traditional ones. Prior to iOS 13, the presentation of
modal views covered the entire screen. Starting with iOS 13, modal views are displayed in
card-like format by default. The modal view doesn't cover the whole screen but partially
covers the underlying content view. You can still see the top edge of the content/parent
view. On top of the visual change, the modal view can now be dismissed by swiping down
from anywhere on the screen. You do not need to write a line of code to enable this
gesture. It's completely built-in and generated by iOS. Of course, if you want to dismiss a
modal view via a button, you can still do that.
I will show you how to present the same detail view that we implemented in the previous
chapter using a modal view. While modal views are commonly used for presenting a
form, it doesn't mean you can't use them for presenting other information. In addition to
modal views, you will also learn how to create a floating button in the detail view. While
the modal views can be dismissed through the swipe gesture, I want to provide a Close
button for users to dismiss the detail view. Furthermore, we will also look into Alerts,
which is another kind of modal view.
Before we dive into the implementation, let me give you a quick introduction to the card-
like presentation of modal views. The card presentation is achieved in SwiftUI using the
sheet presentation style. It's the default presentation style for modal views.
Basically, to present a modal view, you apply the sheet modifier like this:
.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 .
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 .
The remaining item is to detect the user's touch. When building the navigation UI, we
utilize NavigationLink to handle touch. However, this special button is designed for the
navigation interface. In SwiftUI, there is a handler called onTapGesture which can be used
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 by clicking the play button. 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
later section.
In this case, the sheet modifier requires you to pass an optional binding. Here we
specify the binding of the selectedArticle . What this means is that iOS will bring up the
modal view only if the selected article has a value. The code in the closure specifies how
the modal view looks, but it's slightly different than the code we wrote earlier.
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.
Switch over to ArticleDetailView.swift . We'll add the close button to the view as shown
in figure 7.
Do you know how to position the button at the top-right corner? Try not to peek at my
code and come up with your own implementation.
Similar to 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)
.foregroundColor(.white)
}
.padding(.trailing, 20)
.padding(.top, 40)
Spacer()
}
}
)
The button will be overlayed on top of the scroll view so that it appears as a floating
button. Even if you scroll down the view, the button will be stuck at the same position. To
place the button at the top-right corner, here we use a HStack and a VStack , together
with the help of Spacer . To dismiss the view, you simply call the dismiss() function.
Run the app in a simulator or 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 kind of modal view. When it's
presented, the entire screen is blocked. You can't dismiss the dialog without choosing one
of the options. Figure 7 shows a sample alert that we're going to implement in our demo
project. What we're going to display an alert after a user taps the close button.
In SwiftUI, you create an alert using the .alert modifier. Here is an example of .alert :
The sample code initiates an alert view with the title "Warning". The alert prompt also
displays the message, "Are you sure you want to leave" to the user. There are two buttons
in the alert view: 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 he/she has finished reading the article. If the user
chooses Yes, the modal view will be closed. Otherwise, the modal view will stay open.
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 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. 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, what project are we going to work on? Take a look at figure 1. We're going to build
a Setting screen for the Restaurant app we have been working on in earlier chapters. The
screen provides users with the options to configure the order and filter preferences. This
type of form is very common 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, we will focus on implementing the form layout. You will understand how
to use the Form component to lay out a setting screen. We will also implement a picker
for selecting a sort preference. We'll also create a toggle and a stepper for indicating filter
preferences. Once you understand how to lay out a form, in the next chapter, I will show
you how to make the app fully functional by updating the list in accordance with the
user's preferences. You'll learn how to store user preferences, share data between views
and monitor data update with @EnvironmentObject .
in the canvas and you'll see a familiar UI except that it incorporates more detailed
information for a restaurant.
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.
Since we will build a separate screen for Settings, let's create a new file for the form. In
the project navigator, right click the SwiftUIForm folder and choose "New File...." Next,
select to use SwiftUI View as the template and name the file SettingView.swift.
Now, let's start by creating the form. Replace SettingView with this:
.navigationBarTitle("Settings")
}
}
}
To lay out a form, you use the Form container. Inside it, you add sections and form
components (text field, picker, toggle etc.). In the code above, we create two sections:
Sort Preference and Filter Preference. For each section, we have a text view. Your canvas
should display a preview like that shown in figure 4.
For the sort preference, users are allowed to choose the display order of the restaurant
list, in which we offer three options for them to choose:
1. Alphabetically
2. Show Favorite First
3. Show Check-in First
A Picker control is very suitable for handling this kind of input. First, we use an array to
represent each of the options above. Let's declare an array named displayOrders in
SettingView :
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 set to Alphabetical. This is
because selectedOrder is default to 0 . If you click the Play button to text the view,
tapping the option will bring you to the next screen, showing you all the available
options. You can pick any of the options (e.g. Show Favorite First) for testing. When you
go back to the Setting screen, the Display Order will become your selection. This is the
power of the @State keyword. It automatically monitors the changes and helps you store
the state of the selection.
You use Toggle 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 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 selection.
Using Steppers
The last UI control in the setting form is a Stepper. Again, referring to figure 1, users can
filter the restaurants by setting the pricing level. Each of the restaurants 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 . I assume you've read the modal view chapter, so 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 lets you add a button in the navigation bar. You're
allowed to create a button at the leading or trailing position of the navigation bar. Since
we want to display the button at the top-right corner, we use the trailing parameter.
The sheet modifier is used for presenting the SettingView as a modal view.
In the canvas, you should see a gear icon in the navigation bar. 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. You
are not required to implement these buttons. When a user taps any of the buttons, just
dismiss the setting view.
For reference, you can download the complete form project here:
If you haven't finished the exercise in the previous chapter, I encourage you to spend
some time on it. That said, if you can't wait to read this chapter, you can download the
project from https://www.appcoda.com/resources/swiftui4/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. That means you
can create instance methods to provide additional functionality related to the values.
Later, we will add a function for handling the filtering. Meanwhile, let's create a new
Swift file named SettingStore.swift to store the Enum . You can right click SwiftUIForm in
the project navigation 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, of 0, the code is more readable after switching to use an enumeration. Next, you
also need to change the code in the Sort Preference section. Specifically, we update the
code in the ForEach loop:
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's 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")
.foregroundColor(.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 SettingView_Previews like this:
.sheet(isPresented: $showSettings) {
SettingView(settingStore: self.settingStore)
}
If you compile and run the app now, Xcode will show you an error. There is one more
change we need to make before the app can run properly.
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 right now. When a user taps the Save button in 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. So, either we 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 un 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 lets subscribers know 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. Actually we
haven't 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 declared as an
environment object so that we share the data with other views. Update the 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:
Lastly, open SwiftUIFormApp.swift and update the line of code inside WindowGroup like
this:
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 sort options to display only the
restaurants that match the user preferences. Let's start with the implementation of these
two filtering options:
This function takes in a restaurant object and tells the caller if 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 in a simulator and have a quick test. In the setting screen, set the Show
Check-in Only option to ON and configure the price level option to show restaurants that
are with price level 3 (i.e. $$$) or below. Once you tap the Save button, the list view
should be automatically refreshed (with animation) and shows you the filtered records.
method. When you use this method, you need to provide a predicate to it that returns
true when the first element should be ordered before the second.
For example, to sort the restaurants array in alphabetical order. You can use the
sort(by:) method like this:
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 predictate 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 sort preference. When you update the sort option,
the list view will get notified and re-orders the restaurants accordingly.
Demo project
(https://www.appcoda.com/resources/swiftui4/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/swiftui4/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 it in detail.
You can always refer to the Button chapter.
Lastly, it is the description text Already have an account. This text and the Sign in
button are completely optional. I'm mimicing 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. That's completely fine. Here I just want to
show you one of the approaches 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 of 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"? What are these values?
Let's use the registration form as an example. The app continues to generate UI events
when it interacts with users. Each keystroke a user enters in the text field triggers an
event. This becomes 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 framework. With Combine,
Publisher sends events and Subscriber subscribes to receive values from that Publisher.
Again, let's use the text field as an example. By 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 (e.g. validation).
For example, you are writing a form validator which has a property to indicate if the form
is ready to submit. In this case, you can mark that property with the @Published
Every time you change the value of isReadySubmit , it publishes an event to the subscriber.
The subscriber receives the updated value and continues the processing. Let's say, the
subscriber uses that value to determine if the submit button should be enabled or not.
You may think @Published works pretty much like @State in SwiftUI. While it works
pretty much the same for this example, @State only applies to properties that belong to a
specific SwiftUI view. If you want to create a custom type that doesn't belong to a specific
view or that can be used among multiple views, you need to create a class that conforms
to ObservableObject and mark those properties with the @Published annotation.
I know you may have a few questions in mind. First, why do we need to create a view
model? Can we add the properties of the form and perform the form validation in the
ContentView?
Absolutely, you can do that. But as your project grows or the view becomes more
complex, it's a good practice to break a complex component into multiple layers.
Take a look at the registration form again. We have three text fields including:
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:
Therefore, the view model will have seven properties and each of these properties
publishes its value change to those which are interested in receiving the value. The basic
skeleton of the view model can be defined like this:
// Output
@Published var isUsernameLengthValid = false
@Published var isPasswordLengthValid = false
@Published var isPasswordCapitalLetter = false
@Published var isPasswordConfirmValid = false
}
That's the data model for the form view. The username , password , and passwordConfirm
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.
What's missing here is something that connects between these two publishers. And, this
"something" should handle the following tasks:
$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. For sink , it
creates a general purpose subscriber to receive values. assign allows you to create
another type of subscriber that can update a specific property of an object. For example,
it assigns the validation result (true/false) to isUsernameLengthValid directly.
Let me dive deeper into the code above line by line. $username is the source of value
change that we want to listen to. Since we're subscribing to the change of UI events, we
call the receive(on:) function to ensure 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:
With the validation result, the subscriber simply sets the result to the
isUsernameLengthValid property. Recall that isUsernameLengthValid is also a publisher, we
can then update the RequirementText control like this to subscribe to the change and
update the UI accordingly:
Both the icon color and the status of strike through depend on the validation result (i.e.
isUsernameLengthValid ).
This is how we use Combine to validate a form field. We still haven't put the code change
into our project, but I want you to understand the concept of publisher/subscriber and
how we perform validation using this approach. In later section, we will apply what we
learned and make the code change.
$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 the verification result of password length and assigns it to
the isPasswordLengthValid property. The second subscriber hands the validation of the
uppercase letter. We use the range method to test if the password has at least one
uppercase letter. Again, the subscriber assigns the validation result the
isPasswordCapitalLetter property directly.
Okay, what's left is the validation of the password confirm field. For this field, the input
requirement is that the password confirm should be equal to that of 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 verify if the two values 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 went through in the 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 change of the text fields and perform the
corresponding validations.
The assign function, which creates the subscriber, returns you with a cancellable
instance. You can use this instance to cancel the subscription at the appropriate time. The
store function lets us save the cancellable reference into a set for later cleanup. If you do
not store the reference, the app may end up with memory leak issues.
So, when will the clean up happen for this demo? Because cancellableSet is defined as a
property of the class, the cleanup and cancellation of the subscription 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 completely change the way you build apps.
Functional Reactive Programming (FRP) has become more and more popular in recent
years. This is the first time Apple has released their own functional reactive framework.
To me, it's a major paradigm shift. The company finally took position on FRP and
recommends Apple developers embrace this new programming methodology.
Like the introduction of any new technology, there will be a learning curve. Even if you've
been programming in iOS, it will take time to move from the programming methodology
of delegates to publishers and subscribers.
For reference, you can download the complete form validation project here:
Demo project
(https://www.appcoda.com/resources/swiftui4/SwiftUIFormRegistration.zip)
Figure 1. Swipe to delete (left), context menu, and action sheet (right)
While this chapter focuses on the interaction of a list, the techniques that I'm going to
show you can also be applied to other UI controls such as buttons.
If you have a sharp eye, you may spot that the starter project used ForEach to implement
the list. Why did I use ForEach instead of passing the collection of data to List ? The
main reason is that the onDelete handler that I'm going to walk you through only works
with ForEach .
Implementing Swipe-to-delete
Assuming you have the starter project ready, let's begin implementing the swipe-to-
delete feature. I've briefly mentioned the onDelete handler. To activate swipe-to-delete
for all rows in a list, you just need to attach this handler to all the row data. So, update
the List like this:
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.
There is still one thing left before the swipe-to-delete feature works. Whenever a user
removes a row from the list, the UI should be updated accordingly. As discussed in earlier
chapters, SwiftUI has come with a very powerful feature to manage the application's
state. In our code, the value of the restaurants array will be changed when a user
chooses to delete a record. We have to ask SwiftUI to monitor the property and update
the UI whenever the value of the property changes.
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 written the same feature using UIKit, I'm sure you are amazed by SwiftUI. With
just a few lines of code and a keyword, you implemented 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 people touch and hold any
of the rows. The menu provides two action buttons for users to choose: Delete and
Favorite. When selected, the Delete button will remove the row from the list. The
Favorite button will mark the selected row with a star indicator.
To present these two items in the context menu, we attach the contextMenu to each of the
rows in the list like this:
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 :
This delete function takes in a restaurant object and searches for its index in the
restaurants array. To find the index, we call the firstIndex function and specify the
search criteria. The function loops through the array and compares the id of the given
restaurant with those 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. Press and hold
one of the rows to bring up the context menu. Choose Delete and you should see your
selected restaurant removed from the list.
Let's move onto 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:
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 , meaning 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 have 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 block of code in onTapGesture will be run. 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 block of code will be executed. We initiate an ActionSheet with three
buttons: Mark as Favorite, Delete, and Cancel. Action sheet comes with three types of
buttons including default, destructive, and cancel. You usually use the default button
type for ordinary actions. A destructive button is very similar to a default button but the
font color is set to red 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 used 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. For 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
selectedRestaurant binding 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 framework provides several built-in gestures such as the tap gesture we have used
before. additionally, DragGesture, MagnificationGesture, and LongPressGesture are
some of the ready-to-use gestures. We will be looking at a couple of them and seeing how
to work with gestures in SwiftUI. On top of that, 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 in
ContentView.swift .
By modifying the code above a bit 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 is
how you use the .gesture modifier to detect and respond to certain touch events. If you
forget how animation works, please go back to read 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 an
update. In the update function, we pass 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 drag the image around. When you release it,
the image returns to its original position.
Do you know 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 do you do that?
Give yourself 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. Therefore, let's declare a new state
property like this:
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 be very familiar with some parts of the code snippet because we are
combining the long press gesture that we have built with the drag gesture.
Let me explain 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 he/she can begin the dragging.
So, we start by creating the LongPressGesture . Similar to what we have implemented
before, we have a isPressed gesture state property. When someone taps the image, we
will alter the opacity of the image.
The sequenced keyword is how we link the long press and drag gestures together. We tell
SwiftUI that the LongPressGesture should happen before the DragGesture .
The code in both updating and onEnded functions looks pretty similar, but the value
parameter now actually contains two gestures (i.e. long press and drag). We have the
switch statement to differentiate between the gestures. You can use the .first and
.second cases to find out which gesture to handle. Since the long press gesture should be
recognized before the drag gesture, the first gesture here is the long press gesture. In the
code, we do nothing but just print the Tapping message for your reference.
When the long press is confirmed, we will reach the .second case. Here, we pick up the
drag data and update the dragOffset with the corresponding translation.
When the drag ends, the onEnded function will be called. Similarly, we update the final
position by figuring out the drag data (i.e. .second case).
Now you're ready to test the gesture combination. Run the app in the preview canvas
using the debug preview, so you can see the message in the console. You can't drag the
image until holding 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 result of the code is the same. However, it's always good practice to use Enum to
track complicated 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 you've written before. The tricks are to declare the
DraggableView as a generic view and create a content property. This property accepts
any View . We power this content view with the long press and drag gestures.
Now you can test this generic view by replacing the DraggableView_Previews like this:
In the code, we initialize a DraggableView and provide our own content, which is the star
image. In this case, you should achieve the same star image which supports the long
press and drag gestures.
So, what if we want to build a draggable text view? You can replace the code snippet with
the following code:
In the closure, we create a text view instead of the image view. If you run the project in
the preview canvas, you can drag the text view to move it around (remember to long
press for 1 second). Isn't it cool?
If you want to create a draggable circle, you can replace the code like this:
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
We've explored three built-in gestures including tap, drag, and long press in this chapter.
However, there are a couple of them we haven't checked out. As an exercise, try to create
a generic scalable view that can recognize the MagnificationGesture and scale any given
view accordingly. Figure 7 shows you a sample result.
Summary
The SwiftUI framework has made gesture handling very easy. As you've learned in this
chapter, the framework has provided several ready to use gesture recognizers. To enable
a view to support a certain type of gesture, all you need to do is attach the .gesture
It's a growing trend to build gesture-driven user interfaces for mobile apps. With the easy
to use API, try to power your apps with some useful gestures to delight your users.
For reference, you can download the complete gesture project here:
The size of bottom sheets varies depending on the contextual information you want to
display. In some cases, bottom sheets tend to be bigger (which is also known as
backdrops) and take up 80-90% of the screen. Usually, users are allowed to interact with
the sheet with the drag gesture. You can slide it up to expand its size or slide it down to
minimize or dismiss the sheet.
In this chapter, we will build a similar expandable bottom sheet using SwiftUI gestures.
The demo app shows a list of restaurants in the main view. When a user taps one of the
restaurant records, the app brings up a bottom sheet to display the restaurant details.
You can expand the sheet by sliding it up. To dismiss the sheet, you can slide it down.
Starting from iOS 16, the SwiftUI framework comes with a new modifier called
presentationDetents for presenting a resizable bottom sheet.
To present a bottom sheet, you insert the modifier inside the sheet view. Here is an
example:
Spacer()
}
}
}
You specify a set of detents in the presentationDetents modifier. As shown above, the
bottom sheet supports both medium and large size. When it first appears, the bottom
sheet is displayed in medium size. You can expand it to large size by dragging the sheet.
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.
Before you follow me to implement the view, I suggest you consider it as an exercise and
create the detail view on your own. As you can see, the detail view is composed of UI
components including Image, Text, and ScrollView. We have already covered all these
components, so give it a try and provide your own implementation.
Okay, let me show you how to build the detail view. If you have already built the detail
view on your own, you can use my implementation as a reference.
The layout of the detail view is a bit complicated, so it's better to break it into multiple
parts for easier implementation:
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
First, the handlebar. The handlebar is actually a small rectangle with rounded corners. To
create it, all we need to do is to create a Rectangle and give it rounded corners. In the
RestaurantDetailView.swift file, insert the following code:
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
overlayed on top of the image view. Again, we will use a separate struct to implement the
header view:
Text(restaurant.type)
.font(.system(.headline, design: .rounded))
.padding(5)
.foregroundColor(.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 created an Image view and set the content mode to
scaleToFill . The height of the image is fixed at 300 points. Since we use the scaleToFill
mode, we need to attach the .clipped() modifier to hide any content that extends
beyond the edges of the image frame.
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 in two parameters: icon and info . The icon parameter is an
optional, meaning that it can either have a value or nil.
When you need to present a data field with an icon, you use the DetailInfoView like this:
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:
In the code, we pass a sample restaurant (i.e. restaurants[0] ) for testing. If you've
followed everything correctly, Xcode should show you a similar detail view in the preview
canvas to 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:
HandleBar()
ScrollView(.vertical) {
TitleBar()
HeaderView(restaurant: self.restaurant)
Except the handlebar, the rest of the views are wrapped within the scroll view. If you run
the app in the preview canvas again, the detail view is now scrollable.
In the ContentView struct, declare a state variable to store the user's chosen restaurant:
List {
ForEach(restaurants) { restaurant in
BasicImageRow(restaurant: restaurant)
.onTapGesture {
self.selectedRestaurant = restaurant
}
}
}
The detail view, which is the bottom sheet, is expected to overlay on top of the list view.
We check if the detail view is enabled and initialize it like this:
NavigationStack {
.
.
.
}
.sheet(item: $selectedRestaurant) { restaurant in
RestaurantDetailView(restaurant: restaurant)
.presentationDetents([.medium, .large])
}
modifier. Thus, when the user selects a restaurant, the app brings up the detail view in
the form of bottom sheet.
Since the presentation detents support both medium and large sizes, you can drag the
bottom sheet upward to expand it.
RestaurantDetailView(restaurant: restaurant)
.presentationDetents([.medium, .large])
.presentationDragIndicator(.hidden)
Every time you open the bottom sheet, it begins with the .height(200) detent. What if
you want to restore the last selected detent? In this case, you can declare a state variable
to keep track of the currently selected detent:
For the presentationDetents modifier, you specify the binding of the variable in the
selection parameter:
SwiftUI then stores the currently selected detent in the state variable. Even if you dismiss
the bottom sheet, the next time when you bring the bottom sheet, it restores to the last
selected detent.
Summary
In this chapter, I showed you how to create a bottom sheet with the new
presentationDetents modifier. This is one of the most anticipated view components that
many developers have been waited for. With this customizable bottom sheet, you can
now easily display supplementary content that anchors to the bottom of the screen.
For reference, you can download the complete bottom sheet project here:
Demo project
(https://www.appcoda.com/resources/swiftui4/SwiftUIBottomSheet.zip)
What we are going to do in this chapter is to build a simple app with a Tinder-like UI. The
app presents users with a deck of travel cards and allows them to use the swipe gesture to
like/dislike a card.
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 want to use your own images. But to save you time preparing trip
images, I have created a starter project for you. You can download it from
https://www.appcoda.com/resources/swiftui4/SwiftUITinderTripStarter.zip. This
project comes with 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 you
stop here and implement 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 CardView is designed to display different photos and titles. So, declare two variables
for storing these data:
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 if you forgot what the Identifiable protocol is, please refer to chapter 10.
Now let's continue to implement 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(Color.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 by using
the cornerRadius modifier. The text component is used to display the destination of the
trip.
You can't preview the card view yet because you have to provide the values of both image
and title in the CardView_Previews . Therefore, update the CardView_Previews struct like
this:
I simply use one of the images in the asset catalog for preview purposes. You are free to
alter the image and title to fit your own needs. In the preview canvas, you should now see
the card view similar to figure 4.
The three icons are arranged using a horizontal stack with equal spacing. For the bottom
bar menu, the implementation is pretty much the same. 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(Color.black)
.cornerRadius(10)
}
.padding(.horizontal, 20)
Image(systemName: "heart")
.font(.system(size: 30))
.foregroundColor(.black)
}
}
}
We are not going to implement the "Book Trip" feature, so the action block is left blank.
The rest of the code should be self explanatory assuming 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 put these bars in the ContentView in order to preview their look and
feel.
TopBarMenu()
.previewDisplayName("TopBarMenu")
BottomBarMenu()
.previewDisplayName("BottomBarMenu")
}
}
Here we include all the views in the preview section. For TopBarMenu and BottomBarMenu
views, we added the previewDisplayName modifier to give the view a distinct name. If you
take a look at the preview canvas, you will see three previews: Content View,
TopBarMenu, and BottomBarMenu. Simply click the view to preview its layout. Figure 5
gives you a better idea 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.
You can imagine a Tinder-like UI as a deck of piled photo cards. For our demo app, the
photo is a destination of a trip. Swiping the topmost card (i.e. the first trip) slightly to the
left or right unveils the next card (i.e. the next trip) underneath. If the user releases the
card, the app brings the card to the original position. But, when the user swipes hard
enough, he/she can throw away the card and the app will bring the second card forward
to become the 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 code above, we initialize an array of cardViews containing all the trips, which was
defined in the Trip.swift file. In the body variable, we loop through all the card views
and overlay one with another by wrapping them in a ZStack .
The preview canvas should show you the same UI but with another image.
Why did it display another image? If you refer to the trips array defined in Trip.swift ,
the image is the last element of the array. In the ForEach block, the first trip is placed at
the lowermost part of the deck. Thus, the last trip becomes the topmost photo of 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
}
While looping through the card views, we have to figure out a way to identify the topmost
card. The function above takes in a card view, find out its index, and tells you if the card
view is the topmost one.
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, it’s more complicated. Our goal is to make sure the card deck can
support tens of thousands of card views but without becoming resource intensive.
Let’s take a deeper look at the card deck. Do we actually need to initiate an individual
card view for each trip photo? To create this card deck UI, we can just create two card
views and overlay them with each other.
When the topmost card view is thrown away, the card view underneath becomes the
topmost card. And, at the same time, we immediately initiate a new card view with a
different photo and put it behind the topmost card. No matter how many photos you
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 show two card views in the deck.
First, define the DragState enum in ContentView , which represents the possible 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 what we learned in the gesture chapter to implement the dragging.
The .gesture modifier has two gesture recognizers: long press and drag. When the drag
gesture is detected, we update the dragState variable and store the translation of the
drag.
We also made some code changes to the BottomBarMenu . While a user is dragging the card
view, I want to hide the bottom bar. Thus, we apply the .opacity modifier and set its
value to zero when it's in the dragging state.
After you make the change, run the project in a simulator to test it. You should be able to
drag the card and move around. And, when you release the card, it returns to its original
position.
Do you notice a problem here? While the drag is working, you're actually dragging the
whole card deck! It's supposed to only drag the topmost card and the card underneath
should stay unchanged. Also, the scaling effect should only apply to the topmost card.
To fix the issues, we need to modify the code of the offset , scaleEffect , and
rotationEffect modifiers such that the dragging only happens for the topmost card view.
})
)
}
}
Just focus on the changes to the offset , scaleEffect , and rotationEffect modifiers.
The rest of the code was kept intact. For those modifiers, we introduce an additional
check such that the effects are only applied to the topmost card.
Now if you run the app again, you should see the card underneath and drag the topmost
card.
Once the translation of a drag passes the threshold, we will overlay an icon (either heart
or xmark) on the card. Furthermore, if the user releases the card, the app will remove it
from the deck, create a new one, and place the new card to the back of the deck.
To overlay the icon, add an overlay modifier to the cardViews . You can insert the
following code under 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 its 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 unveil one of the images when the drag's translation
exceeds the threshold.
You can run the project to have a quick test. When your drag exceeds the threshold, the
heart/xmark icon will appear.
First, let's mark the cardViews array with @State so that we can update its value and
refresh the UI:
return views
}()
Next, declare another state variable to keep track of the last index of the trip. Say, when
the card deck is first initialized, we display the first two trips stored in the trips array.
The last index is set to 1 .
Okay, here comes 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, then it
instantiates a new card view with the subsequent trip's image. Since cardViews is defined
as a state property, SwiftUI will render the card views again once the array's value is
changed. This is how we remove the topmost card and insert a new one to the deck.
Next, update the .gesture modifier and insert the .onEnded function:
.gesture(LongPressGesture(minimumDuration: 0.01)
.sequenced(before: DragGesture())
.updating(self.$dragState, body: { (value, state, transaction) in
.
.
.
})
.onEnded({ (value) in
self.moveCard()
}
})
)
When the drag gesture ends, we check if the drag's translation exceeds the threshold and
call the moveCard() accordingly.
Now run the project in the preview canvas. Drag the image to the right/left until the icon
appears. Release the drag and the topmost card should be replaced by the next card.
To fine tune the animation effect, we will attach the transition modifier and apply an
asymmetric transition to the card views.
Add the extension, AnyTransition to the bottom of ContentView.swift and define two
transition effects:
The reason why we use asymmetric transitions is that we only want to animate the
transition when the card view is removed. When a new card view is inserted in the deck,
there should be no 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/swiftui4/SwiftUITinderTrip.zip)
Project Preparation
To keep you focused on learning animations and view transitions, begin with this starter
project (https://www.appcoda.com/resources/swiftui4/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.
The testCards variable was defined in Card.swift . Therefore, we use ForEach to loop
through the cards and set the name of each preview by calling previewDisplayName . 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()
WalletView()
Switch back to WalletView.swift . The compilation error will be fixed once you make the
change. Now let's continue to layout the wallet view. First, we'll start with the title bar. In
the WalletView.swift file, insert a new struct for the bar:
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:
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. The first item being put into the ZStack becomes the lowermost
layer, while the last item is the uppermost layer. If you look at 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.
Let's first talk about the z-index. Each card's z-index is the negative value of its index in
the cards array. The last item with the largest array index will have the smallest z-index.
For this implementation, we will create an individual function to handle the computation
of z-index. In the WalletView , insert the following code:
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.
Next, let's fix the first issue to spread out the cards. Each of the cards should be offset by
a certain vertical distance. That distance is computed by using the card's index. Say, we
set the default vertical offset to 50 points. The last card (with the index #4) will be offset
by 200 points (50*4).
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 view at the app launch? The trick is
to attach an id modifier to each of the card view:
The value is set to isCardPresented . Now attach the onAppear modifier and attach it 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 :
.gesture(
TapGesture()
.onEnded({ _ in
withAnimation(.easeOut(duration: 0.15).delay(0.1)) {
self.isCardPressed.toggle()
self.selectedCard = self.isCardPressed ? card : nil
}
})
)
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 card selected
by the user, we set the offset to .zero . For those cards that are right below the selected
card, we will also move them upward. This is why we set the offset to .zero . For the rest
of the cards, we move them off the screen. Therefore, the vertical offset is set 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
is shown or not. Insert the following 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 little upward. This is
the feedback that we want to give to users, telling them we are ready to drag the card
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 tell
SwiftUI to either capture the tap gesture or the long press gesture. In other words,
SwiftUI will ignore 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(.spring()) {
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 a deeper understanding of SwiftUI
animation and view transitions. If you compare SwiftUI with the original UIKit
framework, SwiftUI has made it pretty easy to work with animation. Do you remember
how you rendered the card animation when the user releases the dragging card? All you
need to do is to update the state variable and SwiftUI handles the heavy lifting. That is
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 will explain to you, in
detail, the two different approaches in handling JSON in Swift.
As usual, in order to learn about JSON and its related APIs, you will build a simple JSON
app that utilizes a JSON-based API provided by Kiva.org. If you haven't heard of Kiva, it
is a non-profit organization with a mission to connect people through lending to alleviate
poverty. It lets individuals lend as little as $25 to help create opportunities around the
world. Kiva provides free web-based APIs for developers to access their data. For our
demo app, we'll call up a free Kiva API to retrieve the most recent fundraising loans and
display them in a list view as shown in figure 1.
Additionally, we will demonstrate the usage of a Slider, which is one of the many built-in
UI controls provided by SwiftUI. With the slider, you will implement a data filtering
option in the app so that users can filter the loan data in the 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 whole process by offering developers a different way to decode (or
encode) JSON. As long as your type conforms to the Codable protocol, together 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 rawValue 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 need to define an additional enumeration. In the code above, we name it
LocationKeys and declare the case country that 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
Assuming you have launched Xcode, go up to the menu and select File > New > Projects
to create a new project. As usual, use the App template. Name the project
SwiftUIKivaLoan or whatever name you prefer.
We will start by building the model class that stores all the latest loans retrieved from
Kiva. 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 default 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 to connect to the Kiva's web API, decode the JSON data, and store 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 :
Later, the decoder will decode the loans JSON objects and store them into the loans
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, requires some extra work. When the @Published property wrapper is
used, we need to implement the required method of Decodable manually. If you look into
the documentation (https://developer.apple.com/documentation/swift/decodable), 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 by 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 the reason 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 LoanCellView_Previews like this:
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 chapter 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 up the fetchLatestLoans() method
to retrieve the latest loans from Kiva.
If this is the first time you use .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:
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 the UI update
manually and pass the 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 the data presentation
and sharing, I'll assume that you've read the following chapters:
If you haven't done so or forgot what Combine and Environment Objects are, go back and
read the chapters again.
What are we going to do in this chapter to understand Core Data? 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 gone. We will modify
the app and convert it to use Core Data 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 walk you through the starter project so you
fully understand how the code works. Other than Core 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 of SwiftUI. I will show you how to create these checkboxes by
customizing the Toggle's style.
The Core Data framework simply shields developers from the inner details of the
persistent store. Take the SQLite database as an example. You do not need to know how
to connect to the database nor understand SQL to retrieve data records. All you need to
figure out is how to work with the Core Data APIs such as NSManagedObjectContext and the
Managed Object Model.
Feeling confused? No worries. You will understand what I mean after we convert the
ToDo app from arrays to Core Data.
By enabling Core Data, Xcode will generate all the required code and the managed object
model for you. Once the project created, you should see a new file named
CoreDataTest.xcdatamodeld . In Xcode, the managed object model is defined in a file with
the extension .xcdatamodeld . This is the managed object model generated for your
project and this is where you define the entities for interacting with the persistent store.
Take a look at the Persistence.swift file, which is another file generated by Xcode. This
file contains the code for loading the managed object model and saving the data to the
persistent store.
If you've developed apps using UIKit before, you usually use the container to manage the
data in the database or other persistent stores. In SwiftUI, it's a little bit different. We
seldom use this container directly. Instead SwiftUI injects the managed object context
into the environment, so that any view can retrieve the context and manage the data.
Take a look at the CoreDataTestApp.swift file. Xcode adds a constant that holds the
instance of PersistenceController and a line of code to inject the managed object context
is injected into the environment.
This is all the code and files generated by Xcode when enabling the Core Data option. If
you open ContentView.swift , Xcode also generates sample code for loading data from the
local data store. Look at the code to get an idea of how this works. In general, to save and
manage data on the local database, the procedures are:
2. Define a managed object, which inherits from NSManagedObject , to associate with the
entity
3. In the views that need to save and update the data, get the managed object context
from the environment using @Environment like this:
And then create the managed object and use the save method of the context to add
the object to the database. Here is a sample code snippet:
@FetchRequest(
entity: ToDoItem.entity(),
sortDescriptors: [ NSSortDescriptor(keyPath: \ToDoItem.priorityNum, ascending:
false) ])
var todoItems: FetchedResults<ToDoItem>
This property wrapper makes it very easy to perform a fetch request. You just need
to specify the entity object you want to retrieve and how the data is ordered. The
framework will then use the environment's managed object context to fetch the data.
Most importantly, SwiftUI will automatically update any views that are bound to the
fetched results because the fetch result is a collection of NSManagedObject , which
conforms to the ObservableObject protocol.
This is how you work with Core Data in SwiftUI projects. I know you may be confused by
some of the terms and procedures. This section is just a quick introduction. Later, when
you work on the demo app, we will go through these procedures in detail.
Run the app in the preview canvas or a simulator. Tap the + button to add a to-do item.
Repeat the procedure to add a few more items. The app then lists the to-do items.
Tapping the checkbox of a to-do item will cross out that item.
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 adopts the ObservableObject protocol. The three properties are 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)
.foregroundColor(.purple)
}
}
.padding()
List {
ForEach(todoItems) { todoItem in
ToDoListRow(todoItem: todoItem)
}
}
}
.rotation3DEffect(Angle(degrees: showNewTask ? 5 : 0), axis: (x: 1, y: 0, z: 0))
.offset(y: showNewTask ? -50 : 0)
.animation(.easeOut)
I declared a state variable named todoItems to hold all the to-do items. It's marked with
@State so that the list will be refreshed whenever there are any changes. In the List
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.
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 this 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.
In the view, we let users input the name of the task and set its priority
(low/normal/high). The state variable isEditing indicates whether the user is editing
the task name. To avoid the software keyboard from obscuring the editing view, the app
will shift the view upward while the user is editing the text field.
self.isEditing = editingChanged
})
...
After the Save button is tapped, we verify if the text field is empty. If not, we create a new
ToDoItem and call the addTask function to append it to the todoItems array, otherwise
we do nothing.
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)
struct PersistenceController {
static let shared = PersistenceController()
/*
Typical reasons for an error here include:
* The parent directory does not exist, cannot be created, or disal
lows writing.
* The persistent store is not accessible, due to permissions or da
ta protection when the device is locked.
* The device is out of space.
* The store could not be migrated to the current model version.
Check the error message to determine what the actual problem was.
*/
fatalError("Unresolved error \(error), \(error.userInfo)")
}
})
}
}
You should be familar with the code because it is the same as the code generated by
Xcode, except that the name of the container is changed to ToDoList.
Next, in the same file, attach the environment modifier to ContentView() like this:
ContentView()
.environment(\.managedObjectContext, persistenceController.container.viewConte
xt)
In the code above, we inject the managed object context into the environment of
ContentView . This allows us to easily access the context in the content view for managing
the data in the database.
Once created, select the model file and click the Add Entity button to create a new entity.
Change the name of the entity from Entity to ToDoItem. You can think of this entity as a
record in the database table. Therefore, this entity should store the properties of a
ToDoItem . We need to add 4 attributes for the entity including (see figure 8):
class. But why does the priority is set to the type Integer 32? If you take a look at the code
in ToDoItem.swift , you see that the priority property is an Enum:
To save this enum into the database, we have to store its raw value which is an integer.
This is why we use the type Integer 32 and name the attribute priorityNum to avoid
naming conflicts.
By default, Xcode automatically generates the model class of this ToDoItem entity.
However, I prefer to create this class manually in order to have better control. So, select
the ToDoItem entity and open the Data Model Inspector. If you can't see the inspector, go
up to the menu and select View > Inspectors > Show Data Model Inspector. In the Class
section, set the Module to Current Product Module and Codegen to Manual/None. This
disables the code generation.
As you can see, everything we've developed so far does not require you have the
knowledge of database programming. No SQL, no database tables. All the things you deal
with are object based. This is the beauty of Core Data.
import CoreData
set {
self.priorityNum = Int32(newValue.rawValue)
}
}
}
The model class of Core Data should be inherited from NSManagedObject . Each property is
annotated with @NSManaged and corresponds to the attribute of the Core Data model we
created earlier. By using @NSManaged , this tells the compiler that the property is taken
care by Core Data.
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
@FetchRequest . This makes it very easy to load data from the database.
@FetchRequest(
entity: ToDoItem.entity(),
sortDescriptors: [ NSSortDescriptor(keyPath: \ToDoItem.priorityNum, ascending:
false) ])
var todoItems: FetchedResults<ToDoItem>
Recall that we've injected the managed object context in the environment, this fetch
request automatically utilizes the context and fetches the required data for you. In the
code above, we specify to fetch the ToDoItem entity and how the results should be
ordered. Here, we would like to sort the items based on priority.
Once the fetch completes, you will have a collection of ToDoItem managed objects, these
are based on the ToDoItem class we defined earlier in the model layer.
This is how you perform a fetch request and retrieve data from database. And, since the
properties of ToDoItem are kept intact, we DO NOT need to make any code changes for
the list view. We can use the fetch result directly in ForEach :
ForEach(todoItems) { todoItem in
ToDoListRow(todoItem: todoItem)
}
On top of that, you can directly pass the todoItem , which is a NSManageObject to create a
ToDoListRow . Do you know why we do not need to make any changes?
One more thing. You may also wonder if we need to manually perform a fetch request
when there are changes to todoItems (say, we add a new item). This is another advantage
of using @FetchRequest . SwiftUI automatically manages the changes and refreshes the UI
accordingly.
Since we no longer use an array to hold the to-do items, you can remove this line of code:
do {
try context.save()
} catch {
print(error)
}
}
To insert a new record into the database, you create a ToDoItem with the managed
context and then call the save() function of the context to commit the changes.
Since we removed the todoItems binding, we need to update the preview code:
Now let's move back to ContentView.swift . Similarly, you should see an error in the
ContentView (see figure 10).
We simply remove the todoItems parameter. This is how we convert the demo app from
using an in-memory array as storage to a persistent store.
Whenever there is a change to the toggle, the isComplete property of a todoItem will be
updated. But, how we can save it to the persistent store? Recall that the todoItem
conforms to ObservableObject , this implies that it has a publisher that transmits changes
in values.
Here, the onChange modifier listens for these changes (say, the change of isComplete )
and saves them to the persistent store by calling the save() function of the context.
Now you can run the app in a simulator to try it out. You should be able to add new tasks
to the app. Once the new tasks are added, they should appear in the list view
immediately. The checkbox should work too. Most importantly, all the changes are now
saved permanently in the device's database. After you restart the app, all the items are
still there.
DispatchQueue.main.async {
do {
try context.save()
} catch {
print(error)
}
}
}
This function takes in an index set which stores the index of the items for deletion. To
delete an item from the persistent store, you can call the delete function of the context
and specify the item to delete. Lastly, call save() to commit the change.
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 the user deletes an item, we call the deleteTask function to remove the item from
the database.
Run the app and swipe to delete an item. This will completely remove it from the
database.
First, we need to create an in-memory data store and populate it with some test data.
Open Persistence.swift and declare a static variable like this:
do {
try viewContext.save()
} catch {
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
return result
}()
parameter set to true . 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:
We inject the context of the in-memory container to the environment of the content view.
By doing so, the content view can now load the sample to-do items and display them in
the preview canvas.
For reference, you can download the complete ToDoList project here:
If you are 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 shows you the
standard search bar in iOS. SwiftUI, however, doesn't come with this standard UI
component. To implement a search bar in a SwiftUI project (say, our ToDo app), one
approach is to make use of the UISearchBar component in UIKit.
For the purpose of backward compatibility, Apple introduced a couple of new protocols,
namely UIViewRepresentable and UIViewControllerRepresentable in the iOS SDK. With
these protocols, you can wrap a UIKit view (or view controller) and make it available to
your SwiftUI project.
To see how it works, we will enhance our Todo app with a search function. We will add a
search bar right below the app title and let users filter the to-do items by entering a
search term.
Understanding UIViewRepresentable
To use a UIKit view in SwiftUI, you wrap the view with the UIViewRepresentable protocol.
Basically, you just need to create a struct in SwiftUI that adopts the protocol to create
and manage a UIView object. Here is the skeleton of the 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
}
}
The code is similar to the code shown in the previous section but with the following
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 named 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 have to implement the makeCoordinator method and provide a Coordinator instance.
This Coordinator acts as a bridge between UIView's delegate and SwiftUI. Let's have a
look at the code, so you will understand what it 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)")
}
}
searchBar.delegate = context.coordinator
That's it! Test the app again on a simulator 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
}
The first method is triggered when the cancel button is clicked. In the code, we call
resignFirstResponder() to dismiss the keyboard and tell the search bar to end the editing.
The second method ensures that the Cancel button appears when the user taps the search
field.
You can perform a quick test by running the app in a simulator. Tapping the Cancel
button while editing should dismiss the software keyboard.
Basically the first approach is good enough for this app because the todoItems is in sync
with the to-do item stored in the database. I also want to show you how to perform the
search using FetchRequest . So, we will look into both approaches.
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 the items. Otherwise, we check if
the name field contains the search term.
That's it. You can now run the app to test it out. Type in the search field and the app will
filter those records that match the search term.
Using FetchRequest
The filter approach performs the search on the existing fetch results. The other approach
is to perform the search directly using Core Data. When we fetch the data from database,
we specify clearly the todo items to retrieve.
The @FetchRequest property wrapper allows you to pass a predicate, which we haven’t
discussed before, to specify the filter criteria.
Here is an example:
@FetchRequest(
entity: ToDoItem.entity(),
sortDescriptors: [ NSSortDescriptor(keyPath: \ToDoItem.priorityNum, ascending:
false) ],
predicate: NSPredicate(format: "name CONTAINS[c] %@", "Buy")
)
Assuming you’ve added some todo items with "Buy" in the item name, you should only
see the to-do items with the search term "buy" after the code change.
Figure 5. The app only displays the to-do items containing the keyword "buy"
It looks simple, right? But when you need to create a fetch request with a dynamic
predicate, then it is not that simple. Once the fetch request is initialized with a specific
predicate, you can't change it. The same goes for the sort descriptor.
The trick is not to use the @FetchRequest property wrapper. Instead, we create the fetch
request manually. In order to do that, we will create a separate view called FilteredList
which accepts the search text as an argument. This FilteredList is responsible to create
the fetch request, search for the related to-do items, and present them in a list view.
ZStack {
List {
ForEach(todoItems) { todoItem in
ToDoListRow(todoItem: todoItem)
}
.onDelete(perform: deleteTask)
}
.listStyle(.plain)
if todoItems.count == 0 {
NoDataView()
}
}
do {
try context.save()
} catch {
print(error)
}
}
}
Take a look at the body and deleteTask . Both are exactly the same as before. We just
extract the code and put them in the FilteredList . The core changes are in the init
We declare a variable named fetchRequest to hold the fetch request and another variable
named todoItems to store the fetched results. The fetched results can actually be
retrieved from the wrappedValue property of the fetch request.
Now let's dive into the init method. This custom init method accepts the search text
as an argument. To be clear, it's the binding for the search text. The reason why we need
to create a custom init is that we are creating a dynamic fetch request based on the
given search text.
The first line of the init method is to store the binding of the search text. To assign a
binding, you use the underscore like this:
self._searchText = searchText
Next, we check if the search text is empty (or not) and build the predicate accordingly:
As you can see, the usage is very similar to that of the @FetchRequest property wrapper.
This is it! We now have a FilteredList that can handle a fetch request with different
predicates. Now let's modify the ContentView struct to make use of this new
FilteredList .
Since we've moved the fetch request to FilteredList , we can delete the following
variables:
@FetchRequest(
entity: ToDoItem.entity(),
sortDescriptors: [ NSSortDescriptor(keyPath: \ToDoItem.priorityNum, ascending:
false) ],
predicate: NSPredicate(format: "name CONTAINS[c] %@", "buy")
)
var todoItems: FetchedResults<ToDoItem>
With:
FilteredList($searchText)
Here we use the FilteredList to render the list view. We pass the binding of searchText
for performing the search. Since searchText is a state variable, any change on the search
text will trigger the update of the FilteredList . In reality, the app creates a different
predicate and fetches a new set of to-do items as the user types in the search field.
Next, remove the following code because it's in the FilteredList also:
DispatchQueue.main.async {
do {
try context.save()
} catch {
print(error)
}
}
}
Now you're ready to test! If you've made all the code changes correctly, the app should
filter the to-do items as you type in the search term.
Summary
In this chapter, you've 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.
We also explored a couple of approaches for performing data search. You should now
know how to use the filter function and understand how to create a dynamic fetch
request.
Demo project
(https://www.appcoda.com/resources/swiftui4/SwiftUIToDoListUISearchBar.zip)
component of the old UIKit framework. Have you ever thought of building one from
scratch? If you look at the search bar carefully, it's not too difficult to implement. So, let's
try to build a SwiftUI version of a search bar in this chapter.
Not only will you learn how to create the search bar view, we will show you how to work
with custom bindings. We've discussed bindings before, but haven't showed you how to
create a custom binding. Custom binding is particularly useful when you need to insert
additional program logic while it's being read and written. In addition to all that, you will
learn how to dismiss the software keyboard in SwiftUI.
Figure 1 shows you the search bar we're going to build. The look & feel is the same as that
of UISearchBar in UIKit. We will also implement the Cancel button which only appears
when the user starts typing in the search field.
Open SearchBar.swift , which is the file we will focus on. We will rewrite the whole code
but keep its struct name intact. We still call it SearchBar , which still accepts a binding of
search text as an argument. To the caller (i.e. ContentView), there is nothing to change.
The usage is still like this:
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")
.foregroundColor(.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 is the binding of the search text and the other one is a
variable for storing the state of the search field (editing or not).
We used a HStack to layout the text field and the Cancel button. For the text field, we
overlay a magnifying glass icon and the cross icon (i.e. multiply.circle.fill), which is only
displayed when the search field is in editing mode. The same goes for the Cancel button,
which appears when the user taps the search field.
In order to preview the search bar, please also insert the following code:
What's more is that the search bar already works! Run the app on a simulator and
perform a search. It should filter the result based on the search term.
To fix that, we need to add a line of code in the action block of the Cancel button in
SearchBar.swift :
It works great for our current implementation. But let me ask you. What if we needed to
add extra logic when reading or writing this binding? For example, how can you
capitalize each word in the search field?
Swift has a built-in feature to capitalize a string. You can use the capitalized property of
the text and retrieve the capitalized text. The question is how do we update the binding of
text ?
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
}
)
}
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 great feature introduced since Swift 5.1, in case you are not aware.
We are still passing the text binding to TextField . Before the custom binding change
takes effect, we will need to make one more change. Modify the parameter in TextField
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 showed you another approach to implementing a search bar. As you
can see, it's not difficult to build one entirely using SwiftUI. You've also learned how to
create a custom binding. This is very useful when you need to add extra program logic
when setting or retrieving the binding value.
For reference, you can download the complete search bar project here:
Demo project
(https://www.appcoda.com/resources/swiftui4/SwiftUIToDoListSearchBarView.zip
)
Let me stress this once again. This app is the result of what you 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 Core Data (chapter 22). If you
haven't read these chapters, I suggest you go read them first. In this chapter, we will
mostly focus on techniques that haven't been discussed before.
The app uses Core Data for data management. The records are persisted locally in the
built-in database, so you should see the records even after restarting the app.
For the rest of the chapter, I will explain how the code works in detail. But I encourage
you to take a look at the code first to see how much you understand.
set {
self.typeNum = Int32(newValue.rawValue)
}
}
}
The PaymentActivity class represents a payment record which can either be an expense or
income. In the code above, we use an enum to differentiate the payment types. Each
payment has the following properties:
Since we use Core Data to persist the payment activity, this PaymentActivity class inherits
from NSManagedObject . Later, you will see in the Core Data model that this class is set as a
custom class of the managed object. Again, if you don't understand Core Data, 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.
Lastly, we adopt the Identifiable protocol. Why do we need to adopt it? We will use the
List view to present all the payment activities. This is why the PaymentActivity class
adopts the protocol. If you forget what the Identifiable protocol is, you can read about it
in chapter 10.
This class, as we discussed earlier, is the custom class of this entity. You can click the
Data Model inspector to reveal the settings. As mentioned before, I prefer to create the
custom class manually (instead of codegen). This gives me more flexibility to customize
the class.
Next, let's head over to Persistence.swift (inside the Model group) to see how this data
model is loaded. In the PersistenceController struct, you should see the following code:
.
.
.
}
}
}
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:
With these two common views created, it's very straightforward to layout the form. We
use a ScrollView , together with a VStack to arrange the form fields. The validation error
messages are only displayed 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)
.foregroundColor(.primary)
.padding(.vertical, 10)
HStack(spacing: 0) {
Button(action: {
self.paymentFormViewModel.type = .income
}) {
Text("Income")
.font(.headline)
.foregroundColor(self.paymentFormViewModel.type == .income ? Color
.white : Color.primary)
}
.frame(minWidth: 0.0, maxWidth: .infinity)
.padding()
.background(self.paymentFormViewModel.type == .income ? Color("IncomeCard"
) : Color.white)
Button(action: {
self.paymentFormViewModel.type = .expense
}) {
Text("Expense")
.font(.headline)
.foregroundColor(self.paymentFormViewModel.type == .expense ? Color
.white : Color.primary)
}
.frame(minWidth: 0.0, maxWidth: .infinity)
.padding()
.background(self.paymentFormViewModel.type == .expense ? Color("ExpenseCar
d") : Color.white)
}
.border(Color("Border"), width: 1.0)
}
The date field is implemented using the DatePicker component. It's very easy to use the
DatePicker . All you need is 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 improved with better UI and
more styles. If you run the view and tap the date field, the app displays a full calendar
view for users to pick the date. The user interface is much much better than the old
version of 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:
TextEditor(text: $value)
.frame(minHeight: height)
.font(.headline)
.foregroundColor(.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. Just like any other SwiftUI view, you apply view modifiers to
style its appearance. This is how we created the Memo field for users to type long form
text.
At the end of the form, is the Save button. This button is disabled by default. It's only
enabled when all the required fields are filled. The disabled modifier is used to control
the button's state.
}
.padding()
.disabled(!paymentFormViewModel.isFormInputValid)
When the button is tapped, it calls the save() method to save the payment activity
permanently into the database. And then, it invokes the dismiss() method to dismiss the
view. If you are not familiar with the environment value presentationMode , please read
chapter 12.
Form Validation
That's pretty much how we layout the form UI. Let's talk about how the form validation is
implemented. Basically, we followed what's discussed in chapter 15 to perform the 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:
// 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)
$memo
.receive(on: RunLoop.main)
.map { memo in
return memo.count < 300
}
.assign(to: \.isMemoValid, 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 will execute
the validation code, update the results, and notify 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 and edit an existing
activity. This is why the init method takes in an optional payment activity object. If the
object is nil , we display an empty form. Otherwise, we fill the form fields with the given
values of the PaymentActivity object.
return Group {
PaymentFormView(payment: testTrans)
PaymentFormView(payment: testTrans)
.preferredColorScheme(.dark)
.previewDisplayName("Payment Form View (Dark)")
}
}
}
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 extra view model to hold the view's data? Take a look at the
icon right next to the title Payment Details. This is a dynamic icon that changes in
reference to the payment type. Additionally, do you notice the format of the amount? A
requirement for our app is to format the amount with only two decimal places. We can
implement all these logics in the view, but if you keep adding all the logics in the view,
the view will become too complex to maintain.
struct PaymentDetailViewModel {
switch payment.type {
case .income: icon = "arrowtriangle.up.circle.fill"
case .expense: icon = "arrowtriangle.down.circle.fill"
}
return icon
}
return formattedAmount
}
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 stores the collection of payment activities. So, to calculate
the total income, we first use the filter function to filter those activities with type
.income and then use the reduce function to compute the total amount. The same
technique was applied to calculate the total expense. Higher order functions in Swift are
very useful. If you don't know how to use filter and reduce, you can further check out this
tutorial (https://www.appcoda.com/higher-order-functions-swift/).
HStack(spacing: 20) {
if transaction.isFault {
EmptyView()
} else {
VStack(alignment: .leading) {
Text(transaction.name)
.font(.system(.body, design: .rounded))
Text(transaction.date.string())
.font(.system(.caption, design: .rounded))
.foregroundColor(.gray)
}
Spacer()
}
}
To list the transaction, we use ForEach to loop through the payment activities and create
a TransactionCellView for each activity:
}) {
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).environment(\.managedOb
jectContext, self.context)
}
When a user taps and holds a row, it displays a context menu with both the delete and
edit options. When selecting the edit option, the app will create the PaymentFormView with
the selected payment activity. For the delete operation, the app will completely remove
the activity from the database using Core Data.
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])
}
@FetchRequest(
entity: PaymentActivity.entity(),
sortDescriptors: [ NSSortDescriptor(keyPath: \PaymentActivity.date, ascending:
false) ])
var paymentActivities: FetchedResults<PaymentActivity>
This property wrapper makes it very easy to perform a fetch request. We simply specify
the entity, which is the PaymentActivity , and the sort descriptor describing how the data
should be ordered. The Core Data framework will then use the environment's managed
object context to fetch the data. Most importantly, SwiftUI will automatically update the
list views or any other views that are bound to the fetched results.
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:
do {
try self.context.save()
} catch {
print("Failed to save the context: \(error.localizedDescription)")
}
}
newPayment.paymentId = UUID()
newPayment.name = paymentFormViewModel.name
newPayment.type = paymentFormViewModel.type
newPayment.date = paymentFormViewModel.date
newPayment.amount = Double(paymentFormViewModel.amount)!
newPayment.address = paymentFormViewModel.location
newPayment.memo = paymentFormViewModel.memo
do {
try context.save()
} catch {
print("Failed to save the record...")
print(error.localizedDescription)
}
}
The first line of the code checks if we have any existing activity. If not, we will instantiate
a new one. We then assign the form values to the payment object and call the save
function of the managed object context to add/update the record in the database.
extension Date {
static var today: Date {
return Date()
}
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 make use of 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 code above, we have a state variable to store the height of the keyboard. By using
the Combine framework, we have a publisher that captures the
keyboardWillShowNotification and emits the current height of the keyboard.
Additionally, we have another publisher which listens to the
keyboardWillHideNotification and emits a value of zero. For both publishers, we use the
built-in assign subscriber to assign the value emitted by these publishers to the
currentHeight variable.
This is how we detect the keyboard appearance, capture its height, and add the bottom
padding. But 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 the ground up. Most of the techniques
we used shouldn't be new to you. You combine what you learned in the earlier chapters to
build the app.
SwiftUI is a very powerful and promising framework, allowing you to build the same app
with less code than UIKit. If you have some programming experience with UIKit, you
know it would take you more time and lines of code to create the personal finance app. I
really hope you enjoy learning SwiftUI and building UIs with this new framework.
In this chapter, we will build a similar list view and implement the animated transition
using SwiftUI. In particular, 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/swiftui4/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 look at how the card view looks in full content mode. As you can see in the
figure below, the card view expands to a full screen that displays the content. Other than
that, the image is a little bit bigger and the sub-headline is hidden. Furthermore, the
close button appears on screen for users to dismiss the view. Please also take 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(.secondary)
Text(self.headline)
.font(.title)
.fontWeight(.bold)
.foregroundColor(.primary)
.minimumScaleFactor(0.1)
.lineLimit(2)
.padding(.bottom, 5)
if !self.isShowContent {
Text(self.subHeadline)
.font(.subheadline)
.foregroundColor(.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 a mention. 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 it truncates.
Previewing the UI
To preview the excerpt view, you can modify the preview code like this:
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)
.foregroundColor(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 within the Group
section of ArticleCardView_Previews :
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.
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 fix the issue, we have to set the frame's width and ensure it doesn't exceed the width of
the screen. The question is how do you find out the screen width? SwiftUI provides a
container view called GeometryReader which lets you 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.
Hold the command key and click on ScrollView , you should then see a context menu.
Choose Embed in ZStack to embed the scroll view in a ZStack .
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))
.foregroundColor(.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 have 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. Though it
doesn't work as expected, the card view should show the content of the article and hide
the sub-headline. Additionally, you should be able to tap the close button to return to the
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 to embed the current scroll
view in a ZStack view. Initially, the app displays the list of card view. 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.
.opacity(showContent ? 0 : 1)
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:
You can place the line of code above before the onTapGesture modifier. For the
ArticleCardView , attach another matchedGeometryEffect modifier and use the same
namespace (insert it above the ignoresSafeArea modifier):
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've 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 the UI these days. As you can see, SwiftUI has made it
very easy for developers to build some 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.
Figure 1. Sample carousel in the Music, App Store, and Instagram app
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/swiftui4/SwiftUICarouselStarter.zip and unzip the
package.
In the code above, we embed an HStack with a horizontal ScrollView to create the image
slider. In the HStack , we loop through the sampleTrips array and create a TripCardView
for each trip. To have better control of the card size, we have two GeometryReaders:
outerView and innerView, where the outer view represents the size of the device's screen
and the inner view wraps around the card view to control its size. If you haven't read the
previous chapter and don't 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.
Does this mean we have completed the carousel? Not yet. There are a couple of major
issues:
1. The current implementation doesn't support paging. In other words, you can swipe
the screen to continuously scroll through the content. The scroll view can stop at any
location. For instance, take a look at figure 4. The scroll stops in between two card
views. This is not our desired behavior. We expect the scrolling will stop on paging
boundaries of the content view.
2. When a card view is tapped, we need to find out its index and display its details in a
separate view. The problem is that it is not easy to figure out 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. However, Apple didn't bring that feature to the SwiftUI framework. To
resolve the issue, We need to build our own horizontal scroll view with paging support.
At first, you may think it's hard to develop our own scroll view. But in reality, it is not that
hard. 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 its best to fit all the card views in the available screen space. You
should see something like 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 the screen width and height, while the innerView
parameter allows us to have better control of 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
whole screen width. For the height of the card view, we set it to 500 points to make it a
bit smaller. After making the changes, you should see the card view showing the
"London" image.
Why the "London" card view? If you switch to the Selectable mode, the preview canvas
should display something like that shown in figure 8. We have 13 items in the
sampleTrips array. Since each of the card views 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 equals the width of the screen. Suppose the
screen width is 300 points and we want to display the third card view, 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. In order to display the third
card view, as explained before, we need to move the stack by 2 x 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 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 computes 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:
By updating the code, we make the visible card view a little bit larger than the rest. On
top of that, attach the .opacity modifier to the card view like this:
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.
Command-click the GeometryReader of the outer view and choose embed in ZStack.
VStack(alignment: .leading) {
Text("Discover")
.font(.system(.largeTitle, design: .rounded))
.fontWeight(.black)
The code above is self explanatory but I'd 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")
.foregroundColor(.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)
.foregroundColor(.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(Color.white)
.cornerRadius(15)
Image(systemName: "bookmark.fill")
.font(.system(size: 40))
.foregroundColor(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)
}
}
}
}
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))
.animation(.interpolatingSpring(mass: 0.5, stiffness: 100, damping: 10, in
itialVelocity: 0.3))
Button(action: {
self.isCardTapped = false
}) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 30))
.foregroundColor(.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's expected that
the detail view will appear from the bottom of the screen and move upward with an
animation. This is why we attach both the .transition and .animation 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 {
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've built a custom scroll view with paging support and learned how to bring up
a detail view with animated transition. This technique is not limited to to an image
carousel. In fact, you can modify the code to create a set of onboarding screens. I hope
you love 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:
view and introduced several new features. In this chapter, we will show you how to build
an expandable list / outline view and explore the inset grouped list style.
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 like the one 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 sub menu items, we rely on
OutlineGroup to create the hierachy of data. If you've made the change in
ContentView.swift , you should see an outline view like that 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 sub menu 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 ,
introduced in iOS 14.
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've introduced a couple of new features of SwiftUI. As you can see in the
demo, it is very easy 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/swiftui4/SwiftUIExpandableList.zip)
In this chapter, I will walk you through how to create both horizontal and vertical views.
Both LazyVGrid and LazyHGrid are designed to be flexible, so that developers can easily
create various types of grid layouts. We will also see how to vary the size of grid items to
achieve different layouts. After you manage the basics, we will dive a little bit deeper and
create complex layouts like that 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 and tell the vertical grid to use a 3-column layout. We also specify that
there is a 20 point space between rows. In the code block, we have a ForEach loop to
present a total of 10,000 image views. If you've made the change correctly, you should
see a three column grid in the preview.
This is how we create a vertical grid with three columns. The frame size of the image is
fixed to 50 by 50 points, which is controlled by the .frame modifier. If you want to make
a grid item wider, you can alter the frame modifier like this:
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. 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 . We have set its value to 20 points.
You can simply change it to 0 such that there is no space between rows.
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, you can initialize the
gridLayout variable like this:
For each GridItem , we specify to use a spacing of zero. For simplicity, the code above can
be rewritten like this:
.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 minimize size for a grid item. In the
code above, each grid item has a minimum size of 50. If you modify the gridItemLayout
variable as above and set the spacing of LazyVGrid back to 20 , you should 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 .flexible and .adaptive , you can also use .fixed if you want to create
fixed width columns. For example, you want to layout the image in two columns such
that the first column has a width of 100 points and the second one has a width of 150
points. You write the code like this:
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, you can rewrite a couple lines of code to transform a grid view from vertical
orientation to horizontal:
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 SwiftUIGridView 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 a list view. Other than using List , you can actually use
LazyVGrid to build a list view. We do this by defining the gridLayout with one grid item.
By telling LazyVGrid to use a single column grid layout, it will arrange the items like a list
view. 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)
.foregroundColor(.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. It has an image property for storing the cafe photo
and the coffeePhotos property for storing a list of coffee photos. In the code above, we
also create an array of Cafe for demo purposes. For each cafe, we randomly pick some
coffee photos. Please feel free to modify the code if you have other images you prefer.
Instead of modifying the ContentView.swift file, let's create a new file for implementing
this grid view. Right click SwiftUIPhotoGrid and choose New File.... Select 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: 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)
Do you find the UI in landscape mode less appealing? The app works great in the portrait
orientation. However, the grid layout doesn't look as expected on landscape orientation.
What we expect is that the UI should look pretty much the same as that in portrait mode.
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:
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 now here. The introduction of
LazyVGrid and LazyHGrid in SwiftUI lets developers create different types of grid layouts
with a few lines of code. This tutorial is just a quick overview of these two new UI
components. I encourage you to try out 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/swiftui4/SwiftUIGridLayout.zip
https://www.appcoda.com/resources/swiftui4/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:
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 is very easy to create an activity ring.
For example, we can overlay another circular progress bar with bigger size & different
color on top of the one shown in figure 4 to become 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. 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 you can see in the
figure, the thickness property is used to control the width of the outline. The width
property is 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 allows the user to specify the percentage of progress.
The thickness property, 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 you have a ring shape that is adjustable by passing different progress values to
it, wouldn't it be great if we 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:
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 is to return an
instance of EmptyAnimatableData , which means no animatable data. This is why
ProgressRingView cannot animate the progress change.
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 little circle, that its size is based on the
thickness of the ring, at the end of the arc. Additionally, we will add a drop shadow for
that little 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 just highlighted it using red
color for purpose of illustration.
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 come 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 arc end. 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 that we need to figure out where we add the drop shadow.
The calculation of the shadow position is very similar to that of the ring tip. So, in
ProgressRingView.swift , 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 to have a
darker shadow, increase the opacity value (say, 1.0). After 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 to set 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 also needs to
provide four buttons for adjusting the activity ring like you see in figure 21.
Summary
By building an activity ring, we covered a number of SwiftUI features in this chapter. You
should now have a better idea of implementing your custom shape and how to animate a
shape using the Animatable protocol.
Demo project
(https://www.appcoda.com/resources/swiftui4/SwiftUIProgressRing.zip)
Solution
(https://www.appcoda.com/resources/swiftui4/SwiftUIProgressRingExercise.zip)
So, what are we going to animate? We will build on top of what we've implemented in the
previous chapter and add a text label at the center of the progress ring. The label will
show the current percentage of progress. As the progress bar moves, the label will be
updated accordingly. Figure 2 shows you what the label looks like.
Before we dive into the AnimatableModifier protocol, let me ask you. How are you going to
layout the progress label and animate its change? Actually, we've built a similar progress
indicator in chapter 9. So, based on what you learned, you may layout the progress label
(in the ProgressRingView.swift file) like this:
ZStack {
Circle()
.stroke(Color(.systemGray6), lineWidth: thickness)
Text(progressText)
.font(.system(.largeTitle, design: .rounded))
.fontWeight(.bold)
.foregroundColor(.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 is a very straightforward
implementation. However, there is an issue with the solution. The text animation doesn't
work so well.
This is not what we expect. The progress label shouldn't jump from one value (e.g. 100%)
to another value (e.g. 50%) directly. We expect the progress label follows the animation
of the progress bar and updates its value step by step like this:
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
view.
This is how we animate the text using AnimatableModifier . For convenience purposes,
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.
Using LibraryContentProvider
Xcode allows developers to add custom views to the library by using a protocol called
LibraryContentProvider . To add a custom view to the View library, all you need to do is 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:
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
You may also wonder what the @LibraryContentBuilder property wrapper is. It just saves
you from writing the code for creating the array of LibraryItem instances. The code above
can be rewritten like this:
Once you create the struct, Xcode automatically discovers the implementation of the
LibraryContentProvider protocol in your project and adds the progress ring view to the
View library. You can now add the progress ring view to your UI by using drag and drop.
Note that at the time of this writing, you can't add documentation for your custom
control.
@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 very powerful protocol for animating changes of any
views. In this chapter, we showed you how to animate the text change of a label. You can
apply this technique to animate other values such as color and size.
Demo project
(https://www.appcoda.com/resources/swiftui4/SwiftUITextAnimation.zip)
The solution of the exercise is also included in the demo project. Please refer to the
TaskGridView.swift file.
In this chapter, we will show you how to use both TextEditor and TextField for handling
multiline input.
Using TextEditor
It is very easy to use TextEditor . You just 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/swiftui4/SwiftUITextEditor.zip)
For any mobile apps, it is very common that you need to move from one view to another.
Creating a delightful transition between views will definitely improve the user
experience. With the matchedGeometryEffect modifier, you describe the appearance of two
views. The modifier will then compute the difference between those two views and
automatically animate the size/position change.
Feeling confused? No worries. You will understand what I mean after going through the
demo apps.
With the matchedGeometryEffect modifier, you no longer need to figure out these
differences. All you need to do is describe two views: one represents the start state and
the other is for the final state. matchedGeometryEffect will automatically interpolate the
size and position between the views.
// Final State
Circle()
.fill(Color.green)
.matchedGeometryEffect(id: "circle", in: shapeTransition)
.frame(width: 300, height: 300)
.offset(y: -200)
.onTapGesture {
withAnimation(.easeIn) {
expand.toggle()
}
}
} else {
// Start State
Circle()
.fill(Color.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 & namespace. By doing so, SwiftUI computes the size & position difference
between these two views and interpolates the transition. Along with the withAnimation
The ID and namespace are used for identifying which views are part of the same
transition. This is why both Circle views use the same ID and namespace.
This is how you use matchedGeometryEffect to animate transition 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, I suggest you run the app in an iPhone simulator. At the
time of this writing, there is a bug in Xcode that you can't test the animation 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 then handle the
transformation. Replace the body variable of the ContentView struct like this:
// 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 make use of 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 view. You may wonder why we used
RoundedRectangle to create the circle. The main reason is that it gives you a more smooth
transition.
However, you may notice that the modifier doesn't animate the color change. This is
right. 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 as shown in figure 5. It starts with an orange
circle view. When the circle is tapped, it will transform into a full screen background. You
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 any of the circles, it will trigger 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(Color.green)
.frame(width: 30, height: 30)
.matchedGeometryEffect(id: "greenCircle", in: dotTransition)
Spacer()
Circle()
.fill(Color.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(Color.orange)
.frame(width: 30, height: 30)
.matchedGeometryEffect(id: "orangeCircle", in: dotTransition)
Spacer()
Circle()
.fill(Color.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, you should see the swap animation when you tap any of the
circles.
Exercise #2
Earlier, we used the matchedGeometryEffect on two circles and swap their position. 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 one 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 in a simulator 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 command 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/swiftui4/SwiftUIMatchedGeometry.zip)
One way of presenting the 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 inserted into 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 inserted back into the grid. Figure 1 illustrates how the 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/swiftui4/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 about grid view, the code is self explanatory.
We simply 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? In iOS 14, Apple introduced 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 , you wrap it around a ScrollView . Each of the child views should be
given their own identifier. You can then call the scrollTo function of the
ScrollViewProxy with the specific ID to move the scroll view to that particular location.
Now let's get back to our demo app. To programmatically scroll the ScrollView of the
dock, we need to first give each photo an identifier. The scrollTo function requires us to
provide 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:
.id(photo.id)
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:
scrollProxy.scrollTo(id)
})
Summary
In this chapter, we continue to explore the usage of matchedGeometryEffect and use this
modifier to create an amazing view transition. The modifier opens up a lot of
opportunities for developers to improve the user experience of their iOS apps. We also
experimented with the new ScrollViewReader to see how to use it to scroll a scroll view
programatically.
Demo project
(https://www.appcoda.com/resources/swiftui4/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.
TabView {
Text("Home Tab")
.font(.system(size: 30, weight: .bold, design: .rounded))
.tabItem {
Image(systemName: "house.fill")
Text("Home")
}
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 {
}
.accentColor(.red)
If you attach the modifier to TabView , the icon and text of the tab bar should be changed
to red.
TabView(selection: $selection)
TabView(selection: $selection) {
Text("Home Tab")
.font(.system(size: 30, weight: .bold, design: .rounded))
.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)
}
You can create a Next button that switches to the next tab like this:
ZStack(alignment: .topTrailing) {
TabView(selection: $selection) {
.
.
.
}
.accentColor(.red)
Button {
selection = (selection + 1) % 4
} label: {
Text("Next")
.font(.system(.headline, design: .rounded))
.padding()
.foregroundColor(.white)
.background(Color.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")
}
}
.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)
.navigationTitle("TabView Demo")
}
We just altered the code of the Home tab to display a list of items. We wrap each list item
with a NavigationLink , so that it will navigate to the detail view when the item is tapped.
If you run the app using a simulator or in the preview canvas, you should see that the tab
bar is hidden when we navigate to the detail view.
For some scenarios, you probably don't want the tab bar to be hidden. In these cases, you
can create the navigation interface the other way round. Instead of wrapping the tab view
in a navigation view, you embed the navigation view in a tab view 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. The framework doesn't provide you with
many options for customizing the tab bar. However, you may still rely on the APIs of
UIKit to customize its appearance. This chapter only shows you how to work with the
built-in tab bar. You can actually create your own tab bar if you need full customization.
We will discuss it in the 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. AsyncImage then does the heavy lifting to
grab the remote image and show it on screen.
In this chapter, I will show you how to work with AsyncImage in SwiftUI projects.
AsyncImage then connects to the given URL and download 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.
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)
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/swiftui4/SwiftUIAsyncImage.zip)
views. Developers have to create your own solution. In earlier chapters, I have shown you
how to implement a search bar in SwiftUI using TextField and display the search result.
With the release of iOS 15, the SwiftUI framework brings a new modifier named
searchable to 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 implements a list view to display a set of articles. What I want
to enhance is provide 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)
modifier to keep track of the change of the search field. Update the code of
NavigationStack like below:
.listRowSeparator(.hidden)
}
.listStyle(.plain)
.navigationTitle("AppCoda")
}
.searchable(text: $searchText)
.onChange(of: searchText) { searchText in
if !searchText.isEmpty {
articles = sampleArticles.filter { $0.title.contains(searchText) }
} 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 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 are building an app that needs to support the 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/swiftui4/SwiftUISearchable.zip)
import SwiftUI
import Charts
BarMark(
x: .value("Day", "Tuesday"),
y: .value("Steps", 7200)
)
}
}
}
If you input the code in Xode 14, 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've written the code in
your Xcode project, the preview should render the bar chart as shown in figure 2.
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 store the weather data, I 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 hkWeatherData = [
WeatherData(year: 2021, month: 7, day: 1, temperature: 30.0),
WeatherData(year: 2021, month: 8, day: 1, temperature: 29.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: 23.0),
WeatherData(year: 2021, month: 12, day: 1, temperature: 19.0),
WeatherData(year: 2022, month: 1, day: 1, temperature: 18.0),
WeatherData(year: 2022, month: 2, day: 1, temperature: 15.0),
WeatherData(year: 2022, month: 3, day: 1, temperature: 22.0),
WeatherData(year: 2022, month: 4, day: 1, temperature: 24.0),
WeatherData(year: 2022, month: 5, day: 1, temperature: 26.0),
WeatherData(year: 2022, month: 6, day: 1, temperature: 29.0)
]
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),
The Chart initializer takes in a list of Identifiable objects. This is why we make the
WeatherData conform 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 types of chart using the Charts framework, you have to first
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:
What the code above does is to plot a line chart for displaying the average temperature of
Hong Kong. The ForEach statement loops through all items stored in hkWeatherData . For
each item, we create a LineMark object that the x axis is set to the date and the y axis
is set to the average temperature.
Optionally, you can resize the chart using the frame modifier. If you preview the code in
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))
}
}
Inside chartXAxis , we create a visual mark called AxisMarks for the values of month. For
each value, we display a value label by using a specific format. This line of code tells
SwiftUI chart to use the digit format:
.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.
Chart {
ForEach(chartData, id: \.city) { series in
ForEach(series.data) { item in
LineMark(
x: .value("Month", item.date),
y: .value("Temp", item.temperature)
)
}
.foregroundStyle(by: .value("City", series.city))
}
}
We have another ForEach to loop through all the cities of the chart data. Here, the
foregroundStyle modifier is used 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 great addition to SwiftUI. Even if you just begin learning
SwiftUI, you can create delightful charts with a few lines of code. While this chapter
focuses on line charts and bar charts, the Charts API makes it very easy for developers to
convert the chart to other forms such as scatter plots. You can check out the Swift Charts
documentation for further reading.
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 features for most users. As a developer, wouldn't
it be great if you can incorporate this Live Text feature in 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 recognization is not a new feature on iOS 16. On older version of iOS, you can use
APIs from the AVFoundation and Vision framework to detect and recognize text.
However, the implementation is quite complicated, especially for those who are new to
iOS development.
In iOS 16, all the above is simplified to a new class called DataScannerViewController in
VisionKit. By using this view controller, your app can automatically display a camera UI
with the 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
All you need is create an instance of DataScannerViewController and specify the recognized
data types. For text recognition, you pass .text() as the data type. Once the instance is
ready, you can present it and start the scanning process by calling the startScanning()
method.
Working with
DataScannerViewController in SwiftUI
The DataScannerViewController class now only supports UIKit. For SwiftUI, it needs 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 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. Add the key Privacy - Camera Usage
Description in the Info.plist file and 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. Though the new
DataScannerViewController is not specifically designed for SwiftUI, we can easily port it to
our Swift project and incorporate the live text feature in our own apps. If you are going to
publish your next app update, consider to bundle this great feature. It'll definitely
improve the user experience.
The ShareLink view is designed to share any types of data. In this chapter, we will show
you how to use ShareLink to let users share text, URL, 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 lets you include a title about the URL or any item to share. The
message parameter allows you to specify the description of the item. Depending on the
activities the user shares to, iOS will present the subject or message or both. Say, if you
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 brings up 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
For custom types, you adopt the protocol and provide a transfer representation by using
one of the built-in TransferRepresentation types. We briefly discuss the
ProxyRepresentation type. If you need to share a file between applications, you can use
the FileRepresentation type.
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've 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/swiftui4/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 easily build a function for users to share
the chart view.
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.
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 up, you can use this new API to create some
convenient features for your users. Other than rendering images, ImageRenderer also lets
you render a PDF document. You can refer to the official documentation for further
details.
Demo project
(https://www.appcoda.com/resources/swiftui4/SwiftUIImageRenderer.zip)
In this chapter, we will build on the top of the previous demo and add the Save to PDF
function. You should have Xcode 14 installed to follow the content. Please upgrade to the
latest version of Xcode if you haven't done so.
To follow this chapter, you can first download the starter project from
https://www.appcoda.com/resources/swiftui4/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:
}
.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. And
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, ImageRenderer allows developers to turn a
view into a PDF document. With this new API in iOS 16, you can easily add some PDF-
related features in your iOS apps.
Demo project
(https://www.appcoda.com/resources/swiftui4/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 already add the SpeedometerView in
the preview struct. Your preview should fill half of the progress bar as we set the current
speed to 100km/h.
We change the text label of the gauge to a system image. And, for the current value label,
we create a stack to arrange the image and text. Your preview should display the gauge
like that 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 & feel of the Gauge view is very similar to that of ProgressView . Optionally, you
can customize the Gauge view using the gaugeStyle modifier. The modifier supports
several built-in styles.
linearCapacity
This is the default style that displays a bar that fills from leading to trailing edges. Figure
4 shows a sample gauge in this style.
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
This style displays a closed ring that's partially filled in to indicate the gauge's current
value. The current value is also displayed at the center of the gauge.
The built-in gauge styles are limited but SwiftUI allows you to create your own gauge
style. Let me show you a quick demo to build a gauge style like the one displayed in figure
9.
To create a custom gauge style, you have to adopt the GaugeStyle protocol and provide
your own implementation. Here is our implementation of the custom style:
Circle()
.foregroundColor(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))
.foregroundColor(.gray)
Text("KM/H")
.font(.system(.body, design: .rounded))
.bold()
.foregroundColor(.gray)
}
}
.frame(width: 300, height: 300)
Once we implement our custom gauge style, we can apply it by attaching the gaugeStyle
}
.gaugeStyle(SpeedometerGaugeStyle())
}
}
I created a separate view for the demo. To preview the CustomGaugeView , you need to
update the ContentView_Previews struct to include the CustomGaugeView :
CustomGaugeView()
.previewDisplayName("CustomGaugeView")
}
}
Summary
In this chapter, you've learned how to use the new Gauge view to build a speedometer.
Other than 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 just 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
In this chapter, you will learn how to use AnyLayout to switch between vertical and
horizontal layout.
Using AnyLayout
Let's first create a new Xcode project using the App template. Name the project
SwiftUIAnyLayout or whatever 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)
.foregroundColor(.white)
Image(systemName: "ferry")
.font(.system(size: 80))
.frame(width: 120, height: 120)
.background(in: RoundedRectangle(cornerRadius: 5.0))
.backgroundStyle(.yellow)
.foregroundColor(.white)
Image(systemName: "scooter")
.font(.system(size: 80))
.frame(width: 120, height: 120)
.background(in: RoundedRectangle(cornerRadius: 5.0))
.backgroundStyle(.indigo)
.foregroundColor(.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 good enough
for arranging the type of layouts you need? The Layout protocol introduced in iOS 16
allows you to define your own 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 a
couple lines of code. This definitely 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.
Demo project
(https://www.appcoda.com/resources/swiftui4/SwiftUIAnyLayout.zip)