Twitterアプリのタイムライン表示などはUITableViewを使っていると思いますが、ツイートの内容に応じてセルの高さが変わります。あのようなことを実現するためのサンプルをgithubにcommitしました。ここではそのサンプルの説明をしていきます。

サンプル: https://github.com/stack3/STFlexibleCellHeightSample

※UITableViewで最低限セルの表示をしたことがある人を前提としてます。

サンプルを起動してTweetsを選択すると以下の様な画面になります。

f:id:eimei23:20121210165740p:plain

異なる高さのセルが表示されているのがわかるはずです。

この画面を構成するクラス

STFlexibleCellHeightViewController

この画面のViewControllerです。UITableViewを配置し、UITableViewDataSourceやUITableViewDelegateの受取り手になります。

STTweetRowData

ツイートもどきのデータを管理するクラスです。以下をプロパティに持ちます。

  • username – ユーザー名
  • status – ステータス(ツイート内容)
  • createdAt – 生成日時

STTweetCell

STTweetDataの内容を表示するセル。UITableViewCellを継承しています。実際はSTTableViewCellを継承していますが、とりあえず今は頭の脇においてください。

STTweetCellは以下のsubviewを保持しています。
  • userIconView – ユーザーのアイコン表示(UIImageView)
  • usernameLabel – ユーザー名表示(UILabel)
  • statusLabel – ステータス表示(UILabel)
  • createdAtLabel – 生成日時表示(UILabel)

UITableViewDataSource/UITableViewDelegateの各メソッドでの処理

tableView:numberOfRowsInSection:で表示すべきデータの総数を返します。STFlexibleCellHeightViewControllerがメンバー変数に持つ_tweetsのcountを返します。

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
    return _tweets.count;
}

tableView:cellForRowAtIndexPath:で要求されたIndexPath(列のインデックス)のセルを返します。

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
    STTweetRowData *tweet = [_tweets objectAtIndex:indexPath.row];

    NSString *cellId = @"cellId";
    STTweetCell *cell = (STTweetCell *)[_tableView dequeueReusableCellWithIdentifier:cellId];
    if (cell == nil) {
        cell = [[STTweetCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:cellId];
    }

    [cell setupRowData:tweet];

    return cell;
}

UITableView#dequeueReusableCellWithIdentifierはcell identifierに対応する再利用可能なセルオブジェクトを返します。もしまだないならnilを返すのでセルを生成します。

UITableViewは画面内に一度に表示すべき個数プラスαのセルオブジェクトを確保し再利用します。cell identifierが同じでも再利用に必要なセルの個数が揃わない限りはUITableView#dequeueReusableCellWithIdentifierはnilを返すことに注意してください。逆に言うなら表示するセルのsubview構成が同じであれば、cell identifierは同じで良いということです。よって今回も@”cellId”で固定にしています。

次に今回最も大事な部分、tableView:heightForRowAtIndexPath:です。このメソッドでセルの高さを返します。今回はSTTweetRowDataの内容に応じて高さが変わります。

tableView:heightForRowAtIndexPath:についての理解

tableView:heightForRowAtIndexPath:でありがちな誤解は、表示するセルのIndexPathに対してのみ呼ばれると思うことです。そうではなく、表示範囲外のIndexPathについても呼ばれます。なぜでしょうか?

UITableViewはスクロールバーを持っています。スクロールバーの表示位置や長さを求めるためには、表示範囲外のセルも含めた全体の高さが必要になります。

UITableViewCellのsubclassのプロパティでセルの高さを保持するようなことは避けたほうが良いでしょう。セルのオブジェクトは表示範囲内で再利用するので、実際のセルの総数分オブジェクトが作られるわけではありません。よってセルオブジェクトがセルの高さを保持することはナンセンスです。

この辺が高さが異なるセルを持つ場合の実装が面倒になる理由なのですが・・・

セルの高さを求めるジレンマ

セルの高さはセル内で表示するテキストの長さなどによって決まります。テキストはUILabelで表示することが多いでしょう。UILabel#sizeThatFits:を使ってテキストに応じた高さを求めることになります。UILabelが1つだけなら良いですが、twitterのツイート表示などは複数のsubviewによって高さが決まり複雑です。

また、tableView:heightForRowAtIndexPath:では高さを返すことだけを求められますが、表示の際にはセルのsubviewのframeを設定して適切な配置が必要です。

セルの高さをセルのプロパティで持つのはナンセンスとはいっても、セルオブジェクト無しで高さを求めるのは難しいです。また、セルの高さを求めるメソッドとセルのsubview配置のメソッドが完全に分離されてしまうと、コードが重複して管理が面倒になります。

STTweetCellの実装

できるだけセルの高さの計算とセルのsubviewの配置を同じメソッドでできるような仕組みにするとよいでしょう。それらをSTTweetCellに実装します。
肝となるメソッドは、

  • sizeThatFits:
  • layoutSubviews

です。これらのメソッドはUIViewで用意されsubclassで必要に応じて実装します。

sizeThatFits:

このメソッドでは引数のsizeで渡された範囲内で適切に収まるサイズを返すように実装します。あくまで、このメソッドはサイズのみを返し、実際のsubviewのframe設定を行うべきではありません。

layoutSubviews

subviewをレイアウトすべきタイミングで呼ばれるメソッドです。実際のsubviewのframe設定は、このメソッドで行います。

sizeThatFits:withLayout:

このように高さを求めるメソッドとsubview配置のメソッドは、あらかじめ用意されているのですが、先ほど言ったように両方がわかれているとコードの管理が面倒なので、ひとつにまとめてしまいたいです。自分はこのようなメソッドを用意しました。

- (CGSize)sizeThatFits:(CGSize)size withLayout:(BOOL)withLayout
{
    CGRect userIconViewFrame;
    userIconViewFrame.origin.x = STMargin;
    userIconViewFrame.origin.y = STMargin;
    userIconViewFrame.size.width = STUserIconSize;
    userIconViewFrame.size.height = STUserIconSize;
    if (withLayout) {
        _userIconView.frame = userIconViewFrame;
    }
    CGFloat minHeight = userIconViewFrame.origin.y + userIconViewFrame.size.height + STMargin;

    CGRect usernameViewFrame;
    usernameViewFrame.origin.x = userIconViewFrame.origin.x + userIconViewFrame.size.width + STMargin;
    usernameViewFrame.origin.y = STMargin;
    usernameViewFrame.size.width = size.width - usernameViewFrame.origin.x - STMargin;
    usernameViewFrame.size.height = size.height - usernameViewFrame.origin.y;
    usernameViewFrame.size = [_usernameView sizeThatFits:usernameViewFrame.size];
    if (withLayout) {
        _usernameView.frame = usernameViewFrame;
    }

    CGRect statusViewFrame;
    statusViewFrame.origin.x = usernameViewFrame.origin.x;
    statusViewFrame.origin.y = usernameViewFrame.origin.y + usernameViewFrame.size.height;
    statusViewFrame.size.width = size.width - statusViewFrame.origin.x - STMargin;
    statusViewFrame.size.height = size.height - statusViewFrame.origin.y;
    statusViewFrame.size = [_statusView sizeThatFits:statusViewFrame.size];
    if (withLayout) {
        _statusView.frame = statusViewFrame;
    }

    CGRect updatedAtViewFrame;
    updatedAtViewFrame.origin.x = statusViewFrame.origin.x;
    updatedAtViewFrame.origin.y = statusViewFrame.origin.y + statusViewFrame.size.height;
    updatedAtViewFrame.size.width = size.width - updatedAtViewFrame.origin.x  - STMargin;
    updatedAtViewFrame.size.height = size.height - updatedAtViewFrame.origin.y;
    updatedAtViewFrame.size = [_updatedAtView sizeThatFits:updatedAtViewFrame.size];
    if (withLayout) {
        _updatedAtView.frame = updatedAtViewFrame;
    }

    size.height = updatedAtViewFrame.origin.y + updatedAtViewFrame.size.height + STMargin;
    if (size.height < minHeight) {
        size.height = minHeight;
    }

    return size;
}

withLayoutがNOならばsubviewのframeを求めて高さを返すだけですが、YESなら同時にsubviewのframe設定も行います。これで1つのメソッドに高さ計算とsubview配置が集約されました。

あとは先程のメソッドでこれらを呼ぶだけです。

- (void)layoutSubviews
{
    [super layoutSubviews];

    CGRect bounds = self.bounds;
    [self sizeThatFits:bounds.size withLayout:YES];
}

- (CGSize)sizeThatFits:(CGSize)size
{
    return [self sizeThatFits:size withLayout:NO];
}

layoutSubviewsが呼ばれる時には、UIView自身(この場合STTweetCell) のframeやboundsが決定されています。よってbounds.sizeをsizeThatFits:withLayoutに渡しています。

その2へつづく。