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

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

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.

struct FlashCard {
}
var card: Challenge
let id = UUID()
var isActive = true
struct FlashCard: Identifiable {
  ...
}
extension FlashCard: Equatable {
  static func == (lhs: FlashCard, rhs: FlashCard) -> Bool {
    return lhs.card.question == rhs.card.question
        && lhs.card.answer == rhs.card.answer
  }
}

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.

class FlashDeck {
  var cards: [FlashCard]
}
init(from words: [Challenge]) {
  cards = words.map {
    FlashCard(card: $0)
  }
}
var cards: [FlashCard]
@Published var cards: [FlashCard]
class FlashDeck: ObservableObject {
  ...
}

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.

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
  }
}
class LearningStore: ObservableObject {
  ...
}

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.

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)
The deck card
Wpi cury nocg

ZStack {
  CardView()
  CardView()
}
VStack {
  Spacer()
  Text("Swipe left if you remembered"
    + "\nSwipe right if you didn’t")
    .font(.headline)
  DeckView()
  Spacer()
  Text("Remembered 0/0")
}
The learn view
Fbe peivl xois

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)
Text("Remembered 0/0")
Text("Remembered \(learningStore.score)"
  + "/\(learningStore.deck.cards.count)")
@ObservedObject var deck: FlashDeck

let onMemorized: () -> Void

init(deck: FlashDeck, onMemorized: @escaping () -> Void) {
  self.onMemorized = onMemorized
  self.deck = deck
}
DeckView(
  deck: FlashDeck(from: ChallengesViewModel.challenges),
  onMemorized: {}
)
DeckView(
  deck: learningStore.deck,
  onMemorized: { learningStore.score += 1 }
)
let flashCard: FlashCard

init(_ card: FlashCard) {
  self.flashCard = card
}
Spacer()
Text(flashCard.card.question)
  .font(.largeTitle)
  .foregroundColor(.white)
Text(flashCard.card.answer)
  .font(.caption)
  .foregroundColor(.white)
Spacer()
let card = FlashCard(
  card: Challenge(
    question: "こんにちわ",
    pronunciation: "Konnichiwa",
    answer: "Hello"
  )
)
return CardView(card)
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
}
ZStack {
  ForEach(deck.cards.filter { $0.isActive }) { card in
    getCardView(for: card)
  }
}
Completed deck card
Gikgnatuw logj tibg

Applying Settings

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

@AppStorage("learningEnabled")
var learningEnabled: Bool = true
@AppStorage("cardBackgroundColor")
var cardBackgroundColorInt: Int = 0xFF0000FF
.onChange(of: cardBackgroundColor, perform: { newValue in
  cardBackgroundColorInt = newValue.asRgba
})
cardBackgroundColor = Color(rgba: cardBackgroundColorInt)
@AppStorage("learningEnabled")
var learningEnabled: Bool = true
if learningEnabled {
  LearnView()
    .tabItem({
      VStack {
        Image(systemName: "bookmark")
        Text("Learn")
      }
    })
    .tag(0)
}
Settings with learning disabled
Nigxajpm cejm xaaszupk jizujnar

@Binding var cardColor: Color
init(
  _ card: FlashCard,
  cardColor: Binding<Color>
) {
  flashCard = card
  _cardColor = cardColor
}
.fill(cardColor)
@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)
}
@AppStorage("cardBackgroundColor")
var cardBackgroundColorInt: Int = 0xFF0000FF
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
}
Choosing the card background color
Bpioqedp xfu kehq novxnvoihh caqol

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.

@State var revealed = false
.gesture(TapGesture()
  .onEnded {
    withAnimation(.easeIn, {
      revealed.toggle()
    })
})
Text(flashCard.card.answer)
  .font(.caption)
  .foregroundColor(.white)
if revealed {
  Text(flashCard.card.answer)
    .font(.caption)
    .foregroundColor(.white)
}
Tap gesture flow
Jus tasbero vmok

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.

enum DiscardedDirection {
  case left
  case right
}
typealias CardDrag = (
  _ card: FlashCard,
  _ direction: DiscardedDirection
) -> Void

let dragged: CardDrag
init(
  _ card: FlashCard,
  cardColor: Binding<Color>,
  onDrag dragged: @escaping CardDrag = {_,_  in }
) {
  flashCard = card
  _cardColor = cardColor
  self.dragged = dragged
}
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
}
DeckView(
  deck: learningStore.deck,
  onMemorized: { learningStore.score += 1 }
)
@State var offset: CGSize = .zero
ZStack {
return ZStack {
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
    }
  }
.offset(offset)
.gesture(drag)
.animation(.spring(), value: 0)
.animation(.spring(), value: offset)
Card's drag gesture
Roph'w hzeq tihdase

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.

@GestureState var isLongPressed = false
let longPress = LongPressGesture()
  .updating($isLongPressed) { value, state, transition in
    state = value
  }
  .simultaneously(with: drag)
.gesture(drag)
.gesture(longPress)
.scaleEffect(isLongPressed ? 1.1 : 1)
.animation(
  .easeInOut(duration: 0.3),
  value: isLongPressed
)
.gesture(TapGesture()
  ...
)
.simultaneousGesture(TapGesture()
  ...
)

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:

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:

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.
© 2024 Kodeco Inc.

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now