Chapters

Hide chapters

SwiftUI by Tutorials

Second Edition · iOS 13 · Swift 5.2 · Xcode 11

Before You Begin

Section 0: 3 chapters
Show chapters Hide chapters

Section II: Building Blocks of SwiftUI

Section 2: 6 chapters
Show chapters Hide chapters

10. Gestures
Written by Antonio Bello

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

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:

  1. Creating a tab view, for now it only has an empty view.
  2. Using the accentColor modifier, 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:

  1. This is the view that’s displayed when the tab is active
  2. You use the tabItem modifier to configure the tab
  3. You’re displaying an icon and a label below it, using a VStack to keep them together.
  4. 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.

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

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")
}

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(onMemorized: @escaping () -> Void, deck: FlashDeck) {
  self.onMemorized = onMemorized
  self.deck = deck
}
DeckView(
  onMemorized: {},
  deck: FlashDeck(from: ChallengesViewModel().challenges)
)
DeckView(
  onMemorized: { self.learningStore.score += 1 },
  deck: learningStore.deck
)
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)
  }
}

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

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,
  onDrag dragged: @escaping CardDrag = {_,_  in }
) {
  self.flashCard = card
  self.dragged = dragged
}
func createCardView(for card: FlashCard) -> CardView {
  let view = CardView(card, 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)

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)

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 of 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 accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now