Monday, May 17, 2010

Custom Alert Views

Quite some time ago, I posted a way to accept text input using an alert view. The problem with that technique is that it relies on one of Apple's private APIs. I've had many people ask me how to use that technique without getting rejected in the review process. The short answer is: you can't.

What you can do, however, is create your own class that simulates the behavior of UIAlertView. You can find a sample implementation of the technique discussed in this post by downloading this project. In this simple example, we're going to create an alert view that lets the user enter a value into a single text field. The same technique can be used to present any view that you can build in Interface Builder. From a programming perspective, you will not get rejected for using private APIs if you use this technique, but be aware that you can still get rejected for HIG violations, so make sure you're familiar with the HIG.

There are many ways to mimic the behavior of the UIAlertView. One of the most common ways I've seen is to simply design an alert view in the nib of the view controller that needs to present it. This works, but it's a bit messy and pretty much completely non-reusable.

A better approach is to design the alert view as its own view controller and nib pair. We can even model it after UIAlertView's fire-and-forget approach so that the calling code can look exactly like code to create and display a UIAlertView. Before we get started, we need a few resources.

You've probably noticed that when you use an alert view, the active view grays out a bit. I don't know for sure how Apple has accomplished this, but we can simulate the behavior using a PNG image with a circular gradient that goes from 60% opaque black to 40% opaque black:

behind_alert_view.png

We also need a background image for the alert view. Here's one I hacked out in Pixelmatoralert_background.png

For your own alerts, you might need to resize or turn this into a stretchable image. For simplicity's sake, I just made it the size I want it. We also need buttons. Again, in a real example, you might want to turn these into stretchable images, but I just kept things simple by making the image the size I wanted it:

alert_button.png

The next thing we need is some animation code. We could, of course, create the CAAnimation instances right in our class, but I'm a big fan of both code reuse and Objective-C categories, so instead of doing that, I've created a category on UIView that will handle the two animations we need. One animation "pops in" a view and will be used to show the alert view, the other fades in a view and will be used for the gray background image. The two animations happen at the same time so that when the alert has fully popped into view, the background view is grayed out.

The keyframe timings on the "pop in" animation code are not quite a 100% match for Apple's animation, so if anybody wants to tweak the keyframe values to make a closer match, I'd be happy to update the code with the "correct" values. Here is the category I created to hold the animations:

UIView-AlertAnimations.h
#import <Foundation/Foundation.h>


@interface UIView(AlertAnimations)
- (void)doPopInAnimation;
- (void)doPopInAnimationWithDelegate:(id)animationDelegate;
- (void)doFadeInAnimation;
- (void)doFadeInAnimationWithDelegate:(id)animationDelegate;
@end



UIView-AlertAnimations.m
#import "UIView-AlertAnimations.h"
#import <QuartzCore/QuartzCore.h>

#define kAnimationDuration 0.2555

@implementation UIView(AlertAnimations)
- (void)doPopInAnimation
{
[self doPopInAnimationWithDelegate:nil];
}

- (void)doPopInAnimationWithDelegate:(id)animationDelegate
{
CALayer *viewLayer = self.layer;
CAKeyframeAnimation* popInAnimation = [CAKeyframeAnimation animationWithKeyPath:@"transform.scale"];

popInAnimation.duration = kAnimationDuration;
popInAnimation.values = [NSArray arrayWithObjects:
[NSNumber numberWithFloat:0.6],
[NSNumber numberWithFloat:1.1],
[NSNumber numberWithFloat:.9],
[NSNumber numberWithFloat:1],
nil
]
;
popInAnimation.keyTimes = [NSArray arrayWithObjects:
[NSNumber numberWithFloat:0.0],
[NSNumber numberWithFloat:0.6],
[NSNumber numberWithFloat:0.8],
[NSNumber numberWithFloat:1.0],
nil
]
;
popInAnimation.delegate = animationDelegate;

[viewLayer addAnimation:popInAnimation forKey:@"transform.scale"];
}

- (void)doFadeInAnimation
{
[self doFadeInAnimationWithDelegate:nil];
}

- (void)doFadeInAnimationWithDelegate:(id)animationDelegate
{
CALayer *viewLayer = self.layer;
CABasicAnimation *fadeInAnimation = [CABasicAnimation animationWithKeyPath:@"opacity"];
fadeInAnimation.fromValue = [NSNumber numberWithFloat:0.0];
fadeInAnimation.toValue = [NSNumber numberWithFloat:1.0];
fadeInAnimation.duration = kAnimationDuration;
fadeInAnimation.delegate = animationDelegate;
[viewLayer addAnimation:fadeInAnimation forKey:@"opacity"];
}

@end


As you can see, I've provided two versions of each animation, one that accepts an animation delegate and one that doesn't. This allows the calling code to set an animation delegate so it can be notified of things like when the animation finishes. We'll use a delegate for one of our animations, but we might as well have the option with both.

The next thing we need to do is create the view controller header, implementation, and nib files. In my sample code, I've named the class CustomAlertView. It may seem odd that I giving a view controller class a name that makes it sound like a view instead of something like CustomAlertViewController. You can feel free to name yours however you wish, but this controller class will actually mimic the behavior of UIAlertView and I wanted the name to reflect that. I debated with myself over the name for a bit, but ultimately decided that CustomAlertView felt better since the name would provide a clue as to how to use it. If it feels dirty to you to "lie" in the class name, then by all means, name yours differently.

We're going to need some action methods and some outlets so that our controller and nib file can interact. One outlet will be for the background image, another for the alert view itself, and one for the text field. The latter is needed so we can tell the text field to accept and resign first responder status. We'll use a single action method for both of the buttons on the alert, and we'll use the button's tag value to differentiate between the two buttons.

Because we're implementing fire-and-forget, we also need to define a protocol containing the methods the delegate can implement. Since the alert is designed to accept input, I've made the delegate method used to receive the input @required, but the other method (called only when cancelled) @optional. The enum here is just to make code more readable when it comes to dealing with the button tag values.

CustomAlertView.h
#import <UIKit/UIKit.h>

enum
{
CustomAlertViewButtonTagOk = 1000,
CustomAlertViewButtonTagCancel
}
;

@class CustomAlertView;

@protocol CustomAlertViewDelegate
@required
- (void) CustomAlertView:(CustomAlertView *)alert wasDismissedWithValue:(NSString *)value;

@optional
- (void) customAlertViewWasCancelled:(CustomAlertView *)alert;
@end



@interface CustomAlertView : UIViewController <UITextFieldDelegate>
{
UIView *alertView;
UIView *backgroundView;
UITextField *inputField;

id<NSObject, CustomAlertViewDelegate> delegate;
}

@property (nonatomic, retain) IBOutlet UIView *alertView;
@property (nonatomic, retain) IBOutlet UIView *backgroundView;
@property (nonatomic, retain) IBOutlet UITextField *inputField;

@property (nonatomic, assign) IBOutlet id<CustomAlertViewDelegate, NSObject> delegate;
- (IBAction)show;
- (IBAction)dismiss:(id)sender;
@end


Once the header file is completed and saved, we can skip over to Interface Builder and create our interface. I won't walk you through the process of building the interface, but I'll point out the important things. Here's what the view will looks like in Interface Builder:

Screen shot 2010-05-17 at 12.42.51 PM.png

Some important things:
  • The content view needs to be NOT opaque and its background color should be set to a white with 0% opacity. The view's alpha should be 1.0. Alpha is inherited by subviews, background color is not, so by making it transparent using background color, we'll be able to see all of the subviews. If we had set alpha to 0.0 instead, we wouldn't be able see the alert view;
  • The background view is a UIImageView that is the same size as the content view. Its autosize attributes are set so that it resizes with the content view and it is connected to the backgroundView outlet. It needs to not be opaque and its alpha needs to be set to 1.0. Even though we will be animating the alpha, we want the nib to reflect the final value;
  • All of the UI elements that make up the actual alert are all subviews of a single instance of UIView whose background color is also set to white with 0% opacity and is NOT opaque so that it is invisible. This view is used just to group the elements so they can be animated together and is what the alertView outlet will point to;
  • The alert view is not centered. In this case, we want it centered in the space remaining above the keyboard;
  • The OK button has its tag set to 1,000 in the attribute inspector. The Cancel button has its tag set to 1,001. These numbers match the values in the enum we created in the header file
  • File's Owner is the delegate of the text field. This allows the controller to be notified when the user hits the return key on the keyboard


Once the interface is created, all that's left to do is implement the controller class. Here is the implementation; I'll explain what's going on in a moment:

CustomAlertView.m
#import "CustomAlertView.h"
#import "UIView-AlertAnimations.h"
#import <QuartzCore/QuartzCore.h>

@interface CustomAlertView()
- (void)alertDidFadeOut;
@end


@implementation CustomAlertView
@synthesize alertView;
@synthesize backgroundView;
@synthesize inputField;
@synthesize delegate;
#pragma mark -
#pragma mark IBActions
- (IBAction)show
{
// Retaining self is odd, but we do it to make this "fire and forget"
[self retain];

// We need to add it to the window, which we can get from the delegate
id appDelegate = [[UIApplication sharedApplication] delegate];
UIWindow *window = [appDelegate window];
[window addSubview:self.view];

// Make sure the alert covers the whole window
self.view.frame = window.frame;
self.view.center = window.center;

// "Pop in" animation for alert
[alertView doPopInAnimationWithDelegate:self];

// "Fade in" animation for background
[backgroundView doFadeInAnimation];
}

- (IBAction)dismiss:(id)sender
{
[inputField resignFirstResponder];
[UIView beginAnimations:nil context:nil];
self.view.alpha = 0.0;
[UIView commitAnimations];

[self performSelector:@selector(alertDidFadeOut) withObject:nil afterDelay:0.5];



if (sender == self || [sender tag] == CustomAlertViewButtonTagOk)
[delegate CustomAlertView:self wasDismissedWithValue:inputField.text];
else
{
if ([delegate respondsToSelector:@selector(customAlertViewWasCancelled:)])
[delegate customAlertViewWasCancelled:self];
}

}

#pragma mark -
- (void)viewDidUnload
{
[super viewDidUnload];
self.alertView = nil;
self.backgroundView = nil;
self.inputField = nil;
}

- (void)dealloc
{
[alertView release];
[backgroundView release];
[inputField release];
[super dealloc];
}

#pragma mark -
#pragma mark Private Methods
- (void)alertDidFadeOut
{
[self.view removeFromSuperview];
[self autorelease];
}

#pragma mark -
#pragma mark CAAnimation Delegate Methods
- (void)animationDidStop:(CAAnimation *)theAnimation finished:(BOOL)flag
{
[self.inputField becomeFirstResponder];
}

#pragma mark -
#pragma mark Text Field Delegate Methods
- (BOOL)textFieldShouldReturn:(UITextField *)textField
{
[self dismiss:self];
return YES;
}

@end



So, what're we doing here? First, we start by importing the category with our alert view animations and also QuartzCore.h which gives us access to all the datatypes used in Core Animation.

#import "CustomAlertView.h"
#import "UIView-AlertAnimations.h"
#import <QuartzCore/QuartzCore.h>

Next, we declare a class extension with a single method. By putting this method here, we can use it anywhere in our class without getting warnings from the compiler yet we do not advertise the existence of this method to the world. This is, essentially, a private method. In a dynamic language like Objective-C, there are no truly private methods, but since the method is not declared in the header file, that's our way of saying "this is ours, don't touch". This method, which you'll see in a moment, will be called after the alert has been dismissed to remove it from its superview. We don't want to remove it until after the fade-out animation has finished, which is why we've declared a separate method.

@interface CustomAlertView()
- (void)alertDidFadeOut;
@end


After we synthesize our properties, the first method we write is show. This is the method that gets called to, well… show the alert. I matched the method name used in UIAlertView and also made it an IBAction so that it can be triggered directly inside a nib file.

The weirdest part of this method is that it actually retains self. This is something you're generally not going to want to do. Since we've implemented our alert view as a view controller instead of a UIView subclass like UIAlertView, we need to cheat a little because a view controller is not retained by anything by virtue of its view being in the view hierarchy. This isn't wrong - we're going to bookend our retain with a release (well, actually, an autorelease) so no memory will leak, but it is unusual and not something you're going to want to use in very many places. When you retain self, you need to take a long hard look at your code and make sure you have a darn good reason for doing it. In this instance, we do.

After retaining, we grab a reference to the window by way of the application delegate and add our view to the window, matching its frame. Then we call the two animation methods we created earlier to fade in the image with the circular gradient and "pop" in the alert view:

- (IBAction)show
{
// Retaining self is odd, but we do it to make this "fire and forget"
[self retain];

// We need to add it to the window, which we can get from the delegate
id appDelegate = [[UIApplication sharedApplication] delegate];
UIWindow *window = [appDelegate window];
[window addSubview:self.view];

// Make sure the alert covers the whole window
self.view.frame = window.frame;
self.view.center = window.center;

// "Pop in" animation for alert
[alertView doPopInAnimationWithDelegate:self];

// "Fade in" animation for background
[backgroundView doFadeInAnimation];
}

The next action method we write is the one that gets called by the two buttons on the alert. Regardless of which button was pushed, we want the text field to resign first responder status so that the keyboard disappears, and we want the alert to fade away. We're going to use implicit animations this time and then use performSelector:withObject:afterDelay: to trigger our private method that will remove the view from its superview. After that, we check sender's tag value to see which delegate method to notify.

- (IBAction)dismiss:(id)sender
{
[inputField resignFirstResponder];
[UIView beginAnimations:nil context:nil];
self.view.alpha = 0.0;
[UIView commitAnimations];

[self performSelector:@selector(alertDidFadeOut) withObject:nil afterDelay:0.5];

if (sender == self || [sender tag] == CustomAlertViewButtonTagOk)
[delegate CustomAlertView:self wasDismissedWithValue:inputField.text];
else
{
if ([delegate respondsToSelector:@selector(customAlertViewWasCancelled:)])
[delegate customAlertViewWasCancelled:self];
}

}

The viewDidUnload and dealloc are bog standard, so there's no point in discussing them. The next method after those is our "private" method. It does nothing more than remove the now invisible alert view from the view hierarchy and autoreleases self so that the view controller will be released at the end of the current run loop iteration. We don't want to use release because we really don't want the object disappearing while one of its method is executing:

- (void)alertDidFadeOut
{
[self.view removeFromSuperview];
[self autorelease];
}

Earlier, when we created the "pop in" animation, we specified self as the delegate. The next method we implement is called when the "pop in" animation completes by virtue of that fact. All we do here is make sure the keyboard is shown to the user and associated with our text field. We do all that simply by making the text field the first responder:

- (void)animationDidStop:(CAAnimation *)theAnimation finished:(BOOL)flag
{
[self.inputField becomeFirstResponder];
}

Finally, we implement one of the text field delegate methods so that when the user presses the return key on the keyboard, it dismisses the dialog.

- (BOOL)textFieldShouldReturn:(UITextField *)textField
{
[self dismiss:self];
return YES;
}

At this point, we're done. We can now use this custom alert view exactly the same way we use UIAlertView:

    CustomAlertView *alert = [[CustomAlertView alloc]init];
alert.delegate = self;
[alert show];
[alert release];


As I stated earlier, you can use this same technique to present anything you can build in Interface Builder, and the result will be a highly-reusable alert object.

No comments:

Post a Comment