2hours
iOS
Medium
Swift
In Part 1 of this series, the design and requirements for the game were explored and the framework built in Interface Builder. Key accomplishments include:
- Adding all the UIViewControllers needed for the embedded "bases", the main game screen, and the win Screen.
- Using Auto Layout to position fire buttons within the embedded UIViewControllers
- Assigning height/width to each players "base" as embedded UIViewController and the set of subviews.
- Laying out the winner's screen that shows the players their total win tally and who won the current game.
- Adding a segue to push the win screen onto the stack when a player wins the game.
- Creating an overlay that will show the end user a countdown to start the game
The next steps include making the necessary connections between the Interface Builder components to code. This tutorial lays out the design and interactivity between components.
Refer to the links below to other posts in the series:
- Part 1: Turn fundamental knowledge of CABasicAnimation, CALayers, Delegates, embedded UIViewControllers and Auto Layout into a simple and fun game
- Part 3: Use CALayers and CABasicAnimation to build firing animations and finalize the interface using UIView animations
- Part 4: Wrap up the game code by adding final polishing including animations to the win screen and connecting the screens using an unwind segue.
Download the entire project with source code for this app from the repo on bitbucket.org
Overview
It will help to get an end-to-end picture of the target product, as it was initially envisioned. Refer to the animated version of the final product:
Why does one side fire followed by the other? Only one button at a time can be fired from the simulator and that's where this was recorded. The actual game allows both sides to fire simultaneously.
Recapping the design and functionality of the game that were established in part one:
- Overlay on top of the main game screen will countdown to start the game while also preventing player interaction with the game
- Embedded UIViewControllers serve as each player's base. Each has a UIButton assigned to trigger the weapon firing
- Animated shots fire across the board and will hit the opponents' base. The size of the base will shrink by a predetermined amount on impact
- Once a player's base size has been reduced to zero there is a winner. The win screen is triggered and shown to the end user with a tally of wins per side.
Embedded UIViewControllers
In Part 1 of the series, the player's bases were embedded as UIViewControllers inside Container Views. Each has a UIButton to fire on the other player's base. Now create custom classes for each of those UIViewControllers so each player's base will have its type in code.
Create a new file by selecting File->New->File in XCode, then select the Cocoa Touch Class option, giving it the name UpperViewController and making it a subclass of "UIViewController".
Create another UIViewController file the same way but name it LowerViewController.
In the main screen UIViewController, add local variable placeholders for the embedded UpperViewController and LowerViewController.
weak var lowerVC:LowerViewController!
weak var upperVC:UpperViewController!
These UIViewControllers are initialized through the call to the prepare(for segue: UIStoryboardSegue, sender: Any?) method after triggering the segue to WinScreenViewController.
Fire Buttons
Next, tie each of the embedded UIViewControllers to the respective subclass file that was created. Go back to the storyboard and select the top embedded UIViewController by clicking on the top leftmost button of the frame. Refer to the image below, the highlighted button is in blue.
Select the Identity Inspector and then the Class dropdown. Set it to UpperViewController. Refer to the image below:
Follow those same steps for the bottom UIViewController, setting the class to LowerViewController
The embedded UIViewControllers tie into to the custom classes so now connect the fire buttons in each of them to their respective class.
Go back to the storyboard and select the UpperViewController using the top, leftmost button on the frame. Open the Assistant Editor in XCode. That is the button with two rings on it in the top right corner of the XCode interface.
The Assistant Editor provides a split view of the storyboard and the code for the UpperViewController. If it doesn't, make sure it's set to Automatic on the code side of the split view. Refer to the image below:
Select the UpperViewController and then the UIButton. Control-drag from the UIButton over to the UpperViewController.
When the popup appears, set the button's name to upperFire, make sure the Connection type is @IBOutlet, and click the Connect button. Here's what that popup looks like:
Follow these same steps for the LowerViewController, making the same @IBOutlet connection between the fire button and the UIViewController.
Enable the fire buttons by creating the @IBAction connection to each of the UIViewControllers First select the UpperViewController, then the UIButton, and control-drag from the fire button over to the code under the viewDidLoad method. It should look like this:
When the popup appears, make sure the Connection type is Action and give it the name triggerWeapon. Hit the Connect button.
Follow those same steps for the LowerViewController to complete the connections for the player bases.
Test the connection by putting a different print statement inside each triggerWeapon method and running the app. If the respective output of the print statements for each side is visible in the console then the connections are successful.
Height Constraints
The height of each container must be stored as a local variable inside MainGameScreenViewController. It is used to adjust the height of the base after an opponent's shot makes impact.
Next, do the following:
- From storyboard, select the MainGameScreenViewController
- select the Assistant Editor button up at the top for split-screen view
- If the overlay is visible, select it in storyboard
- Click the Attributes Inspector, and de-select the "Installed" button, as shown below:
The underlying Container Views are now visible. Select the top container and expand the constraints. Control-drag from the height constraint over to the MainGameScreenViewController. Refer to the screenshot below:
When the popup appears, name the outlet upperContainerHeightConstraint. Now do the same for the lower Container View, naming the outlet lowerContainerHeightConstraint.
Success! Local variables are in-place that will control the size of each player's bases as they're receiving shots from the opponent.
Delegates: Communication From Embedded ViewController to Main Game Screen ViewController
Each of the embedded UIViewControllers needs to communicate when a player fires a shot to the MainGameScreenViewController. Rendering the shots will also happen inside the MainGameScreenViewController.
The solution to the communication issue is to design a protocol to be implemented by the MainGameScreenViewController. Place delegate variables of that protocol type inside UpperViewController and LowerViewController.
Each time a player presses their respective fire button, call the delegate method from the UpperViewController or LowerViewController. That will trigger the protocol implementation code inside MainGameScreenViewController that animates the shot and more.
Select File->New->File from the XCode menu and then select "Swift File". Name the new file BoomDelegate and place the following code inside:
import Foundation
protocol BoomDelegate{
func boom(sender:Any?)
}
The protocol sends an object of type Any? to the receiver. The MainGameScreenViewController needs to know which embedded UIViewController sent the shot in order to render the shot from the correct side towards the target.
In code, send a reference to the calling class through the delegate for MainGameScreenViewController to handle. It will cast the incoming Any? to test which class is the calling class, either UpperViewController or LowerViewController
Implement the protocol by going back to the MainGameScreenViewController and add the following code to the end of the file:
//MARK:- BoomDelegate
extension ViewController:BoomDelegate{
func boom(sender: Any?) {
print("FIRING FROM \(sender)"
}
}
Add the following variable to the UpperViewController and LowerViewController so each gets an instance of the BoomDelegate.
var boomDelegate:BoomDelegate!
The final step to complete the inter-UIViewController communication is setting the delegate of each embedded UIViewController to the MainGameScreenViewController
Add the following code to override the prepare(for segue: UIStoryboardSegue, sender: Any?) method in MainGameScreenViewController:
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
// Setup the channels of communication between Child VCs and Parent via BoomDelegate
if segue.identifier == "uppercontainer"{
upperVC = segue.destination as? UpperViewController
upperVC.boomDelegate = self
}else if segue.identifier == "lowercontainer"{
lowerVC = segue.destination as? LowerViewController
lowerVC.boomDelegate = self
}
}
The prepare method is called before the creation of the embedded UIViewControllers to give the opportunity to setup that UIViewController with any specific data necessary for creation.
Specifically, the code checks the segue identifier to see which UIViewController is being setup, either the "uppercontainer" or "lowercontainer". Cast to the correct type and assign the boomDelegate class variable to self.
Finally, set the lowerVC and upperVC class variables in MainGameScreenViewController for references to each of the embedded UIViewControllers.
Go ahead and run the code. The print statement output from the delegate should be visible, refer below:
- "Firing from Optional(<Communication.UpperViewController: 0x7fb54d403f80>)"
- "FIRING FROM Optional(<Communication.LowerViewController: 0x7fb54a60b4a0>)"
Validate that the hex code for each button is different from the other.
Overlay Countdown
In Part 1 of this tutorial series, an overlay was created in Interface Builder by placing a UIView at the top level of the MainGameScreenViewController and adding a UILabel to show the countdown numbers to the players.
The next sections detail the code required to tie these components together.
Make the Interface Builder Connections and Required Variables
First, connect the overlay to the MainGameScreenViewController class. Go to the storyboard, select the Assistant Editor so the code for the class is next to the storyboard in XCode, control-drag from the overlay to the variables section of the class definition. Refer to the image below:
Name the newly created @IBOutlet overlayView
Select the UILabel and run through those same steps, control-dragging over to the code and naming the @IBOutlet countdownLabel.
The components for the overlay are connected to the MainGameScreenViewController. Now, setup a Timer to callback and modify the countdownLabel, giving it a starting value of 3.
Add the following variables to the MainGameScreenViewController class:
var countdownTimer:Timer!
var countdownVal = 3
Setting the color in code, taking advantage of a method in UIColor called withAlphaComponent(). This method allows setting the underlying UIView alpha value independently from the UILabel.
Add the following code to viewDidLoad():
self.countdownLabel.textColor = UIColor.white.withAlphaComponent(0.8)
self.overlayView.backgroundColor = UIColor.black.withAlphaComponent(0.7)
self.countdownLabel.text = ""
Notice the text is set to an empty string so no initial value is immediately visible when the game loads. That's important because the label will be animated, not immediately visible.
Configure the Timer and Set the Selector Method
In viewDidAppear() add the following code that will initialize the Timer on a recurring schedule:
// Start the timer to display countdown to end user
countdownTimer = Timer.scheduledTimer(timeInterval: 1.0, target: self, selector: #selector(animateCountdown), userInfo: nil, repeats: true)
The scheduledTimer is used because it allows setting an interval on which the selector will be called if the repeats parameter is true. Set that interval to one second using the timeInterval parameter.
Create a method named animateCountdown to act as the selector method for the Timer. Add it to the code in MainGameScreenViewController:
@objc private func animateCountdown(){
// Start by shrinking the current countdown value
UIView.animate(withDuration: 0.1, delay: 0, usingSpringWithDamping: 0.5, initialSpringVelocity: 5, options: .curveEaseInOut, animations: {
self.countdownLabel.transform = CGAffineTransform.init(scaleX: 0.0, y: 0.0)
}) { _ in
// Once size is 0, set to new number currently in class parameter countdownVal
self.countdownLabel.text = String(self.countdownVal)
// Enlarge the countdown value
UIView.animate(withDuration: 0.6, delay: 0, usingSpringWithDamping: 0.5, initialSpringVelocity: 5, options: .curveEaseInOut, animations: {
self.countdownLabel.transform = CGAffineTransform.init(scaleX: 1.0, y: 1.0)
}){_ in
// Decrement the countdown since we've done a round now.
self.countdownVal -= 1
// If reached 0 invalidate the timer so it doesn't trigger again
if self.countdownVal == 0{
self.countdownTimer.invalidate()
self.countdownTimer = nil
//Animate the disappearance of the overlay
UIView.animate(withDuration: 0.5, animations: {
self.overlayView.alpha = 0.0
}, completion:{_ in
// hide the overlay and reset the countdown values.
self.overlayView.isHidden = true
self.countdownVal = 3
self.countdownLabel.text = ""
})
}
}
}
}
Explanation of the animateCountdown Method
First, show the players the current countdownVal of three. That's where the initial call to UIView.animate() comes into play:
// Start by shrinking the current countdown value
UIView.animate(withDuration: 0.1, delay: 0, usingSpringWithDamping: 0.5, initialSpringVelocity: 5, options: .curveEaseInOut, animations: {
self.countdownLabel.transform = CGAffineTransform.init(scaleX: 0.0, y: 0.0)
})
The component calls UIView.animate() and uses a CGAffineTransform to change the size of the label to zero. It's set to zero because the next time the animateCountdown method is called, exactly one second later, the three will be visible and it should animate to shrink down.
Shrink the UILabel in the completion closure where it sets the text value of the UILabel to the current value in the class variable countdownVal. Refer to the code below:
{ _ in
// Once size is 0, set to new number currently in class parameter countdownVal
self.countdownLabel.text = String(self.countdownVal)
// Enlarge the countdown value
UIView.animate(withDuration: 0.6, delay: 0, usingSpringWithDamping: 0.5, initialSpringVelocity: 5, options: .curveEaseInOut, animations: {
self.countdownLabel.transform = CGAffineTransform.init(scaleX: 1.0, y: 1.0)
})
There is also another call to UIView.animate that animates the UILabel back to full size using a CGAffineTransform
The completion closure of final animation must be implemented. Remember, every second this method will be called by the Timer until it is invalidated. The countdownVal class variable must be decremented while the previous value it held is showing to the players. finally, check the decremented value to see if it's hit zero.
{_ in
// Decrement the countdown since we've done a round now.
self.countdownVal -= 1
// If reached 0 invalidate the timer so it doesn't trigger again
if self.countdownVal == 0{
self.countdownTimer.invalidate()
self.countdownTimer = nil
//Animate the disappearance of the overlay
UIView.animate(withDuration: 0.5, animations: {
self.overlayView.alpha = 0.0
}, completion:{_ in
// hide the overlay and reset the countdown values.
self.overlayView.isHidden = true
self.countdownVal = 3
self.countdownLabel.text = ""
})
}
}
Perform the test, if the value is zero then immediately invalidate the Timer and set it to nil so it does not fire again during this duration of the current game.
We make the overlay UIView fade away by calling UIView.animate on the overlayView's alpha channel. On completion, set it to hidden, reset the countdownVal to 3 and the text label to the empty string.
The Timer initialization code is placed in viewDidAppear(). Each time the players restart another game, the MainGameScreenViewController's viewDidAppear() is called, and theTimer values are reset for reassignment.
One other goal has been accomplished. The players are prevented from hitting any buttons while the overlay is in-place. Touches on the screen are intercepted by the overlay UIView which performs no action upon them.
Run the code now. The following animation should be visible if everything went according to plan:
Win Screen
There is a UIViewController already setup to serve as a Win Screen that displays the winner and current score. This was performed in Part 1 of the tutorial series.
It's time to wire that up now and start fleshing out its functionality.
Interface Builder Connections
First, create a new file by:
- Selecting File->New->File from the XCode menu
- Selecting Cocoa Touch Class
- Naming the file WinScreenViewController
- Making it a subclass of UIViewController.
There are two UIButtons on the screen. One is assigned to triggering another game and the other to reset the scores. Select the WinScreenViewController in Interface Builder then hit the Assistant Editor button to get the split screen (refer above for direction on how to do that).
Control-drag in Interface Builder from each of the UILabels and UIButtons to create the following @IBOutlets in the WinScreenViewController class:
@IBOutlet weak var resetScores: UIButton!
@IBOutlet weak var playAgainButton: UIButton!
@IBOutlet weak var bottomWinCountLabel: UILabel!
@IBOutlet weak var topWinCountLabel: UILabel!
@IBOutlet weak var winnerLabel: UILabel!
Next, setup the @IBActions for the UIButtons that will reset the scores and trigger another round of the game. Control-drag from each button over to the WinScreenViewController class and name the @IBActions resetScores for the "Reset" UIButton and returnToMainScreen for the "Play Again" button.
Storing Scores in User Defaults
How will the player scores be stored? Take advantage of UserDefaults to save the win tally. It stores key-value pairs that are typically used to store user preferences for quick access on app startup and shutdown.
It makes sense to place the code to store and pull from UserDefaults in WinScreenViewController because it will display the overall score to the players.
Add a class variable to WinScreenViewController that will serve as the key used to pull the stored scores from UserDefaults and save them. Refer to the code:
let SCORES_KEY = "scores_key"
Next, add the following enumeration above the class definition in MainGameScreenViewController:
enum Winner:String{
case orange, blue
}
Now that it's available to every class in the package, the enum will be used throughout the app to reference the winner of the game.
The MainGameScreenViewController will pass a value of orange or blue to the WinScreenViewController and that WinScreenViewController will pass it back (as a demo of passing data via closures only!). It is also used to store and retrieve from UserDefaults.
I'll use a dictionary that will be stored in UserDefaults tied to the key defined above for clarity. This dictionary will be a String->Int type where the String is either orange or blue and the Int is the win count for that player.
Add the following code to the viewDidLoad() method of WinScreenViewController:
if var currentScore = UserDefaults.standard.dictionary(forKey: SCORES_KEY){
// Set the current text and stored values depending on winner set from main game screen
if let upper = currentScore["orange"] as? Int{
topWinCountLabel.text = (winner == .orange) ? String(upper + 1) : String(upper)
currentScore["orange"] = (winner == .orange) ? upper + 1 : upper
}
if let lower = currentScore["blue"] as? Int{
bottomWinCountLabel.text = (winner == .blue) ? String(lower + 1) : String(lower)
currentScore["blue"] = (winner == .blue) ? lower + 1 : lower
}
UserDefaults.standard.set(currentScore, forKey: SCORES_KEY)
}else{
//otherwise we've never stored anythimg yet so create a new dictionary, set the score based on the winner, and save it.
var scores:[String:Int] = (winner == .orange) ? [Winner.orange.rawValue:1, Winner.blue.rawValue:0] : [Winner.orange.rawValue:0, Winner.blue.rawValue: 1]
UserDefaults.standard.set(scores, forKey: SCORES_KEY)
topWinCountLabel.text = String(scores[Winner.orange.rawValue]!)
bottomWinCountLabel.text = String(scores[Winner.blue.rawValue]!)
}
Breaking down that code:
- If the UserDefaults value that's tied to the SCORES_KEY exists, increment the correct "Win Count Label" and store the incremented value in UserDefaults.
- If the SCORES_KEY doesn't exist then create a dictionary with a String->Int mapping, check which side won, make their win count one, and store the dictionary in UserDefaults
Extract the string value from the enum by grabbing the rawValue from the respective enum:
Winner.orange.rawValue // equals "blue"
Winner.blue.rawValue // equals "orange"
Complete the @IBActions and Set the Closure to Pass Data Between UIViewControllers
Finish the @IBActions by adding the following code:
@IBAction func resetScores(_ sender: Any) {
// completely remove dictionary if stored and set text to 0
UserDefaults.standard.removeObject(forKey: SCORES_KEY)
self.topWinCountLabel.text = "0"
self.bottomWinCountLabel.text = "0"
}
If the players decide to reset the scores, remove the stored info that's keyed to the SCORES_KEY, then immediately reset the top and bottom UILabels to zero.
Next, use a closure to pass the winner back to MainScreenViewController. This is a demonstration of passing data using closures and it is not a requirement. It would be just as easy to pull out the code from the closure because the MainScreenViewController already knows which side wins.
Add the following code to WinScreenViewController:
@IBAction func returnToMainScreen(_ sender: Any) {
self.passBackClosure?(winner)
}
Add the following class variable:
var passBackClosure: ((Winner)->())?
Go back to MainGameScreenViewController and add the following code as the final piece of the if/else conditional check on the segue.identifier:
else if segue.identifier == "winScreenSegue"{ //
if let winVC = segue.destination as? WinScreenViewController{
// set the closure
winVC.passBackClosure = {winner in
if let winner_return_value = winner as? Winner{
print("Winner is \(winner)"
}
}
// Set the winner and color in the newly created WinScreenVC
if let _ = sender as? UpperViewController{
winVC.winner = .orange
winVC.winnerColor = self.upperVC.view.backgroundColor!
}else if let _ = sender as? LowerViewController{
winVC.winner = .blue
winVC.winnerColor = self.lowerVC.view.backgroundColor!
}
}
}
Breaking down that code:
- The closure is set based on the fingerprint established in the WinScreenViewController of ((Winner)->())?. This indicates that the WinScreenViewController will be passing back to the MainGameScreenViewController the winner value, orange or blue.
How is the winner determined? That code is reserved for the next tutorial, but the logic will be defined as follows:
- Method that handles firing the weapons for both players and tracks the current size of each player's base.
- When one player's base is reduced to zero, it calls performSegue(withIdentifier: sender) to show the WinScreenViewController
- Before showing the players the Win Screen, iOS will call the prepare(for segue: sender:) method where the winner information is passed, as shown above.
Final Thoughts
This is a great place to stop and review what has been accomplished thus far. There are a number of steps taken today, including:
- Created LowerViewController and UpperViewController classes that extend UIViewController
- Linked the Interface Builder components (created in Part 1 of the series) to those classes
- Created a Protocol named BoomDelegate, implementing that protocol in MainGameScreenViewController, assigning a class BoomDelegate variable to each of LowerViewController and UpperViewController, then setting each delegate to MainGameScreenViewController
- Linked the fire buttons for each player's base to an @IBAction and local class variable in LowerViewController and UpperViewController
- Linked the overlay to code in MainGameScreenViewController and set a recurring Timer to display the countdown to the players.
- Animated the countdown to make the game look more polished.
- Connected the WinScreenViewController from Interface Builder to code and added the IBActions to trigger score resets and new games.
- Added code in the prepare method to check the winner, pass data forward to the WinScreenViewController via class variables, and set the closure used to pass data back to MainGameScreenViewController
- Defined UserDefaults used to store the player's scores.
The end is in sight now, it's just out there on the horizon. Finish up the bulk of the code in Part 3 of the tutorial series.
Here are some relevant links to help flesh out concepts shown in this tutorial: