[iOS] UICollectionView でセルの領域外にはみ出る要素でいい感じにタップ検知したい

前説

以下動画のように、セルからはみ出した赤い要素でいい感じにタップ検知したい。

はみ出した赤い要素をタップすると青くなる

hitTest(_:with:) というメソッドの使い方と、それが呼ばれる順番がキモになります。

前提条件

Xcode: 12.1
シミュレータ: iOS14系

UICollectionView でセルの領域外にはみ出る要素を配置する

セルに対してUIView.layer.zPosition と UIView.layer.masksToBounds を設定すればサクッとできます。

本ブログの [iOS] UICollectionView でセルの領域外にはみ出る要素を配置する
にも方法を書いています。

以降、はみ出る要素でいい感じにタップ検知していきます。
hitTest(_:with:) を設定していきます!

hitTest の挙動についてざっくりと把握

というわけでhitTest(_:with:) を調整していきますが、その前に以下の画像でピンと来なかった場合、
これ以降の内容は分かりづらいかなと思います。
そのときはまずhtTest メソッドの使い所について調べるなり、
本ブログの [iOS] hitTest(_:with:) の挙動の概要 をサラッと見てもらった方がよさそうです。

まず、hitTest を調整しない場合の挙動

まずhitTest を調整しないで、領域からはみ出るView で
touchesEnded(_:with:) などでタップイベントを検知してみます。
すると以下のような挙動になります。
(赤いView は28のセルではなく30のセルにaddSubview されています。)

はみ出したView がうまくタップ検知されない

ぱっと見よく分からない感じになってますが、hitTest の挙動を考えるとしっくりきます。

青枠の領域をタップしたとき。
hitTest が向かって奥のViewから再帰的にコールされます。
で、28のセルまで処理がきたとき、28の子ビューは存在しない(つまり、はみ出した赤いView も存在しない)ため、
hitTest の再起処理は 28のセルで終わります。
結果、28のセルが最上位の要素だと判定されてしまうのです。

緑枠の領域をタップしたときも同様の考えです。
hitTest の処理が30のセルまできたとき、subview にはみ出した赤いView がいるため、
そちらまで再帰処理が及びます。
結果、はみ出したView が最上位の要素だと判定されます。

というわけでtestTest の調整をしていきます。

hitTest 調整の方針について

ではどうすれば、はみ出した赤いView 全体をタップ領域と判定させられるか。

30のセルのhitTest の処理を書き換え、
はみ出した赤いViewの領域がタップされたときは、そちらのhitTest を呼ぶようにすればOKです!

ここで疑問が。
セルからはみ出した部分はそもそも、例えば28のセルのhitTest が呼ばれるだけで
30のセルのhitTestは呼ばれないのでは?

そんなことはなくて、実はhitTest はCollectionView においては、
表示されているセル全てに対して順番に走ります。
そして最初にUIView を返却した要素を、タップされたと判定するわけです。

方針が決まったのであとは実装するだけです!

hitTest を調整する

まずはみ出たViewを以下のようなクラスとして定義しておきます。
タップ検知するために、UIControl の子クラスにします。

class TappableControl: UIControl {
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        super.touchesEnded(touches, with: event)
    }
}

で、このTappableControl を適当なセルにaddSubview しています。

次にいよいよ、セルのhitTest を以下のように調整します。

override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
    for subview in contentView.subviews as [UIView] {
        if subview is TappableControl
            && subview.point(inside: convert(point, to: subview), with: event) {
                // タップされたポイントがはみ出た要素の領域内であれば、その要素を返却する
                return subview
        }
    }
    return super.hitTest(point, with: event)
} 

これで、いい感じにタップが検知できるようになりました!

全ての領域でタップされたと判定可能

完成!

と言いたいところですが、これで終わりではありません!🤧
実はまだタップ領域の判定は不安定です。

どういうことかというと。。
以下分かりづらいですが、2回タップしており、どちらもはみ出たView をタップしています。
1回目は反応して青くなりましたが、2回目はその下の28セルが青くなってしまっています。
つまり、タップ検知できる場合とできない場合が発生してしまいました。

はみ出したView のタップ検知ができたりできなかったり

どうしてこうなった🤔

hitTest は最初に返却したUIView をタップしたと判断する

見出し↑がどういうことかと言うと、先にさらっと書きましたが、
hitTest は表示されているセル全てに対して順番に走ります。
その順番は、iOSの内部の処理のため不明ですが、
少なくともUICollectionView では後から表示されたセル順のようです。

そのため上記動画の1回目のタップは28のセルよりも前に30のセルのhitTest メソッドが呼ばれています。
なぜなら30のセルの方が後で表示されたから。
そして2回目のタップは28のセルの方が早くhitTest メソッドが呼ばれています。
なぜなら28のセルの方が後で表示されたから。

なので、hitTest の順番に依らずにはみ出した要素のタップ検知をするためには、
はみ出したView が覆いかぶさっていないか、各セルのhitTest 内で逐一確認する必要があります。

はみ出したView が覆いかぶさっていないかを判断する

各セルは、他のセルのことを把握できないので、
はみ出た要素の位置をVCなどで把握し、覆いかぶさっているかどうか判定する必要があります。

VCなどに以下のように判定メソッドを追加します。

func isOverhangingViewCovering(_ view: UIView, point: CGPoint) -> Bool {
    let indexPath = IndexPath(item: 30, section: 0) // はみ出る要素を持つセルを取得
    guard let overhangingCell = collectionView?.cellForItem(at: indexPath) as? OverhangingCell else {
        return false
    }
    
    // はみ出る要素を取得
    guard let overhangingView = overhangingCell.overhangingView else {
        return false
    }
    
    // タップ領域の比較をするために、はみ出る要素のframe をコレクションビュー基準でのframe に変換
    let overhangingViewFrame = overhangingCell.convert(
        overhangingView.frame,
        to: collectionView
    )
    
    // 引数のpoint(タップ位置)をコレクションビュー基準でのpoint に変換
    let pointAtCollectionView = view.convert(point, to: self.collectionView)
    
    // タップ位置が、はみ出る要素のframe に含まれるかどうかを判定
    return overhangingViewFrame.contains(pointAtCollectionView)
} 

その後、セルのhitTest にて作成したメソッドを呼んでやります。

override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
    // 30のセルはこのfor文の中でreturn される
    for subview in contentView.subviews as [UIView] {
        if subview is TappableControl
            && subview.point(inside: convert(point, to: subview), with: event) {
                return subview
        }
    }
    // 30以外のセルはこの判定を通る
    if SampleViewController.isOverhangingViewCovering(self, point: point) {
        // 覆いかぶさっている領域をタップしていた場合は、
        // そのセルはタップ検知させたくないのでnil を返却
        return nil
    }
    
    return super.hitTest(point, with: event)
} 

これで、いい感じにタップ検知ができます!

完成!

いい感じにタップ検知できた

今度こそ完成です!

最後に

hitTest についてだいぶ理解が深まりました。
これでタップ領域を好きに制御できそうです。

以上です。お疲れ様でした!

[iOS] UICollectionView でセルの領域外にはみ出る要素でいい感じにタップ検知したい」に2件のコメントがあります

コメントを残す

以下に詳細を記入するか、アイコンをクリックしてログインしてください。

WordPress.com ロゴ

WordPress.com アカウントを使ってコメントしています。 ログアウト /  変更 )

Google フォト

Google アカウントを使ってコメントしています。 ログアウト /  変更 )

Twitter 画像

Twitter アカウントを使ってコメントしています。 ログアウト /  変更 )

Facebook の写真

Facebook アカウントを使ってコメントしています。 ログアウト /  変更 )

%s と連携中

%d人のブロガーが「いいね」をつけました。