Chapters

Hide chapters

SwiftUI by Tutorials

Third Edition · iOS 14 · Swift 5.3 · Xcode 12

Before You Begin

Section 0: 3 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.

Start by opening the starter project.

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 tap, 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 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 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.swift 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]) {
  self.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 = self.deck.cards.last else {
      return nil
    }

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

    return self.card
  }
}
class LearningStore: ObservableObject {
  ...
}

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.

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())
The deck card
Nba juxz fext

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
Cgi kuokr cuem

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)
Text("Remembered 0/0")
Text("Remembered \(self.learningStore.score)"
  + "/\(self.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: { self.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: "Apple",
    pronunciation: "Apple",
    answer: "Omena"
  )
)
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
    self.getCardView(for: card)
  }
}
Completed deck card
Juqmriyon fevd piql

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
selection: Binding(
  get: { cardBackgroundColor },
  set: { newValue in
    cardBackgroundColorInt = newValue.asRgba
    cardBackgroundColor = newValue
  }
)
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
Lofzurgl havl qaopcegt fevuprot

@Binding var cardColor: Color
init(
  _ card: FlashCard,
  cardColor: Binding<Color>
) {
  self.flashCard = card
  self._cardColor = cardColor
}
.fill(cardColor)
struct CardView_Previews: PreviewProvider {
  @State static var cardColor = Color.red

  static var previews: some View {
    let card = FlashCard(
      card: Challenge(
        question: "Apple",
        pronunciation: "Apple",
        answer: "Omena"
      )
    )
    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
Skaewuqn mfo hecz naggzreiml vuciw

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.

@State var revealed = false
.gesture(TapGesture()
  .onEnded {
    withAnimation(.easeIn, {
      self.revealed = !self.revealed
    })
})
Text(flashCard.card.answer)
  .font(.caption)
  .foregroundColor(.white)
if self.revealed {
  Text(flashCard.card.answer)
    .font(.caption)
    .foregroundColor(.white)
}
Tap gesture flow
Loh jebciyi dyaf

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 }
) {
  self.flashCard = card
  self._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 {
        self.onMemorized()
      }
    }
  )

  return view
}
@State var offset: CGSize = .zero
ZStack {
return ZStack {
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
    }
  }
.offset(self.offset)
.gesture(drag)
Card's drag gesture
Yokk'm gfan sixdave

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