[iOS] hitTest(:with:)を具体例で解説する

[hitTest(:with:)](https://developer.apple.com/documentation/uikit/uiview/1622469-hittest)メソッドについて、具体例を用いてわかりやすく解説してみたいと思います。

## 検証用アプリの実装
### ビュー階層と画面
FrontButtonはBlackViewの上に重なっています。
BackButtonはBlackViewの背後にあり、タップできない位置にあります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81715/dd26405d-f9f2-4397-0abb-4028ff6e883e.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81715/099d7cd4-7a45-9a39-7841-5341f11297ce.png)

### 実装
愚直なやり方ですが、各メソッドにprintを仕込んで、メソッドが呼び出される様子を観察します。

```swift
class RootView: UIView {
    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        print("RootView hitTest(:with:)")
        return super.hitTest(point, with: event)
    }

    override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
        let result = super.point(inside: point, with: event)
        print("RootView point(inside:with:): \(result)")
        return result
    }

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        print("RootView touchesBegan(:with:)")
        super.touchesBegan(touches, with: event)
    }
}

class FrontButton: UIButton {
    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        print("FrontButton hitTest(:with:)")
        return super.hitTest(point, with: event)
    }

    override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
        let result = super.point(inside: point, with: event)
        print("FrontButton point(inside:with:): \(result)")
        return result
    }

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        print("FrontButton touchesBegan(:with:)")
        super.touchesBegan(touches, with: event)
    }
}

class BackButton: UIButton {
    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        print("BackButton hitTest(:with:)")
        return super.hitTest(point, with: event)
    }

    override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
        let result = super.point(inside: point, with: event)
        print("BackButton point(inside:with:): \(result)")
        return result
    }

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        print("BackButton touchesBegan")
        super.touchesBegan(touches, with: event)
    }
}

class BlackView: UIView {
    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        print("BlackView hitTest(:with:)")
        return super.hitTest(point, with: event)
    }

    override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
        let result = super.point(inside: point, with: event)
        print("BlackView point(inside:with:): \(result)")
        return result
    }

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        print("BlackView touchesBegan(:with:)")
        super.touchesBegan(touches, with: event)
    }
}
```

## hitTest(:with:)の動作の解説
具体例を見る前に、簡単に`hitTest(:with:)`の動作を説明しておきます。

`hitTest(:with:)`は内部で[`point(inside:with:)`](https://developer.apple.com/documentation/uikit/uiview/1622533-point)を呼び出し、ビューがタッチ位置に含まれているかどうかを確認します。
含まれていると判定(`true`)されたら、サブビューの`hitTest(:with:)`を呼び、これを再帰的に繰り返していきます。
ビュー階層の一番深い位置で`true`と判定されたビューが、タッチされたビューであると判断されます。

## ケーススタディ
`hitTest(:with:)`の動作を具体例で見てみましょう。

まず、以下の部分をタッチしたときの動きを見てみます。

![名称未設定.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81715/d307a242-6a0f-088d-cbee-8b75615f352c.png)

コンソールの出力結果です。

```
RootView hitTest(:with:)
RootView point(inside:with:): true
BlackView hitTest(:with:)
BlackView point(inside:with:): true
FrontButton hitTest(:with:)
FrontButton point(inside:with:): false
BlackView touchesBegan(:with:)
RootView touchesBegan(:with:)
```

以下のような流れになります。

- RootViewのhitTest()が呼ばれる。point()がtrueなので続ける
- 一番手前にあるサブビューであるBlackViewのhitTest()を呼ぶ。point()がtrueなので続ける
- BlackViewのサブビューであるFrontButtonのhitTest()を呼ぶ。point()がfalseなので終了
- タッチがBlackViewで発生したと判断される。
- BlackViewのtouchesBegan()が呼ばれる
- タッチイベントがフォワードされ、RootViewのtouchesBegan()が呼ばれる

次に、FrontButtonをタップしたときの動きを見てみます。

![名称未設定.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81715/0db1c6c3-cfa1-ef5b-ffa8-d8e80a54cbb3.png)

コンソールの出力結果です。

```
RootView hitTest(:with:)
RootView point(inside:with:): true
BlackView hitTest(:with:)
BlackView point(inside:with:): true
FrontButton hitTest(:with:)
FrontButton point(inside:with:): true
FrontButton touchesBegan(:with:)
```

以下のような流れになります。

- RootViewのhitTest()が呼ばれる。point()がtrueなので続ける
- 一番手前にあるサブビューであるBlackViewのhitTest()を呼ぶ。point()がtrueなので続ける
- BlackViewのサブビューであるFrontButtonのhitTest()を呼ぶ。point()がtrueなので続ける
- FrontButtonはサブビューを持っていないので終了
- タッチがFrontButtonで発生したと判断される
- FrontButtonのtouchesBegan()が呼ばれる
- ボタンはタッチイベントをフォワードしない

次に、以下の部分をタッチしたときの動きを見てみます。

![名称未設定.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81715/4f38753c-30b5-094f-3388-9752244147b6.png)

コンソールの出力結果です。

```
RootView hitTest(:with:)
RootView point(inside:with:): true
BlackView hitTest(:with:)
BlackView point(inside:with:): false
BackButton hitTest(:with:)
BackButton point(inside:with:): false
RootView touchesBegan(:with:)
```

以下のような流れになります。

- RootViewのhitTest()が呼ばれる。point()がtrueなので続ける
- 一番手前にあるサブビューであるBlackViewのhitTest()を呼ぶ。point()がfalseなので、もう一つのサブビューであるBackButtonのhitTest()を呼ぶ。point()がfalseなので終了。
- タッチがRootViewで発生したと判断される。

### BackButtonのあたりをタッチするとどうなるか
![名称未設定.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81715/06e4c4fc-534b-495c-5636-f23a8c5494aa.png)

この場合は最初のケースと同じです。
最初にBlackViewの階層をたどっていき、この時点でBlackViewがタッチ位置に含まれていると判定されるので、BlackViewと同階層にあるBackButtonのhitTest()は行われません。

![IMG_8905.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81715/70a635a5-6122-bb59-6389-35832223b2de.jpeg)

## BackButtonをタップできるようにする
ビュー階層は変更しないまま、BackButtonをタップできるようにするには、BlackViewの`hitTest(:with:)`をオーバーライドします。

```swift:BlackView
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
    print("BlackView hitTest(:with:)")
    let hitView = super.hitTest(point, with: event)

    if hitView == self {
        return nil
    }

    return hitView
}
```

BlackViewの階層を調べていった結果、BlackView自身がタッチの発生元と判定されたら`nil`を返すようにしてあげます。
hitTestの結果が`nil`つまり何もヒットしなかったと判定された場合、RootViewの`hitTest(:with:)`はもう一つのサブビューであるBackButtonの`hitTest(:with:)`を呼び出します。
その結果BackButtonがヒットしたと判断されるようになります。

![IMG_8906.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81715/ed9984cb-eea0-eea4-8096-48286f8fe7f2.jpeg)

コンソール出力

```
RootView hitTest(:with:)
RootView point(inside:with:): true
BlackView hitTest(:with:)
BlackView point(inside:with:): true
FrontButton hitTest(:with:)
FrontButton point(inside:with:): false
BackButton hitTest(:with:)
BackButton point(inside:with:): true
BackButton touchesBegan
```

## 参考
- [Using Responders and the Responder Chain to Handle Events](https://developer.apple.com/documentation/uikit/touches_presses_and_gestures/using_responders_and_the_responder_chain_to_handle_events)
- [hitTest(:with:)](https://developer.apple.com/documentation/uikit/uiview/1622469-hittest)
- [point(inside:with:)](https://developer.apple.com/documentation/uikit/uiview/1622533-point)