DEV Community

Danilo Miranda
Danilo Miranda

Posted on

Building a Card Memory Game with Swift and SwiftUI

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.

XCode first step creating a 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.

Choosing single view

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.

Alt Text

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)

Alt Text

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()
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
    }
}
Enter fullscreen mode Exit fullscreen mode

Alt Text

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.

Alt Text

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 {}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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 Texttype, 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")
    }
}
Enter fullscreen mode Exit fullscreen mode

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")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The very last element will be on top of all other preceding elements and so on.

ZStack illustrated example

HStack

An element used to align the children elements in the horizontal axis (x-axis)

Alt Text

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("👻")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

We start by creating a new Struct called CardView that will behave like a View

struct CardView: View {}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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("👻")
}
Enter fullscreen mode Exit fullscreen mode

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, called foregroundColor that receives one unlabelled parameters that will set the color. We access available colours by using the constant Color 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 same foregroundColor 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("👻")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

We may have something like this for now:

Alt Text

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
Enter fullscreen mode Exit fullscreen mode

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()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Just to see as an example, let's place three cards inside our HStack.

You now have something like this:

Alt Text

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()
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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> {}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)")
}
Enter fullscreen mode Exit fullscreen mode

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()
    }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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))
}
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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 the Model. Every communication between Model and View must pass between the ModelView

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]
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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 = ["👻", "🧟‍♂️", "🧙🏻‍♂️", "🎃", "🕸"]
Enter fullscreen mode Exit fullscreen mode

And declare a random number of pairs of cards the game will have once it starts:

let randomNumberOfPairs = Int.random(in:2...5)
Enter fullscreen mode Exit fullscreen mode

And in the end, we return a new instance of CardGame

return CardGame<String>(numberOfPairsOfCards: randomNumberOfPairs) {
  pairIndex in emojis[pairIndex]
}
Enter fullscreen mode Exit fullscreen mode

But where is this syntax coming from?

Remember when e declared the initializer for our Model?

init(numberOfPairsOfCards: Int, contentFactory: (Int) -> CardContent) { }
Enter fullscreen mode Exit fullscreen mode

Our Model's initializer requires two parameters:

  • numberOfPairsOfCars which is simply an Int telling how many pairs of cards the game will have
  • contentFactory which is a function 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]
}
Enter fullscreen mode Exit fullscreen mode

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 to randomNumberOfPairs

Now, where's the second required argument, contentFactory?

It's right here:

{ pairIndex in emojis[pairIndex] }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode
func choose(card: CardGame<String>.Card) {
    model.chooseCard(card: card)
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

Alt Text

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
            }
        }
    }
Enter fullscreen mode Exit fullscreen mode

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("👻")
Enter fullscreen mode Exit fullscreen mode

To this:

Text(card.content)
Enter fullscreen mode Exit fullscreen mode

We also added the font modifier to the Text element.

.font(numberOfPairs == 5 ? Font.title : Font.largeTitle)
Enter fullscreen mode Exit fullscreen mode

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)
    }
Enter fullscreen mode Exit fullscreen mode

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
    }
Enter fullscreen mode Exit fullscreen mode

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())
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

Since now ContentView requires an emojiGame we just to do the same we just did in our ContentView_Previews:

let contentView = ContentView(emojiGame: EmojiMemoryGame())
Enter fullscreen mode Exit fullscreen mode

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:

Alt Text

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

Apple Developer Documentation

  • HStack

Apple Developer Documentation

  • ZStack

Apple Developer Documentation

  • RoundedRectangle

Apple Developer Documentation

  • Text

Apple Developer Documentation

  • Opaque types

Opaque Types - The Swift Programming Language (Swift 5.3)

  • Closures

Closures - The Swift Programming Language (Swift 5.3)

Top comments (1)

Collapse
 
gscrawley profile image
Gideon S Crawley • Edited

hi Dan, thanks for this great tutorial. where can i find the rest? (the reactive part)