Chapters

Hide chapters

SwiftUI by Tutorials

Fifth Edition · iOS 16, macOS 13 · Swift 5.8 · Xcode 14.2

Before You Begin

Section 0: 4 chapters
Show chapters Hide chapters

11. 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 item 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.

There’s quite some work to be done in order to get the project ready to take gestures. Exceptionally for this chapter only, you’ll find two starter projects under the starter folder, contained in these folders:

  • starter-chapter
  • starter-gestures

If you want to do all the preparatory work, either reuse the project you completed in the previous chapter, or use the one contained in starter-chapter and keep reading.

If you want to skip the preparatory work and jump to gestures right away, then skip the next Adding the Learn Feature section (but it’s recommended to at least taking a quick look anyway) and start reading Your first gesture.

If you decided to take the blue pill, start by opening the starter project from the starter/starter-chapter folder — or your own project brought from the previous project if you prefer.

Adding the Learn Feature

In the previous chapter you added a tab bar to the app, with two tabs only: Challenge and Settings. Now you’re going to add a 3rd tab, occupying the first position in the tabs list, which will take care of the Learn section.

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 Shared group, choose New Group, and name it Learn.

The view you’ll be building will be used for learning new words; therefore, it can intuitively be called LearnView. So, go ahead and create a new SwiftUI view file named LearnView inside the Learn group.

Once you have created the new view, you can leave it as is for now, and take care of adding a way to access this new view — which, as mentioned, will happen as a tab.

Open HomeView and before the PracticeView tab add this new tab:

LearnView()
  .tabItem({
    VStack {
      Image(systemName: "bookmark")
      Text("Learn")
    }
  })
  .tag(0)

If you resume the preview, this is what you’ll see:

The newly created learn tab
The newly created learn tab

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. 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 conform to the Identifiable protocol, as the ForEach SwiftUI block will 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 inside the Learn group, using the Swift File template. FlashDeck needs just a single property: an array of FlashCard objects — Add the following class:

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]) {
  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 - Part II"):

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 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 = deck.cards.last else {
      return nil
    }

    self.card = card
    deck.cards.removeLast()

    return self.card
  }
}

Going over this step-by-step:

  1. Like in FlashDeck, you’ll use Combine to provide @Published attributes to your properties. The store will maintain the complete deck (deck),
  2. … the current card (card),
  3. … and the current score (score).
  4. You add an initializer that sets up the deck.
  5. You also add a convenience method, 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.

Building the User Interface

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 using the SwiftUI View template, 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(), value: 0)

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:

The deck card
The deck card

Next up, the deck view. Create a SwiftUI file named (you guessed it) DeckView 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 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.

The learn view
The learn view

Adding LearningStore to the Views

Staying inside LearnView, you can add the store you previously created as a property to the view:

@StateObject var learningStore =
  LearningStore(deck: ChallengesViewModel.challenges)

As LearningStore is a StateObject, 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 \(learningStore.score)"
  + "/\(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 and add the following at the top of the struct, before body:

@ObservedObject var deck: FlashDeck

let onMemorized: () -> Void

init(deck: FlashDeck, onMemorized: @escaping () -> Void) {
  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(
  deck: FlashDeck(from: ChallengesViewModel.challenges),
  onMemorized: {}
)

And finally, inside LearnView find DeckView() in the body and replace it with:

DeckView(
  deck: learningStore.deck,
  onMemorized: { learningStore.score += 1 }
)

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 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 content 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 update CardView_Previews’s previews to:

let card = FlashCard(
  card: Challenge(
    question: "こんにちわ",
    pronunciation: "Konnichiwa",
    answer: "Hello"
  )
)
return CardView(card)

Next, inside DeckView, you’ll need to modify the body to dynamically support multiple CardViews. 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
    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:

Completed deck card
Completed deck card

Applying Settings

In the previous chapter you added two settings that affect the Learning section:

  • Learning Enabled, in the game category, used to enable or disable the learning screen.
  • Card Background Color in the appearance category, used to personalize the card background.

Now it’s time to put them to use. The first thing to do is to expose both parameters via the UserDefaults, turning them from @State into @AppStorage properties.

The first is very simple: In SettingsView replace the line where learningEnabled is declared with:

@AppStorage("learningEnabled")
var learningEnabled: Bool = true

As for the other property, it’s of Color type, which is not a type that UserDefaults can handle, so you have to either make it RawRepresentable, or use a shadow property - see the previous chapter to know more about their differences.

You’ll use the latter method, by adding a shadow property of Int type. Add this property before cardBackgroundColor:

@AppStorage("cardBackgroundColor")
var cardBackgroundColorInt: Int = 0xFF0000FF

Next, in body add a new onChange(of:perform) modifier to List, right after the other two that take care of daily reminder enabled and daily reminder time:

.onChange(of: cardBackgroundColor, perform: { newValue in
  cardBackgroundColorInt = newValue.asRgba
})

Last, in the .onAppear modifier, initialize the card background color from the shadow property - add this after setting dailyReminderTime:

cardBackgroundColor = Color(rgba: cardBackgroundColorInt)

With these settings adjustment accomplished, you need to use them appropriately.

You use learningEnabled to enable or disable the learning section, and the easiest way to achieve that is by showing or hiding the respective tab.

Open HomeView and add the same AppStorage property as defined in SettingsView:

@AppStorage("learningEnabled")
var learningEnabled: Bool = true

Next, surround the first tab with an if statement, so that the tab is included only if learningEnabled is true:

if learningEnabled {
  LearnView()
    .tabItem({
      VStack {
        Image(systemName: "bookmark")
        Text("Learn")
      }
    })
    .tag(0)
}

Note: For simplicity, you’re using an anti-pattern that has been discouraged in the previous chapter — You are declaring the same learningEnabled app storage property in two different places, and you’re going to do the same with the other property, cardBackgroundColor. The solution is to move these property to a dedicated data structure, as you did in the previous chapter by using ChallengesViewModel to host them.

Now run the app, go to the settings view, when you disable Learning Enabled you see the Learning tab disappearing, whereas if you enable it, it will reappear.

Settings with learning disabled
Settings with learning disabled

Now to change the card background color, add a corresponding property to CardView, in CardView:

@Binding var cardColor: Color

You declare it as a binding because you will pass it, so that the source of truth is defined elsewhere — namely, in DeckView.

You might be tempted to do it directly in CardView, but that would be inefficient, because you would read the same property from UserDefaults for each card, whereas passing it from DeckView you’d read it once, and pass the same binding to all cards via their respective initializers.

Replace the CardView’s initializer to account for the new property:

init(
  _ card: FlashCard,
  cardColor: Binding<Color>
) {
  flashCard = card
  _cardColor = cardColor
}

Next, replace the statically-set red background color with the value of the newly added property. In body, the Rectangle view has a .fill(Color.red) modifier — replace it with:

.fill(cardColor)

Last for CardView, you need to amend the preview to handle the additional parameter. Replace CardView_Previews content with:

@State static var cardColor = Color.red

static var previews: some View {
  let card = FlashCard(
    card: Challenge(
      question: "こんにちわ",
      pronunciation: "Konnichiwa",
      answer: "Hello"
    )
  )
  return CardView(card, cardColor: $cardColor)
}

Now open up DeckView and add this property:

@AppStorage("cardBackgroundColor")
var cardBackgroundColorInt: Int = 0xFF0000FF

You will use just the shadow property instead of adding a second property — you’ll convert it to Color when passing to CardView.

Next, replace the createCardView(for:) implementation with:

func createCardView(for card: FlashCard) -> CardView {
  // 1
  let view = CardView(card, cardColor: Binding(
      get: { Color(rgba: cardBackgroundColorInt) },
      set: { newValue in cardBackgroundColorInt = newValue.asRgba }
    )
  )

  return view
}

Here you’ve passed the new cardColor parameter to the CardView initializer, using an explicit binding.

You can now run the app, re-enable learning if it was still disabled, and pick a card color of your choice — if you activate the Learning tab, you’ll see that cards are now shown with the shiny newly selected background color.

Choosing the card background color
Choosing the card background color

Your First Gesture

Note: If you skipped the previous section and jumped straight into this, open the updated starter project that you’ll find in the starter/starter-gestures folder.

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, 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(_:):

.gesture(TapGesture()
  .onEnded {
    withAnimation(.easeIn, {
      revealed.toggle()
    })
})

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 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.

Tap gesture flow
Tap gesture flow

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 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 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,
  cardColor: Binding<Color>,
  onDrag dragged: @escaping CardDrag = {_,_  in }
) {
  flashCard = card
  _cardColor = cardColor
  self.dragged = dragged
}

Next up, you need to modify DeckView so it supports the new card functionality. Open up DeckView and replace the implementation createCardView(for:) with the following:

func createCardView(for card: FlashCard) -> CardView {
  let view = CardView(
    card,
    cardColor: Binding(
      get: { Color(rgba: cardBackgroundColorInt) },
      set: { newValue in cardBackgroundColorInt = newValue.asRgba }
    ),
    onDrag: { card, direction in
      if direction == .left {
        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 — That’s because when instantiating DeckView from LearnView you passed a closure for the onMemorized parameter that does that:

DeckView(
  deck: learningStore.deck,
  onMemorized: { learningStore.score += 1 }
)

The final step is to add the actual drag gesture. Go back to CardView, then add the following property after revealed:

@State var offset: CGSize = .zero

You’ll use this offset to move the card to a new position.

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 { offset = $0.translation }
  // 2
  .onEnded {
    if $0.translation.width < -100 {
      offset = .init(width: -1000, height: 0)
      dragged(flashCard, .left)
    } else if $0.translation.width > 100 {
      offset = .init(width: 1000, height: 0)
      dragged(flashCard, .right)
    } else {
      offset = .zero
    }
  }

This DragGesture does most of the work for you, but there are a few things worth noting:

  1. With each movement recorded during the drag, the onChanged event will occur. You’re modifying the offset property (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 onChanged triggered when the user was still dragging 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.

  2. The onEnded event 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 -100 and 100 as the decision markers for whether the user selected left or right during the drag, and that decision is being passed into the dragged closure.

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(offset)
.gesture(drag)

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.

There’s a spring animation also included to make the card spring back to position smoothly — but it requires a small adjustment to work properly. It’s currently specified as:

.animation(.spring(), value: 0)

But the value parameter should be a value that’s monitored for changes, so that the animation is performed only when that value changes. Since you’re using offset to calculate the position of the card, that’s the value to use. Change as follows:

.animation(.spring(), value: offset)

Now you can build and run to check your progress. You can now drag the card around and swipe left and right.

You can also try previewing LearnView using Live Preview and see the drag gesture in action.

Card's drag gesture
Card's drag gesture

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)

Note how you’re creating a new gesture and combining it in simultaneous way with another gesture, 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.

You may notice that no animation is applied to this pulse effect — but that’s easy to fix. Just add an animation for it, linked to the isLongPressed property after .scaleEffect(:_):

.animation(
  .easeInOut(duration: 0.3),
  value: isLongPressed
)

Now if you run or preview again, you’ll see that animation applied!

This is a simple, but effective simultaneous combined gesture written with just a handful of code and a simple gesture modifier. Great job!

However you can see that the tap gesture to reveal the translation that you added earlier no longer works: if you tap on the card, nothing happens — This happens because the long press gesture hides it.

A quick way to fix this is to use the .simultaneousGesture(). Replace:

.gesture(TapGesture()
  ...
)

With:

.simultaneousGesture(TapGesture()
  ...
)

And the tap to reveal the translation gesture will work again!

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 gesture modifier 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 the following resource for more information on where to go from here:

SwiftUI gesture documentation: apple.co/3cBuVgd

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.