Event Delivery on iOS: Part 3

We’ve reached the end of our journey through the event delivery system in iOS. Before we get into how target-action pattern works and how we can use some of the same API for sending custom events down the Responder Chain, let’s recap what’s been covered so far.

In Part 1, we examined how UIKit handles touch events with hit-testing and where gesture recognizers fit into the system. We also briefly looked at how the Responder Chain works with those touch events, including the path they follow to the view and view controller hierarchy.

In Part 2, we covered the remaining events defined in UIResponder and more about how the Responder Chain functions.

Target-Action

The target-action pattern is used heavily by UIKit. It’s defined in UIControl. Let’s take a look at a snippet of UIControl’s header.

NS_CLASS_AVAILABLE_IOS(2_0) @interface UIControl : UIView

----snip----

- (void)addTarget:(nullable id)target action:(SEL)action forControlEvents:(UIControlEvents)controlEvents;
- (void)removeTarget:(nullable id)target action:(nullable SEL)action forControlEvents:(UIControlEvents)controlEvents;

- (NSSet *)allTargets;
- (UIControlEvents)allControlEvents;
- (nullable NSArray<NSString *> *)actionsForTarget:(nullable id)target forControlEvent:(UIControlEvents)controlEvent;

- (void)sendAction:(SEL)action to:(nullable id)target forEvent:(nullable UIEvent *)event;
- (void)sendActionsForControlEvents:(UIControlEvents)controlEvents;

----snip----

These methods define the target-action pattern. The pattern lets us define an event to handle, a target to receive the event, and the action (message) to send to the target.

This decoupling of events and actions also lets us define multiple recipients for one or more actions. For more information about UIControl and the target-action pattern, check out Apple’s UIControl documentation.

So where does this fit in with event delivery? Currently, UIControl sends actions with a call trace that looks similar to this:

frame #0:-[BPXLTableViewCell cellButtonTapped:]
frame #1:-[UIApplication sendAction:to:from:forEvent:]
frame #2:-[UIControl sendAction:to:forEvent:]
frame #3:-[UIControl _sendActionsForEvents:withEvent:]
frame #4:-[UIControl touchesEnded:withEvent:]
frame #5:_UIGestureEnvironmentSortAndSendDelayedTouches
---snip---

The important API call here is -[UIApplication sendAction:to:from:forEvent]. In this case, the control is sending the appropriate action to the appropriate targets based on the event coming into the control. Setting up a nil target for an action on a UIControl winds up passing nil in the to: parameter on the -[UIApplication sendAction:to:from:forEvent] call. This is referred to as a nil-targeted action.

Nil-Targeted Actions

-[UIApplication sendAction:to:from:forEvent] is the method that takes care of sending an action to a given target. UIControl uses it for its control event handling. It can also be used to send arbitrary actions to arbitrary objects. The real power comes from the ability to pass nil as the to: parameter. This causes the action to get sent down the Responder Chain.

There is a convention for the types of actions you can send, however. The action messages you can send have to have one of the following signatures:

- (void) action
- (void) action:(id) sender
- (void) action:(id) sender forEvent:(UIEvent *) event

With this knowledge, let’s take a look at how to use a nil-targeted action in an app.

In Practice

We’re going to look at the same app implemented in both Objective-C and Swift. To get started, clone the GitHub repository. The app itself looks like this:

As you can see, the app consists of a table view that shows a list of items. Each cell has a button that lets the user change the title of the navigation bar. This could easily be implemented with a delegate on the cell and the view controller set as the cell’s delegate. But that could be problematic as the view controller may not be the responder of a given message. The cell may need to send an action one or even two levels up the hierarchy if the table view is nested a few layers deep with view controller containment. Here’s a pretty straightforward cell implementation:

@implementation BPXLTableViewCell

+ (NSString *) reuseIdentifier {
return NSStringFromClass([self class]);
}

- (void) configureWithTitle:(NSString *) title {
self.titleLabel.text = title;
}

- (IBAction)cellButtonTapped:(id)sender {
BPXLEvent *event = [[BPXLEvent alloc] init];
event.title = self.titleLabel.text;

[[UIApplication sharedApplication]
sendAction:@selector(updateTitle:forEvent:)
to:nil
from:self
forEvent:event];
}
@end

The interesting bits happen in cellButtonTapped:. Here, we create a BPXLEvent defined as such:

@interface BPXLEvent : UIEvent

@property (copy) NSString *title;

@end

We grab the model information from the cell and give it to the event to be passed along. Then we send a nil-targeted action by invoking -[UIApplication sendAction:to:from:forEvent:]. The action is the selector we want to send. A good way to let consumers know what actions are sent is to create a protocol. The protocol created for the cell is defined in its header file. This also stops the compiler from complaining about an undeclared selector.

@protocol BPXLTableViewCellActionHandler <NSObject>

-(void) updateTitle:(id) sender forEvent:(BPXLEvent *) event;

@end

All that is left is to handle the event in the view controller.

@implementation BPXLTableViewController

---snip---

#pragma mark - Actions

-(void) updateTitle:(id) sender forEvent:(BPXLEvent *) event{
self.title = event.title;
}

@end

By implementing updateTitle:forEvent:, the view controller will have it invoked any time that action is sent to the Responder Chain. The real power is that we don’t have to participate in handling this event in the view controller. As I mentioned above, a view or view controller several levels removed from the object broadcasting the action can participate.

What Is Really Going on Here?

When you invoke -[UIApplication sendAction:to:from:forEvent:] with nil as the to: parameter, the action is started at the application’s first responder. I’ve set a symbolic breakpoint (this only works in the simulator, the registers are for Intel 64-bit chips).

The output of the breakpoint when it is triggered is:

===
<UITableViewWrapperView: 0x7fd062002600; frame = (0 0; 414 736); gestureRecognizers = <NSArray: 0x7fd061c32570>; layer = <CALayer: 0x7fd061c31c90>; contentOffset: {0, 0}; contentSize: {414, 736}>

(unsigned long) $1 = 0x00000001041c2562 "updateTitle:forEvent:"
<UITableView: 0x7fd062021c00; frame = (0 0; 414 736); clipsToBounds = YES; autoresize = W+H; gestureRecognizers = <NSArray: 0x7fd061c30780>; layer = <CALayer: 0x7fd061c147f0>; contentOffset: {0, -64}; contentSize: {414, 308}>

===
<UITableView: 0x7fd062021c00; frame = (0 0; 414 736); clipsToBounds = YES; autoresize = W+H; gestureRecognizers = <NSArray: 0x7fd061c30780>; layer = <CALayer: 0x7fd061c147f0>; contentOffset: {0, -64}; contentSize: {414, 308}>

(unsigned long) $4 = 0x00000001041c2562 "updateTitle:forEvent:"
<BPXLTableViewController: 0x7fd064137540>

===
<BPXLTableViewController: 0x7fd064137540>

(unsigned long) $7 = 0x00000001041c2562 "updateTitle:forEvent:"
<UIViewControllerWrapperView: 0x7fd061c2ab50; frame = (0 0; 414 736); autoresize = W+H; layer = <CALayer: 0x7fd061c114d0>>

In this case, the first responder is an instance of UITableViewWrapperView. Hello, private API! The action being sent is updateTitle:forEvent:, and its next responder is the instance of UITableView. It doesn’t handle that action, so it moves to the next responder and so on. The final entry in the output is BPXLTableViewController (the view controller for the table view). This is the responder that can handle the action, so the chain stops there. Here’s what it looks like when you remove the action handler from the view controller:

===
<UITableViewWrapperView: 0x7fbde5828a00; frame = (0 0; 414 736); gestureRecognizers = <NSArray: 0x7fbde501e1c0>; layer = <CALayer: 0x7fbde5004460>; contentOffset: {0, 0}; contentSize: {414, 736}>

(unsigned long) $1 = 0x000000010923c5ab "updateTitle:forEvent:"
<UITableView: 0x7fbde4024000; frame = (0 0; 414 736); clipsToBounds = YES; autoresize = W+H; gestureRecognizers = <NSArray: 0x7fbde5011590>; layer = <CALayer: 0x7fbde3c5fe00>; contentOffset: {0, -64}; contentSize: {414, 308}>

===
<UITableView: 0x7fbde4024000; frame = (0 0; 414 736); clipsToBounds = YES; autoresize = W+H; gestureRecognizers = <NSArray: 0x7fbde5011590>; layer = <CALayer: 0x7fbde3c5fe00>; contentOffset: {0, -64}; contentSize: {414, 308}>

(unsigned long) $4 = 0x000000010923c5ab "updateTitle:forEvent:"
<BPXLTableViewController: 0x7fbde3d20440>

===
<BPXLTableViewController: 0x7fbde3d20440>

(unsigned long) $7 = 0x000000010923c5ab "updateTitle:forEvent:"
<UIViewControllerWrapperView: 0x7fbde5020b20; frame = (0 0; 414 736); autoresize = W+H; layer = <CALayer: 0x7fbde5000f60>>

===
<UIViewControllerWrapperView: 0x7fbde5020b20; frame = (0 0; 414 736); autoresize = W+H; layer = <CALayer: 0x7fbde5000f60>>

(unsigned long) $10 = 0x000000010923c5ab "updateTitle:forEvent:"
<UINavigationTransitionView: 0x7fbde3d214f0; frame = (0 0; 414 736); clipsToBounds = YES; autoresize = W+H; layer = <CALayer: 0x7fbde3d216a0>>

===
<UINavigationTransitionView: 0x7fbde3d214f0; frame = (0 0; 414 736); clipsToBounds = YES; autoresize = W+H; layer = <CALayer: 0x7fbde3d216a0>>

(unsigned long) $13 = 0x000000010923c5ab "updateTitle:forEvent:"
<UILayoutContainerView: 0x7fbde3c659c0; frame = (0 0; 414 736); autoresize = W+H; gestureRecognizers = <NSArray: 0x7fbde3d2b4c0>; layer = <CALayer: 0x7fbde3c65490>>

===
<UILayoutContainerView: 0x7fbde3c659c0; frame = (0 0; 414 736); autoresize = W+H; gestureRecognizers = <NSArray: 0x7fbde3d2b4c0>; layer = <CALayer: 0x7fbde3c65490>>

(unsigned long) $16 = 0x000000010923c5ab "updateTitle:forEvent:"
<UINavigationController: 0x7fbde4824600>

===
<UINavigationController: 0x7fbde4824600>

(unsigned long) $19 = 0x000000010923c5ab "updateTitle:forEvent:"
<UIWindow: 0x7fbde501cce0; frame = (0 0; 414 736); gestureRecognizers = <NSArray: 0x7fbde52053c0>; layer = <UIWindowLayer: 0x7fbde5101780>>

===
<UIWindow: 0x7fbde501cce0; frame = (0 0; 414 736); gestureRecognizers = <NSArray: 0x7fbde52053c0>; layer = <UIWindowLayer: 0x7fbde5101780>>

(unsigned long) $22 = 0x000000010923c5ab "updateTitle:forEvent:"
<UIApplication: 0x7fbde3d04c60>

===
<UIApplication: 0x7fbde3d04c60>

(unsigned long) $25 = 0x000000010923c5ab "updateTitle:forEvent:"
<AppDelegate: 0x7fbde3d0d880>

===
<AppDelegate: 0x7fbde3d0d880>

(unsigned long) $28 = 0x000000010923c5ab "updateTitle:forEvent:"
nil

You can see that it traverses the full Responder Chain all the way down to the AppDelegate as the final stop along the chain. So what happens when none of the responders handle the action? -[UIApplication sendAction:to:from:forEvent:] returns a BOOL that indicates if the action was handled or not.

Once More, This Time With Swift

Normally, I’d stop here. But how do we do this with Swift? It’s essentially the same technique, but this time we enforce protocol conformance in our approach.

For the Objective-C version, we started with the table view cell. Here, we’ll start with defining the protocol that defines the events sent from our component broadcasting an event up the Responder Chain.

@objc protocol TitleTableViewCellActionHandler {
func updateTitleForCell(sender: AnyObject, forEvent event:TitleEvent);
}

As in the Objective-C version, we have an action that will broadcast the updateTitleForCell(_:forEvent:) action. Here’s the same code in Swift:

@IBAction func cellButtonTapped(sender:AnyObject) {
let event = TitleEvent(title: titleLabel.text!)

UIApplication.sharedApplication().sendAction(#selector(TitleTableViewCellActionHandler.updateTitleForCell(_:forEvent:)), to: nil, from: self, forEvent: event)
}

That works, but who wants to go around typing UIApplication.sharedApplication().sendAction(#selector(TitleTableViewCellActionHandler.updateTitleForCell(_:forEvent:)), to: nil, from: self, forEvent: event) for every action they want to send up the Responder Chain? We can make this a bit easier.

For starters, we can move the selector up to a private Selector extension:

private extension Selector {
static let TitleUpdated = #selector(TitleTableViewCellActionHandler.updateTitleForCell(_:forEvent:))
}

This makes it a tad better by turning the action sending into UIApplication.sharedApplication().sendAction(.TitleUpdated, to: nil, from: self, forEvent: event). That’s still a bit much, so let’s create a protocol and protocol extension.

protocol ResponderChainActionSenderType {
}

extension ResponderChainActionSenderType {
func sendNilTargetedAction(selector: Selector,
sender: AnyObject?,
forEvent event: UIEvent? = nil) -> Bool {
        let application = UIApplication.sharedApplication()
if application.targetForAction(selector,
withSender: sender) == nil {
print("\(selector) not handled")
}

return application.sendAction(selector,
to: nil,
from: sender,
forEvent: event)
}
}

Once we make our cell conform to ResponderChainActionSenderType, our action sending method turns into this:

@IBAction func cellButtonTapped(sender:AnyObject) {
let event = TitleEvent(title: titleLabel.text!)

sendNilTargetedAction(.TitleUpdated,
sender: self,
forEvent: event)
}

Much better! All that is left is to make our view controller conform to TitleTableViewCellActionHandler and implement the appropriate method.

Final Notes

The Responder Chain is a great way to send a different kind of event throughout your application. It sits on the same level as NSNotificationCenter, delegation, and KVO. Each of those have their own uses and misuses. The use case outlined in this article is one of the ways I use the Responder Chain to decouple certain actions in my application. It helps me decouple some user actions from being dependent on a view-to-view controller relationship. This lets me define an action and handle it a few levels up from where the action is sent.


For more insights on design and development, subscribe to BPXL Craft and follow Black Pixel on Twitter.