UITableViewCellにRxTapGestureを追加する


概要

下記のような実装ができるExtensionを設計しました。

cell.rx.tapGesture
.subscribe(onNext: { _ in
// タップ時の処理
})

目的

少し前の記事「RxDataSourcesの使い方」でDelegateとDataSourceをViewControllerから分離して実装したかったのでcellにアクションイベントを実装できると嬉しいと思い設計しました。

ちなみに今回はそれほど複雑なジェスチャーを必要としていなかったことと、今後のメンテナンスのことを考え、ライブラリを使わず独自実装しましたがRxSwiftCommunity/RxGestureという素晴らしいライブラリがあるので自前で実装せずともサクッと同じような感じで使えます。

実装

まずUIViewにtapGestureを生成し、イベントを流す関数とremoveする関数を作ります。排他制御にはobjc_sync_enterを使っています。
gestureはすでにオブジェクトを持っていればそれを使い、なければ新しく生成しaddするようにし、無駄にオブジェクトを増やさないようにします。

UIViewExtension.swift
extension UIView {
    // MARK: Gesture
    func tapGesture(_ delegate: UIGestureRecognizerDelegate? = nil) -> Observable<Void> {
        objc_sync_enter(self)
        let tapGesture: UITapGestureRecognizer
        if let gesture = gestureRecognizers?.compactMap({ $0 as? UITapGestureRecognizer }).first {
            tapGesture = gesture
        } else {
            tapGesture = UITapGestureRecognizer()
            addGestureRecognizer(tapGesture)
        }
        objc_sync_exit(self)

        tapGesture.delegate = delegate
        return tapGesture.rx.event.map { _ in }
    }

    func removeTapGestures() {
        objc_sync_enter(self)
        gestureRecognizers?.compactMap({ $0 as? UITapGestureRecognizer }).forEach { tapGesture in
            removeGestureRecognizer(tapGesture)
        }
        objc_sync_exit(self)
    }
}

これをRxから使えるようにラップするExtensionを作ります。

UITableViewCell+Rx.swift
extension Reactive where Base: UITableViewCell {
    var tapGesture: Observable<Void> {
        return base.tapGesture().takeUntil(prepareForReuse)
    }

    private var prepareForReuse: Observable<Void> {
        return sentMessage(#selector(base.prepareForReuse))
            .do(onNext: { [weak base] _ in
                base?.removeTapGestures()
            }).map { _ in }
    }
}

sentMessageを使いUITableViewCellのprepareForReuseが呼ばれるタイミングで流れるObservableを作成します。
そしてこれを使い、セルがReuseされるタイミングでTapGestureをremoveします。
tapGestureはUITableViewで作成したtapGesture()をハンドリングし、takeUntilを使うことでprepareForReuseが流れてくると破棄されるようにします。これにより、UITableViewCellのReuse時にDisposeBagを破棄して更新するような処理を書く必要がなくなります。
Reuse対策がちゃんとできているか(リソースが解放されているかは)こちらの記事を参考にし確認させていただきました。

ここまででtapGestureの実装ができたので実際の使い方を見ていきます。

使い方

_ = cell.rx.tapGesture
                .subscribe(onNext: { _ in
                    // セルタップ時の挙動をここに書く
                }, onCompleted: {
                    // セルがReuseされるとcompletedが流れる
                })

このように書くことで下記のようにDataSourceの中でそれぞれのデータに対してタップ時の処理がかけて処理が見やすくなりますね。

SampleDelegate.swift
final class SampleDelegate: NSObject, UITableViewDelegate {
    lazy var dataSource = RxTableViewSectionedAnimatedDataSource<SampleSectionModel>.init(animationConfiguration: AnimationConfiguration(insertAnimation: .fade, reloadAnimation: .none, deleteAnimation: .fade), configureCell: { [weak self] dataSource, table, indexPath, item in
        guard let me = self else { return UITableViewCell() }
        switch item {
        case .type1(let data):
            let cell = table.dequeueReusableCell(withIdentifier: "SampleType1Cell", for: indexPath) as! SampleType1Cell
            cell.data = data
            _ = cell.rx.tapGesture
                .subscribe(onNext: { _ in
                    // セルタップ時の挙動
                })
            return cell

        case .type2(let data):
            let cell = table.dequeueReusableCell(withIdentifier: "SampleType2Cell", for: indexPath) as! SampleType2Cell
            cell.data = data
            _ = cell.rx.tapGesture
                .subscribe(onNext: { _ in
                    // セルタップ時の挙動
                })
            return cell
        }
    })
}

感想

このようにcellに対してtapGestureを書けることで、ViewControllerからdelegateを分離し、書けるようになりました。
私が作っているプロダクトは一画面に様々なタイプのcellが膨大にあり、差分更新も頻繁にあるので、このように書けることでTableViewやCollectionView周りの処理の可読性は飛躍的にあがるような気がします。(まだ導入していないのであくまで予想です。笑)
Rxを使ったCellのReuse対策で色々と悩んだのですが、DisposeBagを使わずいい感じに作れた思います。
問題はまだまだ色々ありそうですが、そこはおいおい改善していきたいと思います。