51
50

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

iOSAdvent Calendar 2012

Day 25

sparrowライクなセルをスワイプすると出てくるメニュー

Posted at

8/25担当の@masaichiです。qiitaも初投稿です。よろしくおねがいします。

今回、sparrowやtwitterのクライアントで良く使われている、セルをスワイプするとスライドして下からメニューが出てくる機能を実装してみる、というお話です。

メールやtwitterを使っていて多いのは、多分、その項目に対して返信したり、favをつけたりする事だと思いますが、セルをさっとスワイプすることでメニューが出てきて機能を選ぶ事ができるので大変便利です。
最近ではこの機能を組み込んでいるアプリも多く、定番のインターフェスになってきた感があります。

では、早速作ってみましょう。
ちなみに出来上がったものはこちらです。(ライブラリとしてはまだテスト段階です)
本文中はその中から抜粋してソースを載せていますが、細かい処理など省いていますので、githubのソースも合わせて参照してください。

実装

今回はスワイプするとビューがスライドするカスタムセル(RevealTableViewCell)と、それを利用するTableViewControllerを作りました。

カスタムセル

まずはRevealTableViewCellを作ります。
UITableViewCellを継承して作り、initWithStyle:reuseIdentifierの中で、
前面に表示するビュー(frontContentView)と、スライドすると出てくるビュー(backContentView)をcontentViewに重ねて配置します。

@implementation MIRevealTableViewCell

@synthesize frontContentView  = _frontContentView;
@synthesize backContentView   = _backContentView;

- (id)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier
{
    self = [super initWithStyle:style reuseIdentifier:reuseIdentifier];
    if (self) {
        self.frontContentView = [[[UIView alloc] initWithFrame:CGRectZero] autorelease];
        self.backContentView  = [[[UIView alloc] initWithFrame:CGRectZero] autorelease];
        [self.contentView addSubview:self.backContentView];
        [self.contentView addSubview:self.frontContentView];
    }
    return self;
}

- (void)layoutSubviews {
    [super layoutSubviews];
    self.backContentView.frame = self.contentView.bounds;
    self.frontContentView.frame = self.contentView.bounds;
}

@end

前面に配置したビューをスワイプ操作に応じてずらしてあげることで、下に配置したビューが見えるという構造です。
では、配置したところでずらせるようにしてみましょう

ドラッグして動かしてみる

ドラッグ操作はtouchesBegan〜touchesEndedを上書きして実装しました。
UISwipeGestureRecognizerを使って実装しても良かったのですが、ドラッグできるようにしたいなーと思って上記の方法を取ることにしました。
実装はこんな感じです。

@implementation MIRevealTableViewCell
...
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
    self.startLocation = [[touches anyObject] locationInView:self.frontContentView];
    [super touchesBegan:touches withEvent:event];
}

- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event {
    CGPoint pos = [[touches anyObject] locationInView:self.frontContentView];
    CGFloat div = pos.x - self.startLocation.x;

    self.frontContentViewDragging = YES;
    [self setTableViewScrollEnabled:NO];
    
    CGPoint currentLocation = self.frontContentView.frame.origin;
    currentLocation.x += div;
    
    // 右から左へのドラッグだけ:p
    if (currentLocation.x > 0) {
        currentLocation.x = 0;
    }
    
    CGRect r = self.frontContentView.frame;
    r.origin.x = currentLocation.x;
    self.frontContentView.frame = r;
    
    [super touchesMoved:touches withEvent:event];
}

- (void)touchesEndedProcess {
    self.frontContentViewDragging = NO;
    [self setTableViewScrollEnabled:YES];
}

- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event {
    [self touchesEndedProcess];
    [super touchesEnded:touches withEvent:event];
}

- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event {
    [self touchesEndedProcess];
    [super touchesCancelled:touches withEvent:event];
}

- (void)setTableViewScrollEnabled:(BOOL)enabled {
    UIView *view = self.superview;
    
    while (view != nil && ![view isKindOfClass:[UITableView class]]) {
        view = view.superview;
    }
    
    [(UITableView*)view setScrollEnabled:enabled];
}

@end

touchesBeganで最初にタッチされた位置を保存しておいて、touchesMovedでの位置と開始位置の差分を取って、frontContentViewをその差分だけずらしてあげています。
また、横になぞる操作は縦にも動いてしまいがちで、縦スクロールが発生してしまうので、touchesMovedのタイミングでTableViewのスクロールを無効にしています。

スライドのアニメーション

次は指を離したタイミングでシュパッとスライドさせてしまうようにします。
スライドをさせる処理はUIViewのアニメーションを使えば簡単に出来ますね。
スピード感を出すために、時間は最大で0.1秒で動作もEaseInOutではなくLinearで指定しています。
また、指を離した位置に応じてアニメーションをさせる時間を小さくしています。
例えばギリギリまで自分でドラッグして離したときにはすぐにアニメーションが終わり、
最初の方であれば通常通り0.1秒間アニメーションするようになります。


- (void)showBackContentViewAnimated:(BOOL)animated {
    CGRect r = self.frontContentView.frame;
    r.origin.x = -r.size.width;
    
    if (animated) {
        self.isFrontContentViewAnimating = YES;
        CGFloat width = self.frame.size.width;
        if (width == 0) {
            width = 1;
        }
        CGFloat duration = 0.1 * (width+self.frontContentView.x) / width;
        duration = MAX(duration, 0.1);
        [UIView animateWithDuration:duration
                             delay:0.0
                           options:UIViewAnimationCurveLinear
                        animations:^{
                            self.frontContentView.frame = r;
                        }
                        completion:^(BOOL finished) {
                        }];
    }
    else {
        self.frontContentView.frame = r;
    }
}

- (void)hideBackContentViewAnimated:(BOOL)animated {
    // showと同じ
    ...
}

指を離したタイミング、つまりtouchesEndedで上記の関数を呼んであげます。
その際、指を動かしている方向に向かってスライドが走ると自然ですね。
今回の場合、左に向かってスワイプしているときはshowを右に向かってスワイプしているときはhideを呼んであげるようにします。
スワイプの方向を判断するロジックですが、
指を離した瞬間と、前回のタッチ位置を比べてるだけで良さそうですが、
実際に使ってみると、指を離した瞬間というのは位置がブレていて、意図した挙動になりませんでした。
そこで、touchesMovedで移動方向をいくつか記録しておいて判断するように実装しています。


- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event {

    ...
    
    [self addDirectionPointForLocation:currentLocation prevLocation:self.currentLocation];
    self.currentLocation = currentLocation;

    ...
}

- (void)touchesEndedProcess {
    ...
    
    NSInteger direction = [self calcrateDraggingDirection];
    if (direction == kMIRevealTableViewCellSlideDirectionLeft) {
        [self showBackContentViewAnimated:YES];
    }
    else {
        [self hideBackContentViewAnimated:YES];
    }
}

- (void)addDirectionPointForLocation:(CGPoint)pos prevLocation:(CGPoint)prevLocation {
    CGFloat point = 0.0;
    if (pos.x >= prevLocation.x) {
        point = pos.x - prevLocation.x;
        if (point > 5.0) {
            point = 5.0;
        }
    }
    else {
        point = pos.x - prevLocation.x;
        if (point < -5.0) {
            point = -5.0;
        }
    }
    
    _directionPoints[_directionPointCursor]  = point;
    _directionPointCursor++;
    if (_directionPointCursor >= kMIRevealTableViewCellPointsSize) {
        _directionPointCursor = 0;
    }
}

- (NSInteger)calcrateDraggingDirection {
    CGFloat point = 0.0;
    
    for (NSInteger i = 0; i < kMIRevealTableViewCellPointsSize; i++) {
        point += _directionPoints[i];
    }
    
    if (point > 0) {
        return kMIRevealTableViewCellSlideDirectionRight;
    }
    else {
        return kMIRevealTableViewCellSlideDirectionLeft;
    }
}

スワイプしている方向を判断するロジック、これでうまく動いていますが、もう少しスマートな方法があるかもしれないです。
(あったら教えてください)

さてこれでスワイプして上のビューをどかせるようになりました。
あとはdelegateなりなんなりで、ViewControllerにスワイプされたよーなど通知してあげて、連携を取れば良さそうです。

UITableViewControllerから使う

ViewController側の実装ですが、今回は以下のような挙動を実現します。

  • 他のセルが触られたら、開いているセルは閉じる
  • スクロールされたら開いているセルを閉じる

上記2点を実現するために、ViewControllerで現在開いているセルを保持してあげれば良さそうです。
セルからViewControllerへ状態をコールバックしてあげて、ViewControllerはそれを受け取り応じた処理をするようにしましょう。
セルを保持さえしておけば、スクロールして閉じる処理は単にUIScrollViewのdelegateを処理するだけで良いので簡単ですね

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
    static NSString *CellIdentifier = @"Cell";
    MIRevealTableViewCell *cell = (MIRevealTableViewCell*)[tableView dequeueReusableCellWithIdentifier:CellIdentifier];
    if (cell == nil) {
        cell = [[[MIRevealTableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:CellIdentifier] autorelease];
        ...
    }
    cell.revealCellDelegate = self;
    
    // Configure the cell...
    
    return cell;
}

#pragma mark - reveal cell delegate

// セルのタッチが開始された
- (void)revealTableViewCellWillBBeginTouchesCell:(MIRevealTableViewCell*)cell {
    if (cell != self.activeCell) {
        [self.activeCell hideBackContentViewAnimated:YES];
        self.activeCell = nil;
    }    
}

// 開こうとしている
- (void)revealTableViewCellWillShowBackContentView:(MIRevealTableViewCell*)cell {
    self.activeCell = cell;
}

// 閉じようとしている
- (void)revealTableViewCellWillHideBackContentView:(MIRevealTableViewCell*)cell {
    self.activeCell = nil;
}

#pragma mark - scroll view delegate 

- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
    [self.activeCell hideBackContentViewAnimated:YES];
}

ViewController側からの微調整

ここまででも動くには動きますが、メニューを開いたのにdidSelectRowがコールバックされたりと誤爆が多いです。
その他、スワイプするつもりがスクロールしてしまいtouchesBeganが呼ばれず処理が走らない等の問題があります。
そのため、ViewController側で誤爆防止や自然な挙動のために微調整をします。

スワイプの反応が悪い

UITableViewのdelaysContentTouchesをNOにしてあげると即座にtouchesBeganが呼ばれるようになり、反応が良くなります。

- (void)viewDidLoad {
    [super viewDidLoad];
    self.tableView.delaysContentTouches = NO;
}

didSelectRowの誤爆

UITableViewDelegateに(NSIndexPath*)tableView:willSelectRowAtIndexPath:というメソッドがあり、これでnilを返すとdidSelectRowをキャンセルできます。
多少泥臭いですが、いまのセルの状態を見て、アニメーションしている場合にはnilを返して抑制してあげるようにします。


- (NSIndexPath*)tableView:(UITableView*)tableView willSelectRowAtIndexPath:(NSIndexPath *)indexPath {
    if (self.activeCell && self.isFrontContentViewAnimating) {
        return nil;
    }
    return indexPath;
}

ここらへんのところはセルのカスタマイズだけでなんとか解決したかったのですが、ちょっと厳しそうでViewControllerで対応をしました。
何か良い方法をご存知の方がいたら教えてください!

実際には、もう少し細かい判定や制御を入れてありますが、長くなってしまうので割愛しました。

まとめ

スワイプしてメニューを表示するセルの実装ができました。
touchesBeganを上書きして実装していったので、実装コストや泥臭さが多い感じでしたが、
細かく挙動を制御できるので実装してみて損はなかったかなと思います。

51
50
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
51
50

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?