Apr 29, 2013

Creating Custom UIAlertView For iPhone

Overview

There are certain situations when blue Alert View just doesn't look good with your app - either because of color or because of style, so that you need to customize the way it looks. The first idea that comes to mind would be to subclass UIAlertView, but class reference states following:
The UIAlertView class is intended to be used as-is and does not support subclassing. The view hierarchy for this class is private and must not be modified.
This tutorial will show how to create your own CustomAlert class which inherits from UIView and provides functionality similar to UIAlertView class by implementing the following features:

  1. Scaling background picture depending on message size
  2. Managing message text (by decreasing font size and adding scroll)
  3. Informing delegate about button pressed
This tutorial uses graphics which is very similar to native iOS alert view, but by replacing background and button pictures with your own you can achieve a very different look.

Here's how final result will look like:


1. Setting up the project 

Open XCode and create new project using Single View Application template: 


Name the project, select "iPhone" option from "Devices" menu, make sure that "Use Automatic Reference Counting" is checked, press "Next" one more time and save your project.

Now create new file (shortcut CMD+N) which inherits from UIView and name it CustomAlert. 
By now your project hierarchy should look like this:

2. Adding graphics

In order to create custom alert, you will need to include the following files to your project:

1. Alert background picture:

alert_bg.png

2. Cancel button picture:

cancel_btn.png

3. Other button picture:

other_btn.png

Add those pictures to your project as well (you can replace them with the ones you need), but please note that @2x version of the files should be added for Retina display support.

3. Adding functionality 

Setting up CustomAlert.h

Open CustomAlert.h file and do the following:
  • Add property of type id named delegate
  • Add variable of type UIView named AlertView
By now it should look like this:

#import <UIKit/UIKit.h>

@interface CustomAlert : UIView
{
    id delegate;
    UIView *AlertView;
}
@property id delegate;

@end

Now let's think about class methods - basically alert should be initialized and shown. During initialization alert should be supplied with title, subtitle, message, button names and delegate. Add the following code to header file to represent this functionality:

- (id)initWithTitle:(NSString *)title message:(NSString *)message delegate:(id)AlertDelegate cancelButtonTitle:(NSString *)cancelButtonTitle otherButtonTitle:(NSString *)otherButtonTitle;
- (void)showInView:(UIView*)view;

After alert was initialized and shown, user is going to press some button, and that's when delegate becomes useful. Add CustomAlert delegate as follows:

@protocol CustomAlertDelegate
- (void)customAlertView:(CustomAlert*)alertView clickedButtonAtIndex:(NSInteger)buttonIndex;
@end

Setting up CustomAlert.m

Open CustomAlert.m file and add the following line before init method:

@synthesize delegate;

Now let's modify initialization method so that it will look the same was as in header. In order to do so, replace the following line:

- (id)initWithFrame:(CGRect)frame

with this one:

- (id)initWithTitle:(NSString *)title message:(NSString *)message delegate:(id)AlertDelegate cancelButtonTitle:(NSString *)cancelButtonTitle otherButtonTitle:(NSString *)otherButtonTitle

You've probably noticed that old init message was taking frame as parameter, and since new method does not have it, we have nothing to pass to superview. The reason why new init message does not take frame as parameter is very simple - alert should take the whole screen, so frame would be equal to screen size. Let's write this in code (insert it before self = [super initWithFrame:frame]; line):

CGRect frame;
if ([[UIApplication sharedApplication] statusBarOrientation] == UIDeviceOrientationLandscapeLeft || [[UIApplication sharedApplication] statusBarOrientation] == UIDeviceOrientationLandscapeRight)
    frame = CGRectMake(0.0, 0.0, [[UIScreen mainScreen] bounds].size.height, [[UIScreen mainScreen] bounds].size.width);
else
    frame = CGRectMake(0.0, 0.0, [[UIScreen mainScreen] bounds].size.width, [[UIScreen mainScreen] bounds].size.height);

This way depending on screen orientation, CustomAlert view will have frame equal to screen size. But what happens when user rotates the phone? That's when autoresizingMask is used (add the following code after "if (self) {" line):

self.autoresizingMask = UIViewAutoresizingFlexibleHeight | UIViewAutoresizingFlexibleWidth;

Now let's focus on how alert looks on iPhone - it definitely has black opaque background, but setting background color doesn't do the whole trick. As you might notice, blue alert is transparent itself as well, which is achieved by adding transparency to the whole view in addition to setting black opaque background:


self.backgroundColor = [UIColor colorWithRed:0.0 green:0.0 blue:0.0 alpha:0.6];
self.autoresizingMask = UIViewAutoresizingFlexibleHeight | UIViewAutoresizingFlexibleWidth;


Next step would be proper scaling alert_bg.png picture. Basically you wouldn't want to stretch 25 pixels at left, right and bottom sides due to rounded corners, but at the top picture also has lighter glow which would be around 40 px high. To achieve that, resizableImageWithCapInsets: function is available for iOS 5.0 and up, but if you want to support older iOS versions, your code should look like this (details here):

UIImage *AlertBg = [UIImage imageNamed:@"alert_bg.png"];

if ([AlertBg respondsToSelector:@selector(resizableImageWithCapInsets:)])
    AlertBg = [[UIImage imageNamed: @"alert_bg.png"] resizableImageWithCapInsets:UIEdgeInsetsMake(40.0, 25.0, 25.0, 25.0)];
else
    AlertBg = [[UIImage imageNamed: @"alert_bg.png"] stretchableImageWithLeftCapWidth: 25 topCapHeight: 40];



Now all we need to do is to add labels for title and message, and buttons. The following code checks all possible combinations (basically you can create alert with no title or no buttons, etc).

There's only one problem left - what to do if message text is really long? First, let's define maximum possible alert height to 300 px (so that it will still fit screen in landscape mode) by adding the following line at the top of file:

#define MAX_ALERT_HEIGHT 300.0

Scaling message would be implemented like this - first we'll try to gradually decrease font size, but we can't do that indefinitely since message might be impossible to read. So to make sure the whole message can be read, we will add scroll view. This way if message fits - scroll is disabled, but if it doesn't fit - user can use scroll to see the whole message.

The here's the final code:

UIImage *CancelBtnImg = [UIImage imageNamed:@"cancel_btn.png"];
UIImage *OtherBtnImg = [UIImage imageNamed:@"other_btn.png"];

UIImageView *AlertImgView = [[UIImageView alloc] initWithImage:AlertBg];

float alert_width = AlertImgView.frame.size.width;
float alert_height = 5.0;

//add text
UILabel *TitleLbl;
UIScrollView *MsgScrollView;

if (title)
{
    TitleLbl = [[UILabel alloc] initWithFrame:CGRectMake(10.0, alert_height, alert_width-20.030.0)];
    TitleLbl.adjustsFontSizeToFitWidth = YES;
    TitleLbl.font = [UIFont boldSystemFontOfSize:18.0];
    TitleLbl.textAlignment = UITextAlignmentCenter;
    TitleLbl.minimumFontSize = 12.0;
    TitleLbl.backgroundColor = [UIColor clearColor];
    TitleLbl.textColor = [UIColor whiteColor];
    TitleLbl.text = title;
    
    alert_height += TitleLbl.frame.size.height + 5.0;
}
else
    alert_height += 15.0;

if (message)
{
    float max_msg_height = MAX_ALERT_HEIGHT - alert_height - ((cancelButtonTitle || otherButtonTitle)?(CancelBtnImg.size.height+30.0):30.0);
    
    UILabel *MessageLbl = [[UILabel alloc] initWithFrame:CGRectMake(10.00.0, alert_width-40.00.0)];
    MessageLbl.numberOfLines = 0;
    MessageLbl.font = [UIFont systemFontOfSize:16.0];
    MessageLbl.textAlignment = UITextAlignmentCenter;
    MessageLbl.backgroundColor = [UIColor clearColor];
    MessageLbl.textColor = [UIColor whiteColor];
    MessageLbl.text = message;
    
    [MessageLbl sizeToFit];
    MessageLbl.frame = CGRectMake(10.00.0, alert_width-40.0, MessageLbl.frame.size.height);
    
    while (MessageLbl.frame.size.height>max_msg_height && MessageLbl.font.pointSize>12) {
        MessageLbl.font = [UIFont systemFontOfSize:MessageLbl.font.pointSize-1];
        [MessageLbl sizeToFit];
        MessageLbl.frame = CGRectMake(10.00.0, alert_width-40.0, MessageLbl.frame.size.height);
    }
    
    MsgScrollView = [[UIScrollView alloc] initWithFrame:CGRectMake(10.0, alert_height, alert_width-20.0, (MessageLbl.frame.size.height>max_msg_height)?max_msg_height:MessageLbl.frame.size.height)];
    MsgScrollView.contentSize = MessageLbl.frame.size;
    [MsgScrollView addSubview:MessageLbl];
    
    alert_height += MsgScrollView.frame.size.height + 15.0;
}
else
    alert_height += 15.0;

//add buttons
UIButton *CancelBtn;
UIButton *OtherBtn;

if (cancelButtonTitle && otherButtonTitle)
{
    float x_displ = (int)((alert_width-CancelBtnImg.size.width*2)/3.0);
    CancelBtn = [[UIButton alloc] initWithFrame:CGRectMake(x_displ, alert_height, CancelBtnImg.size.width, CancelBtnImg.size.height)];
    [CancelBtn setTag:1000];
    [CancelBtn setTitle:cancelButtonTitle forState:UIControlStateNormal];
    [CancelBtn.titleLabel setFont:[UIFont boldSystemFontOfSize:18.0]];
    [CancelBtn setBackgroundImage:CancelBtnImg forState:UIControlStateNormal];
    [CancelBtn addTarget:self action:@selector(onBtnPressed:) forControlEvents:UIControlEventTouchUpInside];
    
    OtherBtn = [[UIButton alloc] initWithFrame:CGRectMake(x_displ*2+CancelBtnImg.size.width, alert_height, OtherBtnImg.size.width, OtherBtnImg.size.height)];
    [OtherBtn setTag:1001];
    [OtherBtn setTitle:otherButtonTitle forState:UIControlStateNormal];
    [OtherBtn.titleLabel setFont:[UIFont boldSystemFontOfSize:18.0]];
    [OtherBtn setBackgroundImage:OtherBtnImg forState:UIControlStateNormal];
    [OtherBtn addTarget:self action:@selector(onBtnPressed:) forControlEvents:UIControlEventTouchUpInside];
    
    alert_height += CancelBtn.frame.size.height + 15.0;
}
else if (cancelButtonTitle)
{
    CancelBtn = [[UIButton allocinitWithFrame:CGRectMake((int)((alert_width-CancelBtnImg.size.width)/2.0), alert_height, CancelBtnImg.size.width, CancelBtnImg.size.height)];
    [CancelBtn setTag:1000];
    [CancelBtn setTitle:cancelButtonTitle forState:UIControlStateNormal];
    [CancelBtn.titleLabel setFont:[UIFont boldSystemFontOfSize:18.0]];
    [CancelBtn setBackgroundImage:CancelBtnImg forState:UIControlStateNormal];
    [CancelBtn addTarget:self action:@selector(onBtnPressed:) forControlEvents:UIControlEventTouchUpInside];
    
    alert_height += CancelBtn.frame.size.height + 15.0;
}
else if (otherButtonTitle)
{
    OtherBtn = [[UIButton allocinitWithFrame:CGRectMake((int)((alert_width-OtherBtnImg.size.width)/2.0), alert_height, OtherBtnImg.size.width, OtherBtnImg.size.height)];
    [OtherBtn setTag:1001];
    [OtherBtn setTitle:otherButtonTitle forState:UIControlStateNormal];
    [OtherBtn.titleLabel setFont:[UIFont boldSystemFontOfSize:18.0]];
    [OtherBtn setBackgroundImage:OtherBtnImg forState:UIControlStateNormal];
    [OtherBtn addTarget:self action:@selector(onBtnPressed:) forControlEvents:UIControlEventTouchUpInside];
    
    alert_height += OtherBtn.frame.size.height + 15.0;
}
else
    alert_height += 15.0;

//add background

AlertView = [[UIView allocinitWithFrame:CGRectMake((int)((self.frame.size.width-alert_width)/2.0), (int)((self.frame.size.height-alert_height)/2.0), alert_width, alert_height)];
AlertView.autoresizingMask = UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleRightMargin | 
UIViewAutoresizingFlexibleTopMargin | UIViewAutoresizingFlexibleBottomMargin;
AlertImgView.frame = AlertView.bounds;
[AlertView addSubview:AlertImgView];

[self addSubview:AlertView];

if (TitleLbl)
    [AlertView addSubview:TitleLbl];

if (MsgScrollView)
    [AlertView addSubview:MsgScrollView];

if (CancelBtn)
    [AlertView addSubview:CancelBtn];

if (OtherBtn)
    [AlertView addSubview:OtherBtn];

Next step would be to add onBtnPressed: method (all we need to do is to inform delegate which button was pressed):

- (void)onBtnPressed:(id)sender
{
    UIButton *button = (UIButton *)sender;
    
    int button_index = button.tag-1000;
    
    if ([delegate respondsToSelector:@selector(customAlertView:clickedButtonAtIndex:)])
        [delegate customAlertView:self clickedButtonAtIndex:button_index];
    
    [self animateHide];
}

Now let's implement showInView: method:

- (void)showInView:(UIView*)view
{
    if ([view isKindOfClass:[UIView class]])
    {
        [view addSubview:self];
        [self animateShow];
    }
}

And the last step would be implementing animation, which requires adding QuartzCore.framework to your project and adding #import <QuartzCore/QuartzCore.h> line to CustomAlert.h file. Here are animation functions:


- (void)animateShow
{
    CAKeyframeAnimation *animation = [CAKeyframeAnimation
                                      animationWithKeyPath:@"transform"];
    
    CATransform3D scale1 = CATransform3DMakeScale(0.5, 0.5, 1);
    CATransform3D scale2 = CATransform3DMakeScale(1.2, 1.2, 1);
    CATransform3D scale3 = CATransform3DMakeScale(0.9, 0.9, 1);
    CATransform3D scale4 = CATransform3DMakeScale(1.0, 1.0, 1);
    
    NSArray *frameValues = [NSArray arrayWithObjects:
                            [NSValue valueWithCATransform3D:scale1],
                            [NSValue valueWithCATransform3D:scale2],
                            [NSValue valueWithCATransform3D:scale3],
                            [NSValue valueWithCATransform3D:scale4],
                            nil];
    [animation setValues:frameValues];
    
    NSArray *frameTimes = [NSArray arrayWithObjects:
                           [NSNumber numberWithFloat:0.0],
                           [NSNumber numberWithFloat:0.5],
                           [NSNumber numberWithFloat:0.9],
                           [NSNumber numberWithFloat:1.0],
                           nil];
    [animation setKeyTimes:frameTimes];
    
    animation.fillMode = kCAFillModeForwards;
    animation.removedOnCompletion = NO;
    animation.duration = 0.2;
    
    [AlertView.layer addAnimation:animation forKey:@"show"];
}

- (void)animateHide
{
    CAKeyframeAnimation *animation = [CAKeyframeAnimation
                                      animationWithKeyPath:@"transform"];
    
    CATransform3D scale1 = CATransform3DMakeScale(1.0, 1.0, 1);
    CATransform3D scale2 = CATransform3DMakeScale(0.5, 0.5, 1);
    CATransform3D scale3 = CATransform3DMakeScale(0.0, 0.0, 1);
    
    NSArray *frameValues = [NSArray arrayWithObjects:
                            [NSValue valueWithCATransform3D:scale1],
                            [NSValue valueWithCATransform3D:scale2],
                            [NSValue valueWithCATransform3D:scale3],
                            nil];
    [animation setValues:frameValues];
    
    NSArray *frameTimes = [NSArray arrayWithObjects:
                           [NSNumber numberWithFloat:0.0],
                           [NSNumber numberWithFloat:0.5],
                           [NSNumber numberWithFloat:0.9],
                           nil];
    [animation setKeyTimes:frameTimes];
    
    animation.fillMode = kCAFillModeForwards;
    animation.removedOnCompletion = NO;
    animation.duration = 0.1;
    
    [AlertView.layer addAnimation:animation forKey:@"hide"];
    
    [self performSelector:@selector(removeFromSuperview) withObject:self afterDelay:0.105];
}


By this point you should be able successfully compile project, but there's no visible result yet. Let's add a button to view controller which would display alert when clicked.

Setting up ViewController.h

Basically we just need to import CustomAlert.h and set view controller to be its delegate. Here's how the code should look like:

#import <UIKit/UIKit.h>
#import "CustomAlert.h"

@interface ViewController : UIViewController <CustomAlertDelegate>

@end

Setting up ViewController.m

Let's add button by pasting the following code in viewDidLoad function (you can also use interface builder instead of manual code):

UIButton *AlertBtn = [UIButton buttonWithType:UIButtonTypeRoundedRect];
AlertBtn.frame = CGRectMake((int)((self.view.frame.size.width-200.0)/2.0), 120.0, 200.0, 50.0);
[AlertBtn setTitle:@"Show Alert" forState:UIControlStateNormal];
[AlertBtn addTarget:self action:@selector(onAlertBtnPressed) forControlEvents:UIControlEventTouchUpInside];
[self.view addSubview:AlertBtn];
AlertBtn.autoresizingMask = UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleRightMargin | UIViewAutoresizingFlexibleTopMargin | UIViewAutoresizingFlexibleBottomMargin;

Now let's add alert in onAlertBtnPressed implementation:

- (void)onAlertBtnPressed
{
    CustomAlert *alert = [[CustomAlert alloc] initWithTitle:@"Warning" message:@"Set background color:" delegate:self cancelButtonTitle:@"Gray" otherButtonTitle:@"White"];
    [alert showInView:self.view];
}

And the final step would be implementing delegate method:

- (void)customAlertView:(CustomAlert*)alertView clickedButtonAtIndex:(NSInteger)buttonIndex
{
    if (buttonIndex == 0)
    {
        //Gray
        self.view.backgroundColor = [UIColor lightGrayColor];
    }
    else if (buttonIndex == 1)
    {
        //White
        self.view.backgroundColor = [UIColor whiteColor];
    }
}

4. Running the project

General case

Compile and run the app, alert view should look like this:



Alert with no buttons

Modify onAlertBtnPressed function the following way:

- (void)onAlertBtnPressed
{
    CustomAlert *alert = [[CustomAlert alloc] initWithTitle:@"Warning" message:@"This is message with no buttons" delegate:self cancelButtonTitle:Nil otherButtonTitle:Nil];
    [alert showInView:self.view];
}

Now alert looks like this:


Please note that in this case you should handle removing alert view manually

5. Conclusions

By replacing graphics and styling text you can achieve pretty much any look that would look good with your app style.

If your app requires three or more buttons, you might want to consider using table view instead of buttons since this way it would be easier to manage. Sample implementation of such approach can be found here.

You can download source code here

8 comments:

  1. Nice post. Detailed description with images.

    ReplyDelete
  2. Hi Marichka , I've just stared out iOS programming and was googling for examples on how to include a table in a uiview subclass. I'm came your post eventually and struggle to understand "frame vs bounds" while trying your example. Everything makes sense until you initiated an "AlertView" uiview, and use it to add the buttons and various labels. The part that confused me is when "self.frame", instead of bounds, is used for initializing the "AlertView" frame.

    So what I did for myself is using self (i.e. the view property of my UIView subclass) to include the "AlertImgView" and have everything else (like the buttons and labels) included in "AlertImgView".

    [self addSubview:AlertImgView];
    [AlertImgView addSubview:titleLabel];
    [AlertImgView addSubview: CancelBtn];

    And I basically center the labels and buttons inside AlertImgView using AlertImgView's bounds property.
    e.g. titleLabel.center = AlertImgView.bounds.size.width/2.0;

    Everything was working alright until i try to click a button on my SubView.
    The selector assigned to my button was not called.

    I'd really appreciate if you can shed some light to my probelms.

    ReplyDelete
    Replies
    1. Hi,

      Regarding frame vs bounds - I find this post to be helpful: http://stackoverflow.com/questions/5361369/uiview-frame-bounds-and-center

      Regarding selector not being called - it's difficult to know the reason without the actual code. If possible, please share your project via dropbox or github, I'll try to help

      And if you are trying to use table view inside uiview subclass - please take a look at this post, it might be better suited for your needs:
      http://iosdevtricks.blogspot.com/2013/04/ipad-style-action-sheet-for-iphone.html

      Delete
    2. This comment has been removed by the author.

      Delete
    3. Wow! Thanks for the lightning fast reply :)

      Yes! I should've shared my code somewhere.

      I think I've solved my mystery. My UIButton is in an UIImageView. And the "setUserInteractionEnabled" property of UIImageView is set to "NO" by default. And that prevents the button from recognizing clicks event.

      Thanks! And I'll definitely check out the links you provided.

      Delete
  3. This comment has been removed by the author.

    ReplyDelete
  4. Thanks,
    it would be useful .
    nice job.

    ReplyDelete
  5. iOS App Developers : Think of the market you intend to target. If you want to target the mass market, Android development is the best choice for you. If you want to target business and executive community, BlackBerry can still be a better choice. But you are supposed to choose a platform by putting the potential needs of customers first.

    ReplyDelete