10.
Gestures
Written by Antonio Bello
When developing an engaging and fun user interface in a modern mobile app, it’s often useful to add additional dynamics to user interactions. Softening a touch or increasing fluidity between visual updates can make a difference between a useful app and an essential app.
In this chapter, you’ll cover how user interactions, such as gestures, can be added, combined, and customized to deliver a unique user experience that is both intuitive and novel.
You’re going to go back to the Kuchi flashcard app covered in the previous chapters; you’ll add a tab bar and a new view for learning new words. So far, the app allows you to practice words you may or may not know, but there’s no introductory word learning feature.
Start by opening the starter project.
Adding the learn feature
You’re going to add a tab bar to the app, with two tabs:
- The new learn view, which you’re going to create in this chapter.
- The existing challenge view.
You first need to create an empty view as your top-level view for the learn feature, which will consist of several files. You will place them in a new group called Learn. This will sit at the same level as the existing Practice folder.
So in the Project Navigator right-click on the App group, choose New Group, and name it Learn.
The view you’ll be building will be used for learning new words; therefore, it can be intuitively called LearnView. So, go ahead and create a new SwiftUI view file named LearnView.swift inside the Learn group.
Once you have created the new view, you can leave it empty and take care of adding a way to access this new view. In the App folder create a new HomeView.swift file, which you’ll use to host your tabs. Replace its body content with:
// 1
TabView {
EmptyView()
}
// 2
.accentColor(.orange)
This is very simple — you are:
- Creating a tab view, for now it only has an empty view.
- Using the
accentColormodifier, making icon and text an orange color when the tab is selected.
Now you need to add the two tabs. The first is for the new learn view, so inside TabView, replace EmptyView() with:
// 1
LearnView()
// 3
.tabItem({
// 3
VStack {
Image(systemName: "bookmark")
Text("Learn")
}
})
// 4
.tag(0)
Adding a tab is pretty straightforward:
- This is the view that’s displayed when the tab is active
- You use the
tabItemmodifier to configure the tab - You’re displaying an icon and a label below it, using a
VStackto keep them together. - This is the index of the learn tab
If you resume the preview, this is what you’ll see:
To add the second tab you first need to do some refactoring. In WelcomeView you need to replace the instance of PracticeView to the new HomeView. To do so, first, open up WelcomeView.swift. You see that in body the if branch shows PracticeView, cut the following code:
PracticeView(
challengeTest: $challengesViewModel.currentChallenge,
userName: $userManager.profile.name,
numberOfAnswered:
.constant(challengesViewModel.numberOfAnswered)
)
.environment(
\.questionsPerSession,
challengesViewModel.numberOfQuestions
)
and replace it with:
HomeView()
Next, go back to HomeView, and right after the first LearnView tab paste the code you cut above:
PracticeView(
challengeTest: $challengesViewModel.currentChallenge,
userName: $userManager.profile.name,
numberOfAnswered:
.constant(challengesViewModel.numberOfAnswered)
)
.environment(
\.questionsPerSession,
challengesViewModel.numberOfQuestions
)
Because of missing properties, this is creating a few errors. You’ll fix these in a bit. But first, you’ll finish the body of HomeView.
Before the environment modifier of PracticeView, add this code to configure the tab:
.tabItem({
VStack {
Image(systemName: "rectangle.dock")
Text("Challenge")
}
})
.tag(1)
To avoid any ambiguity, be sure that body looks like this:
TabView {
LearnView()
.tabItem({
VStack {
Image(systemName: "bookmark")
Text("Learn")
}
})
.tag(0)
PracticeView(
challengeTest: $challengesViewModel.currentChallenge,
userName: $userManager.profile.name,
numberOfAnswered:
.constant(challengesViewModel.numberOfAnswered)
)
.tabItem({
VStack {
Image(systemName: "rectangle.dock")
Text("Challenge")
}
})
.tag(1)
.environment(
\.questionsPerSession,
challengesViewModel.numberOfQuestions
)
}
.accentColor(.orange)
Next, you’ll fix those errors. PracticeView requires two properties that you left in WelcomeView. Go back to it, and copy them:
@EnvironmentObject var userManager: UserManager
@EnvironmentObject var challengesViewModel: ChallengesViewModel
Then paste them at the top of HomeView. Since they are environment objects, if you want to take a peek of how the view looks like using the preview, you need to add them to the HomeView() initializer in HomeView_Previews. Do so by replacing the contents of previews with:
HomeView()
.environmentObject(UserManager())
.environmentObject(ChallengesViewModel())
You can now resume the preview, and you’ll see the new “Challenge” tab added at the right of “Learn”.
If you want to make things right, in WelcomeView you notice that challengeViewModel is no longer used, so you can delete the property.
Creating a flashcard
With the new “Learn” tab in place, the first component of the Learn feature you’ll be working on is the flash card. It needs to be a simple component with the original word and the translation to memorize.
When talking about the card, two distinct understandings within the app are useful to recognize: the visual card (a UI component) and the card data (the state).
Both are integral to the card feature, and the card itself is a composite of both elements. However, the visual card cannot exist without state; to start with, you need a data structure that can represent the state.
Using the Swift file template, create a new file in your Learn folder named FlashCard.swift. It’s going to be an empty struct for now — add it:
struct FlashCard {
}
Within the struct, you’ll need the data the user is trying to learn. In this case, it’s the word. Add a property of type Challenge with the name card to your struct:
var card: Challenge
This is the basic data structure for your flashcard, but to make it useful for your SwiftUI views, you’ll need a few more properties.
First, an id may be useful for iterating through multiple flashcards in a view. This is best achieved by making a structure comply with the Identifiable protocol, as the ForEach SwiftUI block will initially look for an id unless an explicit identifier has been specified.
As there are no id generators within the app, you can simply rely on Foundation’s UUID constructor to provide a unique identifier each time a FlashCard is created. Add the following property to FlashCard:
let id = UUID()
As you can see, there’s no explicit use of the Identifiable protocol yet. This will be covered shortly. The final step needed within your basic FlashCard state structure is to add a flag called isActive. Add the following property:
var isActive = true
This is a simple property for filtering cards that are intended to be part of the learning session.
The user may not want to go through a whole deck of cards that they already know every time so this allows you to selectively filter cards whether through user curation or internal logic. To ensure compliance with the Identifiable protocol, add it to the struct declaration:
struct FlashCard: Identifiable {
...
}
You don’t need to do anything extra to make FlashCard identifiable, but you will want to make sure it’s Equatable. This will enable you to provide comparisons quickly and easily in code, to ensure the same card is not duplicated, or that one card matches another when relevant.
Add this extension after FlashCard:
extension FlashCard: Equatable {
static func == (lhs: FlashCard, rhs: FlashCard) -> Bool {
return lhs.card.question == rhs.card.question
&& lhs.card.answer == rhs.card.answer
}
}
With this property, you’ll be able to use the == operator to compare two flash cards.
There you go; that’s your FlashCard state object defined and ready for use! The user is not going to be learning one card at a time though, so you’ll need to build on this object with the concept of a deck. There is a deck for the Practice feature of the app as a simple array of cards, but the Learn feature has different needs so you’re going to be more explicit with how the deck works this time.
Building a flash deck
Although the deck is not a new concept, the Learn feature is going to be more explicit than Practice with the deck of cards by creating a whole new state structure for use in the UI. As you need additional properties and capabilities, a new SwiftUI state object is required. Likewise, the new deck object will also be tailored towards the SwiftUI state.
Start by creating a new Swift file called FlashDeck.swift inside the Learn group.
FlashDeck needs just a single property: an array of FlashCard objects.
class FlashDeck {
var cards: [FlashCard]
}
What makes the FlashDeck a powerful SwiftUI state object comes from two modifications. The first will be from a constructor. Add the following:
init(from words: [Challenge]) {
self.cards = words.map {
FlashCard(card: $0)
}
}
This constructor simply maps the words (Challenges) passed in into FlashCards.
The second power-up for the FlashDeck model comes from Combine. To make the UI responsive to changes in the deck, the cards property will be prefixed with the @Published attribute to allow subscribers of the model to receive notifications of updates.
Change the cards property from:
var cards: [FlashCard]
Into:
@Published var cards: [FlashCard]
And finally, you need to extend the class to be an ObservableObject (as per Chapter 9: “State & Data Flow”):
class FlashDeck: ObservableObject {
...
}
You now have your FlashCard and FlashDeck built and ready to go.
Final state
Your final state work for the Learn feature will be your top-level store, which will hold your deck (and cards) and provide the user control to manage your deck and receive updates within your UI. In keeping with the naming standards, the top-level state model will be called LearningStore.
Create a new file name LearningStore.swift in the Learn group, using the Swift File template.
Next, populate the file with the following:
class LearningStore {
// 1
@Published var deck: FlashDeck
// 2
@Published var card: FlashCard?
// 3
@Published var score = 0
// 4
init(deck: [Challenge]) {
self.deck = FlashDeck(from: deck)
self.card = getNextCard()
}
// 5
func getNextCard() -> FlashCard? {
guard let card = self.deck.cards.last else {
return nil
}
self.card = card
self.deck.cards.removeLast()
return self.card
}
}
Going over this step-by-step:
- Like in
FlashDeck, you’ll useCombineto provide@Publishedattributes to your properties. The store will maintain the complete deck (deck), - …the current card (
card), - …and the current score (
score). - You add an initializer that sets up the deck.
- You also add a convenience method called
getNextCard, which will get the next card in the deck. It does this by removing the last card of the deck and returning it.
The final step of setting up this store is to make it conform to ObservableObject:
class LearningStore: ObservableObject {
...
}
Phew — that’s a lot of setup without any UI code, right? But you’ve now made a nice foundation for building the view for the Learn feature.
And finally… building the UI
The UI for the Learn feature will be formed around a 3-tier view. The first is your currently empty LearnView. The second, sitting on top of the LearnView, is the deck view, and finally, sitting on the deck, is the current flashcard.
You’ll start by adding the missing views: DeckView and CardView.
First up, still in the Learn group, create a SwiftUI view file named CardView.swift, and replace the contents of body with:
ZStack {
Rectangle()
.fill(Color.red)
.frame(width: 320, height: 210)
.cornerRadius(12)
VStack {
Spacer()
Text("Apple")
.font(.largeTitle)
.foregroundColor(.white)
Text("Omena")
.font(.caption)
.foregroundColor(.white)
Spacer()
}
}
.shadow(radius: 8)
.frame(width: 320, height: 210)
.animation(.spring())
This creates a simple red card view with rounded corners and a couple of text labels centered on the card. You’ll be expanding on this view later in the tutorial.
If you preview this in the Canvas you should see the following:
Next up, the deck view. Create a SwiftUI file named (you guessed it) DeckView.swift and replace the contents of body with:
ZStack {
CardView()
CardView()
}
This is a simple view containing two cards, but you’ll flesh this view out shortly by using the state objects you created earlier to support the loading of dynamically generated cards into the learning flow.
As the cards are stacked on top of each other, previewing the deck view in the Canvas will give you the same result as before.
Next, you need to add DeckView to LearnView.
Go back to LearnView.swift and replace the contents of body with the following:
VStack {
Spacer()
Text("Swipe left if you remembered"
+ "\nSwipe right if you didn’t")
.font(.headline)
DeckView()
Spacer()
Text("Remembered 0/0")
}
This is fairly simple: you have a Text label providing instructions, a score at the bottom, and the DeckView in the center of the screen.
Adding LearningStore to the views
Staying inside LearnView, you can add the store you previously created as a property to the view:
@ObservedObject var learningStore =
LearningStore(deck: ChallengesViewModel().challenges)
As LearningStore is an ObservableObject, it can be used within the LearnView to ensure the view is rebuilt when any of the published properties change. With this setup, you can even update the score Text at the bottom of the view.
Replace:
Text("Remembered 0/0")
With:
Text("Remembered \(self.learningStore.score)"
+ "/\(self.learningStore.deck.cards.count)")
That’s good for now. You’ll come back to LearnView later, but now DeckView needs to be able to receive some of the data from within the LearningStore to pipe card data through to the individual CardView components.
To enable this, open up DeckView.swift and add the following at the top of the struct, before body:
@ObservedObject var deck: FlashDeck
let onMemorized: () -> Void
init(onMemorized: @escaping () -> Void, deck: FlashDeck) {
self.onMemorized = onMemorized
self.deck = deck
}
You’re adding a FlashDeck property for getting the items the view will be subscribing to, as well as a callback onMemorized, for when the user memorizes a card. Both are passed in through a custom initializer.
For the preview to still work, you need to update DeckView_Previews’s previews to the following:
DeckView(
onMemorized: {},
deck: FlashDeck(from: ChallengesViewModel().challenges)
)
And finally, inside LearnView.swift find DeckView() in the body and replace it with:
DeckView(
onMemorized: { self.learningStore.score += 1 },
deck: learningStore.deck
)
Notice how you increase the score when the user memorizes the card. There’s not yet a way to trigger the onMemorized, but you’ll be adding this later in the chapter.
Next up, getting the data from the learning store into the individual cards. To do so, open up CardView.swift and add the following to the top, before body:
let flashCard: FlashCard
init(_ card: FlashCard) {
self.flashCard = card
}
Here you add a FlashCard property to the view and pass it in through the initializer. The property isn’t a state object because you’re not planning on changing the value of the FlashCard at any time; the card data is fixed for the lifetime of the object.
With an actual card model, you can also update the body of the view to use it. Replace the contents of the view’s VStack with:
Spacer()
Text(flashCard.card.question)
.font(.largeTitle)
.foregroundColor(.white)
Text(flashCard.card.answer)
.font(.caption)
.foregroundColor(.white)
Spacer()
Here you simply use the question and answer from the flashcard instead of hardcoded values.
With the new initializer, you need to make an update to the places where CardView is used, namely: CardView_Previews and DeckView.
Inside CardView.swift update CardView_Previews’s previews to:
let card = FlashCard(
card: Challenge(
question: "Apple",
pronunciation: "Apple",
answer: "Omena"
)
)
return CardView(card)
Next, inside DeckView.swift, you’ll need to modify the body to dynamically support multiple CardViews. Till you’ve added support for this, just replace the body with EmptyView() to remove any errors.
To add support for multiple CardViews, first add the following helper methods at the bottom of the view:
func getCardView(for card: FlashCard) -> CardView {
let activeCards = deck.cards.filter { $0.isActive == true }
if let lastCard = activeCards.last {
if lastCard == card {
return createCardView(for: card)
}
}
let view = createCardView(for: card)
return view
}
func createCardView(for card: FlashCard) -> CardView {
let view = CardView(card)
return view
}
These methods help with creating a CardView using a FlashCard.
Then, replace the contents of body of the view with the following:
ZStack {
ForEach(deck.cards.filter { $0.isActive }) { card in
self.getCardView(for: card)
}
}
Here the ForEach takes all active cards from the deck and creates a CardView for each using the helper methods just created.
Looking at the Canvas for either LearnView or DeckView, you should now see a card like this:
Your first gesture
Gestures in SwiftUI are not that dissimilar from their cousins in AppKit and UIKit, but they are simpler and somewhat more elegant, giving a perception amongst some developers of being more powerful.
Although they’re not any better than their predecessors in terms of capability, their SwiftUI approach makes for easier and more compelling uses for gestures where before they were often nice-to-haves.
Starting with a basic gesture, it’s time to revisit CardView. Previously, you added both the original word and the translated word to CardView, which is somewhat useful. But what if the user wanted to test their knowledge without being given the answer immediately?
It would be nice if the card had the original word, and then the translated word could be displayed if needed.
To achieve this, you can add a simple tap gesture (literally a TapGesture) for this interaction to happen. Taps are ubiquitous and necessary, so it’s a great place to start with gestures.
Start by opening CardView.swift, then add the following property stating whether the answer has been revealed or not to the top of the view:
@State var revealed = false
Next, in the body add the following .gesture modifer at the bottom, after .animation(.spring()):
.gesture(TapGesture()
.onEnded {
withAnimation(.easeIn, {
self.revealed = !self.revealed
})
})
Here you’re using a pre-built gesture from Apple that adds a lot of convenience by dealing with human tap gestures consistently across all apps. The onEnded block enables you to provide additional code for what happens once the tap gesture has ended. In this case, you’ve provided an animation that eases in (.easeIn) with the revealed property being inverted.
Currently, inverting revealed does nothing, but what you want to do is have the Text displaying the translation render only when revealed is true.
To achieve this, inside body, replace the following:
Text(flashCard.card.answer)
.font(.caption)
.foregroundColor(.white)
With:
if self.revealed {
Text(flashCard.card.answer)
.font(.caption)
.foregroundColor(.white)
}
Try previewing the app in the Canvas with Live Preview and tapping the card. You should see a rather fluid and pleasant ease-in animation for the translated word. This is as simple as gestures get, and with the animation blocks, it provides a level of fluidity and sophistication users will appreciate.
Also notice how tapping the card multiple times in rapid succession will still give a seamless animation experience.
Easy, right?
Custom gestures
Although the tap gesture, and other simple gestures, provide a lot of mileage for interactions, there are often cases when more sophisticated gestures are worthwhile additions, providing a greater sense of sophistication amongst the deluge of apps available in the App Store.
For this app, you still need to provide an interaction for the user to declare whether they’ve memorized a card or not. You can do this by adding a custom drag gesture and evaluating the result based on the direction of the drag. That’s much more complicated than a simple tap gesture but, thanks to the elegance of SwiftUI, it’s still quite painless compared to previous methods of achieving the same thing.
The first step is adding an enum that denotes the direction a card is discarded in. In DeckView.swift add the following code before DeckView:
enum DiscardedDirection {
case left
case right
}
You could identify more complicated metrics for this interaction (up, down, …) but this view only needs to understand two potential options.
Next, time to make cards draggable! In CardView.swift add a new typealias and property to the top of the view, just below the revealed property:
typealias CardDrag = (_ card: FlashCard,
_ direction: DiscardedDirection) -> Void
let dragged: CardDrag
Called dragged, this property accepts the card to be dragged and the enum result for which direction the card was dragged in.
Next, update init to accept the dragged closure as a parameter:
init(
_ card: FlashCard,
onDrag dragged: @escaping CardDrag = {_,_ in }
) {
self.flashCard = card
self.dragged = dragged
}
Next up, you need to modify DeckView so it supports the new card functionality. Open up DeckView.swift and replace the implementation createCardView(for:) with the following:
func createCardView(for card: FlashCard) -> CardView {
let view = CardView(card, onDrag: { card, direction in
if direction == .left {
self.onMemorized()
}
})
return view
}
Here you add the onDrag callback to the CardView instance.
If the drag direction is .left, you trigger onMemorized(), and the counter in LearningStore will be incremented by one.
The final step is to add the actual drag gesture. Go back to CardView.swift, then add the following property after revealed:
@State var offset: CGSize = .zero
To move the card around, the offset needs to be updated.
Next up, creating the drag gesture. At the top of the body change the line:
ZStack {
into:
return ZStack {
You need to return the ZStack as you’ll be adding the drag gesture setup above it. Right above this code line, and still inside the body, add the following:
let drag = DragGesture()
// 1
.onChanged { self.offset = $0.translation }
// 2
.onEnded {
if $0.translation.width < -100 {
self.offset = .init(width: -1000, height: 0)
self.dragged(self.flashCard, .left)
} else if $0.translation.width > 100 {
self.offset = .init(width: 1000, height: 0)
self.dragged(self.flashCard, .right)
} else {
self.offset = .zero
}
}
This DragGesture does most of the work for you, but there are a few things worth noting:
-
With each movement recorded during the drag, the
onChangedevent will occur. You’re modifying theoffsetproperty (which is an x and y coordinate object) to match the drag motion of the user.For example, if the user started dragging at (0, 0) in the coordinate space, and the
onChangedtriggered when the user was still dragged at (200, -100) then the offset x-axis would be increased by 200 and the offset y-axis would be decreased by 100. Essentially this means the component would move right and up on the screen to match the motion of the user’s finger. -
The
onEndedevent occurs when the user stops dragging, typically when their finger is removed from the screen. At this point, you want to determine which direction the user dragged the card and whether they dragged it far enough to be considered a decision (at which point you record the decision and discard the card) or whether you consider it still undecided (at which point you reset the card to the original coordinates).You’re using -1000 and 1000 as the decision markers for whether the user selected left or right during the drag, and that decision is being passed into the
draggedclosure.
That’s all you need for the drag gesture. Now you simply need to add it to the body as a modifier along with the previously defined offset. Right above .gesture(TapGesture(), add:
.offset(self.offset)
.gesture(drag)
There’s a spring animation also included to make the card spring back to position smoothly. The drag gesture can be passed into the gesture method as a parameter, and you should see that the tap gesture is simply another gesture added to the object: there is no conflict with including multiple gestures and stacking them up in an object if needed. Build and run to check your progress.
You can now drag the card around and swipe left and right.
Try previewing LearnView using Live Preview and see the drag gesture in action.
But, what if you wanted to combine gestures?
Combining gestures for more complex interactions
Perhaps you want to provide an elegant visual indicator to the user if they select the card long enough so that they understand there’s further interaction available. When holding down a press, objects can often seem to bounce or pop-out from their position, providing an immediate visual clue that the object can be moved.
SwiftUI provides the ability to add such a change by combining two gestures. When combining gestures, SwiftUI provides a few options about how they interact:
- Sequenced: a gesture that follows another gesture.
- Simultaneous: gestures that are active at the same time.
- Exclusive: gestures that can be both added, but only one can be active at a time.
You’re going to add a simultaneous gesture in this case because you want to provide a simple clue to the potential of the possible drag gesture, without preventing the drag gesture being invoked at the same time.
This may sound complicated, but it’s incredibly simple, as you’ll see.
First, add a new property to store the state of the drag gesture to CardView:
@GestureState var isLongPressed = false
You’ll notice a new state attribute called @GestureState. This attribute enables the state of a gesture to be stored and read during a gesture to influence the effects that gesture may have on the drawing of the view.
This property will be used to record whether the card has been pressed for a long time or not, and will automatically be reset when the gesture is completed. If you use a @State property instead, the property won’t be reset when the gesture has ended.
Next, at the top of the body, right below the setup of drag, add a new gesture for the long press:
let longPress = LongPressGesture()
.updating($isLongPressed) { value, state, transition in
state = value
}
.simultaneously(with: drag)
This gesture is a LongPressGesture: another consistent gesture provided by Apple. In it, you’re using the updating body to bind a value to the state, and then adding the previous drag gesture as a potential simultaneous gesture.
To see it in action, at the bottom of body replace the previously created drag gesture:
.gesture(drag)
with:
.gesture(longPress)
.scaleEffect(isLongPressed ? 1.1 : 1)
Note that you’ve also added a scaleEffect modifier to increase the scale of the view 10% if the isLongPressed property is true.
Try it out, either by previewing LearnView or running the app in the Simulator. You should now be able to press the card and see it scale, whilst still being able to drag it left or right. This is a simple, but effective simultaneous combined gesture written with just a handful of code and a simple gesture modifier. Great job!
Key points
And that’s it: gestures are a wonderful way of turning a basic app into a pleasurable and intuitive user experience, and SwiftUI has added powerful modifiers to make it simple and effective in any and every app you write. In this chapter you’ve learned:
- How to create simple gestures from Apple’s built-in library. Simply use the
gesturemodifier along with the gesture to use. - How to create custom gestures for more unique interactions.
- How to combine animations and gestures for more fluid experiences.
Where to go from here?
You’ve done a lot with gestures but there’s a lot more that’s possible. Check out of the following resource for more information on where to go from here:
SwiftUI gesture documentation: apple.co/3cBuVgd