The responder chain is a simple but powerful concept present in any Cocoa/Cocoa Touch app. Simply put, the responder chain is a linked list of responders (most commonly views and view controllers) to which an event or action is applied. When a responder doesn’t handle a particular message, it simply passes the message up the chain.
But this post isn’t about how the responder chain works, the documentation already does an excellent job at that. And there’s also this thorough explanation by Bruno Rocha mentioned on last week’s iOS Dev Weekly issue.
This post is about how the simplicity of the responder chain can work against you.
The problem
Imagine you have the typical master-detail application. On the left there a list of items and on the right you show the details of the selected item. Example applications are Mail, Contacts and Secrets. In such apps, this is how the responder chain could look like.
The List Controller handles the list of items and selection, the Detail Controller handles displaying a selected item and performing actions on it. Fairly simple right?
The problem arises when you want to handle an action at all times but the controller responsible for that action isn’t in the current responder chain starting at the first responder. Lets look at some examples to make this clear.
Keyboard shortcuts
Say you want to handle the up and down arrow keys to move the current selection. The List Controller is the one responsible for handling the selection so it makes sense it should be the one handling these events. On the Mac you would override -keyDown:
and on iOS you would override -keyCommands
and whenever the first responder is either the List Controller, the List View or any of its subviews this would work just fine.
But anytime the first responder is elsewere, such as on one of the Detail View’s subviews your shortcut stops working.
There are a few different and “hacky” solutions here:
-
Move selection handling up the chain to Split Controller for example.
This would work, but it’s not really the responsibility of the Split Controller to do this. This would also mean a tighter coupling of the Split Controller and the List Controller making controller reuse more difficult. And if you apply this solution every time you encounter this problem, soon the Split Controller will be 90% of you controller code.
-
Also handle the arrow keys on the detail controller and notify.
Arguably a better solution than the last one but still not ideal. By using notifications you avoid the coupling of the two view controllers. But if you have many different Detail Controller classes you must make sure all of them do this. And if you need to use the framework’s QuickLook view controller or a third party view controller you’ll need to subclass it.
-
Modify the responder chain.
You could also have the
-nextResponder
of the Detail Controller point to the List Controller instead of directly to the Split View. To me, this chain makes more sense than the one shown above and I’ve used this solution successfully on the Mac before. But it’s still a bit difficult to maintain. And on iOS it gets worse because there’s no setter for the next responder… meaning you’ll have to implement the setter on any Detail Controller class/subclass.
Although not suitable for the problem mentioned above, if you’re reading this because you’re implementing some keyboard shortcut on the Mac you should always consider using an NSMenuItem
or setting the key equivalent on the corresponding NSControl
if there is one. The reason why both of these solutions work is left as an exercise for the reader.
User activities & the Touch Bar
Handoff and Siri Shortcuts are implemented using the NSUserActivity
class and the responder chain. The way they work is the current “user activity” is determined by whichever responder is the first to return a non-nil NSUserActivity
from -userActivity
.
So the Detail Controller could implement -userActivity
and return a “Viewing item XYZ” NSUserActivity
to inform the OS of the current user activity. But what if the first responder is on the List Controller side? The Detail Controller is not currently on the responder chain path but isn’t the user still viewing the item XYZ?
Similar to user activities, the controls that appear on the Touch Bar are decided by traversing the responder chain and composing the result of calling -touchBar
on all responders to form the final layout.
If your Detail Controller returns an Edit button on its -touchBar
it would also be nice this button was present when the user is interacting with the List Controller.
Again you could try to apply some of the “hacky” solutions mentioned above but the same drawbacks would apply.
Late Responders
To summarize what was said above, some features that rely on the responder chain should be available even when the relevant responder isn’t on the the chain’s path.
With that in mind I propose a solution I’m calling “Late Responders”. The way it works is that you setup a late responder registry further up the chain, such as the window. Then, the responders further down the chain can register a late responder with the registry and let it handle any features that should be always available.
This way even if the responder itself isn’t in the chain’s path, its late responder surely will be.
Lets look at the registry first. It’s simply responsible for maintaining a list of responders — it’s internal responder chain.
@interface OCLateResponderRegistry : NSObject
@property (nonatomic, strong, readonly) OCResponder *initialResponder;
@property (nonatomic, strong, readonly) OCResponder *lastResponder;
- (void)registerLateResponder:(OCLateResponder *)responder;
- (void)deregisterLateResponder:(OCLateResponder *)responder;
@end
The initialResponder
and the lastResponder
are the entry and exit points of the internal chain. Clients use the -registerLateResponder:
and -deregisterLateResponder:
to add and remove late responders.
The late responder class is also very simple and is meant to be subclassed.
@interface OCLateResponder : OCResponder
#if TARGET_OS_OSX
<NSTouchBarProvider>
#endif
- (instancetype)initWithWeight:(NSInteger)weight NS_DESIGNATED_INITIALIZER;
- (void)deregister;
@property (nonatomic, readonly) NSInteger weight;
@property (nonatomic, nullable, weak) OCLateResponderRegistry *registry;
#if TARGET_OS_OSX
@property (nullable, strong) NSTouchBar *touchBar NS_AVAILABLE_MAC(10.12.2);
#elif TARGET_OS_IOS
@property (nullable, strong) NSArray<UIKeyCommand *> *keyCommands;
#endif
@end
Support for setting a Touch Bar (macOS) and key commands (iOS) is already there. The weight
property allows responders to be sorted on the registry’s internal chain but this is rarely necessary.
Finally, we just need a protocol to be implemented by the responder in the chain that will contain the registry.
@protocol OCLateResponderRegistering <NSObject>
@property (nonatomic, strong, readonly) OCLateResponderRegistry *lateResponderRegistry;
@end
Putting it all together
Lets look at the handling of the up and down arrow to change the list selection mentioned above. Your List Controller class could add the following late responder subclass to the registry.
@interface ListControllerLateResponder: OCLateResponder
@property (nonatomic, weak) ListController *listController;
- (void)moveUp:(id)sender;
- (void)moveDown:(id)sender;
@end
The implementation of -moveUp:
and -moveDown:
would simply relay the action to the listController
.
The only thing missing is the registering that late responder. On the List Controller you would do something like this:
- (void)registerLateResponders
{
OCLateResponderRegistry *registry = /* search the chain for the registry */;
ListControllerLateResponder *lateResponder = [[ListControllerLateResponder alloc] init];
lateResponder.listController = self;
lateResponder.keyCommands = @[
[UIKeyCommand keyCommandWithInput:UIKeyInputUpArrow modifierFlags:0 action:@selector(moveUp:)],
[UIKeyCommand keyCommandWithInput:UIKeyInputDownArrow modifierFlags:0 action:@selector(moveDown:)],
];
[registry registerLateResponder:lateResponder];
self.lateResponder = lateResponder;
}
- (void)deregisterLateResponders
{
[self.lateResponder deregister];
self.lateResponder = nil;
}
You would then call the above methods on -viewWillAppear:
and -viewDidDisappear:
for example. By doing so, when the List Controller isn’t in the responder chain’s path, the late responder would pick up the up and down key events and the notify the List Controller to change the selection.
Making it easier with a proxy
Putting it all together was fairly easy, but it still involved creating a LateResponder
subclass which can be quite tedious. Specially if you consider it’s only forwarding the -moveUp:
and -moveDown:
actions to the List Controller.
We can make this even easier by using a general purpose late responder proxy.
@interface OCLateResponderProxy : OCLateResponder
+ (instancetype)proxyForResponder:(InterfaceKitResponder *)responder;
- (instancetype)initWithProxiedResponder:(InterfaceKitResponder *)responder;
- (instancetype)initWithProxiedResponder:(InterfaceKitResponder *)responder weight:(NSInteger)weight NS_DESIGNATED_INITIALIZER;
/** If this property is set, then only the specified selectors are proxied. */
@property (nullable, nonatomic, strong) NSArray<NSString *> *proxiedSelectorNames;
@end
By using this late responder proxy we can rewrite the - registerLateResponders
method above and do away with the ListControllerLateResponder
class.
- (void)registerLateResponders
{
OCLateResponderRegistry *registry = /* search the chain for the registry */;
OCLateResponderProxy *lateResponder = [[ListControllerLateResponder proxyForResponder:self];
lateResponder.keyCommands = @[
[UIKeyCommand keyCommandWithInput:UIKeyInputUpArrow modifierFlags:0 action:@selector(moveUp:)],
[UIKeyCommand keyCommandWithInput:UIKeyInputDownArrow modifierFlags:0 action:@selector(moveDown:)],
];
lateResponder.proxiedSelectorNames = @[
NSStringFromSelector(@selector(moveDown:)),
NSStringFromSelector(@selector(moveUp:))
];
[registry registerLateResponder:lateResponder];
self.lateResponder = lateResponder;
}
And that’s it. You can find the code for Late Responders here. As usual, pull requests and issues are welcome. You can also reply to this tweet.