Controller
As the app grows, we have created a variety of classes. For example, we used a Table View Controller, a Tab Bar Controller, and we built a Web View Controller.
A Controller is usually the sum of two natures, Delegate and Data Source. It makes the middle ground between the data to be displayed, what we see on screen, and how we interact with it.
Of all the things Buttons, Tables, Collections or Images are capable of doing, they are not capable of conveying intent and guiding the user. There comes the designer’s hand on the Controller.
Downloads for Delegation
To follow this tutorial, you’ll need Xcode 9, and the finished Xcode project from Progress Rings. Also, make sure to download the final project for comparison.
Delegation Pattern
Delegation is often used to separate concerns. For example, it was mentioned before that a Table View is capable of many things but incapable of knowing what to display.
For instance, if you were to write a Table View on your own, you would have to write how it scrolls, how it positions its items, how it determines what is being tapped and many other major and minor features.
Instead, you simply call it and customize it using the controller. This is possible because Table View has a protocol of communication in which another object can fulfill its needs without needing to override its core behavior.
Handoff
In the previous section, the Exercises Scene was built upon a Table View Controller. In each of its cells, a Collection View is being controlled by the Cell. And finally, these Collection Views have cells for the Questions and the Score.
These three layers of behavior do naturally communicate by themselves to make scrolling and tapping possible. Nonetheless, they lack the configuration to be able to perform some actions. In this section, we will make the tap events of the Answer, Try Again and Share Buttons bubble up from layer to layer and trigger appropriate responses.
Question Cell
We will take a bottom to top approach. Begin by opening the project's main storyboard and navigating to the Question Cell on the Exercises Scene. As we have not done before, create a new Cocoa Touch Class for the Question Collection View Cell.
To make use of it, class the question cell to it and open the Assistant Editor. Before this cell is presented, it needs to know what question is being asked and configure its question label and answer buttons labels.
var question : Dictionary<String,Any>!
@IBOutlet var questionLabel: UILabel!
@IBOutlet var answerButtons: [UIButton]!
Notice that an outlet collection does not guarantee order. Even if you connect them one by one, UI Kit only ensures the assignment. For these buttons, this is acceptable because the order in which the answers appear on the screen does not matter.
Tap Answer Button
Select one of the buttons and create an action to receive its tap events. Label it "did tap answer button" and type the sender argument as a UI Button.
@IBAction func didTapAnswerButton(_ sender: UIButton) {}
Just like outlet collections, an action is specific to a given object, but may be connected to many. Connect all the Answer Buttons to it.
This action should trigger two behaviors. First, it should change the button image to the exercises checked, which it can do on its own.
sender.setImage(UIImage(named: "Exercises-Check"), for: .normal)
Question Cell Delegate
Second, it should be determined if the answer was right, show the partial score and move to the next question or the final score. This part is not a responsibility of the cell. In this case, it should tell a delegate that a question cell did receive an answer to a given question.
Above this class, create a protocol to which it can communicate this:
protocol QuestionCellDelegate : class {
func questionCell(_ cell : QuestionCollectionViewCell, didTapAnswerButton button : UIButton, forQuestion question : Dictionary<String,Any>)
}
Inside the cell class, create a new property for this delegate:
weak var delegate : QuestionCellDelegate?
And call it appropriately when the button is tapped:
delegate?.questionCell(self, didTapButton: sender, forQuestion: question)
Configuring the Question Cell
Back in the Exercises Table View Cell class, the question cells are dequeued, yet they lack the question and the possible answers. This can be easily fixed by configuring the cell right before it is returned.
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Question Cell", for: indexPath) as! QuestionCollectionViewCell
let question = questions[indexPath.row]
cell.question = question
cell.delegate = self
cell.questionLabel.text = question["question"] as? String
if let possibleAnswers = question["answers"] as? Array<String> {
for index in 0..<possibleAnswers.count {
cell.answerButtons[index].setTitle(possibleAnswers[index], for: .normal)
}
}
return cell
As mentioned before, the answer buttons on the question cell do not guarantee order. Take notice that this is not a reliable way to shuffle the possible answers, although it may happen.
Did Tap Answer Button for Question
Notice that, by setting the Exercise Table View Cell as the delegate of a Question Cell, Xcode notifies an error. This error is due to the lack of functionality of the cell, and can be easily corrected.
Create an extension for the Exercise Table View Cell that complies with Question Cell Delegate, and create a function for the questionCell:didTapButton:forQuestion selector.
func questionCell(_ cell: QuestionCollectionViewCell, didTapButton button: UIButton, forQuestion question: Dictionary<String, Any>)
This function needs to move to the next cell to display the next question as it has ownership of the collection view.
let indexPath = collectionView.indexPath(for: cell)!
let nextIndex = IndexPath(row: indexPath.row + 1, section: indexPath.section)
if indexPath.row < questions.count {
collectionView.scrollToItem(at: nextIndex, at: .centeredHorizontally, animated: false)
}
Receive Answer
As an Exercise Cell is not entitled to create and issue events, it should also not be able to perform a segue to show the partial score. Once again we need to create a pathway to inform another object that a cell received an answer to a question.
Above this class, create a protocol to which we can communicate this:
protocol ExerciseTableViewCellDelegate : class {
func exerciseCell(_ cell : ExerciseTableViewCell, receivedAnswer correct : Bool, forQuestion question : Dictionary<String,Any>)
}
Inside the cell class, create a new reference to this delegate:
weak var delegate : ExerciseTableViewCellDelegate?
To send this event, the cell should also determine if the question was correctly answered. The simplest way to do that is by comparing the content of the correct answer to the title of the button that was tapped.
var answerCorrect : Bool = false
if let correctAnswer = question["correctAnswer"] as? String, let answer = button.titleLabel?.text {
answerCorrect = correctAnswer == answer
}
delegate?.exerciseCell(self, receivedAnswer: answerCorrect, forQuestion: question)
Did Receive Answer for Question
Go back to the Exercises Table View Controller class and set the Exercises Cell's delegate as self. Once again, an error is going to show due to the lack of functionality of the controller.
Create an extension for the Exercises Table View Controller that complies with Exercise Table View Cell Delegate, and create a function for the exerciseCell:receivedAnswer:forQuestion selector.
performSegue(withIdentifier: "Present Exercise Dialog", sender: nil)
As a View Controller, the Exercises Table View Controller has both the authority and functionality to present the Exercise Dialog View Controller by performing a segue.
Don’t forget to remove the perform segue from the view did appear method.
Score Cell
Going back to the Score Cell, it will present itself by animating the final score while also receiving events for two of its buttons. As we have not done it before, create a Cocoa Touch Class for it named Score Collection View Cell, class it on the storyboard and trigger the Assistant Editor.
When this cell shows on the screen, it should animate its progress elements. Include the MKRingProgressView library and create outlets and variables for those elements and the exercise:
@IBOutlet var percentageLabel : UILabel!
@IBOutlet var percentageView : MKRingProgressView!
var exercise : Array<Dictionary<String,Any>>!
Different from a View Controller, a Cell does not receive view did load events. When a view is placed from the storyboard, it receives an awakening call. Use it to animate the elements:
override func awakeFromNib() {
percentageLabel.animateTo(72)
percentageView.animateTo(72)
}
Actions
Similarly to the Question Cell, the Score Cell is the one receiving events from the Try Again and Share Buttons. Create actions from the Score Collection View Cell to try again button tapped and share button tapped with a typed sender of UI Button.
@IBAction func tryAgainButtonTapped(_ sender: UIButton) {}
@IBAction func shareButtonTapped(_ sender: UIButton) {}
These actions are going to be relayed to another object, the delegate. So the Collection Cell knows who and how to send events, create a protocol for a Score Cell Delegate:
protocol ScoreCellDelegate : class {}
This protocol is going to define how this cell is going to communicate its events to another object which will be entitled to take the necessary measures. Declare a reference to this delegate on the cell class:
weak var delegate : ScoreCellDelegate?
Try Again Button
The first of its events should have the Table Cell reload the questions so that the user can answer them once again. Inside the protocol, create a function to receive this event:
func scoreCell(_ cell : ScoreCollectionViewCell, didTapTryAgainExercise exercise : Array<Dictionary<String,Any>>)
And, inside the did tap try again button action, call the delegate appropriately:
delegate?.scoreCell(self, didTapTryAgainExercise: exercise)
Share Button
The Share button should gather information about the exercise, and present a Share Sheet with the provided sharing options. Similarly to Try Again, create a function to which the score cell can tell its delegate that the user did tap share on it for exercises:
func scoreCell(_ cell : ScoreCollectionViewCell, didTapShareExercise exercise : Array<Dictionary<String,Any>>)
And, inside the share button tapped action, call the delegate appropriately:
delegate?.scoreCell(self, didTapShareExercise: exercise)
Showing the Score Cell
Back in the Table View Cell, only question cells are being dequeued and shown on the screen. Let's both make the Score Cell available and perform the required enhancements to be able to respond to its events.
Firstly, on the collectionView:numberOfItemsInSection selector, increment the return value by one, so the collection view asks for all the questions plus one score cell:
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return questions.count + 1
}
And refactor the collectionView:cellForItemAtIndexPath, so it can provide a score cell when in the last position. Let's add to the top of it so the code is called before anything else.
if indexPath.row == questions.count {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Score Cell", for: indexPath) as! ScoreCollectionViewCell
cell.exercise = questions
cell.delegate = self
return cell
}
Did Tap Try Again
Just like responding to events from the question cell, create an extension complying with the Score Cell Delegate protocol to provide appropriate behavior:
func scoreCell(_ cell: ScoreCollectionViewCell, didTapTryAgainExercise exercise: Array<Dictionary<String, Any>>) {}
By tapping the try again button, the user should be moved back to the first question. To do that, scroll the collection view to the first index instantly and reload its data.
collectionView.scrollToItem(at: IndexPath(row: 0, section: 0), at: .centeredHorizontally, animated: false)
collectionView.reloadData()
Did Tap Share Exercise
To receive the share event, create another function under the same extension with the methods signature of:
func scoreCell(_ cell: ScoreCollectionViewCell, didTapShareExercise exercise: Array<Dictionary<String, Any>>) {}
Although we have relayed the share button event to a superior layer, a Table View Cell is also not equipped to present an Activity View Controller (i.e., Share Sheet). We need to move this event to a higher layer.
On the Exercise Table View Cell Delegate protocol, create a function to which it can relay this event another level up to its delegate by:
func exerciseCell(_ cell : ExerciseTableViewCell, didReceiveShareFor exercise : Array<Dictionary<String,Any>>, onScoreCell scoreCell : ScoreCollectionViewCell)
And call the delegate appropriately on the scoreCell:didTapShareExercise function.
delegate?.exerciseCell(self, didReceiveShareFor: exercise, onScoreCell: cell)
Did Receive Share for Exercise
Inside the extension of the Exercises Table View Controller, comply with the delegate method we have just declared so we may implement the Share Sheet functionality:
func exerciseCell(_ cell: ExerciseTableViewCell, didReceiveShareFor exercise: Array<Dictionary<String, Any>>, onScoreCell scoreCell: ScoreCollectionViewCell) {}
Inside this method, it will be provided mock information about the score on a given exercise as well as an illustrative image of the Score Cell.
Image from a View
Although it is possible to form an image from a view, UI Image does not provide an interface to make this transformation in a single line. To do that, let's extend UI Image functionality by creating a convenience initializer:
extension UIImage {
convenience init?(view: UIView) {}
}
Convenience initializers are initializers that do not provide core initialization features but rely on other initializers to do their work.
UIGraphicsBeginImageContext(view.frame.size)
guard let currentContext = UIGraphicsGetCurrentContext() else { return nil }
view.layer.render(in: currentContext)
let image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
guard let graphicImage = image?.cgImage else {
return nil
}
self.init(cgImage: graphicImage)
You may notice that this initializer is optional. If something should happen in the initialization process that would invalidate the object, the initializer returns nil. In this case, if the UI Graphics Context is unable to guard an image of the view, it defaults.
Activity View Controller
Back in the delegate method, let's gather the required information and render the image from the score cell.
let message = "🙌 72% in the iOS Design challenge from the Design+Code app by @MengTo"
let link = URL(string: "index.html")!
guard let image = UIImage(view: scoreCell) else { return }
let objectsToShare = [message, link, image] as Array<Any>
let activity = UIActivityViewController(activityItems: objectsToShare, applicationActivities: nil)
activity.excludedActivityTypes = [.airDrop, .addToReadingList, .saveToCameraRoll]
activity.popoverPresentationController?.sourceView = scoreCell
present(activity, animated: true)
An Activity View Controller can share information in many formats such as URL, text, and images. In this case, the score, a link and the view image of the score cell are being offered to share on all available services except over AirDrop, Reading List, and Camera Row.
Also, an Activity View Controller is required to always present itself as a popover to another view. For instance, in small size classes, such as iPhone, it comes from the bottom up. In contrast, on the iPad, it is required to expand from an anchor view. Set its source to be the score cell and present it just like any other view controller.
Conclusion
In this example, we built a multilayer delegation system in which messages are sent upwards in the hierarchy to the objects that may act on them. Delegation methods are often used for back and forth communications.
In the Design+Code app, there are use cases of handling gestures, making sense of data, displaying information, and coordinating between controls.
Swift has robust Protocol Oriented features that lead to elegant and efficient architecture. Being capable of using protocols unlocks possibilities beyond delegation that enable productivity.