a dead simple solution to lift up errors in ios
In this post, I want to show you how you can utilize the responder chain to bubble up an error message through your app UI structure till it gets to the node which can handle it. It is especially useful if you are a fan of Container View Controller. Responder Chain is a linked list built and maintained by iOS that represents the prioritized list of object that potentially can respond to events that occur in UI. If you like to learn more about Responder Chain pattern in general here or more specific in iOS here and here.
Suppose that you have a nested structure of views and view controllers that presents the UI of your app. If something goes wrong in any of this view controllers or views and you don’t want to handle the error there and you prefer you pass it up to its ancestors, you might use delegate pattern or closure or other solution. But you usually end up adding some kind of one-direction wire between views and view controllers which have parent-child relation. Fortunately, we already have this one-direction wire between parent and child for free and we just need to use it.
Here is what you can do:
extension UIResponder {
@objc
func showError(message: String) {
self.next?.showError(message: message)
}
}
Here we add a method to UIResponder
that consequently, this method will be available on any UIView, UIViewController, UIWindow or UIApplication. What it does is really simple, it passes the error to the ancestor of the current UIResponder object. If we override this method in a subclass of UIResponder, we will be able to catch the error and handle it however we like.
You might use a more sophisticated parameter for showError
. For example, you can pass an Error instance, so that an UIResponder can handle just some type of error and passes those that don’t handle to its ancestor.
Here is an example:
class WhiteViewController: UIViewController {
var errorLabel: UILabel!
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
// Add a child view controller
let childVC = RedViewController()
childVC.view.frame = vc.view.bounds.insetBy(dx: 50, dy: 50)
addChild(childVC)
view.addSubview(childVC.view)
childVC.didMove(toParent: self)
// Add a label for showing error messages
errorLabel = UILabel(frame: .init(x: 0, y: 0, width: view.frame.width, height: 40))
view.addSubview(errorLabel)
}
override func showError(message: String, fatal: Bool) {
// Show the error message and clear it after 1sec
errorLabel.text = message
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
self?.errorLabel.text = ""
}
}
}
class RedViewController: UIViewController {
var errorLabel: UILabel!
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .red
// Add a child view controller
let childVC = GreenTableViewController()
childVC.view.frame = view.bounds.insetBy(dx: 50, dy: 50)
addChild(childVC)
view.addSubview(childVC.view)
childVC.didMove(toParent: self)
// Add a label for showing error messages
errorLabel = UILabel(frame: .init(x: 0, y: 0, width: view.frame.width, height: 40))
view.addSubview(errorLabel)
}
override func showError(message: String, fatal: Bool) {
if fatal {
// Show the error message and clear it after 1Sec
errorLabel.text = message
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
self?.errorLabel.text = ""
}
} else {
// Pass the error to the ancestor
next?.showError(message: message, fatal: fatal)
}
}
}
class SimpleCellView: UITableViewCell {
var fatal: Bool!
override func setSelected(_ selected: Bool, animated: Bool) {
super.setSelected(selected, animated: animated)
if selected {
showError(message: "Something went wrong!", fatal: fatal)
}
}
}
class GreenTableViewController: UITableViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .green
self.tableView.register(SimpleCellView.self, forCellReuseIdentifier: "SimpleCellView")
}
override func numberOfSections(in tableView: UITableView) -> Int {
return 1
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return 10
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "SimpleCellView") as? SimpleCellView
cell?.textLabel?.text = "Cell #\(indexPath.row)"
cell?.fatal = indexPath.row % 2 == 0
return cell!
}
}
extension UIResponder {
@objc
func showError(message: String, fatal: Bool) {
self.next?.showError(message: message, fatal: fatal)
}
}
This is the responder chain of this example:
Responder Chain:
UIApplication (next: nil)
|
|__ UIWindow (next: UIApplication)
|
|__ WhiteViewController (next: UIWindow)
|
|__ RedViewController (next: WhiteViewController)
|
|__ GreenTableViewController (next: RedViewController)
|
|__ SimpleCellView #1 (next: GreenTableViewController)
SimpleCellView #2 (next: GreenTableViewController)
SimpleCellView #3 (next: GreenTableViewController)
SimpleCellView #4 (next: GreenTableViewController)
SimpleCellView #5 (next: GreenTableViewController)
SimpleCellView #6 (next: GreenTableViewController)
SimpleCellView #7 (next: GreenTableViewController)
SimpleCellView #8 (next: GreenTableViewController)
SimpleCellView #9 (next: GreenTableViewController)
SimpleCellView #10 (next: GreenTableViewController)
In SimpleCellView
when user selects a cell, showError
is called:
override func setSelected(_ selected: Bool, animated: Bool) {
super.setSelected(selected, animated: animated)
if selected {
showError(message: "Something went wrong!", fatal: fatal)
}
}
Because we haven’t overriden showError
in SimpleCellView
, the default implementation is called:
func showError(message: String, fatal: Bool) {
self.next?.showError(message: message, fatal: fatal)
}
The next item in the chain is GreenTableViewController
which doesn’t have an overridden version of showError
, so the default implementation is called again and the next item in the responder chain is RedViewController
. It has an implementation of showError
but catches and handles the error which is fatal, otherwise passes it to its ancestor which in this case is WhiteViewController
:
override func showError(message: String, fatal: Bool) {
if fatal {
// Show the error message and clear it after 1Sec
errorLabel.text = message
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
self?.errorLabel.text = ""
}
} else {
// Pass the error to the ancestor
next?.showError(message: message, fatal: fatal)
}
}
Those errors which are not fatal get to WhiteViewController
and it handles them all equally:
override func showError(message: String, fatal: Bool) {
// Show the error message and clear it after 1sec
errorLabel.text = message
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
self?.errorLabel.text = ""
}
}
Do you have question, suggestion, feedback? Hit me on twitter: @coybit🙂