アカベコマイリ

HEAR NOTHING SEE NOTHING SAY NOTHING

いろいろな UIAlertView

iPhone でユーザー通知や警告などに使われる UIAlertView について代表的なカスタマイズ方法をまとめてみる。

セレクターをボタン毎に設定する

UIAlertView の内容やボタンが複数あるときに標準の UIAlertViewDelegate - clickedButtonAtIndex でハンドリングするのは非常に面倒だ。内容を判定するには UIAlertViewtag プロパティを利用するかオーナーとなるクラス側に状態を持つことになる。押されたボタンについてはインデックスしか情報がないため可変長のときに困る。

tag に設定する値をビットフラグにすれば組み合わせの複雑さも多少は緩和できるだろう。願わくば直感的に、例えばボタンとそのハンドラを一対一で結びつけられないものか。というわけで UIAlertView とデリゲート関連でググっていたら以下の記事を見つけた。

これらの記事では UIAlertView 派生クラスを実装して UIAlertViewDelegateblocksselector を結びつけるアイディアを提示している。記事を読むに前者の blocks 案が元でそれを iOS 3 で動くように selector へ置き換えたのが後者のようだが、そちらの方が好みである。

ただし多くの用途では一つの delegate に複数の selector という設計になると思うので、それを前提に以下のようなクラスを作成してみた。

#import <UIKit/UIKit.h>

/**
 * UIAlertView を拡張したコントロールです。
 */
@interface CustomAlertView : UIAlertView<UIAlertViewDelegate>
+ (CustomAlertView*)alertWithButton:(NSString*)title message:(NSString*)message delegate:(id)delegate button:(NSString*)button;

- (void)addButtonWithTitle:(NSString *)title selector:(SEL)selector userInfo:(id)userInfo;
- (void)close:(NSInteger)buttonIndex;
- (void)setFirstButtonDelegate:(SEL)selector userInfo:(id)userInfo;
@end

ひとつの delegate を持ち addButtonWithTitlesetFirstButtonDelegateselector を追加してゆく。close は好みのインデックスを指定してアラートを閉じる dismissWithClickedButtonIndex の Wrapper である。

alertWithButton に対応する initWithButton は非公開とした。コンビニエンス メソッドだけ公開というのは一般的ではない気がするがどうなのだろう?個人的に同じような目的を持つ方法が複数あるのは好ましくなく、また autorelease を強制するという意味でこのようなケースならありだと思うのだが。もしこのあたりに意見をお持ちの方がいらっしゃったら意見をいただけるとありがたいです。

クラス実装は以下。

#import "CustomAlertView.h"
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
#pragma mark - CustomAlertEvent

/**
 * CustomAlertView 用のイベント用データを表します。
 */
@interface CustomAlertEvent : NSObject
@property (nonatomic, assign) SEL selector; //! コールバック メソッド
@property (nonatomic, assign) id  userInfo; //! コールバックメソッドに指定されるパラメータ

- (id)initWithSelector:(SEL)aSelector userInfo:(id)aUserInfo;
@end

@implementation CustomAlertEvent
@synthesize selector, userInfo;

/**
 * インスタンスを初期化します。
 *
 * @param aSelector セレクター。
 * @param aUserInfo ユーザー データ。
 *
 * @return インスタンス。
 */
- (id)initWithSelector:(SEL)aSelector userInfo:(id)aUserInfo
{
    self = [super init];
    if( self )
    {
        self.selector = aSelector;
        self.userInfo = aUserInfo;
    }

    return self;
}

/**
 * メモリを解放します。
 */
-(void)dealloc
{
    self.selector = nil;
    self.userInfo = nil;

    [super dealloc];
}

@end

////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
#pragma mark - CustomAlertView
@interface CustomAlertView()
@property (nonatomic, assign) id              myDelegate; //! 利用者
@property (nonatomic, retain) NSMutableArray* events;     //! イベント情報のコレクション

- (id)initWithButton:(NSString*)title message:(NSString*)message delegate:(id)aDelegate button:(NSString*)button;
- (BOOL)hasCancelButton;
@end

@implementation CustomAlertView
@synthesize myDelegate, events, indicator, progress, table, textField;

#pragma mark - Lifecycle

/**
 * インスタンスを初期化します。
 *
 * @param title     タイトル。
 * @param message   本文。
 * @param aDelegate デリゲート。
 * @param button    ボタンの文言。
 *
 * @return インスタンス。
 */
- (id)initWithButton:(NSString *)title message:(NSString *)message delegate:(id)aDelegate button:(NSString *)button
{
    self = [super initWithTitle:title message:message delegate:nil cancelButtonTitle:button otherButtonTitles:nil];
    if( self )
    {
        self.delegate   = self;
        self.myDelegate = aDelegate;
        self.events     = [[[NSMutableArray alloc] init] autorelease];

        if( [self hasCancelButton] )
        {
            [self setFirstButtonDelegate:nil userInfo:nil];
        }
    }

    return self;
}

/**
 * メモリを解放します。
 */
- (void)dealloc
{
    self.events = nil;
    [super dealloc];
}

/**
 * ボタンを追加します。
 *
 * @param title    ボタンのタイトル。
 * @param selector コールバック メソッド
 * @param userInfo コールバック メソッドに指定されるパラメータ。
 */
- (void)addButtonWithTitle:(NSString *)title selector:(SEL)selector userInfo:(id)userInfo
{
    [self addButtonWithTitle:title];

    CustomAlertEvent* event = [[CustomAlertEvent alloc] initWithSelector:selector userInfo:userInfo];
    [self.events addObject:event];
    [event release];
}

/**
 * アラートを閉じます。
 *
 * @param buttonIndex 閉じる時に押されたボタンのインデックス。ボタンの無いアラートの場合は 0 を指定します。
 */
- (void)close:(NSInteger)buttonIndex
{
    [self dismissWithClickedButtonIndex:buttonIndex animated:YES];
}

/**
 * はじめのボタンが押された時のハンドラを設定します。
 *
 * @param selector コールバック メソッド
 * @param userInfo コールバック メソッドに指定されるパラメータ。
 */
- (void)setFirstButtonDelegate:(SEL)selector userInfo:(id)userInfo
{
    if( [self hasCancelButton] )
    {
        if( self.events.count > 0 )
        {
            [self.events removeObjectAtIndex:0];
        }

        CustomAlertEvent* event = [[CustomAlertEvent alloc] initWithSelector:selector userInfo:userInfo];
        [self.events insertObject:event atIndex:0];
        [event release];
    }
}

selector やパラメータは CustomAlertEvent というファイルスコープのクラスを実装して、そのコレクションを管理するように設計。なお CustomAlertEventuserInfoassign になっているため寿命は保証しない。

以降のカスタマイズはこのクラスをベースにおこなってゆく。

単純なボタン

まずは基本の標準的なボタン付きアラート。

ボタン付きアラート

見た目は標準のものと一緒だが前述の拡張によりボタンが押された時のハンドラが selector になっている。使い方は以下のような感じ。

/**
 * CustomAlertView 上のボタンが押された時に発生します。
 *
 * @param userInfo ユーザー情報。
 */
- (void)selectAlertNormalButton:(id)userInfo
{
    if( userInfo )
    {
        NSString* text = ( NSString* )userInfo;
        CustomAlertView* alert = [CustomAlertView alertWithButton:nil message:text delegate:nil button:@"OK"];
        [alert show];
    }
}

/**
 * ボタン付きアラートを表示します。
 */
- (void)showAlertButton
{
    CustomAlertView* alert = [CustomAlertView alertWithButton:@"タイトル" message:@"メッセージ" delegate:self button:cancel];
    [alert addButtonWithTitle:@"OK" selector:@selector( selectAlertNormalButton: ) userInfo:@"OK Button"];
    [alert setFirstButtonDelegate:@selector( selectAlertNormalButton: ) userInfo:@"Cancel Button"];
    [alert show];
}

ボタンが押された時に selectAlertNormalButton で更にその種別をアラートで表示するようにしている。userInfo には静的な文字列を指定しているため寿命については意識する必要がない。一つの selector で複数ボタンをサポートするならこの使い方が基本になる。

テキスト フィールド

パスワード入力などを求める時に使えそうなテキスト フィールドつきアラート。

テキスト フィールド

テキスト フィールドつきアラートを作成するため CustomAlertView に以下のコンビニエンス メソッドを追加。

+ (CustomAlertView*)alertWithTextField:(NSString*)title message:(NSString*)message delegate:(id)delegate textDelegate:(id<UITextFieldDelegate>)textDelegate placeholder:(NSString*)placeholder button:(NSString*)button;

テキスト フィールドの実体やデザイン的な部分については非公開とする。最低限、必要であろう UITextFieldDelegateplaceholder のみを指定出来るようにした。

@interface CustomAlertView()
@property (nonatomic, retain) UITextField* textField;  //! テキスト フィールド
- (id)initWithTextField:(NSString*)title message:(NSString*)message delegate:(id)aDelegate textDelegate:(id<UITextFieldDelegate>)textDelegate placeholder:(NSString*)placeholder button:(NSString*)button;

// ... 中略

/**
 * テキスト フィールド付きアラートのインスタンスを初期化します。
 *
 * @param title        タイトル。
 * @param aDelegate    デリゲート。
 * @param textDelegate テキスト フィールドからのメッセージ通知先。
 * @param placeholder  テキスト フィールドのプレースホルダー文言。
 * @param button       ボタンの文言。
 *
 * @return インスタンス。
 */
- (id)initWithTextField:(NSString *)title message:(NSString *)message delegate:(id)aDelegate textDelegate:(id<UITextFieldDelegate>)textDelegate placeholder:(NSString*)placeholder button:(NSString *)button
{
    self = [self initWithButton:title message:message delegate:aDelegate button:button];
    if( self )
    {
        self.textField = [[[UITextField alloc] initWithFrame:CGRectMake( 12, 80, 260, 31 )] autorelease];
        self.textField.delegate    = textDelegate;
        self.textField.placeholder = placeholder;
        self.textField.borderStyle = UITextBorderStyleRoundedRect;

        [self.textField setBackgroundColor:[UIColor whiteColor]];
        [self addSubview:self.textField];
    }

    return self;
}

/**
 * UIAlertView が表示される時に発生します。
 *
 * @param alertView 表示対象となる UIAlertView。
 */
- (void)willPresentAlertView:(UIAlertView *)alertView
{
    // ボタンが皆無なら、表示関連の調整は不要
    if( self.numberOfButtons < 1 ) { return; }

    NSInteger offsetY = 0;
    NSInteger offsetH = 0;

    if( self.textField )
    {
        offsetY = self.textField.frame.origin.y;
        offsetH = self.textField.frame.size.height;
    }
    else
    {
        return;
    }

    const NSInteger padding = 8;

    // アラート本体の位置とサイズを調整。
    // 垂直位置を追加したコントロールの半分、ずらすことで中央としている。
    // ただし、小数点以下の値が混じると描画がぼやけるため、offsetH を意図的に NSInteger で宣言している。
    //
    CGRect frame = self.frame;
    frame.origin.y    -= offsetH / 2;
    frame.size.height += offsetH + padding;
    self.frame = frame;

    // 追加コントロール以下のものを位置調整
    for( UIView* view in self.subviews )
    {
        frame = view.frame;
        if( offsetY < frame.origin.y )
        {
            frame.origin.y += offsetH + padding;
            view.frame = frame;
        }
    }
}

UIAlertView - addSubviewでコントロールを追加すると標準ボタンと重なって表示されてしまう。そのためアラート本体を拡大してボタン位置をずらす必要がある。これは layoutSubviewUIAlertViewDelegate を実装したクラスの willPresentAlertView でおこなう。今回のクラスでは後者を採用。

以降、様々なコントロールを追加してゆくがそれらにも同様の処理が必要。ただし内容は変わりないため処理の解説はこの項にだけ書いておく。

テキスト フィールドの内容を取得したい場合は UITextFieldDelegate - textFieldDidEndEditing を利用。このアラートを閉じたたらこのハンドラが呼び出されるので、指定された textFieldtext プロパティからテキストが得られる。ちなみにアラートを閉じるとキーボードも連動して閉じられる。

テーブル

拡張性が高く、様々な用途に利用できるテーブル付きアラート。

テーブル

テーブルのセルや挙動は UITableViewDataSourceUITableViewDelegate によって定義できるため、これらを受け取るコンビニエンス メソッドを提供する。

+ (CustomAlertView*)alertWithTable:(NSString*)title message:(NSString*)message delegate:(id)delegate tableSource:(id<UITableViewDataSource>)tableSource tableDelegate:(id<UITableViewDelegate>)tableDelegate button:(NSString*)button;

テーブルのスタイルは UITableViewStylePlain。そのまま表示すると角張っているため角丸効果を加えている。

@interface CustomAlertView()
@property (nonatomic, retain) UITableView* table; //! テーブル
- (id)initWithTable:(NSString*)title message:(NSString*)message delegate:(id)aDelegate tableSource:(id<UITableViewDataSource>)tableSource tableDelegate:(id<UITableViewDelegate>)tableDelegate button:(NSString*)button;

// ... 中略
/**
 * テーブル付きアラートのインスタンスを初期化します。
 *
 * @param title         タイトル。
 * @param aDelegate     デリゲート。
 * @param tableSource   テーブルのデータ取得先。
 * @param tableDelegate テーブルからのメッセージ通知先。
 * @param button        ボタンの文言。
 *
 * @return インスタンス。
 */
- (id)initWithTable:(NSString *)title message:(NSString *)message delegate:(id)aDelegate tableSource:(id<UITableViewDataSource>)tableSource tableDelegate:(id<UITableViewDelegate>)tableDelegate button:(NSString *)button
{
    self = [self initWithButton:title message:message delegate:aDelegate button:button];
    if( self )
    {
        self.table = [[[UITableView alloc] initWithFrame:CGRectMake( 12 , 80, 260, 200 ) style:UITableViewStylePlain] autorelease];
        self.table.dataSource          = tableSource;
        self.table.delegate            = tableDelegate;
        self.table.layer.cornerRadius  = 4;

        [self addSubview:self.table];
    }

    return self;
}

UITableView 系デリゲートをハンドリングできるので、かなり柔軟にカスタマイズできるはず。

プログレスバー

処理数が判明しており進捗が取得できる場合に有用なプログレス バー付きアラート。

プログレスバー

メッセージは決め打ちで "%d / %d" としており指定不可。ここには「処理数 / 処理総数」が自動的に割り当てられる。進捗を更新するために updateProgress メソッドを公開。これを処理の度に呼び出すことでメッセージとプログレスバーが更新される。

+ (CustomAlertView*)alertWithProgress:(NSString*)title delegate:(id)delegate button:(NSString*)button;
- (void)updateProgress:(NSInteger)value total:(NSInteger)total;

実装は以下。

#define PROGRESS_MESSAGE @"%d / %d"

@interface CustomAlertView()
@property (nonatomic, retain) UIProgressView* progress; //! プログレスバー
- (id)initWithProgress:(NSString*)title delegate:(id)aDelegate button:(NSString*)button;

// ... 中略

/**
 * プログレスバー付きアラートのインスタンスを初期化します。
 *
 * @param title     タイトル。
 * @param aDelegate デリゲート。
 * @param button    ボタンの文言。
 *
 * @return インスタンス。
 */
- (id)initWithProgress:(NSString *)title delegate:(id)aDelegate button:(NSString *)button
{
    NSString* message = [NSString stringWithFormat:PROGRESS_MESSAGE, 0, 0];
    self = [self initWithButton:title message:message delegate:aDelegate button:button];
    if( self )
    {
        self.progress = [[[UIProgressView alloc] initWithFrame:CGRectMake( 12, 80, 260, 30 )] autorelease];
        [self addSubview:self.progress];
    }

    return self;
}

/**
 * 進捗を更新します。
 *
 * @param value 処理数。
 * @param total 処理の総数。
 */
- (void)updateProgress:(NSInteger)value total:(NSInteger)total
{
    if( self.progress )
    {
        [self setMessage:[NSString stringWithFormat:PROGRESS_MESSAGE, value, total]];
        self.progress.progress = ( float )value / ( float )total;
    }
}

インジケーター

時間の掛かる処理があるとしてその開始と終了しか取れない時に利用できそうなインジケーターつきアラート。

インジケーター

このアラートを選ぶならボタンは不要な気がするけれど念のため指定可能としておく。

+ (CustomAlertView*)alertWithIndicator:(NSString*)title message:(NSString*)message delegate:(id)delegate button:(NSString*)button;

実装は以下。

@interface CustomAlertView()
@property (nonatomic, retain) UIActivityIndicatorView* indicator; //! インジケーター
- (id)initWithIndicator:(NSString*)title message:(NSString*)message delegate:(id)aDelegate button:(NSString*)button;

// ... 中略

/**
 * インジケーター付きアラートのインスタンスを初期化します。
 *
 * @param title     タイトル。
 * @param message   本文。
 * @param aDelegate デリゲート。
 * @param button    ボタンの文言。
 *
 * @return インスタンス。
 */
- (id)initWithIndicator:(NSString *)title message:(NSString *)message delegate:(id)aDelegate button:(NSString *)button
{
    self = [self initWithButton:title message:message delegate:aDelegate button:button];
    if( self )
    {
        self.indicator = [[[UIActivityIndicatorView alloc] initWithFrame:CGRectMake( 125, 80, 30, 30 )] autorelease];
        self.indicator.activityIndicatorViewStyle = UIActivityIndicatorViewStyleWhiteLarge;
        [self addSubview:self.indicator];
        [self.indicator startAnimating];
    }

    return self;
}

ボタンを用意しないとユーザー操作でアラートが閉じられない。よってプログラム側で確実に閉じるよう実装すること。

サンプル プログラム

サンプル プログラムのプロジェクト一式を GitHub にて公開した。開発は Xcode 4.5.1、動作確認には iPhone 5.1 シミュレーター、iPod touch 第 5世代 ( 昨日とどいた ) を使用。

ライセンスは The MIT License (MIT)。fork、改変などはご自由にどうぞ。

以下はメモリー リークのある旧サンプル

Comments from WordPress

  • 藤岡 良英 藤岡 良英 2012-10-30T10:56:38Z

    ありがとうございます。すごく役に立ちそうです。
    参考にさせて頂きたいと思います。

    それで、早速自分のアプリに組み込んで動かそうとしたところ、Instrumentsでメモリーリークが検出されました。
    サンプルソフトそのものでも、同様に発生します。

    私はまだ、初心者のため、原因が分からないのですが、私のツールの使い方が悪いかも知れません。
    ちなみにXcodeは、V4.5です。
    気にしなくても良いでしょうか?

    ご教示の程、よろしくお願いします。

  • akabeko akabeko 2012-10-30T23:03:59Z

    ご指摘ありがとうございました。

    メモリーリークについて、ちかい内に時間をとって調査しようと思います ( 週末になるかもしれません )。結果については、この記事に追記する予定です。

  • akabeko akabeko 2012-11-03T14:24:45Z

    藤岡さん、こんばんは。

    その後、メモリー リークについて調査をおこない、原因が判明したので修正いたしました。修正版は GitHub に公開しています。

    あわせて記事内のソースを修正し、サンプル プログラムのコーナーに GitHub へのリンクと注意書きを追加しました。

    ご指摘がなければ、問題は放置されたままになっていたと思います。今後も、疑問点などがありましたら、コメントをいただけると助かります。ありがとうございました。