[iOS] UITableView でプルダウンすると再読込するユーザインタフェースを実装

2011年7月7日木曜日 | Published in | 7 コメント

このエントリーをはてなブックマークに追加

(2011-08-22 追記)
矢印画像のアニメーションにひっかかりが発生する件は解決策が見つかった。下記を参照のこと。
Cocoaの日々: Blocks を使ったアニメーションのひっかかりを解消する



[前回] Cocoaの日々: [iOS] UITableView でセルをスワイプするとスライドするユーザインタフェースを実装

今回も Twitterアプリのまね。UITableView をプルダウンした時に出てくるあのユーザインターフェイスを実装してみた。

"pull down to refresh" と呼ばれているらしい。


サンプル


まずはサンプルの動作から。

ここでプルダウンすると上部にメッセージが現れる。
さらに引き下げるとメッセージが変わる。左の画像もクルッと回転して上向きになる。
ここで指を離すとしばらく止まる。サンプルでは単純に2秒のウェイトを入れてある。
2秒すぎると自動的に上へスクロールして最初の状態に戻る。


実装


表示ロジック


プルダウンすると現れるエリアの表示には UITableView のヘッダ(tableHeaderView)をつかう。Xib で UIView を一つ作り、これを UITableViewへ紐付けておく。
- (void)viewDidLoad
{
       :
    self.tableView.tableHeaderView = self.headerView;
}
ただこのままだと初期表示でこのヘッダが表示されてしまう。
そこで UIScrollView の contentInsets プロパティを調整してヘッダを見えなくする。contentInsets は Xib でもコードでも変更可能。
ヘッダビューの高さが 60ポイントなので cotentInsets.top = -60 としてやると表示エリアが下に 60ポイント下がり、初期状態ではヘッダ部分が隠れるようになる。
これが最初に見えている状態。指で下にドラッグするとヘッダ部分が現れるようになる。
この状態で指を離すとスクロールして最初の状態(ヘッダが隠れる状態)に戻る。一方、下へのドラッグ距離がある閾値(60ピクセル+α)を越えたところ指を離した時には、contentInsets.top = 0 を設定してやる。こうするとスクロールしてもヘッダ表示された状態でピタリと止まる。
処理が終わったら(サンプルでは2秒間のウェイトが入れてある) contentInsets.top = -60 へ戻してやる。すると最初のヘッダが隠れた表示に戻る。なお contentInsets も UIViewアニメーションの対象なので +[UIView animateWithDuration:aniamations:] を使うとヘッダが隠れるまでがアニメーションになる(逆に使わないと一瞬で切り替わり、やや不自然になる)。

コードではヘッダの表示制御用にメソッドを用意してある。こんな感じ。
- (void)_setHeaderViewHidden:(BOOL)hidden animated:(BOOL)animated
{
    CGFloat topOffset = 0.0;
    if (hidden) {
        topOffset = -self.headerView.frame.size.height;
    }
    if (animated) {
        [UIView animateWithDuration:0.2
                         animations:^{
                                self.tableView.contentInset = UIEdgeInsetsMake(topOffset, 0, 0, 0);
                         }];
    } else {
        self.tableView.contentInset = UIEdgeInsetsMake(topOffset, 0, 0, 0);        
    }
}
上記を適切なタイミングで呼んでやる。なおアニメーションを制御の対象にしているのは一番最初の初期表示時にはアニメーションを使わないため。


カスタムヘッダビュー


ヘッダ用にカスタムクラス HeaderView を用意した。
typedef enum {
    HeaderViewStateHidden = 0,
    HeaderViewStatePullingDown,
    HeaderViewStateOveredThreshold,
    HeaderViewStateStopping
} HeaderViewState;

@interface HeaderView : UIView {
    
}

@property (nonatomic, retain) IBOutlet UILabel* textLabel;
@property (nonatomic, retain) IBOutlet UILabel* detailTextLabel;
@property (nonatomic, retain) IBOutlet UIImageView* imageView; 
@property (nonatomic, retain) IBOutlet UIActivityIndicatorView* activityIndicatorView;

@property (nonatomic, assign) HeaderViewState state;

- (void)setUpdatedDate:(NSDate*)date;

@end
ラベルなどの表示項目の保持の他、矢印画像のアニメーション、UIActivityIndicatorView の表示制御をここで行わせる。またプルダウンの状態管理もここで行う。プルダウンの状態は4つ定義している。
typedef enum {
    HeaderViewStateHidden = 0,          // ヘッダが隠れた状態
    HeaderViewStatePullingDown,         // プルダウン状態1(ただし閾値は超えていない)
    HeaderViewStateOveredThreshold,     // プルダウン状態2(閾値を越えている)
    HeaderViewStateStopping             // プルダウン停止状態(処理中)
} HeaderViewState;
状態毎にラベルの表示内容、矢印画像の向き、UIActivityIndicatorView の表示・非表示を決める。state プロパティのセッターメソッドを自前で用意してこの処理を書いておく。
- (void)setState:(HeaderViewState)state
{
    switch (state) {
        case HeaderViewStateHidden:
            [self.activityIndicatorView stopAnimating];
            self.imageView.hidden = YES;
            break;

        case HeaderViewStatePullingDown:
            [self.activityIndicatorView stopAnimating];
            self.imageView.hidden = NO;
            self.textLabel.text = @"引き下げて...";
            if (state_ != HeaderViewStatePullingDown) {
                [self _animateImageForHeadingUp:NO];
            }
            break;
            
        case HeaderViewStateOveredThreshold:
            [self.activityIndicatorView stopAnimating];
            self.imageView.hidden = NO;
            self.textLabel.text = @"指をはなすと更新";
            if (state_ == HeaderViewStatePullingDown) {
                [self _animateImageForHeadingUp:YES];
            }
            break;

        case HeaderViewStateStopping:
            [self.activityIndicatorView startAnimating];
            self.textLabel.text = @"更新中...";
            self.imageView.hidden = YES;
            break;
    }

    state_ = state;
}
画像の回転は前回のブログで紹介した。
Cocoaの日々: [iOS] transform を使った画像のお手軽回転アニメーション

こんな感じ。
- (void)_animateImageForHeadingUp:(BOOL)headingUp
{
    CGFloat startAngle = headingUp ? 0 : M_PI + 0.00001;
    CGFloat endAngle = headingUp ? M_PI + 0.00001 : 0;

    self.imageView.transform = CGAffineTransformMakeRotation(startAngle);           
    [UIView animateWithDuration:0.1
         animations:^{
             self.imageView.transform =
                CGAffineTransformMakeRotation(endAngle);
         }];
}
headingUp によって上→下、下→上のアニメーションを切り替えている。


※画像入手元および画像作者
入手元:Arrow, Up icon | Icon Search Engine
作成者: Kyo Tux (ホームページ kyo-tux on deviantART


プルダウンの検出


プルダウンの検出には UIScrollViewDelegate を使う。こんな感じ。
- (void)scrollViewDidScroll:(UIScrollView *)scrollView
{
    if (self.headerView.state == HeaderViewStateStopping) {
        return;
    }

    CGFloat threshold = self.headerView.frame.size.height;

    if (PULLDOWN_MARGIN <= scrollView.contentOffset.y &&
        scrollView.contentOffset.y < threshold) {
        self.headerView.state = HeaderViewStatePullingDown;

    } else if (scrollView.contentOffset.y < PULLDOWN_MARGIN) {
        self.headerView.state = HeaderViewStateOveredThreshold;
        
    } else {
        self.headerView.state = HeaderViewStateHidden;
    }
}
図にすると多分わかりやすい。

ドラッグ状態(≒スクロール状態)が閾値を越えているかどうかによってヘッダのプルダウン状態を変えている。先ほど見たようにヘッダは state の値によってラベルの表示を変えたり、矢印画像のアニメーションを実行している。 contentOffset.y はヘッダの表示・非表示にかかわらずその上の部分が基準(0)となる。ここを基準に PULLDOWN_MARGIN よりも上まで表示するようにプルダウンした時に閾値を越えたと判断する( HeaderViewStateOveredThreshold)。また PULLDOWN_MARGIN からヘッダの高さまでの間の時はプルダウン状態(HeaderViewStatePullingDown)とみなし、それより下は非表示状態(HeaderViewStateHidden )

プルダウン中に指を離した時の処理はこう。
- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate
{
    if (self.headerView.state == HeaderViewStateOveredThreshold) {
        self.headerView.state = HeaderViewStateStopping;
        [self _setHeaderViewHidden:NO animated:YES];
        
        [self performSelector:@selector(_taskFinished) withObject:nil afterDelay:2.0];
    }
}
scrollViewDidEndDragging:willDelegate: は UIScrollView を指でドラッグした後、指を離したときに呼び出される。このメソッドが呼び出された時(=指を離した時)にプルダウン状態が HeaderViewStateOveredThreshold ならヘッダを表示状態(位置固定)にしている。サンプルでは2秒のディレイを入れていて、これが終わったら再びヘッダを閉じる処理を入れている。
- (void)_taskFinished
{
    self.headerView.state = HeaderViewStateHidden;
    [self.headerView setUpdatedDate:[NSDate date]];
    [self _setHeaderViewHidden:YES animated:YES];
}
なお UIScrollViewDelegate でプルダウンを検出するアイディアは下記の本がヒントになった。


備考


矢印が上下に回転する処理は若干重め。当初アニメーション時間を 0.2秒にしていたが 3GS ではプルダウンしてアニメーションが起きる時に若干のひっかかりを感じた。今は 0.1秒にしていて多少ましになった。多分コマ数が減った分負荷が下がったのだと思う。この辺りはケースによってはチューニングの必要があるかもしれない。なお、お手本にした Twitterクライアントではこのプチフリーズは起きない。


(2011-08-22 追記)
矢印画像のアニメーションにひっかかりが発生する件は解決策が見つかった。下記を参照のこと。




ソースコード

GitHub からどうぞ。
CustomCellSample at 2011-07-07 from xcatsan/iOS-Sample-Code - GitHub


参考情報


UITableView カスタマイズシリーズ


Cocoaの日々: [iOS] UINib を使ったカスタム UITableViewCell の作り方 Cocoaの日々: [iOS] UINib を使ったカスタム UITableViewCell の作り方(その2)ボタンの処理 Cocoaの日々: [iOS] UINib を使ったカスタム UITableViewCell の作り方(その3)ボタンの処理[改良版] Cocoaの日々: [iOS] UITableView でセルをスワイプするとスライドするユーザインタフェースを実装 今回のコードはこれらをベースに作っている。


関連情報


iphone - Pull down to refresh. - Stack Overflow
"Pull down to refresh" の発案者は Twitter公式クライアントの前身である Tweetie の開発者 Loren Brichter氏が考え出したものらしい。

このアイディアの利用について対価を求めるかの問いに対して Brichter氏は "absolutely not" と明確に否定している。
Twitter / @lorenb: @michellusthof absolutely ...
考え出したセンスもアイディアも素晴らしいし、利用に対する態度も素晴らしい。

Responses

  1. kyasu says:
    2011年8月18日 19:43

    いつも参考にさせていただいて大変お世話になっております。

    >プルダウンしてアニメーションが起きる時に若干のひっかかりを感じた。
    この件ですが、Blocksを使わないでbeginAnimations,commitAnimationsを
    使うと動作がブロックされないようです。

  2. kyasu says:
    2011年8月18日 19:43

    いつも参考にさせていただいて大変お世話になっております。

    >プルダウンしてアニメーションが起きる時に若干のひっかかりを感じた。
    この件ですが、Blocksを使わないでbeginAnimations,commitAnimationsを
    使うと動作がブロックされないようです。

  3. xcatsan says:
    2011年8月19日 21:23

    kyasu さん、こんばんは。
    早速試してみました。
    おー、ひっかかりが無くなりました。素晴らしい。
    これはいいですね。後でブログで紹介させて下さい。
    情報ありがとうございました。

  4. xcatsan says:
    2011年8月19日 21:23

    kyasu さん、こんばんは。
    早速試してみました。
    おー、ひっかかりが無くなりました。素晴らしい。
    これはいいですね。後でブログで紹介させて下さい。
    情報ありがとうございました。

  5. xcatsan says:
    2011年8月19日 21:44

    調べていたらわかりました。
    今回のようにドラッグ中に blocks を使ってアニメーションする場合には UIViewAnimationOptionAllowUserInteraction というオプションが必要なようです。

    (例) [UIView animateWithDuration:0.2
    delay:0.0
    options:UIViewAnimationOptionCurveLinear | UIViewAnimationOptionAllowUserInteraction
    animations:^{
    self.imageView.transform =
    CGAffineTransformMakeRotation(endAngle);

    }
    completion:NULL
    ];

    beginAnimations を使った場合はこのオプションが付いているのと同じ動作なんだと思います。

    参考:
    http://stackoverflow.com/questions/3614116/uiscrollview-touch-events-during-animation-not-firing-with-animatewithduration-b

  6. xcatsan says:
    2011年8月19日 21:44

    調べていたらわかりました。
    今回のようにドラッグ中に blocks を使ってアニメーションする場合には UIViewAnimationOptionAllowUserInteraction というオプションが必要なようです。

    (例) [UIView animateWithDuration:0.2
    delay:0.0
    options:UIViewAnimationOptionCurveLinear | UIViewAnimationOptionAllowUserInteraction
    animations:^{
    self.imageView.transform =
    CGAffineTransformMakeRotation(endAngle);

    }
    completion:NULL
    ];

    beginAnimations を使った場合はこのオプションが付いているのと同じ動作なんだと思います。

    参考:
    http://stackoverflow.com/questions/3614116/uiscrollview-touch-events-during-animation-not-firing-with-animatewithduration-b

  7. kyasu says:
    2011年8月20日 8:33

    xcatsanさん

    お役に立てて幸いです。
    というか、早速解決策の発見。さすがです。
    今後ともよろしくお願いいたします。

  8. kyasu says:
    2011年8月20日 8:33

    xcatsanさん

    お役に立てて幸いです。
    というか、早速解決策の発見。さすがです。
    今後ともよろしくお願いいたします。

  9. xcatsan says:
    2011年8月21日 16:59

    kyasu さん、こんにちは。
    これも情報提供のおかげです。
    ありがとうございました!

  10. xcatsan says:
    2011年8月21日 16:59

    kyasu さん、こんにちは。
    これも情報提供のおかげです。
    ありがとうございました!

  11. maito says:
    2013年3月9日 1:39

    xcatsanさん

    記事を参考にして実装しました!!
    動きましたー。

    めちゃめちゃ素敵なエントリーありがとうございます!!

  12. maito says:
    2013年3月9日 1:39

    xcatsanさん

    記事を参考にして実装しました!!
    動きましたー。

    めちゃめちゃ素敵なエントリーありがとうございます!!

  13. xcatsan says:
    2013年3月10日 11:27

    maitoさん、こんにちは。
    お役に立てたようでなによりです!

  14. xcatsan says:
    2013年3月10日 11:27

    maitoさん、こんにちは。
    お役に立てたようでなによりです!

Leave a Response

人気の投稿(過去 30日間)