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.
- 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 aDeck
object. - We can use the
offset
andscale
modifiers to position and scale the cards the further back they are in the deck. - We can use the
gesture
modifier in SwiftUI and pass in aDragGesture
to move the top card. - Using the drag gesture, we can track the position of the drag and check it's position.
- 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 βοΈ