読者です 読者をやめる 読者になる 読者になる

tomoyaonishiのブログ

iOSのことを中心に・・・その他もあるよ!

AutoLayoutでスワイプできるUITableViewCellを実装する

AutoLayoutを使ってセル上を左にスワイプすると、セルの右側がオープンするUITableViewCellを実装してみます。セルの削除のときによく出てくるあれを自分で実装する感じです。 AutoLayoutやstoryboardにある程度知識がある方を前提としていますので、適宜不足している情報は補って実装してみてください><


準備編

  • まずは、通常通りにstoryboardでUITableViewを実装します。
  • 次に、スワイプした時にわかるようにセルの背景色を変えておきます。

f:id:tomoyaonishi:20141012212839p:plain

これで準備完了です。スワイプできるセルを実装していきます。

TableViewを貼り付けているViewControllerは以下のようになっています。普通です。

import UIKit

class ViewController: UIViewController, UITableViewDataSource {

    
    func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        
        return 10
    }
    
    
    func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
        
        return tableView.dequeueReusableCellWithIdentifier("Cell", forIndexPath: indexPath) as UITableViewCell
    }
    
    
}

実装編(storyboard)

  • セルの上にスワイプ用のビューを1枚重ねます。画像のようにセルのcontent view全面に張り付くようにAutoLayoutを設定します。

f:id:tomoyaonishi:20141013205953p:plain

このビューの名前をswipe viewとします。

f:id:tomoyaonishi:20141013210032p:plain

  • swipe viewに設定したAutoLayoutのうち、右側からのConstraintをカスタムセルクラスにIBOutletで接続します。

f:id:tomoyaonishi:20141013210304p:plain

ユーザがセルをスワイプしたとき、このConstraintの値を操作することでセルの右側を開いたり、閉じたりするというわけです。

  • swipe viewもIBOutletで接続しておきます。
@IBOutlet weak var swipeView: UIView!

実装編(コード)

  • ユーザのスワイプを認識するためにカスタムセルに対して、UIPanGestureRecognizerを追加します。
    var panGestureRecognizer: UIPanGestureRecognizer!
    
    
    override func awakeFromNib() {
        super.awakeFromNib()
        self.panGestureRecognizer = UIPanGestureRecognizer(target: self, action: "didPan:")
        self.panGestureRecognizer.delegate = self
        self.swipeView.addGestureRecognizer(self.panGestureRecognizer)
    }

これでユーザがセル上をスワイプした時に、didPan:メソッドが呼ばれるようになります。

  • didPanメソッド内では、ジェスチャーのstateに合わせてそれぞれ処理を行います。

実装は以下のようになるかと思います。

    func didPan(sender: UIPanGestureRecognizer) {
        switch sender.state {
        case .Began:
            let translation = CGPoint(x: -self.swipeViewMarginRight.constant, y: 0.0)
            self.panGestureRecognizer.setTranslation(translation, inView: self.swipeView)
        case .Changed:
            let translation = self.panGestureRecognizer.translationInView(self.swipeView)
            self.swipeViewMarginRight.constant = -translation.x
        case .Cancelled:
            fallthrough
        case .Ended:
            break
        default:
            break
        }
    }

UIPanGestureRecognizerは対象のビュー上をスワイプした量を保持しています。translationInViewメソッドで取得できます。このtranslationのxの値を見ることでユーザが横方向にどれだけスワイプしたのかが分かります。例えば、ユーザが左に100ptスワイプしたときには、translation.xは-100になります。この-100の値をswipe viewの右側からのConstraintにセットすることで、swipe viewはsuperviewの右端から100pt離れるようになります。このようにしてセルの開閉を実装していきます。

.Changedの処理はユーザがビュー上をスワイプするごとに呼ばれます。よって、ここで移動量を取得し、それをConstraintにセットします。.Beganの処理はUIPanGestureRecognizerの保持しているtranslationの値の初期化です。文章で説明がしづらいので割愛しますが、この処理がある場合とない場合で実際に動かしてみるとわかると思います。

  • ひとまずスワイプできるセルの完成です。

実行してみると各セルが右、左にスワイプできることが確認できるかと思います。

f:id:tomoyaonishi:20141013214745p:plain

しかし!!ここで大問題が発生します。なんとスワイプはできてもTableViewのスクロールが出来ないのです。このコードが糞なわけではなく、GestureRecognizerの仕様がそうしています。実はUIGestureRecognizerはデフォルトでは2つ以上同時に認識してくれません。UITableViewにはスクロール認識用のUIPanGestureRecognizerが動いています。そしてセルには自分で追加したUIPanGestureRecognizerがあります。階層的に最初に動作するジェスチャーは自分で追加したジェスチャーですので、こちらの処理だけが動いて、TableViewのジェスチャーが動作しなくなっているというわけです。

  • 2つのジェスチャーが同時に動作するようにする やり方は実に簡単です。UIGestureRecognizerDelegateの
optional func gestureRecognizer(gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWithGestureRecognizer otherGestureRecognizer: UIGestureRecognizer) -> Bool

このデリゲートメソッドでreturn trueを返すだけです。最初の引数のジェスチャーは自分でセルに追加したジェスチャーです。2つ目の引数がTableViewのジェスチャーです。2つ目のジェスチャーはTableViewのジェスチャーとは限らないので、TableViewのジェスチャーかどうかをチェックして、そうであればtrueを返す処理にすべきです。

func gestureRecognizer(gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWithGestureRecognizer otherGestureRecognizer: UIGestureRecognizer) -> Bool {
        if gestureRecognizer == self.panGestureRecognizer && otherGestureRecognizer == TableViewのジェスチャー {
        
            return true
        }
        
        return false
    }
  • 実行します。

TableViewのスクロールもできて、セルのスワイプもできると思います。しかし、まだ動作が完璧ではありません。TableView上を少しでも横にスワイプするとセルがオープンしてしまい、使い勝手が悪いです。TableViewを斜めにスワイプするとセルがオープンしつつTableViewもスクロールしてしまうという微妙な実装になっています。できればTableViewのスクロールなのか、セルのスワイプなのかを判定してから、どちらかの動作しかしないようにしたいです。

  • TableVIewのスクロールなのかセルのスワイプなのかを判定する

ここは泥臭い処理になってしまうのですが、セルのジェスチャーの最初の数回のtranslationを監視して、スワイプの角度が一定以上であればTableViewのスクロールとみなしてセルのスワイプの処理をしない、逆にスワイプの角度が一定以下であればセルのスワイプとみなし、TableViewのスクロールをしないように切り替えることになります。ちょっとコードが複雑になっていますが、こんな感じになるでしょうか。(もっとスマートにできる方法知ってる方は教えて下さい!)

    var tableView: UITableView {
        
        return self.superview?.superview as UITableView
    }

    var trackingCount = 0
    var trackingTranslation = CGPointZero
    var allowsSwipe = false
    
    
    func didPan(sender: UIPanGestureRecognizer) {
        switch sender.state {
        case .Began:
            let translation = CGPoint(x: -self.swipeViewMarginRight.constant, y: 0.0)
            self.panGestureRecognizer.setTranslation(translation, inView: self.swipeView)
        case .Changed:
            let translation = self.panGestureRecognizer.translationInView(self.swipeView)
            
            // 最初の数回のtranslationを別途保持してスワイプの角度を計算し、
            // TableViewのスクロールなのかセルのスワイプなのかを判断する
            self.trackingCount++
            self.trackingTranslation.x += translation.x
            self.trackingTranslation.y += translation.y
            if trackingCount == 3 {
                self.allowsSwipe = self.allowsSwipeWithTranslation(self.trackingTranslation)
            }
            
            if self.allowsSwipe {
                self.tableView.scrollEnabled = false
                let translation = self.panGestureRecognizer.translationInView(self.swipeView)
                self.swipeViewMarginRight.constant = -translation.x
            }
        case .Cancelled:
            fallthrough
        case .Ended:
            self.trackingCount = 0
            self.allowsSwipe = false
            self.trackingTranslation = CGPointZero
            self.tableView.scrollEnabled = true
        default:
            break
        }
    }
    
    
    func allowsSwipeWithTranslation(translation: CGPoint) -> Bool {
        // スワイプの角度を計算して判断する
        let slope: CGFloat = abs(translation.y) / abs(translation.x)
        let radian: CGFloat = atan(slope)
        let angle = radian * 180 / CGFloat(M_PI)
        
        if angle >= 0 && angle <= 30 {
            
            return true
        }
        
        return false
    }

.Changedで最初の3回までself.allowsSwipeがfalseなのでセルのスワイプ動作は起こらず、3回目のif文の中で3回分の移動量でスワイプの角度を計算します。xの移動量とyの移動量が取れるので簡単にわかります。そしてその角度が一定以下であればself.allowsSwipeにtrueを入れるようにしてセルのスワイプを動作させます。そのときTableViewはスクロールしないようにします。 逆に3回目のif文でTableViewのスクロールだと判断した場合には、self.allowsSwipeにはfalseが入り、セルのスワイプ処理は動作しません。

.Endedでは、一連の処理で使用した変数の初期化を行います。


まとめ

今回はスワイプできるセルをAutoLayoutで実装しました。骨組み的なところしか載せていないので、細かい処理は記述していません。セルを開くときにアニメーションさせたり、右側だけでなく左側も開けるようにしたり、開くことができる幅の上限を処理したりと完璧なスワイプを実装するにはまだまだ多く実装することがあります。

最後に気づいた方もいるかもしれませんが、今回の実装だとAutoLayout的にswipe viewのwidthが小さくあります。swipe viewが圧縮されるとでもいいますか。左側のConstraintはそのままですから、全体が横にスライドするわけじゃありません。なのでsiwpe viewにUILabelなどを載せているとスワイプする度にUILabelの中身が幅に合わせてリサイズされて再描画されます。そのままスライドしているように見せるには、右側のConstraintの値にマイナスを掛けた値を左側のConstraintにセットします。そうすれば幅は固定のままswipe view全体が左に移動しているように見えます。

以上です。

ソースコード

TomoyaOnishi/SwipableCell · GitHub