UITableViewでラウンドロビン的な無限スクロールCoverFlowを作る

「UITableViewでCoverFlowを作れるか?」というネタに興味が湧いたのでちょっとやってみました。
UITableViewハック

ちなみに、iOSのCoverFlowについて調べてみると下記のようなframeworkが見つかりました。

ソースを覗くと、TapkuがUIScrollView、その他はUIViewでゴリゴリ作っているみたいです。

UITableViewで作る最大のメリットはCellの再利用機構にあり、UITableViewDataSourceとUITableViewDelegateをそのまま活用できます。今回、折角のUITableViewなので無限にスクロールできるラウンドロビン的なCoverFlowを試してみました。
結果、こんな感じのものができました。


色々と使えそうなテクニックもあると思いますので作り方を紹介します。

無限スクロールTableViewの作り方

あらかじめ余分のDataSourceを用意し限界位置までスクロールされたらcontentOffsetを設定し直すという方法で行います。具体的にはDataSourceを4倍分のDataSourceを作成しscrollViewDidScroll:でcontentOffsetをチェックします。contentSize.heightの1/8に到達したら5/8に、6/8に到達したら2/8にcontentOffsetを設定しなおすという具合です。

- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
    [self moveCenterOfContent:scrollView];
}

- (void)moveCenterOfContent:(UIScrollView *)scrollView {
    CGFloat currentOffsetX = scrollView.contentOffset.x;
    CGFloat currentOffSetY = scrollView.contentOffset.y;
    CGFloat contentHeight = scrollView.contentSize.height;
   
    if (currentOffSetY < contentHeight/8) {
        scrollView.contentOffset = CGPointMake(currentOffsetX, currentOffSetY + contentHeight/2);
    }
    if (currentOffSetY > contentHeight*6/8) {
        scrollView.contentOffset = CGPointMake(currentOffsetX, currentOffSetY - contentHeight/2);
    }
}

DataSourceがTableViewサイズより小さい場合(初期表示時にスクロールしない場合)は、初期処理でTableViewサイズを超えるまでDataSourceを等倍し、それを1セットとして4倍して作るようにします。

- (void)fillRecordsForView {
    CGFloat currentOffSetY = self.tableView.contentOffset.y;
    CGFloat contentHeight = self.tableView.contentSize.height;
    CGFloat viewHieght = self.tableView.bounds.size.height;
   
    if (currentOffSetY >= contentHeight - viewHieght) {
        [_records addObjectsFromArray:_records];
        [self.tableView reloadData];
       
        //画面サイズを超えるまで繰り返す
        [self fillRecordsForView];
    }
}

UIScrollViewDelegateを使っているのを見れば分かる通り、このテクニックはUIScrollViewで利用されるものです。


UITableViewのカスタマイズ

設計

カスタマイズしやすくするため、UITableViewControllerは使わずUIViewController.view上にUITableViewを配置し、UITableViewCellの上にUIImageViewを配置します。

それぞれのコンポーネントに役割を持たせたいのでUIKitを拡張してsubclass化します。

  • CFCoverFlowViewController : UIViewController
  • CFCoverFlowView : UITableView
    • CoverFlowのデータ管理
    • layoutSubviewsの実装
  • CFCoverFlowCell : UITableViewCell
    • TableView中央との距離の管理
    • CellのCATransform3D処理
    • スケール、位置、アングル、影の係数算出
  • CFCoverFlowImageView : UIImageView
    • 反射、影などのsublayerの管理
UITableViewのAffine変換

CFCoverFlowViewの初期化処理で左90度のCGAffineTransform変換をかけます。


CFCoverFlowView.m

- (id)initWithCoder:(NSCoder *)aDecoder {
    if ((self = [super initWithCoder:aDecoder])) {
        //左に90度回転
        CGAffineTransform t = CGAffineTransformMakeRotation(-M_PI/2.0f);
        self.transform = t;
    }
    return self;
}
UITableViewCellのCATransform3D処理

変形処理を開始するタイミングは CFCoverFlowViewController#scrollViewDidScroll (スクロール中) と
CFCoverFlowView#layoutSubviews: (初期表示時)に行います。


CFCoverFlowViewController.m

- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
    [self moveCenterOfContent:scrollView];
    // 変換処理
    [(CFCoverFlowView *)scrollView transformView];
}


CFCoverFlowView.m

- (void)layoutSubviews {
    [super layoutSubviews];
    // 変換処理
    [self transformView];
}


変形対象のCellは現在表示されている部分だけに限定して、UITableView#indexPathsForVisibleRowsを使います。
尚、注意点としてUITableViewのCellは再利用されるため、tableView.subviewsはバラバラとなっており、そのまま変形すると遠近感が残念なことになってしまうので、予めソートして配置順を並び替える必要があります。


CFCoverFlowView.m

- (void)transformView {
    NSArray *sortedCells = [self sortedVisibleCellsByDistance];
   
    for (CFCoverFlowCell *cell in sortedCells) {
        //順番に背面から最前面に配置
        [self bringSubviewToFront:cell];
        //Cellの変形処理
        [cell transformCell];
    }
}

- (NSArray *)sortedVisibleCellsByDistance {
    NSMutableArray *sortedCells = [NSMutableArray array];
   
    NSArray *indexPaths = [self indexPathsForVisibleRows];
    for (NSIndexPath *indexPath in indexPaths) {
        CFCoverFlowCell *cell = (CFCoverFlowCell *)[self cellForRowAtIndexPath:indexPath];
        cell.distance = [self distanceFromCenter:indexPath];
        [sortedCells addObject:cell];
    }
    return [sortedCells sortedArrayUsingSelector:@selector(compare:)];
}

- (CGFloat)distanceFromCenter:(NSIndexPath *)indexPath {
    CGRect rect = [self rectForRowAtIndexPath:indexPath];
    CGFloat centerCellOffset = rect.origin.y+rect.size.height/2;
    CGFloat offset = self.contentOffset.y;
    CGFloat centerOffset = (self.bounds.size.height/2)+offset;
    return (centerCellOffset - centerOffset);
}


CFCoverFlowCell.m

- (NSComparisonResult)compare:(CFCoverFlowCell *)cell {
    if (fabs(self.distance) > fabs(cell.distance)) {
        return NSOrderedAscending;
    } else if (fabs(self.distance) < fabs(cell.distance)) {
        return NSOrderedDescending;
    } else {
        return NSOrderedSame;
    } 
}
各Cellの変形処理

ここで視点Z座標の指定、右90度回転、角度調整、スケール調整、位置調整、影の透明度調整を行います。
尚、ここでもUITableViewのCellは再利用されるため、view.layer.positionを毎回初期化してあげる必要がります。

変形パラメータについて、画面の相対位置に対して変形係数を計算する処理を関数化しておくと色々と試したり複合的に使用する場合にメンテナンスがしやすくなります。


CFCoverFlowCell.m

- (void)transformCell {
    static const float zDistance = 800.0f;
   
    //CATransform3D前に毎回positionを設定し直す
    self.coverFlowImageView.layer.position = CGPointMake(self.frame.size.width/2, self.frame.size.height/2);

    float rate = [self rateOfDistance];
   
    CATransform3D t = CATransform3DIdentity;
    //視点の距離
    t.m34 = 1.0f / -zDistance;
    //右に90度回転
    t = CATransform3DRotate(t,
                            M_PI/2.0f,
                            0.0f,
                            0.0f,
                            1.0f);
    //角度
    t = CATransform3DRotate(t,
                            [CFCoverFlowCell angleForDistanceRate:rate],
                            0.0f,
                            1.0f,
                            0.0f);
    //スケール
    t = CATransform3DScale(t,
                           [CFCoverFlowCell scaleForDistanceRate:rate],
                           [CFCoverFlowCell scaleForDistanceRate:rate],
                           [CFCoverFlowCell scaleForDistanceRate:rate]);
    //位置
    t = CATransform3DTranslate(t,
                               [CFCoverFlowCell translateForDistanceRate:rate],
                               0.0f,
                               0.0f);
    self.coverFlowImageView.layer.transform = t;
   
    //影の透明度
    self.coverFlowImageView.shadowLayer.opacity = [CFCoverFlowCell shadowOpacityForDistanceRate:rate];
}


UITableViewの中央を0、左端を-1、右端を1としてUITableView中央からCell中央までの距離を相対値で算出します。


CFCoverFlowCell.m

- (float)rateOfDistance {
    return (float)(self.distance * 2.0f / self.frame.size.width);
}


CoverFlowの動作を想像しながら、それぞれの変形係数について相対値に対した出力値をグラフ化しておきます。特に理由がない限り連続な線になるようにしてください。
最初は複雑な曲線は描かず、単純な区分線形関数で表せる程度のものにして挙動を確認しながら複雑な非線形曲線に調整していくとよいと思います。





グラフを元にそれぞれの関数の計算式を組みます。

CFCoverFlowCell.m

+ (float)scaleForDistanceRate:(float)rate {
    static const float coefficient = 10.0f;
    static const float maxScale = 2.0f;
    static const float minScale = 1.0f;

    if (fabsf(rate) > 0.1f) {
        return minScale;
    }
    return - (coefficient * fabs(rate)) + maxScale;
}

+ (float)angleForDistanceRate:(float)rate {
    static const float coefficient = 4.0f;
    static const float baseAngle = - M_PI/3.0f; //60 degree
   
    if (fabsf(rate) > 0.25f) {
        return copysignf(1.0f, rate) * baseAngle;
    }
    return coefficient * rate * baseAngle;
}

+ (float)translateForDistanceRate:(float)rate {
    static const float coefficient = 200.0f;

    if (fabs(rate) < 0.25f) {
        return coefficient * rate;
    }
    return - (coefficient * rate) + (copysignf(1.0f, rate) * coefficient/2.0f);
}

+ (float)shadowOpacityForDistanceRate:(float)rate {
    static const float coefficient = 1.0f;

    if (fabs(rate) < 0.1f) {
        return 0.0f;
    }
    return coefficient * fabsf(rate) - (coefficient * 0.1f);
}

ImaveViewのエフェクト

init処理にて反射のlayer、反射の影のlayer、ImageView・反射layerに対する相対値に応じた影のlayerを配置する。それぞれのframeは可変なのでframeの設定はlayoutSublayersOfLayer:で行います。
尚、ここでもUITableViewCell再利用のために反射のlayerにゴミが残ってしまうので反射layerのcontents設定はImageView本体のsetImageをオーバーライドしてこのタイミングで設定するようにします。


CFCoverFlowImageView.m

- (id)initWithCoder:(NSCoder *)aDecoder {
    if ((self = [super initWithCoder:aDecoder])) {
        [self addLayer];
    }
    return self;
}

- (void)addLayer {
    static float const reflectionShadowOpacity = 0.7f;
   
    _shadowLayer = [CALayer layer];
    _shadowLayer.backgroundColor = [[UIColor blackColor] CGColor];
     _shadowLayer.masksToBounds = YES;
    _shadowLayer.opacity = 1.0f;

    _reflectionLayer = [CALayer layer];
     _reflectionLayer.masksToBounds = YES;
    _reflectionLayer.opacity = 1.0f;

    _reflectionLayer.transform = CATransform3DMakeScale(1.0f, -1.0f, 1.0f);
    _reflectionLayer.contentsGravity = kCAGravityResize;
    _reflectionLayer.sublayerTransform = _reflectionLayer.transform;
   
    _reflectionShadowLayer = [CALayer layer];
    _reflectionShadowLayer.backgroundColor = [[UIColor blackColor] CGColor];
     _reflectionShadowLayer.masksToBounds = YES;
    _reflectionShadowLayer.opacity = reflectionShadowOpacity;
   
    [_reflectionLayer addSublayer:_reflectionShadowLayer];
    [self.layer addSublayer:_reflectionLayer];
    [self.layer addSublayer:_shadowLayer];
}

- (void)layoutSublayersOfLayer:(CALayer *)layer {
    static float const space = 3.0f;
   
    CGRect shadowRect = layer.bounds;
    shadowRect.size.height = (layer.bounds.size.height * 2) + space;
    _shadowLayer.frame = shadowRect;
   
    CGRect reflectionRect = layer.bounds;
    reflectionRect.origin.y = layer.bounds.size.height + space;
    _reflectionLayer.frame = reflectionRect;
   
    _reflectionShadowLayer.frame = layer.bounds;

    [super layoutSublayersOfLayer:layer];
}


- (void)setImage:(UIImage *)image {
    [super setImage:image];
    _reflectionLayer.contents = self.layer.contents;
}

スクロール停止時の調整処理

スクロール停止時に中央に近いCellをTableView中央にフィットさせます。ハンドリングはドラッグ停止 (scrollViewDidEndDragging:)とスクロール慣性停止 (scrollViewDidEndDecelerating:)で行います。


CFCoverFlowViewController.m

- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate {
    if(!decelerate) {
        [(CFCoverFlowView *)scrollView fitCenterForCell];
    }
}

- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView {
    [(CFCoverFlowView *)scrollView fitCenterForCell];
}

CFCoverFlowView.m

- (void)fitCenterForCell {
    NSArray *sortedCells = [self sortedVisibleCellsByDistance];
    if (sortedCells.count > 0) {
        CFCoverFlowCell *cell = [sortedCells lastObject];
        [self setContentOffset:CGPointMake(self.contentOffset.x, self.contentOffset.y+cell.distance) animated:YES];
    }
}

その他の調整

CoverFlowはcontentの起点がTableView中央に位置するため、初期表示時とデバイス回転時に調整が必要です。

初期表示時のcontentOffsetはtableView.contentSize.heightを元に計算しますが、DataSourceを作成、tableView#reloadDataの実行後である必要があります。考えてみれば当たり前なんですが暫く悩みました。

バイス回転をサポートしている場合は回転時にcontentOffsetの調整が必要になります。いくつか方法があるとは思いますがサンプルコードではデバイスサイズの縦横長の差分の半分を調整しています。


まとめ

UITableViewのCellは再利用されるため、Cellの値の初期化やsubviewの順番やガベージデータ、frame設定のタイミングなどで、いくつかの注意点が必要ですがそれさえ気をつければかなり楽に作ることができます。
この方法を使えば、CoverFlowだけでなく面白いエフェクトが作れそうな気がします。

カスタマイズのポイントとしては

  • UITableViewを左90度回転
  • Cellのlayerに対してCATransform3D変形をかける
    • 右90度回転
    • スケール、位置、アングル、影、反射など
    • TableViewに対するCellの相対位置を計測し、変形係数を算出
    • 変形タイミングはスクロール時、TableView初期表示時に行う
  • 各CellのViewの重なり順の並び替え
    • TableViewの中央に遠い順にソートしCellを重ね合わせる(bringSubviewToFront:)
  • スクロール停止時に近いCellを中央にフィットさせる
  • 初期表示時のcontentOffsetの調整
  • バイス向き変更時のcontentOffsetの調整

サンプルコード

今回作ったサンプルプロジェクトをgithubにアップしました。

https://github.com/hmori/CoverFlowTest