アカベコマイリ

HEAR NOTHING SEE NOTHING SAY NOTHING

iOS でグリッド表示

iOS 標準の写真アプリ的なグリッド表示を利用したくなったので、そういうコントロールがないか調べてみた。

はじめに

UITableView あたりにグリッド表示モードがあると予想していた。しかしこれは行をリスト表示するためのもので列はサポートしていないらしい。Cocoa Controls を Grid で検索してみると様々なグリッド表示コントロールが見つかる。これらを見ると実装方法は UIScrollView または UITableView 派生に大別できるようだ。

前者は UIScrollView 全体をひとつのコンテナと見なし列と行を管理する。実装としては最小限となるため独自拡張に向く。後者は UITableView 中の UITableViewCell をそのまま行として利用、内部に列を設けることでコンテナを形成している。こちらで実装する場合 UITableView & UITableViewCell の制約を受けるが表示アイテムの再利用やセクションなどの豊富な機能を利用可能。

個人的に UITableView 派生は扱いにくいと感じる。本来なら行列を管理するためのコントロールなのでアイテム位置を判別するための処理に列の情報がいるし cellForRowAtIndexPath や deselectRowAtIndexPath などで利用側が行と列を意識しなければならない。

この問題は代替メソッド、例えば IndexPath ではなく GridIndexPath 的なものでやりとりするなどで避けられる。しかしそうすると標準メソッドとの関係性を考慮せねばならず、かえって複雑になるだけ。

以上を踏まえ今回は UIScrollView 派生で検討してみる。

ATArrayView

まずは参考になるものを調べてみる。前述の Cocoa Controls の他、Stack Overflow 上でグリッド表示の実装に関する質疑などもチェック。結果、以下のコントロールが最も好みに合致した。

ATArrayView の特徴は以下。

  1. UIScrollView 系である
  2. 実装は ATArrayView.h/m で完結しており、外部依存はない
  3. アイテム管理が UITableViewDataSource 風なのでわかりやすい
  4. 名前のとおりアイテムは単一の配列として管理しているため、利用側は行と列を意識しなくてもよい
  5. アイテムは UIView として管理されるため、独自コントロール (UITableViewCell など) から派生しなくてもよい
  6. 表示アイテム再利用メソッド dequeueReusableItem を提供している
  7. アイテムの選択通知は未実装

よい感じなのだけど実用するにはアイテム選択の通知機能がほしい。また利用する機能や情報も必要最小限にしたいので改造してみる。

ABGridView

前述の ATArrayView をベースにアイテム選択の通知などを実装した ABGridView というコントロールを作成。ATArrayView を含む SoloComponents-iOS の README.md によるとライセンス MIT。実装の大半を流用するので ABGridView.h、m のファイル ヘッダ コメントに原作者の情報と GitHub リポジトリの URL を掲載しておく。

まず、ABGridView のヘッダは以下のように定義した。

//
//  ABGridView.h
//  GridView
//
//  Created by Akabeko on 2012/09/09.
//  Copyright (c) 2012 Akabeko. All rights reserved. Distributed under the MIT license.
//
//  Original author: Andrey Tarantsov, ATArrayView https://github.com/andreyvit/SoloComponents-iOS/tree/master/ATArrayView
//

#import <Foundation/Foundation.h>

@protocol ABGridViewDelegate;

////////////////////////////////////////////////////////////////////////////////
/**
 * コントロールをグリッド形式で配置するコンテナです。
 */
@interface ABGridView : UIView

@property(nonatomic,   assign) id<ABGridViewDelegate> delegate;         //! デリゲート
@property(nonatomic,   assign) UIEdgeInsets           contentInsets;    //!
@property(nonatomic,   assign) CGSize                 itemSize;         //! グリッド配置するアイテムのサイズ
@property(nonatomic,   assign) CGFloat                minimumColumnGap; //! 最小のカラム間の余白

- (void)reloadData;
- (UIView *)viewForItemAtIndex:(NSUInteger)index;
- (UIView *)dequeueReusableItem;
- (CGRect)rectForItemAtIndex:(NSUInteger)index;

@end

////////////////////////////////////////////////////////////////////////////////
/**
 * ABGridView に関するデリゲートです。
 */
@protocol ABGridViewDelegate <NSObject>

@required
- (NSInteger)numberOfItemsInGridView:(ABGridView *)gridView;
- (UIView *)viewForItemInGridView:(ABGridView *)gridView atIndex:(NSInteger)index;

@optional
- (void)gridView:(ABGridView *)gridView didSelectItemInGridView:(UIView *)view;

@end

大半のデータを .m 側に移行。デリゲートにアイテム選択を通知するための didSelectItemInGridView を追加している。ただしこれは UITableViewDelegate の didSelectRowAtIndexPath 風に optional としている。実装上の大きな変更点はアイテム選択の通知のみ。

/**
 * アイテム設定を更新します。
 *
 * @param item  アイテム
 * @param index インデックス。
 */
- (void)configureItem:(UIView *)item forIndex:(NSInteger)index
{
    item.tag   = index;
    item.frame = [self rectForItemAtIndex:index];

    if( [_delegate respondsToSelector:@selector(gridView:didSelectItemInGridView:)] )
    {
        // UIImageView などがアイテムでもタップ検出するため、常にユーザー操作を許可する
        item.userInteractionEnabled = YES;
        item.gestureRecognizers     = [NSArray arrayWithObjects:[[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(tapItem:)] autorelease], nil];
    }

    [item setNeedsDisplay]; // just in case
}

/**
 * アイテムがタップされた時に発生します。
 *
 * @param gestureRecognizer ジェスチャー情報。
 */
- (void)tapItem:(UIGestureRecognizer *)gestureRecognizer
{
    [_delegate gridView:self didSelectItemInGridView:gestureRecognizer.view];
}

configureItem が呼び出されたとき didSelectItemInGridView が実装されていたならアイテムのジェスチャとしてタップを設定する。

コメントにも書いたが UIImageView などは userInteractionEnabled が NO になっているため YES にしてユーザー操作を受け付けるようにする。なるべくアイテムとなる UIView のパラメータは変更せずに済ませたいところだが frame や tag と同様に userInteractionEnabled も管理すべき要素と判断。

アイテムがタップされると didSelectItemInGridView で選択されたアイテムの UIView が通知される。ここは index でもよい。しかしアイテムの UIView に対する操作が必要になるかもしれないのと表示するアイテムが増えて再利用が実行されると index とアイテムに齟齬が生じる可能性もあるため UIView そのものにした。

前述の Cocoa Controls で公開されているコントロールにはアイテムをキーとするインデックス情報の NSMutableDictionary を用意。再利用されてもアイテムから適切なインデックス情報を得られるようにしているものもある。ただ今回は面倒なので実装しない。そのような情報が必要ならアイテム側で用意する方針。後述するサンプルでは利用側でこれを実装している。

  • 余談
    • ABGridView の AB は私のハンドル名 Akabeko から取ったプレフィックスである
    • Objective-C でクラスにプレフィックスを付ける場合、人名なら姓名のイニシャルを利用するのが一般的なようだ
    • しかし私のハンドル名は単一なので、AB とした
    • じめ AKB も考えたけど某アイドル グループと被るのでやめた。

コントロールが実装できたので次は実際に使ってみる。

サンプル プログラム

この記事の冒頭でグリッド配置の例として iOS 標準の写真アプリを挙げた。サンプルもそれを踏襲してみる。実装にあたり以下の記事を参考にした。

写真アプリでは「アルバム」→「写真リスト」という流れがある。これらは AssetsLibrary → ALAssetsGroup → ALAsset に対応する。AssetsLibrary という iOS 内のコンテンツ全体を表すライブラリがあり、その中にグループ、グループ内のコンテンツという階層構造となっている。

サンプルとしては UIView 派生をそのままと独自コントロールの表示を提示したいので「表示モード選択」→「グループ選択」→「グループ内の写真コンテンツ一覧」→「写真コンテンツ単体表示」という構成にした。

「表示モード選択」→「グループ選択」の画面遷移は以下のようになる。

表示モードとグループ選択

「グループ内の写真コンテンツ一覧」は以下。

グリッド表示

単純な UIImageView をアイテムにしたものと独自セルで分岐する。ABGridViewDelegate - viewForItemInGridView が呼び出されたときモードに応じて異なる UIView を返している。

/**
 * 指定されたインデックスのアイテムを取得します。
 *
 * @param gridView グリッド配置コンテナ。
 * @param index    インデックス。
 *
 * @return アイテム。
 */
- (UIView *)viewForItemInGridView:(ABGridView *)gridView atIndex:(NSInteger)index
{
    return ( self.isViewModeUIImage ?
            [self viewForItemInGridViewAtImageView:gridView atIndex:index] :
            [self viewForItemInGridViewAtOriginalCell:gridView atIndex:index] );
}

UIImageView の場合、単に画像を設定して返す。

- (UIView *)viewForItemInGridViewAtImageView:(ABGridView *)gridView atIndex:(NSInteger)index
{
    UIImageView* item = ( UIImageView* )[gridView dequeueReusableItem];
    if( item == nil )
    {
        item = [[[UIImageView alloc] init] autorelease];
    }

    ALAsset* asset = [self.assets objectAtIndex:index];
    item.image = [UIImage imageWithCGImage:[asset thumbnail]];

    return item;
}

独自セルは UIView 派生の AssetCellView とそれを管理する UIViewController 派生の AssetCellViewController で構成。実装方法については手前味噌であるが、このブログの過去記事 UITableViewCell のカスタマイズ を参照のこと。

これを返すメソッドは以下。再利用できるアイテムがない時に AssetCellViewController から AssetCellView を得る処理は UITableView & UITableViewCell などでおなじみ。

- (UIView *)viewForItemInGridViewAtOriginalCell:(ABGridView *)gridView atIndex:(NSInteger)index
{
    AssetCellView* item = ( AssetCellView* )[gridView dequeueReusableItem];
    if( item == nil )
    {
        AssetCellViewController* controller = [[[AssetCellViewController alloc] initWithNibName:@"AssetCellViewController" bundle:nil] autorelease];
        item = ( AssetCellView* )controller.view;
        [self addViewShadow:item];
    }

    ALAsset* asset = [self.assets objectAtIndex:index];
    item.index           = index;
    item.imageView.image = [UIImage imageWithCGImage:[asset thumbnail]];

    return item;
}

アイテムが選択されたら対応するインデックスから画像を得て、写真を単一表示するための画面に遷移させる。

/**
 * アイテムが選択された時に発生します。
 *
 * @param gridView グリッド配置コンテナ。
 * @param view     アイテム。
 */
- (void)gridView:(ABGridView *)gridView didSelectItemInGridView:(UIView *)view
{
    const NSInteger index = ( self.isViewModeUIImage ? view.tag : ( ( AssetCellView* )view ).index );

    ALAsset* asset = [self.assets objectAtIndex:index];
    if( asset == nil ) { return; }

    ALAssetRepresentation* rep = [asset defaultRepresentation];
    UIImage* image  = [UIImage imageWithCGImage:[rep fullScreenImage] scale:[rep scale] orientation:( UIImageOrientation )[rep orientation]];

    // 戻るボタン
    UIBarButtonItem* back = [[[UIBarButtonItem alloc] initWithTitle:@"Back" style:UIBarButtonItemStyleBordered target:nil action:nil] autorelease];
    self.navigationItem.backBarButtonItem = back;

    PhotoViewController* controller = [[[PhotoViewController alloc] initWithNibName:@"PhotoViewController" bundle:nil] autorelease];
    controller.image = image;

    [self.navigationController pushViewController:controller animated:YES];
}

表示してみる。

選択された写真の表示

サンプルを GitHub で公開した。開発は Xcode 4.4、動作確認には iPhone 5.1 シミュレーターを使用。

ライセンスは原作の ATArrayView にならい The MIT License (MIT)。

今後 iOS 系のサンプル実装はこのリポジトリに公開してゆく予定。ブログの過去記事で公開していたものも移行したい。