Ad

Our DNA is written in Swift
Jump

Block-Based Action Sheet

I am extremely confident that Apple will introduce the ability to set blocks as actions for UIActionSheet (and UIAlertView) in iOS 6. Still, for exercise and because I want to support iOS 5 until iOS 6 is actually released, I set out to implement that.

When I tweeted about it, several people pointed me to existing implementations:

So I could have used one of these. BUT I like to understand the code I’m using and also I’m still learning, so better to solve the problem myself and talk about it. Also there are some implementation choices that I don’t agree with on these projects.

The general issue I have with most of the existing implementations is that they are trying to be clever and create the illusion that the functionality actually exists. (Associated Objects to the rescue). I have to admit, it is extremely tempting to just bend the UIActionSheet delegate to itself and tack the dictionary for the block actions into the action sheet via associated.

Bad Idea!

I was talking to an Apple engineer on Monday who informed me that many people are wishing for the same thing and suggested that I do a subclass instead. This would enable me to preserve the possibility for setting the action sheet delegate but still add my own block handling. The second advantage would be that I could easily create a backwards compatible solution that would call the new iOS 6 methods if available or otherwise call my own.

Hence this tutorial. Let’s start out with the whole thing without blocks, traditional.

- (void)_showActionsForUser:(TwitterUser *)user fromView:(UIView *)view
{
	// need to remember the user for later reference 
	_selectedUser = user;
 
	UIActionSheet *actions = [[UIActionSheet alloc] initWithTitle:user.profileName delegate:self cancelButtonTitle:@"Cancel" destructiveButtonTitle:@"Mute" otherButtonTitles:@"Follow", nil];
	actions.tag = ACTIONS_USER_TAG;
 
	[actions showFromRect:view.bounds inView:view animated:YES];
}

And the matching delegate method:

- (void)actionSheet:(UIActionSheet *)actionSheet didDismissWithButtonIndex:(NSInteger)buttonIndex
{
    if (buttonIndex != actionSheet.cancelButtonIndex)
    {
        switch (actionSheet.tag) 
        {
            case ACTIONS_USER_TAG:
            {
                switch (buttonIndex) 
                {
                case 0:
                   // action
                   break;
 
                case 1:
                   // action
                   break;
                }
            }
        }
    }
}

You can easily see several problems with the traditional approach. If you have multiple action sheets in one view controller you need to tag them so that you can tell apart from which the delegate call is coming from. Since there is no way to transport the context I need to set the _selectedUser instance variable so that I know which object the call is wanted for. And also I would have to probably add #defines for the button indexes as well if I wanted to have my code be descriptive. This is not even touching on the problem of getting a rather large tree of ifs, switches and cases in there.

Instead of the above code we want to be able to write this wile completely ditching the action sheet method.

- (void)_showActionsForUser:(TwitterUser *)user fromView:(UIView *)view
{
	DTActionSheet *actions = [[DTActionSheet alloc] initWithTitle:user.profileName];
 
	// destructive button for mute
	[actions addDestructiveButtonWithTitle:@"Mute" block:^{
		// actions
	}];
 
	// normal button for follow
	[actions addButtonWithTitle:@"Follow" block:^{
		// actions
	}];
 
	// cancel button is last
	[actions addCancelButtonWithTitle:@"Cancel"];
 
	[actions showFromRect:view.bounds inView:view animated:YES];
}

No need to keep context, no need to tag the action sheet. Just simply add the buttons in the appropriate order with the block of code to execute. Since I want the cancel button last I also need to add it last. You cannot already add it with the original initWithTitle method because then it would be at the top.

Let’s look at the header first:

typedef void (^DTActionSheetBlock)(void);
 
@interface DTActionSheet : UIActionSheet
 
- (id)initWithTitle:(NSString *)title;
 
- (NSInteger)addButtonWithTitle:(NSString *)title block:(DTActionSheetBlock)block;
- (NSInteger)addDestructiveButtonWithTitle:(NSString *)title block:(DTActionSheetBlock)block;
 
- (NSInteger)addCancelButtonWithTitle:(NSString *)title;
 
@end

Of the implemented methods the addDestructiveButton… and addCancelButton… methods are for convenience. All they do is to also set the destructiveButtonIndex or cancelButtonIndex after adding the button. Note that you can only have one of each, adding another with the same method will move it further down and make the previous a normal button.

The implementation starts simply with creating an action sheet and setting the delegate to self:

#import "DTActionSheet.h"
 
@implementation DTActionSheet
{
	id <UIActionSheetDelegate> _externalDelegate;
 
	NSMutableDictionary *_actionsPerIndex;
}
 
- (id)initWithTitle:(NSString *)title
{
	self = [super initWithTitle:title delegate:(id)self cancelButtonTitle:nil destructiveButtonTitle:nil otherButtonTitles: nil];
 
	if (self)
	{
		_actionsPerIndex = [[NSMutableDictionary alloc] init];
	}
 
	return self;
}

The type-cast to (id) prevents the warning for the delegate protocol to show. I keep the header as clean and lean as possible I didn’t want to specify the delegate protocol there.

Update: Matthijs Hollemans suggests a different approach for dealing with the protocol warnings. You can also attach the protocol conformance tag to a private anonymous (and empty) category extension. See his pull request for details.

The lookup table for the actions (keyed by button index) is a private instance variable. Also you see _externalDelegate which will restore the external delegate messaging.

For this we need to overwrite the getter and setter thusly.

#pragma mark Properties
 
- (id <UIActionSheetDelegate>)delegate
{
	return _externalDelegate;
}
 
- (void)setDelegate:(id<UIActionSheetDelegate>)delegate
{
	if (delegate == (id)self)
	{
		[super setDelegate:(id)self];
	}
	else if (delegate == nil)
	{
		[super setDelegate:nil];
		_externalDelegate = nil;
	}
	else 
	{
		_externalDelegate = delegate;
	}
}

The comparison with self in the setter is necessary because also the original init is calling this. In this case we need to have the setting of self to go through via super. In all other cases we save it into _externalDelegate.

When implementing this I also discovered that UIActionSheet is nicely nilling its delegate on dealloc. So we pass on the nil as well and also nil our _externalDelegate too.

Next we need to forward all the delegate methods and also execute our blocks in the appropriate place.

#pragma UIActionSheetDelegate (forwarded)
 
- (void)actionSheetCancel:(UIActionSheet *)actionSheet
{
	[_externalDelegate actionSheetCancel:actionSheet];
}
 
- (void)willPresentActionSheet:(UIActionSheet *)actionSheet
{
	[_externalDelegate willPresentActionSheet:actionSheet];	
}
 
- (void)didPresentActionSheet:(UIActionSheet *)actionSheet
{
	[_externalDelegate didPresentActionSheet:actionSheet];
}
 
- (void)actionSheet:(UIActionSheet *)actionSheet willDismissWithButtonIndex:(NSInteger)buttonIndex
{
	[_externalDelegate actionSheet:actionSheet willDismissWithButtonIndex:buttonIndex];
}
 
 
- (void)actionSheet:(UIActionSheet *)actionSheet didDismissWithButtonIndex:(NSInteger)buttonIndex
{
	NSNumber *key = [NSNumber numberWithInt:buttonIndex];
 
	DTActionSheetBlock block = [_actionsPerIndex objectForKey:key];
 
	if (block)
	{
		block();
	}
 
	[_externalDelegate actionSheet:actionSheet didDismissWithButtonIndex:buttonIndex];
}

As always you should check if the block is indeed non-nil because you cannot C-call NULL, this would crash. Also the user still can add buttons the old style and for these cases we would not have a block in stock.

All the other delegate messages are just passed on to the external delegate.

UPDATE: Advantis pointed out in the comments that I was missing the check for the possibly non-existing optional delegate methods. So I implemented that in the GitHub project. Though it is outside the scope of this here post.

Finally, here’s the most important part:

- (NSInteger)addButtonWithTitle:(NSString *)title block:(DTActionSheetBlock)block
{
	NSInteger retIndex = [self addButtonWithTitle:title];
 
	if (block)
	{
		NSNumber *key = [NSNumber numberWithInt:retIndex];
		[_actionsPerIndex setObject:[block copy] forKey:key];
	}
 
	return retIndex;
}
 
- (NSInteger)addDestructiveButtonWithTitle:(NSString *)title block:(DTActionSheetBlock)block
{
	NSInteger retIndex = [self addButtonWithTitle:title block:block];
	[self setDestructiveButtonIndex:retIndex];
 
	return retIndex;
}
 
- (NSInteger)addCancelButtonWithTitle:(NSString *)title
{
	NSInteger retIndex = [self addButtonWithTitle:title];
	[self setCancelButtonIndex:retIndex];
 
	return retIndex;
}

The add… methods always return the index of the newly added button. For the cancel and the destructive variant this index is used to set the cancelButtonIndex or destructiveButtonIndex. And of course the cancelButton does not need to have a block.

Note that the block is copied because we cannot be sure otherwise that it is not still on the stack. Blocks are generally created on the stack and would be released when going out of scope even when having been added to a dictionary. The copy prevents this because it copies the block to the heap.

Conclusion

Implementing block-support via a sub-class has only advantages. You still can use all the stock methods, but using the ones we added for block-support simplify things greatly.

The code quoted in this article is available in my DTFoundation project on GitHub.


Categories: Recipes

4 Comments »

  1. Thanks for the post, Oliver.

    Unfortunately you forgot to put a respondsToSelector: check into each forwarded delegate method. Therefore it crashes when the real delegate does not implement all the optional methods.

    Additionally I would like to admit that UIAlertView/UIActionSheet performs a serie of respondsToSelector: calls to the delegate in its setDelegate: implementation to find out which of the optional protocol methods it implements and cache this info until next delegate assignment. You may be interested in my implementation that takes advantage of this approach (but at a cost of further complication): https://github.com/advantis/ADVAlertView and https://gist.github.com/2428642

    Cheers

  2. Thanks for spotting this. I added the necessary checks to the GitHub project.

  3. Nice article.

    I was using Mugunth Kumar’s UIKitCategoryExtensions but after reading your article I coded a UIAlertView block handler (based on your action sheet design) and it works great. Now I’m using your design/code for both UIActionSheet and UIAlertView blocks in all my projects! For me, it’s less code, easier to implement, and easy to understand.

    Thanks,
    Payne