アカベコマイリ

HEAR NOTHING SEE NOTHING SAY NOTHING

UITableViewCell のカスタマイズ

iPhone アプリで多用される UITableViewCell のカスタマイズにチャレンジしてみる。セルを作成するにあたり可変長で適度な複雑さを持ったデータが欲しいのでサンプルには簡単な Twitter のタイムライン ビューアーを選んでみた。

プロジェクトの準備

はじめにプロジェクトを作成。テンプレートは Navigation based Application にした。プロジェクト名は TestTwitterClient としておく。既定の RootViewController は TimelineViewController にリネーム。今回のサンプルではこの画面に Twitter のタイムラインを表示する。

次に Twitter API から得られたタイムラインの JSON を解析するために JSON framwwork というライブラリを用意する。ライセンスは修正 BSD。

GitHub からダウンロードした圧縮イメージを展開すると Classes というディレクトリがあるので、これをプロジェクト内のソースが格納されているフォルダへコピー。外部ライブラリのソースがプロジェクト本体のものと混ざらないようにしたいので JSON というサブフォルダを作成してその中へ配置。

JSON フレームワークの追加

ソースをコピーしたらプロジェクトに取り込む。

JSON フレームワークの組み込み

サブフォルダに配置しても Xcode 的には自動的に参照してくれるらしく import ディレクティブにはヘッダの名前だけ指定すればよい。これで準備完了。

Twitter のタイムライン取得

Twitter からタイムラインのデータ取得処理を実装。Twitter API としては XML または JSON 形式のデータを提供しているのだけど今回は後者を選ぶ。以下のような URL へアクセスすれば JSON 形式のデータが得られるので、これを解析する。

http://twitter.com/statuses/user_timeline/akabekobeko.json

データ格納クラスを宣言。

#import <Foundation/Foundation.h>

/**
 * タイムライン上のつぶやきを表します。
 */
@interface Tweet : NSObject
{
@private
    NSString* _name;    //! ユーザー名
    NSString* _text;    //! つぶやきの内容
    UIImage*  _icon;    //! アイコン画像
    NSString* _created; //! つぶやかれた日時
}

/** ユーザー名を取得します。 */
@property (nonatomic, readonly) NSString* name;

/** つぶやきの内容を取得します。 */
@property (nonatomic, readonly) NSString* text;

/** アイコン画像を取得します。 */
@property (nonatomic, readonly) UIImage* icon;

/** つぶやかれた日時を取得します。 */
@property (nonatomic, readonly) NSString* created;

- (id)initWithTweet:(NSDictionary*)tweet;

@end

JSON framework が JSON を解析した結果は NSObjectNSArrayNSDictionary となる。それぞれプリミティブ、配列、オブジェクト型に対応している。タイムラインは Tweet を示すオブジェクト型の配列として表現される。よって初期化にはオブジェクト型に対応する NSDictionary のポインタを受け取るように設計した。解析処理は以下のようになる。

NSDictionary* user = [tweet objectForKey:@"user"];

NSString* url = [user objectForKey:@"profile_image_url"];
NSData* data = [NSData dataWithContentsOfURL:[NSURL URLWithString:url]];

_name    = [[user objectForKey:@"screen_name"] copy];
_text    = [[tweet objectForKey:@"text"] copy];
_icon    = [[UIImage alloc] initWithData:data];
_created = [[self convertCreated:[tweet objectForKey:@"created_at"]] copy];

ユーザー名とアイコン画像をつぶやき毎に生成している点が無駄だけど今回はセルのカスタマイズが主なので気にしない。もっと本格的なものを作成するならばユーザー情報を括りだして管理して冗長性を解決するとよいだろう。

タイムラインとなるクラスは以下のように宣言。

#import <Foundation/Foundation.h>

@class Tweet;

/**
 * タイムラインを表します。
 */
@interface TweetTimeline : NSObject
{
@private
    NSString*       _userId; //! ユーザー識別子
    NSMutableArray* _tweets; //! つぶやきのコレクション
}

/** つぶやきの総数を取得します。 */
@property (nonatomic, readonly) NSInteger count;

- (Tweet*)tweetAtIndex:(NSInteger)index;
- (id)initWithUser:(NSString*)userId;
- (void)reload;

@end

タイムラインはつぶやきの集合と見なせるため、そのコレクションをラップするように設計。reload メソッドでユーザー識別子に対応するタイムラインを取得して iOS のデータ型に変換する。

このメソッドはイニシャライザで呼び出される。以降はこのクラスのオーナーが再読込した時に実行することを想定。

#import "SBJson.h"

// ... 中略

/**
 * タイムラインを読み込みます。
 */
- (void)reload
{
    [_tweets release];
    _tweets = [[NSMutableArray alloc] init];

    NSString* str  = [NSString stringWithFormat:@"http://twitter.com/statuses/user_timeline/%@.json", _userId];
    NSURL*    url  = [NSURL URLWithString:str];
    NSString* json = [NSString stringWithContentsOfURL:url encoding:NSUTF8StringEncoding error:nil];
    NSArray*  data = [json JSONValue];

    for( NSDictionary* dic in data )
    {
        Tweet* tweet = [[Tweet alloc] initWithTweet:dic];
        [_tweets addObject:tweet];
        [tweet release];
    }
}

タイムラインが取得できたので、まずは標準の UITableView にテキストだけ表示してみる。

通常の UITableViewCell

やはりテキストだけではさびしい。ということで次の項ではセルをカスタマイズしてもう少し Twitter っぽくしてみる。

セルの作成

私は UI 定義をなるべくコードから分離したい派である。WPF なら XAML、Android であれば XML を使うのが好きだ。Web 開発で JavaScript を利用可能でも UI は HTML/CSS で実装しておきたい。というわけでセルのカスタマイズも XIB 重視でおこなう。

はじめにセルを管理するためのコントローラーを作成。

セル用 UIViewController の作成

派生元は UIViewController を指定。UI 定義に XIB を利用したいので With XIB for user interface もチェック。

XIB も作成する

作成したコントローラーは TweetCellController という名前で保存しておく。

保存

次に TweetCellController.xib を開いて既定の View を削除。かわりに Table View Cell を配置する。

View を Table View Cell に置き換え

この作業が終わったら TweetCellController.h を開き以下の宣言をおこなう。

#import <UIKit/UIKit.h>

////////////////////////////////////////////////////////////////////////////////
/**
 * つぶやきを表示するためのセルを表します。
 */
@interface TweetCell : UITableViewCell
{
@private
}

@end

////////////////////////////////////////////////////////////////////////////////
/**
 * つぶやきを表示するためのセルを管理します。
 */
@interface TweetCellController : UIViewController
{
@private
    IBOutlet TweetCell* _cell; //! セル
}

@end

既定の TweetCellController でセルとなる TweetCell をホストする設計となる。これらは密な関係なので同じヘッダに宣言。実装も同一ファイルとした。

#import "TweetCellController.h"

////////////////////////////////////////////////////////////////////////////////
#pragma mark - TweetCell

@implementation TweetCell
// ... 中略
@end

////////////////////////////////////////////////////////////////////////////////
#pragma mark - TweetCellController

@implementation TweetCellController
// ... 中略
@end

これで XIB との関連づけを実行できるようになった。TweetCellController.xib を開きセルのクラスに TweetCell を指定。

Table View Cell を独自クラスに関連づける

File's Owner の view とセルのインスタンスを XIB 上のセルと関連づけ。

View とセルのインスタンスを関連づけ

最後に XIB 上のセルにあるコントロールと TweetCell を関連づけ。

XIB のコントロールとセル用クラスの関連づけ

好みになるが私はコントロールをプロパティとして公開するより隠蔽して必要なデータだけ公開したいのでインスタンス変数のみが作成されるようにしている。XIB からコントロールをソースにドロップする際、ヘッダのインスタンス変数が宣言されている部分を選ぶと変数のみが作成される。

この方法で関連づけを実行すると自動で dealloc にインスタンス変数の解放処理を追加してくれる。UIViewController なら deallocviewDidUnload の両方に処理を追加してくれるためかなり便利だ。

最終的な TweetCell は以下のようになる。

@interface TweetCell : UITableViewCell
{
@private
    IBOutlet UIImageView* _iconImgeView; //! アイコン画像
    IBOutlet UILabel*     _nameLabel;    //! ユーザー名ラベル
    IBOutlet UILabel*     _tweetLabel;   //! つぶやきラベル
    IBOutlet UILabel*     _dateLabel;    //! 日時ラベル
}

+ (CGFloat)calcHeight:(NSString*)text;

@property (nonatomic, retain) UIImage* icon;
@property (nonatomic, copy) NSString*  name;
@property (nonatomic, copy) NSString*  tweet;
@property (nonatomic, copy) NSString*  date;

プロパティはコントロールのデータに対する Wrapper として実装。icon なら以下のようになる。

/**
 * アイコン画像を取得します。
 *
 * @return 画像。
 */
- (UIImage *)icon { return _iconImgeView.image; }

/**
 * アイコン画像を設定します。
 *
 * @param value 画像。
 */
- (void)setIcon:(UIImage *)value { _iconImgeView.image = value; }

プロパティを宣言する時の定義は、対象データにあわせておく。

セルの利用

カスタマイズしたセルを利用するなら UITableViewControllercellForRowAtIndexPath を以下のように書き換える。_timelineTweetTimeline クラスのインスタンスである。

/**
 * セルを取得します。
 *
 * @param tableView テーブル。
 * @param indexPath インデックス。
 *
 * @return セル。
 */
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
    static NSString* CellIdentifier = @"TweetCell";

    TweetCell* cell = ( TweetCell* )[tableView dequeueReusableCellWithIdentifier:CellIdentifier];
    if( cell == nil )
    {
        TweetCellController* controller = [[TweetCellController alloc] initWithNibName:@"TweetCellController" bundle:nil];
        cell = ( TweetCell* )controller.view;
        [controller release];
    }

    Tweet* tweet = [_timeline tweetAtIndex:indexPath.row];

    cell.icon           = tweet.icon;
    cell.name           = tweet.name;
    cell.tweet          = tweet.text;
    cell.date           = tweet.created;
    cell.selectionStyle = UITableViewCellSelectionStyleNone;

    return cell;
}

標準的な cellForRowAtIndexPath 実装と同じく dequeueReusableCellWithIdentifier を使用してセルを再利用。カスタマイズされたセルであっても UITableViewCell としての機能は有効である。よって selectionStyle も設定可能だしセルの両端に余白を設けておけばチェック マーク表示も可能。UITableViewCell 上に独自コントロールを乗せていると考えれば理解しやすい。

つぶやきの文字数によってはセルの高さが不足するため heightForRowAtIndexPath メソッドで適切な高さを返す必要がある。よって算出用メソッドを定義。これは View に属するものなので TweetCell の静的メソッドとしておく。

/**
 * セルの高さを算出します。
 *
 * @param text 算出基準となるテキスト。
 *
 * @return 高さ。
 */
+ (CGFloat)calcHeight:(NSString*)text
{
    const CGFloat defaultCellHeight = 86;
    const CGFloat defaultTextHeight = 21;

    UIFont* font = [UIFont systemFontOfSize:14];
    CGSize  size = CGSizeMake( 224, 1000 );

    return defaultCellHeight + ( textSize.height - defaultTextHeight );
}

つぶやきを表示するに足る高さを算出し、それを標準のセルの高さと掛け合わせて返している。本来このような部分は XIB による指定で完結すべきだけど対応する機能は見つけられなかった。よって XIB でレイアウトやサイズを変更したならこちらもメンテナンスしなければならない。

利用する側の処理は以下のようになる。

/**
 * 行の高さを算出します。
 *
 * @param tableView テーブル。
 * @param indexPath インデックス。
 *
 * @return 高さ。
 */
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath
{
    Tweet* tweet = [_timeline tweetAtIndex:indexPath.row];
    return [TweetCell calcHeight:tweet.text];
}

実装が完了したのでプログラムを起動してみよう。サンプルでは私の Twitter タイムラインを表示するようになっている。

独自のセル

なかなかよい感じ。最後に今回作成したサンプル プロジェクトを公開しておく。

iPhone シミュレータ 4.3 で動作確認をおこなった。

Comments from WordPress

  • ぼん ぼん 2011-10-23T15:57:55Z

    はじめまして。独学でプログラムを勉強しているボンと申します。

    色々な書籍を読んだり、webの資料を読んだりして少しずつ理解が出来るようにはなってきたものの、やはり簡単には出来ないものだと日々痛感しながら日進月歩の毎日を送っています。

    おそらくプロのプログラマーの方には何でもない事でも、ビギナーの私にとっては見えない答えを手探りで模索しているようなもので、出来るはずと信じて色々なコードを試していると、あるとき突然霧が晴れたように正解に導かれる事がしばしばあります。

    そんな時はなぜかとても嬉しく、ひとりでにガッツポーズが出てしまったりします。

    今回、このアカベコさんのレビューを見て、今までで一番大きなガッツポーズが出ました。

    勉強不足な私にはまだまだ理解が出来ない部分も多々ありますが、私一人ではまだまだ解決出来ないであろう問題をすっぱりと解決し導いていただけました。

    感謝の一言につきます。

    僭越ながらこれからも応援させていただきます。重ねて今回のレビュー含め、今後の記事もあわせて勉強させていただきたいと存じます。

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

  • 匿名希望 匿名希望 2012-01-27T08:02:01Z

    サンプル プロジェクトをダウンロードしてビルド実行したけど以下のエラーでした。。。

    『Couldn't register akabeko.TestTwitterClient with the bootstrap server. Error: unknown error code.
    
    This generally means that another instance of this process was already running or is hung in the debugger.』

    ビルド環境が悪いのでしょうか??><
    すみません初心者なものでして。。。

  • akabeko akabeko 2012-02-05T08:32:40Z

    匿名希望さん、こんにちは。返答が遅くなり、申し訳ありません。

    エラーから察するに、iPhone の実機でデバッグ実行していて、前に実行していたアプリのステータスが残ってしまっているではないでしょうか。

    デバッグ実行中に Xcode がクラッシュしたり、アプリの開始直後あたりでデバッグ実行を停止すると、このエラーに出くわすことがあります。もしそうであれば、iPhone を再起動してからなら、正常に実行できると思います。

    ビルド環境については、情報がないので何ともいえません。ちなみにこのプロジェクトは Xcode 4.2 で作成しました。

  • kohei hayashi kohei hayashi 2012-05-22T13:05:57Z

    はじめまして。テーブルビューのカスタマイズをする上でとても参考にさせていただいております。
    ふと気づいたことがありましてご質問させていただきました。

    上記のTimelineViewController.m内にある

    - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
    {
        static NSString* CellIdentifier = @"TweetCell";
    
        TweetCell* cell = ( TweetCell* )[tableView dequeueReusableCellWithIdentifier:CellIdentifier];
        if( cell == nil )
        {
            TweetCellController* controller = [[TweetCellController alloc] initWithNibName:@"TweetCellController" bundle:nil];
            cell = ( TweetCell* )controller.view;
            [controller release];
        }
    
        Tweet* tweet = [_timeline tweetAtIndex:indexPath.row];
    
        cell.icon           = tweet.icon;
        cell.name           = tweet.name;
        cell.tweet          = tweet.text;
        cell.date           = tweet.created;
        cell.selectionStyle = UITableViewCellSelectionStyleNone;
    
        // Id確認用
        NSLog(@"%@", cell.reuseIdentifier );
        return cell;
    }

    のメソッドなのですが、cellオジェクトを返す手前でIdentifierを確認したところ、常に設定されたTweetCellは戻らず、おそらくTableViewCellのデフォルト値でしょうか。「Cell」が返されます。

    この場合、セルの再利用はできていないという認識でよいのでしょうか?

  • kohei hayashi 2012-05-23T16:44:19Z

    お忙しいとは思いますので、自分なりの変更点で恐縮ですが。
    Identifierの設定とあるので上記のソースの場合は static NSString* CellIdentifier = @"TweetCell"; の部分を

    static NSString* CellIdentifier = [NSString stringWithFormat:@"TweetCell-%d", indexPath.row];

    等に変更したところどうやら大丈夫でした。

  • akabeko akabeko 2012-05-23T14:48:32Z

    kohei hayashi さん、こんばんは。

    セルの再利用についてですが、即答は難しそうなので、今週末に調べてみようと思います。 もし何か分かれば、この記事に補足を書く予定です。

  • akabeko akabeko 2012-05-27T13:48:58Z

    kohei hayashi さん、こんばんは。
    セルの再利用ですが、現在のサンプルで以下の位置にログをしかけたところ、

    TweetCell* cell = ( TweetCell* )[tableView dequeueReusableCellWithIdentifier:CellIdentifier];
    if( cell == nil )
    {
        ... 中略
        NSLog( @"maked" );
    }

    はじめの数行は maked がログ出力されましたが、以降は dequeueReusableCellWithIdentifier でセルを取得できているらしく、その後はスクロールしていっても maked は出力されませんでした。つまり、はじめの数個はセルを生成していますが、後はそれらが再利用されているものと思われます。

    また、私の環境で NSLog(@”%@”, cell.reuseIdentifier ) を実行した場合、TweetCell が出力されました。

    以上を踏まえると、dequeueReusableCellWithIdentifier による再利用には問題がないと思われるのですが、いかがでしょうか。

  • kohei hayashi kohei hayashi 2012-06-04T21:01:54Z

    たしかにその通りです。ちょっと説明が足りませんでした。
    サンプルのソースですが、

    static NSString* CellIdentifier = @”TweetCell”;
    
    TweetCell* cell = ( TweetCell* )[tableView dequeueReusableCellWithIdentifier:CellIdentifier];
    if( cell == nil )
    {
        TweetCellController* controller = [[TweetCellController alloc] initWithNibName:@”TweetCellController” bundle:nil];
        cell = ( TweetCell* )controller.view;
        [controller release];
    }
    
    //↓
    
    Tweet* tweet = [_timeline tweetAtIndex:indexPath.row];
    
    cell.icon = tweet.icon;
    cell.name = tweet.name;
    cell.tweet = tweet.text;
    cell.date = tweet.created;
    cell.selectionStyle = UITableViewCellSelectionStyleNone;
    
    //↑

    この矢印のコメントで囲まれた部分が再利用されるべきかなと思いまして、
    ごっそりその部分を省いてお尋ねしておりました。大変申し訳ありません。

    上記のソースですが、

    static NSString* CellIdentifier = @”TweetCell”;
    
    TweetCell* cell = ( TweetCell* )[tableView dequeueReusableCellWithIdentifier:CellIdentifier];
    if( cell == nil )
    {
        TweetCellController* controller = [[TweetCellController alloc] initWithNibName:@”TweetCellController” bundle:nil];
        cell = ( TweetCell* )controller.view;
        [controller release];
    
        Tweet* tweet = [_timeline tweetAtIndex:indexPath.row];
    
        cell.icon = tweet.icon;
        cell.name = tweet.name;
        cell.tweet = tweet.text;
        cell.date = tweet.created;
        cell.selectionStyle = UITableViewCellSelectionStyleNone;
    }

    このようにした場合、セルの表示に重複が見られるのはお分かりだと思いますが、

    CellIdentifierが@"Cell-"であれば
    まとめてキャッシュされるのでより効率的かなと思い、質問させていただいた次第であります。

    何か押し付けがましくなってしまいましたが、そもそもこちらにてCellのカスタマイズをご教授させていただいた者として、フィードバックさせていただければと思い投稿させていただきました。

  • kohei hayashi kohei hayashi 2012-06-04T21:04:26Z

    大変申し訳ありません;
    誤字訂正です。

    CellIdentifierが@”Cell-”であれば

    ↑の部分は

    CellIdentifierが@”Cell-(ユニークなキャラクター)”であれば

    に変換してお読みください。

  • kohei hayashi 2012-06-06T19:54:24Z

    何度もコメントを汚してしまい大変申し訳ありません。

    どうやらUITableViewのキャッシュ、というよりもUIそのものの仕組みを完全に勘違いしていたようです。

    自分のソースでは無駄なクラスの作成を垂れ流すだけでしたね。
    再度勉強して出直してまいります故、お恥ずかしい話ですが、他の方が混乱しないように私目の稚拙なソースは削除していただけると助かります。

    こんな自分に対して、検証までしてくださって大変ありがとうございました!

  • akabeko akabeko 2012-06-10T13:01:26Z

    kohei hayashi さん、こんばんは。

    コメントされたソースの削除ですが、私としては残しておきたいと思います。処理自体に問題があったとしても、それを理解する過程という意味では重要な記録である、というのが私の見解です。

    よって、私はコメントが汚れたなどとは感じていません。むしろ、検証を放棄せず結論まで書かれたことは好ましいと考えています。

    これからも、疑問・提案などがあれば、コメントしていただけるとありがたいです。