At the time of writing this nearly the whole world is in lockdown, a time of excessive home baking, DIY and video calls. A recent trend to emerge from this pandemic is the zoom quiz, where we all huddle round a laptop webcam and participate in badly composed trivia. This gave me an idea for a small app we're going to step through today, a set of quiz questions laid out in a deck of cards using SwiftUI.

The full playground for this tutorial is available on Github
/

By the end of this post you'll have the above UI inside a playground; we're going to cover some basic SwiftUI Views, as well as property wrappers, view modifiers, custom components, gestures and animations.

I'm writing this assuming you're a SwiftUI beginner, if that's not the case you can skip over a lot of the more lengthy explanations.


Let's start by opening a playground, i'll be using the Playgrounds app available on iPad and now Mac via catalyst (!), but the same code will work inside a playground or project inside of Xcode.

If you're taking the playground route, let's get up and running with SwiftUI, replace the contents of your playground with this.

import SwiftUI
import PlaygroundSupport

struct ContentView: View {
  
    var body: some View {
	Text("Hello SwiftUI")
    }
}

let host = UIHostingController(rootView: ContentView())
PlaygroundPage.current.liveView = host

If you're not familiar with playgrounds, the last line of code here just sets the live view of the playground, you can think of this as the window in which the playground will render your UI, similar to live previews in Xcode.

If you're in an Xcode project you'll get a ContentView by default and you can ignore the playground specific code in this example.


Planning

Before we get into the weeds we need to think about what we need in order to achieve our goal.

  • We need to order cards on top of each other.
  • We need to achieve perspective, i.e. the cards need to move backwards and get smaller as they go.
  • We need to be able to drag the top card around.
  • We need to insert the top card in the front or back of the deck
  • We need to place the card when we let go.

Let's address this point-by-point.

  1. We can use a ZStack to arrange the cards on top of each other and manage their order using an array. To manage the state of all the cards more easily we can create a Deck object.
  2. We can use the offset and scale modifiers to position and scale the cards the further back they are in the deck.
  3. We can use the gesture modifier in SwiftUI and pass in a DragGesture to move the top card.
  4. Using the drag gesture, we can track the position of the drag and check it's position.
  5. Use the onEnded gesture event to reset the top card offset when we let go.

I have a key advantage here in that the code is in front of me...but taking some time to plan before we jump into writing code can be incredibly useful and help us define and alleviate potential pain points before we start.


Creating a card

To start we're going to create a simple Card model to store some basic information about the card itself, and then feed that into a CardView to manage the representation of our model.

struct Card: Identifiable, Equatable {
    let id = UUID()
    let question: String
    let color: Color
}

The Card model is very simple, we're going to define a question of type String and a color or type Color; the id variable is a requirement when conforming to Identifiable and makes working with ForEach in SwiftUI simpler. I won't go into why this is the case here, but if you're interested you can look at Paul Hudson's article on the subject. We make the struct Equatable so we can make use of the firstIndex function on the array storing the deck of cards.

Next let's add our CardView, it takes a single parameter card of type Card that we use to configure some UI elements.

struct CardView: View {
    let card: Card
    
    var body: some View {
        VStack {
            VStack {
                Text("QUIZ NIGHT")
                    .font(.title)
                    .foregroundColor(.white)
                    .bold()
                Divider()
                Spacer()
                Text(card.question)
                    .font(.system(size: 20))
                    .foregroundColor(.white)
                    .bold()
                    .multilineTextAlignment(.center)
                    .padding(.horizontal)
                Spacer()
            }
        }
        .padding()
        .frame(width: 300, height: 400)
        .background(
            RoundedRectangle(cornerRadius: 10)
                .foregroundColor(card.color)
        )
    }
}

Again this is a fairly basic view, we create a parent VStack and a child VStack , the first one is a container for the whole card, if you look at the bottom (line 22) you can see we add some modifiers for the card shape, starting with padding which will by default add 8px of padding on all four edges, we set a frame and add a RoundedRectangle as the background, who's foregorundColor is defined by our card model card.color.

The second VStack contains the content, we use a Text object to set the "QUIZ NIGHT" title and a divider which provides out solid horizontal line, then two Spacer views above and below a second Text object which renders text based on the card models card.question. Β The two spacers center the question Text between the divider and the bottom of the card.

To finish, let's next define our Deck which we'll use to store cards and their positions.

struct Deck {
        
    var cards = [
        Card(question: "The tallest building in the world is located in which city?", color: .purple),
        Card(question: "Which year was the original Toy Story film released?", color: .red),
        Card(question: "Which film was the first to be recognised as part of the Marvel Cinematic Universe?", color: .green),
        Card(question: "Name the longest river in the UK.", color: .blue),
        Card(question: "In which year was the popular video game Fortnite first released?", color: .orange)
    ]   
}

Now we have our deck of cards with different questions and colors, let's get them on screen!

Above var body... in our ContentView, add a reference to the Deck object.

struct ContentView: View {
    
	@State var deck: Deck = Deck()

	var body: some View {	
	...

Notice the @State keyword here, this is very important. @State is a propery wrapper, if you're not familiar with property wrappers you can read about them in my previous post. @State tells SwiftUI that when this variable changes the UI should be redrawn, this is fundamental to how SwiftUI operates. Each time any change occurs within our deck the UI will update to reflect those changes, pretty sweet! In my opinion, this state management is the single biggest advantage SwiftUI has over UIKit.

As we saw in our plan, the containing element should be a ZStack so we'll start there, then using a ForEach we'll loop over our deck of cards and create a CardView. Amend the body of ContentView with the following.

var body: some View {
    ZStack {
	ForEach(deck.cards) { card in
	    CardView(card: card)
	}
    }
}

You should see the following result

Now we're cooking! Here we've learned how to create a basic model and loop over it in SwiftUI, create a view with dependencies and stack views on top of each other, next comes the fun stuff...


Now we have our cards on screen it's time to think about how they should be laid out, this responsibility lies with the deck.

Firstly we're going to look at the scale, z-index and offset; the further back a card is (the lower the z-index) the smaller it should get and the higher it should be, this will allow us to achieve perspective.

Add the following code to our Deck, right below the cards array.

var count: Int {
    return cards.count
}

func position(of card: Card) -> Int {
    return cards.firstIndex(of: card) ?? 0
}

func scale(of card: Card) -> CGFloat {
    let deckPosition = position(of: card)
    let scale = CGFloat(deckPosition) * 0.02
    return CGFloat(1 - scale)
}

func deckOffset(of card: Card) -> CGFloat {
    let deckPosition = position(of: card)
    let offset = deckPosition * -10
    return CGFloat(offset)
}

func zIndex(of card: Card) -> Double {
    return Double(count - position(of: card))
}

Each of these functions takes a Card object as a parameter and returns a value of the type required by the associated view modifier. For example, the scaleEffect view modifier requires a CGFloat, so that's what the scale function returns. Let's break down each function and explain what's happening.

func position(of card: Card) -> Int

This is fairly straightforward, it returns the index of the card in the array using firstIndex(of), which in turn lets us know where it is in the deck.

func scale(of card: Card) -> CGFloat

We want to scale the cards the further back they are, in the scale function we find the position of the card in the deck and sclae it down by 2% of its total size, then multiply it by its position. So index zero (0 x 0.02) will have a scale of one, index one will have a scale of 0.98 (1 x 0.02), index two 0.96 (2 x 0.02) etc.

func deckOffset(of card: Card) -> CGFloat

Similar to scale, we use the position of the card in the deck to calculate the offset, for each index above one we move the card up by 10.

func zIndex(of card: Card) -> Double

You may wonder why for the z-index we don't just use the position of the card in the cards array; in SwiftUI a zIndex of zero would put the card at the back, but in a deck of cards (I think) it makes sense for zero to be the card at the top of the deck (i'm not looking to start any debates or anything, it's personal preference), we simply reverse the index in this function so zero is at the top and (cards.count - 1) is the bottom. We do this by subtracting the number of cards in the deck (the count variable) by the position of the card.

Now we have that out of the way let's use these in our layout. Change CardView(card: card) to look like the following.

CardView(card: card)
    .zIndex(self.deck.zIndex(of: card))
    .shadow(radius: 2)    
    .offset(y: self.deck.deckOffset(of: card))
    .scaleEffect(x: self.deck.scale(of: card), y: self.deck.scale(of: card))

These view modifiers are all fairly self explanatory but we'll quickly run through them. zIndex sets the z-index of the view, placing it behind or in-front of other views with lower or higher z-index values respectively. We used the shadow modifier to add a small drop shadow to provide a better depth effect. offset will change the position of the card's x and/or y value, in this case we only care about the y value. scaleEffect will scale the view's width and/or height between zero and one.

For each of these view modifiers we have an associated function in the deck object which provides the value in the correct type. As we mentioned before, the deck variable uses the @State property wrapper, any change to deck will redraw the UI and recalculate these values.

You should now have something that looks like this...

Starting to look like a real deck of cards! In the final part of this post we're going to add gestures to move the top card and change its position in the deck.


The last (and most exciting) step to get this up and running is to add interactivity, we want to be able to pick up the top card, move it around and change its position in the deck, we also want to change it's rotation slightly as we move it around.

Firstly let's focus on position; using a DragGesture we want to track the position of the users touch and apply that position to an offset view modifier, we'll start there. Add two new variables to Deck at the top, right above var cards.

var topCardOffset: CGSize = .zero
var activeCard: Card? = nil

topCardOffset will track the position of the top card as we move it, and activeCard will store the card that we're moving, if we're not currently interacting with any cards this will be nil.

Now add the following to the CardView view, below scaleEffect.

.gesture(
    DragGesture()
        .onChanged({ (drag) in
        
            if self.deck.activeCard == nil {
                self.deck.activeCard = card
            }
            if card != self.deck.activeCard {return}        
        
            withAnimation(.spring()) {
                self.deck.topCardOffset = drag.translation
            }
        })
        .onEnded({ (drag) in
            withAnimation(.spring()) {
                self.deck.activeCard = nil
                self.deck.topCardOffset = .zero
            }
        })
)

The gesture view modifier allows us to add a Gesture to the view, there are a few gestures SwiftUI has built in, but in our case we need the DragGesture to track when the user touches the card and moves it.

Some gestures have their own view modifiers, for example if you want to add a gesture to detect taps, you could use .onTapGesture directly as a view modifier, bypassing the need to use .gesture.

onChanged will call a closure every time the gesture state changes (every time the user drags the card), and provide us with an updated state of that gesture through our drag variable. We can use the translation variable here to get the new position of our drag and apply that to topCardPosition. Notice that we've wrapped it in a withAnimation closure, this will animate the position change with the animation we've provided, in this case .spring() which will add a nice spring effect to the movement. We also set the activeCard, we need this so we can only move one card at a time and not the entire deck.

onEnded is called when the gesture ends, we'll use this to set topCardPosition back to zero.

If you run the code now nothing new happens, this is because there's one final step! We need to apply topCardOffset to the offset of our card. Above .offset(y: self.deck.deckOffset(of: card)), add a new `offset` view modifier.

.offset(x: self.offset(for: card).width, y: self.offset(for: card).height)

Notice that we call a function inside this view modifier. offset(for:_) checks to see whether the card is the active card and returns the appropriate offset. Let's add that now and see it in action. Add the following code below the end of the body, just before the final } in the struct.

func offset(for card: Card) -> CGSize {
    if card != self.deck.activeCard {return .zero}
    
    return deck.topCardOffset
}

ok...now run the code and drag the card around. Pretty cool!

We have two last things to do here, move the card to the back or front of the deck by changing the zIndex, and apply a rotation as we drag.

We'll start with the latter; add a new function below the offset(for_) we just added.

func rotation(for card: Card) -> Angle {
    guard let activeCard = self.deck.activeCard
        else {return .degrees(0)}
    
    if card != activeCard {return .degrees(0)}
    
    return deck.rotation(for: activeCard, offset: deck.topCardOffset)
}

Separating some of our view logic into functions like this removes the need for excessive ternary operators inside our view modifiers, too many conditions can get confusing after all. This function simply checks that the card we are rotating is the active card and returns a new angle, otherwise it returns zero.

Now inside our Deck add the following.

func rotation(for card: Card, offset: CGSize = .zero) -> Angle {
    return .degrees(Double(offset.width) / 20.0)
}

This code returns an angle based on the offset provided. When we call this with our active card we pass in the topCardOffset variable (the location of our top card when dragging), and divide this by 20 to get a float value that represents an arbitrary rotation. Wrapping this in .degrees provides an Angle type that SwiftUI can use to apply a rotation within the rotationEffect view modifier. Add the following just below the scaleEffect view modifier of our CardView view.

.rotationEffect(self.rotation(for: card))

You should now have the following. Sweet 😁.


If you've made it this far, well done...we have one last step to complete this UI, moving the card between the front and the back of the deck.

We need to add two functions to our Deck object to achieve this, let's start with that. Add the following code to the bottom of the Deck struct, just before the closing }.

mutating func moveToBack(_ state: Card) {
    let topCard = cards.remove(at: position(of: state))
    cards.append(topCard)
}

mutating func moveToFront(_ state: Card) {
    let topCard = cards.remove(at: position(of: state))
    cards.insert(topCard, at: 0)
}

These two functions do the same thing in reverse, moveToBack takes the card out of the cards array and appends it to the array; append will add the card to the end of the array, or the last index. moveToFront takes the card out of the array and inserts it at index zero. Remember that we set this up to use the index of the card in the array to control the zIndex view modifier, so this in itself is enough to move the card, all we need to do is call the appropriate function at the right time.

We need to track the position of the card inside the onChanged closure of our DragGesture, we can use the value of drag.translation to check if the card has moved outside of a certain area and call the new functions appropriately. Add the following code just after self.deck.topCardOffset = drag.translation inside onChanged.

if
    drag.translation.width < -200 ||
    drag.translation.width > 200 ||
    drag.translation.height < -250 ||
    drag.translation.height > 250 {
  	
    self.deck.moveToBack(card)
} else {
    self.deck.moveToFront(card)
}

This is fairly simple, if the card has moved less than -200 or greater than 200 along the x axis (width), or it's moved less than -250 or greater than 250 along the y axis (height), then move it to the back, otherwise move it to the front.

Run the code and you'll see the final result!


It's been a long one so thanks for sticking with me, hopefully you've learned a lot stepping through this tutorial, and you have a cool piece of UI to show off, it's a win-win. Let's recap what we've learned.

  • Render Views inside a ForLoop, using a model as the data source.
  • Apply transformations to the view based on view modifiers.
  • Manage state using the @State property wrapper.
  • Use gestures on a view.
  • apply animations between states.
  • Separate views into separate modules.

That's a fair amount to cover; SwiftUI really is powerful tool in your belt and hopefully you've learned just how simple it can be, constructing views in this way really is the future of Swift!

The full playground for this tutorial is available on Github

If you found this article useful feel free to let me know on Twitter!

Thanks for reading ✌️