Building a Card Memory Game with Swift and SwiftUI
Danilo Miranda
Posted on August 10, 2020
Post also published in: DanMiranda.io
I decided I wanted to start learning Swift and SwiftUI.
I'll use this Stanford's course to learn it, and I'll also document what I'm learning or building through the material.
I already have this simple post about the MVVM architecture that it's used to build applications using Swift and SwiftUI.
Creating the project
Open your Xcode and choose the Create a new Xcode project.
Then Single View App cause we'll start with the basics barebones for a project. But you can see we have some interesting options to bootstrap an application with some pre-configured structure.
Now setup some project's details, like name, choosing your team, organization name and so on.
Just pay attention to the highlighted below. Make sure to choose Swift for language and SwiftUI for User interface.
After that, you'll be asked where you want to save your project and if you want to initiate the project with source control. (Git initiated)
Now that we created our project, we can take a look at the file ContentView.swift
.
import SwiftUI
struct ContentView: View {
var body: some View {
Text("Hello world")
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
We have the SwiftUI import at the very top of the file so we can use UI elements from SwiftUI such as Text
like it's done in the example.
The block of code below is used to create the live preview shown in the Canvas (that's probably positioned at the right side of your code editor.
We won't change this code for now. Just leave it as it is.
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
This preview will only work if you're using OS Catalina or a newer version. If you still don't see the preview, just follow this:
Click where it's highlighted at the top right corner of your editor and then make sure the Canvas option is checked.
Before we start creating our elements, let's see a little bit about UI elements.
If you already know this and want to keep working on the project, you can skip to this part.
Creating an UI Element
You can create an UI element by declaring a new struct
:
struct CardView: View {}
Here we are creating a new struct and naming it as CardView
. Next, by using : View
we are declaring that our CardView
struct will behave like a View
Note that when declaring a struct that will behave like a view, we must declare a body
variable that we use to build our interface or the UI element.
var body: some View {}
Here we are declaring a variable called body
as it is required and declaring its type which is some View
.
Now, what exactly is some View
?
When we type a variable as some View
we are declaring that this variable will hold a value that's is of a View type, such as:
- Text
- HStack
- ZStack
- RoundedRectangle
All these elements mentioned above comply with the View protocol, which is why they are valid returns for something that's declared as holding a value of type of some View
.
But why exactly do we specify the variable as of some View
instead of Text
or RoundedRectangle
for example.
Well, that can be done in case your body will only have one element of the specified type.
For example, we can declare a body that will hold a Text
type, like this:
struct CustomText: View {
var body: Text {
Text("Hello my custom and amazing text element")
}
}
// optionally, you can explictly write the return statement
struct CustomText: View {
var body: Text {
return Text("Hello my custom and amazing text element")
}
}
But, bear in mind that usually some of your UI elements will consistent of a number of different other elements, like Texts
, RoundedRectangles
, ZStack
and so on.
struct CustomText: View {
var body: Text {
ZStack {
Text("Hello my custom and amazing text element")
}
}
}
If you try this, Xcode will throw an error, stating: Cannot convert return expression of type 'ZStack<Text>' to return type 'Text'
So, in this case, you type your variable body
as of type some View
. This is called opaque types
Basic UI Elements (Building Blocks)
ZStack
This element serves as an alignment element which will align all of its children in the Z-axis. Each of the elements will be placed on top of each other.
struct ZStackExample: View {
var body: some View {
ZStack {
Text("Orange text")
Text("Green text")
Text("Blue text")
}
}
}
The very last element will be on top of all other preceding elements and so on.
HStack
An element used to align the children elements in the horizontal axis (x-axis)
Creating our CardView
After getting a grasp of View elements that will help us build our UIs, we can continue building our card game.
For now, we will create something simple as a Card which will contain a string element. In our case we will use emojis for these string elements.
struct CardView: View {
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 10.0).fill().foregroundColor(Color.white)
RoundedRectangle(cornerRadius: 10.0).stroke().foregroundColor(Color.orange)
Text("👻")
}
}
}
We start by creating a new Struct
called CardView
that will behave like a View
struct CardView: View {}
Remember, when declaring a struct that will behave like a View we need to declare a variable called body
which will hold a value that complies with the View protocol.
var body: some View {}
Now, to compose the card component, we'll need three layers. One that will hold a blank rectangle. The other which will hold a rectangle with an orange border and the final layer, the emoji which will be shown.
Since we need to place all of them on top of each other, we need the ZStack
element.
So inside of the body
variable we do:
ZStack {
RoundedRectangle(cornerRadius: 10.0).fill().foregroundColor(Color.white)
RoundedRectangle(cornerRadius: 10.0).stroke().foregroundColor(Color.orange)
Text("👻")
}
So, we added two RoundedRectangle
elements and the Text
element.
One of the parameters that the RoundedRectangle
accepts is the cornerRadius
which we use to determine the border radius for that rectangle.
We can also add some modifiers to style the rectangle even more, like:
-
fill()
which will fill the whole element with a color. We can choose the color that will be used to fill by applying another modifier, calledforegroundColor
that receives one unlabelled parameters that will set the color. We access available colours by using the constantColor
followed by a colour name.Color.white
-
stroke()
will just create a border stroke around the element and we can also choose its colour with the sameforegroundColor
modifier we used to set the fill color.
Wrapping all up we now have this:
struct CardView: View {
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 10.0).fill().foregroundColor(Color.white)
RoundedRectangle(cornerRadius: 10.0).stroke().foregroundColor(Color.orange)
Text("👻")
}
}
}
We may have something like this for now:
It would be interesting to add some padding , to give more space between the card and the main screen.
We can do that by simply adding the padding()
modifier to the ZStack
.
ZStack {
RoundedRectangle(cornerRadius: 10.0).fill().foregroundColor(Color.white)
RoundedRectangle(cornerRadius: 10.0).stroke().foregroundColor(Color.orange)
Text("👻")
}.padding(10) // ADD THIS
Let's now use the HStack
element to place more than one card, all placed horizontally.
In the main View of our app, the ContentView
struct we start by placing the HStack
struct ContentView: View {
var body: some View {
HStack {
AnotherCardView()
AnotherCardView()
AnotherCardView()
}
}
}
Just to see as an example, let's place three cards inside our HStack.
You now have something like this:
As the number of displayed cards will vary in the future, let's implement a simple loop to render a number of cards, instead of having to manually place them in our code.
struct ContentView: View {
var body: some View {
HStack {
ForEach(0..<4) {
index in AnotherCardView()
}
}
}
}
Now that we have the basics we can start building our Model and a ModelView.
Building our Model
The model is the source of truth for all the data that runs across the app.
Let's start by creating a new Swift file, and just in this case (as it's a simple project) let's call it Model.swift
.
Create a struct called CardGame
that will have a generic type, or as called in Swift an opaque type.
struct CardGame<CardContent> {}
This opaque that we are calling CardContent
is what will determine what type of content our card will hold. This content can be of string, number, or whatever, and in our case will be a string, since we'll be placing emojis inside the cards.
Now, what variables will we need to build this game?
Probably a variable that will hold the cards, that we can call cards
and the number of pairs of cards that will be rendered in the screen.
struct CardGame<CardContent> {
var cards: Array<Card>
var numberOfPairs: Int
}
Take note that the cards
variable will hold an Array that will contain a card type. But what exactly is this Card
? Well, let's create it right now.
Create a new struct called Card
.
struct Card: Identifiable {}
The
Identifiable
protocol will be discussed later.
Now let's think about the Memory game itself and how it works.
We need to be able to make cards have their faces with their content facing "down" and once we choose one the card flips and the content is shown.
We also need to "match" cards once 2 cards with the same content are flipped up.
And finally, the card need to hold the content, in our case our emoji. So we need three variables for now:
- isFaceUp
- isMatched
- content
So:
struct Card: Identifiable {
var isFaceUp: Bool = false
var isMatched: Bool = false
var content: ???
var id: Int
}
Let's forget about the id
variable for now, because we only declared it now since Identifiable
protocol requires it. We will make sense for it later on.
Now, se that I "typed" content with ???
. That it is because I wanted to highlight that the CardContent
opaque type we declared for our main struct (the CardGame
) comes into play.
The CardContent
type is what's going to tell what type of content our card holds. Be it a number or a string for example. So our variable content
inside our Card
struct will hold a value of this type:
var content: CardContent
Let's also make a simple method that we will further use to choose the clicked card.
func chooseCard(card: Card) {
print("Chosen card \(card)")
}
For now let's just print the chosen card.
See the \()
syntax inside the string? This is how we interpolate a variable value inside a string, in our case the card
variable.
The only thing left now is to create an initializer to attribute values to our variables cards
and numberOfPairs
.
init(numberOfPairsOfCards: Int, contentFactory: (Int) -> CardContent) {
cards = []
numberOfPairs = numberOfPairsOfCards
for pairIndex in 0..<numberOfPairsOfCards {
let content = contentFactory(pairIndex)
// append two cards (a pair) to the array of cards
cards.append(Card(content: content, id: pairIndex * 2))
cards.append(Card(content: content, id: pairIndex * 2+1))
}
cards.shuffle()
}
What's happening here?
We set cards
to be an empty array at the beginning and the numberOfPairs
to be equal to the value passed in numberOfParisOfCards
when initializing our struct.
cards = []
numberOfPairs = numberOfPairsOfCards
Now we loop over the numberOfPairs
using the for _ in
.
for pairIndex in 0..<numberOfPairsOfCards {
let content = contentFactory(pairIndex)
// append two cards (a pair) to the array of cards
cards.append(Card(content: content, id: pairIndex * 2))
cards.append(Card(content: content, id: pairIndex * 2+1))
}
pairIndex
will hold the current value contained in the iteration which we will use to invoke our contentFactory
function, that will return something of CardContent
type that will put in the content
variable we just declared.
We declared the content
variable with let
since this value will be a constant.
Having the content
set, we can append to our cards
variable a pair of cards.
cards.append(Card(content: content, id: pairIndex * 2))
cards.append(Card(content: content, id: pairIndex * 2+1))
Building our ModelView
Now we'll start creating our ModelView
which is the "entity" that will act as a middle man in the communication between the Model
and the View
.
We could build more than one ModelView
depending on the types of Card games we want. As we want a memory game of cards that will have emojis "printed" on them, we'll create a ModelView called EmojiMemoryGame
.
Start by creating a class:
class EmojiMemoryGame {}
But why a class and not a struct?
First, let's recall what a ModelView
is essentially. It will act as a portal of communication between the Model and View.
But suppose that as an application grows, the number of different views will also grow and probably the number of Views
trying to communicate with the data contained in the Model
will also grow.
In this case, we may have different View
referencing to the same ModelView
.
Different from Struct
, Classes
leave in the heap, they have pointers pointing to their position in the memory.
So anytime a new View
creates a new instance of a ModelView
which was declared as class
it is only pointing to a location in the memory.
If we declare our ModelView
as a Struct
each time a View needs to use the ModelView to communicate with the Model, it will do so by creating a new instance in the memory.
Now let's create the access (a "door") to our model:
class EmojiMemoryGame {
private var model: CardGame<String>
}
We are making our model
var as private
to avoid that Views
can directly control what's inside of model.
By declaring the reference to Model as private, we force ourselves to write the "intents" to modify the Model's data or write our own references inside our ModelView that will reference the values inside the Model.
Remember: In MVVM pattern, the
View
is not supposed to directly access what's inside theModel
. Every communication betweenModel
andView
must pass between theModelView
At this point you might be seeing XCode throwing you an error saying that Class 'EmojiMemoryGame' has no initializers
.
Let's fix this:
class EmojiMemoryGame {
private var model: CardGame<String> = EmojiMemoryGame.createMemoyGame()
static func createMemoryGame() -> CardGame<String> {
let emojis = ["👻", "🧟♂️", "🧙🏻♂️", "🎃", "🕸"]
let randomNumberOfPairs = Int.random(in:2...5)
return CardGame<String>(numberOfPairsOfCards: randomNumberOfPairs) {
pairIndex in emojis[pairIndex]
}
}
}
What we are doing here is, first we created a static method called createMemoyGame()
that will return a CardGame
of Strings
: CardGame<String>
Then we declare a fixed array of five emojis:
let emojis = ["👻", "🧟♂️", "🧙🏻♂️", "🎃", "🕸"]
And declare a random number of pairs of cards the game will have once it starts:
let randomNumberOfPairs = Int.random(in:2...5)
And in the end, we return a new instance of CardGame
return CardGame<String>(numberOfPairsOfCards: randomNumberOfPairs) {
pairIndex in emojis[pairIndex]
}
But where is this syntax coming from?
Remember when e declared the initializer for our Model
?
init(numberOfPairsOfCards: Int, contentFactory: (Int) -> CardContent) { }
Our Model
's initializer requires two parameters:
-
numberOfPairsOfCars
which is simply anInt
telling how many pairs of cards the game will have -
contentFactory
which is afunction
that will generate our card content.
Now let's look back at what our createMemoryGame()
method is returning:
return CardGame<String>(numberOfPairsOfCards: randomNumberOfPairs) {
pairIndex in emojis[pairIndex]
}
We are returning a new instance of the Struct
CardGame<String>
by passing the following arguments:
-
numberOfPairsOfCards
which will be the random generated number we assigned torandomNumberOfPairs
Now, where's the second required argument, contentFactory
?
It's right here:
{ pairIndex in emojis[pairIndex] }
This is the syntax of a Swift closure. I won't explain what these are here because they deserve a post of its own. There's a reference to closure documentation at the end of this.
But for now, just keep in mind that:
pairIndex
is the Int
argument that our contentFactory
argument requires
(Int) -> CardContent
and that what is after the in
keyword is what will be returned from this closure
which is essentially an inline function.
As we declared that our CardGame
will be of String
type, like this: CardGame<String>
we are returning one of the emojis from the array, based on the pairIndex
argument.
emojis[pairIndex]
Now we just need to expose our Model
's variables, cards
and numberOfPairs
and create a simple intent to "choose" a card (for now it will only print a message in the console)
var cards: Array<CardGame<String>.Card> {
model.cards
}
var pairs: Int {
model.numberOfPairs
}
func choose(card: CardGame<String>.Card) {
model.chooseCard(card: card)
}
At the end you should have something like this:
class EmojiMemoryGame {
private var model: CardGame<String> = EmojiMemoryGame.createMemoryGame()
static func createMemoryGame() -> CardGame<String> {
let emojis = ["👻", "🧟♂️", "🧙🏻♂️", "🎃", "🕸"]
let randomNumberOfPairs = Int.random(in:2...5)
return CardGame<String>(numberOfPairsOfCards: randomNumberOfPairs) {
pairIndex in emojis[pairIndex]
}
}
// MARK: - Acces to model
// this will expose cards from model to be used by the View (ContentView)
var cards: Array<CardGame<String>.Card> {
model.cards
}
var pairs: Int {
model.numberOfPairs
}
// MARK: - Intent(s)
// this will expose methods to be used by the View to interact with the Model's cards
func choose(card: CardGame<String>.Card) {
model.chooseCard(card: card)
}
}
Side note: You can add this decorated comments with
// MARK: - SOME_NAME
to create little sections in your code, which will help you navigate in your code when it starts to grow in lines of code.
Clicking in any of these will navigate to that block of code
Adapting our View
Now that we have our Model
and our ViewModel
created, we can start integrating our View
.
Inside our ContentView
struct, now back at our View
file (in our case it's called ContentView.swift
, let's add a reference to our ModelView
:
struct ContentView: View {
var emojiGame: EmojiMemoryGame
}
We will be back here in a moment. For now let's go to our CardView
struct.
Now we need to declare two variables to this struct.
var card: CardGame<String>.Card
var numberOfPairs: Int
We will add a few modifications to the current code of CardView
. First is that we will evaluate if the card should be facing up or down. Second, for now, as everything is being aligned horizontally we need to choose a different font size in case the game has 5 pairs of cards.
var body: some View {
ZStack {
if card.isFaceUp {
RoundedRectangle(cornerRadius: 10).foregroundColor(Color.white)
RoundedRectangle(cornerRadius: 10).stroke().fill(Color.orange)
Text(card.content).font(numberOfPairs == 5 ? Font.title : Font.largeTitle)
} else {
RoundedRectangle(cornerRadius: 10).foregroundColor(Color.orange)
}
}
}
So, inside our ZStack
we check if the card.isFaceUp
is true
or false
. Depending on that we render different elements
We also changed this:
Text("👻")
To this:
Text(card.content)
We also added the font
modifier to the Text
element.
.font(numberOfPairs == 5 ? Font.title : Font.largeTitle)
As previously said, if the numberOfPairs
is 5 we render if a lightly smaller font then when the game has 4 or less pairs.
Now let's go back to our ContentView
struct and modify the current body
.
var body: some View {
HStack {
ForEach(emojiGame.cards) { card in
CardView(card: card, numberOfPairs: self.emojiGame.pairs).onTapGesture {
self.emojiGame.choose(card: card)
}.aspectRatio(0.66, contentMode: .fit)
}
}.padding(10)
}
Now, before we continue, remember that Identifiable
thing we declared in our Card
struct inside our model?
struct Card: Identifiable {
var isFaceUp: Bool = true
var isMatched: Bool = false
var content: CardContent
var id: Int
}
If we hadn't done this and we tried to iterate over the cards variable, as we just did, XCode would throw the following error:
Generic parameter ID could not be inferred
.
That is when the Identifiable
protocol comes into play. We need give a way for the iteration in ForEach
to differentiate each card in the iteration, which requires a unique ID for each element.
So now, in each iteration over cards, we grab a new variable called card
and return a new CardView
, passing its required parameters, the card and the number of pairs.
We also added this modifier aspectRatio
to better fit the cards horizontally, specially when we have 5 pairs (10 cards in total).
The last thing we did is, add this onTapGesture
to our CardView
so any time a user presses a card, we call our "intent" method chooseCard
, passing the tapped card.
Now, before we rebuild our app to see the results, you may have noticed that in ContentView_Previews
XCode is throwing an error, saying that we are missing a parameter for our ContentView
and in fact we are.
Since we declared this var emojiGame: EmojiMemoryGame
inside our ContentView
struct we need to pass this argument:
ContentView(emojiGame: EmojiMemoryGame())
We still have on last thing. This same ContentView
struct also used in SceneDelegate.swift
.
Inside func scene
(probably in line 23) you have this declaration:
let contentView = ContentView()
Since now ContentView
requires an emojiGame
we just to do the same we just did in our ContentView_Previews:
let contentView = ContentView(emojiGame: EmojiMemoryGame())
That's it
Now, you can build by clicking on the play button at the top left area of Xcode, and you may have something like this:
You may end up with a different number of cards since we randomize the number of pairs.
What's next?
There's still a lot to go. We need to add the reactive part of the app, that will react to us choosing cards and flipping them over.
References
- Struct (and Classes)
Structures and Classes - The Swift Programming Language (Swift 5.3)
- View
- HStack
- ZStack
- RoundedRectangle
- Text
- Opaque types
Opaque Types - The Swift Programming Language (Swift 5.3)
- Closures
Posted on August 10, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 30, 2024