Skip to content

Code for Lecture 6: Custom Views & Event Handling

Notifications You must be signed in to change notification settings

cis1951/lec6-code

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

4 Commits
 
 
 
 
 
 
 
 

Repository files navigation

Bubble Game!

This README contains the instructions and code for Lecture 6: Custom Views & Event Handling.

We're going to build a game! In this game, bubbles will pop up on the screen, and based on their appearance you will have to perform different gestures to clear them. You can restart the game when you're done, and also add other neat features/interesting looking bubbles.

Here's a walkthrough of the steps we cover in lecture:

Step 0

Create a new Xcode project. You can call it BubbleGame if you wish (store it in the apps directory).

Step 1

Let's now first start by defining a model for our bubbles. We will probably want to store their size, position, color, and maybe some other properties later. So for now, let's make a file called BubbleModel.swift with the following struct. Note that we want it to conform to Identifiable to make our lives easier in the future, and all of the other properties are declared with var in case we want to change them later. Don't worry too much about the CGFloat type, just think of it as a normal float (it changes its precision based on the device, but you don't need to worry about this). This is the data type that many views and modifiers dealing with coordinates in SwiftUI use.

struct Bubble: Identifiable {
    let id = UUID()
    var color: Color
    var x: CGFloat
    var y: CGFloat
    var size: CGFloat
}

Step 2

Now let's create the main view. In ContentView, let's start by adding a button to start the game. We'll also want to add a ZStack to put the bubbles below the button, and let's give the ZStack an infinite maxWidth and maxHeight so it takes up the whole screen. So it will start something like this:

struct ContentView: View {
    var body: some View {
        ZStack {
            Button(action: {}) {
                Text("Start Game")
            }
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}

Now let's customize the button a little bit, using what you learned about SwiftUI shapes! Let's add a Capsule as the background for the button, and give it some styling so it looks nicer.

struct ContentView: View {
    var body: some View {
        ZStack {
            Button(action: {}) {
                Text("Start Game")
                    .font(.title)
            }
            .buttonBorderShape(.capsule)
            .buttonStyle(.borderedProminent)
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}

Step 3

Now let's add the logic to actually start the game. To do this, we will want 2 things, first a variable to store whether or not the game has started, and also a variable to store the list of bubbles. Therefore we will add these two variables to the top of ContentView. They should also be @State properties since this view "owns" them and needs to update its UI when they change. Now let's also only show the button to start the game if it hasn't been started yet, like so:

struct ContentView: View {
    @State private var bubbles: [Bubble] = []
    @State private var gameStarted = false

    var body: some View {
        ZStack {
            if !gameStarted {
                Button(action: {}) {
                    Text("Start Game")
                        .font(.title)
                }
                .buttonBorderShape(.capsule)
                .buttonStyle(.borderedProminent)
            }
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}

Now when this button is clicked, we want to generate the bubbles, and then start the game. Let's make a function called startGame to handle this for us, so it will set gameStarted = true and for now, generate 10 bubbles randomly. Let's make the size random from 20 to 50, and have them appear anywhere on the screen. For now, you can use UIScreen.main.bounds.width to get the width of the screen, but we will fix this later. Note you should NEVER use UIScreen.main in practice (it won't work on some platforms (like VisionOS!), and it contradicts the idea of views for sizing). Note that (0, 0) is in the TOP LEFT of the screen, and positive y goes downwards.

Thus the function looks like:

private func startGame() {
    for _ in 1...10 {
        let size = CGFloat.random(in: 20...50)
        let x = CGFloat.random(in: 0...UIScreen.main.bounds.width)
        let y = CGFloat.random(in: 0...UIScreen.main.bounds.height)
        let bubble = Bubble(color: Color.blue, x: x, y: y, size: size)
        bubbles.append(bubble)
    }

    gameStarted = true
}

Now we actually need to call this function when the button is clicked. We can do this by adding the startGame function to the Button's action parameter. Additionally, let's show the bubbles on the screen. So for each bubble, let's draw a Circle() with fill color of the button's color, and set its position, like so:

var body: some View {
    ZStack {
        ForEach(bubbles) { bubble in
            Circle()
                .fill(bubble.color)
                .frame(width: bubble.size, height: bubble.size)
                .position(x: bubble.x, y: bubble.y)
        }
        
        if !gameStarted {
            Button(action: {
                startGame()
            }) {
                Text("Start Game")
                    .font(.title)
            }
            .buttonBorderShape(.capsule)
            .buttonStyle(.borderedProminent)
        }
    }
    .frame(maxWidth: .infinity, maxHeight: .infinity)
}

Step 4

Now we actually want to remove the bubbles when they are clicked, or on some other gesture. Let's start with a simple onTapGesture. Let's add the following function to remove a bubble, and reset the game if there's none left:

private func removeBubble(_ bubble: Bubble) {
    bubbles.removeAll { $0.id == bubble.id }
    
    if bubbles.isEmpty {
        gameStarted = false
    }
}

Now we will actually need to call this function. Add an onTapGesture to the bubble's Circle() that calls this function:

ForEach(bubbles) { bubble in
    Circle()
        .fill(bubble.color)
        .frame(width: bubble.size, height: bubble.size)
        .position(x: bubble.x, y: bubble.y)
        .onTapGesture {
            removeBubble(bubble)
        }
}

Step 5

Take a break and play your game! Congrats, the very basic functionality has been implemented. Try running it and seeing how it feels!

Step 6

Now we will improve it. You might have noticed a few issues while playing the game. The first is that bubbles can spawn too close to the screen's edge, making them impossible to tap. Additionally, if we ever want to further customize the bubble's view, we probably should refactor this out to a separate view as it gets more complex.

Let's do this second one first. We will create a new file called BubbleView.swift and move the Circle drawing code into a new BubbleView struct. This struct will take a Bubble as a parameter, and draw the circle with the bubble's properties. You can use something like this:

struct BubbleView: View {
    var bubble: Bubble
    
    var body: some View {
        Circle()
            .fill(bubble.color)
            .frame(width: bubble.size, height: bubble.size)
            .position(x: bubble.x, y: bubble.y)
    }
}

#Preview {
    BubbleView(bubble: Bubble(color: Color.blue, x: UIScreen.main.bounds.width / 2, y: UIScreen.main.bounds.height / 2, size: 50))
}

Now in ContentView, we can replace the Circle with the BubbleView like so:

ForEach(bubbles) { bubble in
    BubbleView(bubble: bubble)
        .onTapGesture {
            removeBubble(bubble)
        }
}

Step 7

Now let's fix the issue mentioned earlier about bubbles spawning too close to the screen border. We could fix this by just subtracting some padding from the border of the screen (e.g. UIScreen.main.bounds.width - 50), but this is not very scalable. Instead, we will create a View that represents our entire game board, and then use GeometryReader to get the size of the view and use that to spawn the bubbles. To do this, let's do a decent amount of refactoring. First, create a new file called GameAreaView.swift. This file will contain a new struct called GameAreaView that will be a View and will represent the entire playable game board. Let's move the definition of the bubbles array inside this struct, since we only care about that array here. Additionally let's move the startGame and removeBubble logic inside this view so we have something like:

struct GameAreaView: View {
    @State private var bubbles: [Bubble] = []

    var body: some View {
        ZStack {
            ForEach(bubbles) { bubble in
                BubbleView(bubble: bubble)
                    .onTapGesture {
                        removeBubble(bubble)
                    }
            }
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }

    private func startGame() {
        for _ in 1...10 {
            let size = CGFloat.random(in: 20...50)
            let x = CGFloat.random(in: 0...UIScreen.main.bounds.width)
            let y = CGFloat.random(in: 0...UIScreen.main.bounds.height)
            let bubble = Bubble(color: Color.blue, x: x, y: y, size: size)
            bubbles.append(bubble)
        }

        gameStarted = true
    }

    private func removeBubble(_ bubble: Bubble) {
        bubbles.removeAll { $0.id == bubble.id }
        
        if bubbles.isEmpty {
            gameStarted = false
        }
    }
}

Now notice that we still probably want the @State var gameStarted defined in the ContentView, so let's leave it there, but we will need access to it in this GameAreaView. Also, since we modify its value in the GameAreaView, we will need it to be a binding variable. Thus we can add

@Binding var gameStarted: Bool

to the top of GameAreaView. Additionally, whenever this variable is set to true, we want to start the game, so we can add:

ZStack {
    ForEach(bubbles) { bubble in
        BubbleView(bubble: bubble)
            .onTapGesture {
                removeBubble(bubble)
            }
    }
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onChange(of: gameStarted) {
    if gameStarted {
        startGame()
    }
}

Overall, this file show now look something like:

struct GameAreaView: View {
    @Binding var gameStarted: Bool
    @State private var bubbles: [Bubble] = []

    var body: some View {
        ZStack {
            ForEach(bubbles) { bubble in
                BubbleView(bubble: bubble)
                    .onTapGesture {
                        removeBubble(bubble)
                    }
            }
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .onChange(of: gameStarted) {
            if gameStarted {
                startGame()
            }
        }
    }

    private func startGame() {
        for _ in 1...10 {
            let size = CGFloat.random(in: 20...50)
            let x = CGFloat.random(in: 0...UIScreen.main.bounds.width)
            let y = CGFloat.random(in: 0...UIScreen.main.bounds.height)
            let bubble = Bubble(color: Color.blue, x: x, y: y, size: size)
            bubbles.append(bubble)
        }

        gameStarted = true
    }

    private func removeBubble(_ bubble: Bubble) {
        bubbles.removeAll { $0.id == bubble.id }
        
        if bubbles.isEmpty {
            gameStarted = false
        }
    }
}

#Preview {
    @Previewable @State var gameStarted = false
    return GameAreaView(gameStarted: $gameStarted)
}

The different preview syntax at the bottom is just getting the preview to work since it needs a binding parameter. We should also now fix the original ContentView so it is just

struct ContentView: View {
    @State private var gameStarted = false

    var body: some View {
        ZStack {
            GameAreaView(gameStarted: $gameStarted)
            
            if !gameStarted {
                Button(action: {
                    gameStarted = true
                }) {
                    Text("Start Game")
                        .font(.title)
                }
                .buttonBorderShape(.capsule)
                .buttonStyle(.borderedProminent)
            }
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}

Step 8

Finally we can use the GeometryReader to get the size of the view and use that to spawn the bubbles. In GameAreaView, wrap the ZStack with GeometryReader { proxy in. Now let's pass in the size of the view into the startGame function so that we know where to spawn the bubbles. Modify startGame so it takes in a size: CGSize. The type CGSize is effectively an ordered pair of CGFloats, but it has some other properties if you want to use those. Now let's modify startGame to be

private func startGame(size: CGSize) {
    bubbles.removeAll()
    
    for _ in 1...10 {
        let bubbleSize = CGFloat.random(in: 20...50)
        let x = CGFloat.random(in: bubbleSize...size.width - bubbleSize)
        let y = CGFloat.random(in: bubbleSize...size.height - bubbleSize)
        let bubble = Bubble(color: Color.blue, x: x, y: y, size: bubbleSize)
        bubbles.append(bubble)
    }
}

Notice now we are randomly making the x and y positions based on the size of the view, so and we are accounting for the bubble's width, so we don't need to worry about it being too close to the border now. Finally, change where startGame is called to pass in the proxy's size, like so: startGame(size: proxy.size).

You should now be able to play the game without worrying about the screen bounds!

Step 9

Now let's add different types of bubbles. When creating them, randomly decide between two types, swipe bubbles and tap bubbles. For tap bubbles, randomly generate a number from 1 to 3, and this will be the number of total times they have to be tapped. For swipe bubbles, randomly generate an angle from 0 to 360, which represents the direction they have to swipe the bubbles. Modify the bubble struct like so to support this:

struct Bubble: Identifiable {
    enum BubbleType: CaseIterable {
        case swipe
        case tap
    }
    
    let id = UUID()
    var color: Color
    var x: CGFloat
    var y: CGFloat
    var size: CGFloat
    var type: BubbleType
    var swipeAngle: Angle?
    var tapCount: Int?
}

The CaseIterable protocol will just let us randomly select one of those cases when generating a bubble. Now let's create an initialization for the bubble inside the Bubble struct, one that's given parameters, and one that initializes randomly like so:

public init(color: Color, x: CGFloat, y: CGFloat, size: CGFloat, type: BubbleType, swipeAngle: Angle? = nil, tapCount: Int? = nil) {
    self.color = color
    self.x = x
    self.y = y
    self.size = size
    self.type = type
    self.swipeAngle = swipeAngle
    self.tapCount = tapCount
}

public init(color: Color, boundary: CGSize) {
    self.color = color
    self.size = CGFloat.random(in: 20...50)
    self.x = CGFloat.random(in: self.size...boundary.width - self.size)
    self.y = CGFloat.random(in: self.size...boundary.height - self.size)
    self.type = BubbleType.allCases.randomElement()!
    
    switch self.type {
    case .swipe:
        self.swipeAngle = Angle(degrees: Double.random(in: -180...180))
    case .tap:
        self.tapCount = Int.random(in: 1...3)
    }
}

Now we can change the start game function to generate these bubbles really easily:

private func startGame(size: CGSize) {
    bubbles.removeAll()
    
    for _ in 1...10 {
        bubbles.append(Bubble(color: Color.blue, boundary: size))
    }
}

Additionally, if you want to fix the preview in BubbleView, you can use:

#Preview {
    BubbleView(bubble: Bubble(color: Color.blue, boundary: UIScreen.main.bounds.size))
}

Step 10

Now let's change the BubbleView based on the type. If it is type tap, let's just add the number to the center. If it's type swipe, let's add an arrow in the corresponding direction, like so:

struct BubbleView: View {
    var bubble: Bubble
    
    var body: some View {
        ZStack {
            Circle()
                .fill(bubble.color)
                .strokeBorder(Color.black)
            
            if bubble.type == .tap {
                Text("\(bubble.tapCount ?? 1)")
            } else {
                Image(systemName: "arrow.right")
                    .rotationEffect(bubble.swipeAngle ?? Angle(degrees: 0))
            }
        }
        .frame(width: bubble.size, height: bubble.size)
        .position(x: bubble.x, y: bubble.y)
    }
}

#Preview {
    BubbleView(bubble: Bubble(color: Color.blue, boundary: UIScreen.main.bounds.size))
}

While we're here, modify the background so it's something cool! Maybe add different strokes to the border, maybe make it an image, do something fun! We'll use the following:

struct BubbleView: View {
    var bubble: Bubble
    
    var body: some View {
        ZStack {
            Circle()
                .fill(RadialGradient(colors: [bubble.color.opacity(0.3), bubble.color], center: UnitPoint(x: 0.2, y: 0.2), startRadius: bubble.size * 0.05, endRadius: bubble.size))
                .strokeBorder(bubble.color, lineWidth: 3)
            
            if bubble.type == .tap {
                Text("\(bubble.tapCount ?? 1)")
            } else {
                Image(systemName: "arrow.right")
                    .rotationEffect(bubble.swipeAngle ?? Angle(degrees: 0))
            }
        }
        .fontDesign(.rounded)
        .fontWeight(.bold)
        .foregroundStyle(.white)
        .frame(width: bubble.size, height: bubble.size)
        .position(x: bubble.x, y: bubble.y)
    }
}

Step 11

Finally we're ready to add the gesture recognition! Let's first modify removeBubble and rename it to tryRemoveBubble, and have it take in two optional parameters taps and angle. This function will be called by the gesture handlers later, one will pass in the number of taps, and the other the angle of the swipe. Now if the bubble has type tap, we should decrement the total number of taps left, and if it reaches 0, remove it. If the bubble has type swipe, we should check that the input angle is within 30 degrees of the bubbles required angle like so:

private func tryRemoveBubble(_ bubble: Bubble, taps: Int = 0, angle: Angle = Angle(degrees: 0)) {
    if bubble.type == .tap {
        if let index = bubbles.firstIndex(where: { $0.id == bubble.id }) {
            bubbles[index].tapCount? -= taps
            if bubbles[index].tapCount == 0 {
                bubbles.remove(at: index)
            }
        }
    } else if let bubbleAngle = bubble.swipeAngle {
        // Check if angle and bubble's angle are within 30 degrees
        let bubbleAngle = bubbleAngle.degrees.truncatingRemainder(dividingBy: 360)
        let swipeAngle = angle.degrees.truncatingRemainder(dividingBy: 360)
        let angleDiff = abs(bubbleAngle - swipeAngle)

        if angleDiff <= 30 || angleDiff >= 330 {
            bubbles.removeAll { $0.id == bubble.id }
        }
    }
    
    if bubbles.isEmpty {
        gameStarted = false
    }
}

Finally, we should add the calls to this function. To the end of the BubbleView in GameAreaView, add a .onTapGesture modifier that calls tryRemoveBubble(bubble, taps: 1). Also add a .gesture which will take in a DragGesture like so:

BubbleView(bubble: bubble)
    .onTapGesture {
        tryRemoveBubble(bubble, taps: 1)
    }
    .gesture(DragGesture(minimumDistance: 3.0, coordinateSpace: .local)
        .onEnded { value in
            let angle = atan2(value.translation.height, value.translation.width)
            tryRemoveBubble(bubble, angle: Angle(radians: angle))
        }
    )

Note the minimumDistance, which is the smallest distance for that gesture to trigger the activation. Then, once this gesture ends, we take the value's translation to calculate the angle of the drag. Then this angle is passed into the tryRemoveBubble function.

Now if you try it out, you will notice that the bubbles just pop into and out of existence. Let's give it a nice animation. To do this, it's really simple. Just wrap the tryRemoveBubble in a withAnimation like so:

BubbleView(bubble: bubble)
    .onTapGesture {
        withAnimation {
            tryRemoveBubble(bubble, taps: 1)
        }
    }
    .gesture(DragGesture(minimumDistance: 3.0, coordinateSpace: .local)
        .onEnded { value in
            let angle = atan2(value.translation.height, value.translation.width)
            withAnimation {
                tryRemoveBubble(bubble, angle: Angle(radians: angle))
            }
        }
    )

Now celebrate!

You're done with a cool game! If you have extra time and are still interested in doing more, try adding a score counter, sound effects, or maybe a timer to have the player race! Try making the game look better!

About

Code for Lecture 6: Custom Views & Event Handling

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages