Responderチェーンを辿る

bitFlyer システム開発本部 フロントエンド開発部 部長の林です。
マネジメント職ですが、気持ちは今もiOSエンジニアです。
この記事は bitFlyer Advent Calendar 2022 の10日目として書かれました。

優秀なiOSアプリエンジニアは画面を平面としてではなく階層として扱う、という持論があります。
ViewやViewControllerの階層が正しく設計され、あるべき層にあるべき情報が保持されていると、複雑な状態を伴うアニメーションにも耐えられる強度を持った美しいアプリが完成します。

さて、階層構造を基本とするUIKitですが、他にもう一つ興味深いデータ構造があります。

タイトルにもなった`Responderチェーン`です。

UIResponderはイベントハンドリングを担当するクラスで、UIViewやUIViewController, UIWindow, UIApplication, UIApplicationDelagateなどUIKitにおける主要なクラスはUIResponderを継承しています。

UIResponderはnextというプロパティで繋がってResponderチェーンを形成します。

```swift
class UIResponder : NSObject {
    var next: UIResponder?
}
```

末端から始まるReponderチェーンは、最終的にAppDelagateまで辿り着きます。

<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2952758/8f7bea54-1c34-412f-5d05-586a10387939.png" />


UIViewがsubviewsというプロパティを持ち、UIWindowを起点に末端へ広がる階層を形成することと対照的ですね。UIViewの階層を図にするとこうなります。


<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2952758/5c74cb1a-8402-a330-2ef9-52ed62f6f3cf.png" />



さて、Responderチェーンを使って、スクロールに合わせてインタラクティブにキーボードを閉じるチャット画面を実装してみます。

最終的にはこの画面が出来上がります。


<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2952758/34fb8171-b2a8-37fc-7b14-46833091f34c.gif" />


UIResponderにはキーボードの上にUIを表示するためのinputAccessoryViewというプロパティが存在します。

```swift
var inputAccessoryView: UIView?
```

UIViewではなく、UIResponderのプロパティというのがポイントです。

例えば以下のようにすると、テキストを入力する際にキーボードの上にtoolBarが表示されます。
```swift
textView.inputAccessoryView = toolBar
```

さて、チャット画面を作りたいのでした。TextViewが含まれるツールバーをinputAccessoryViewに設定すれば良さそうです。

<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2952758/176938c4-6668-8ea8-473c-ccfb5b7d4e83.png" />


しかし、ツールバー自身がUITextViewを含んでいるので以下のコードは動きません。
```swift
// 動作しない例
messengerInputAccessoryView.textView.inputAccessoryView = messengerInputAccessoryView
```
子ViewのinputAccessoryViewで親のViewを参照していますし、UITextViewがFirst responderになっていないとツールバーが出てきません。

困りました。

基本に立ち返って、一次情報に当たりましょう。

inputAccessoryViewは以下のようにコメントで説明されています。
> Called and presented when object becomes first responder.  Goes up the responder chain.

First responderになった時、UIKitはResponderチェーンを辿ってinputAccessoryViewを探します。つまり自身がinputAccessoryViewを返さなくても、ResponderチェーンのどこかでinputAccessoryViewが返されればこれを使うということです。

さらに、inputAccessoryViewの表示はキーボードの有無に依存しません。キーボードを表示しないUIResponderにinputAccessoryViewを設定し、first responderにすることでinputAccessoryViewだけを表示することができます。

このような理由から、ViewControllerのinputAccessoryViewでTextViewを含むツールバーを返すことで、チャットの入力ボックスが実装できます。（ViewControllerもまたUIReponderなのです）


<img width="600" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2952758/9e3de074-dff5-19c8-663b-cce8bb6f8a66.png" />


キーボード非表示の初期状態ではViewControllerがfirst responderとなり、inputAccessoryViewとしてツールバーを返します。
TextViewにフォーカスが当たるとfirst responderが移動しますがResponderチェーンにはViewControllerが含まれており、引き続きinputAccessoryViewを返しています。

Responderチェーンをうまく使うことで、複雑に見える挙動もシンプルに実装することができます。

```swift
class ViewController: UIViewController {

    @IBOutlet var inputAccessoryTextView: MessengerInputAccessoryView!

    override var inputAccessoryView: UIView? {
        return inputAccessoryTextView
    }

    override var canBecomeFirstResponder: Bool { true }

    override func viewDidLoad() {
        super.viewDidLoad()
        becomeFirstResponder()
    }
}
```
また、keyboardDismissModeを設定することでaccessory viewを含むキーボードをインタラクティブに閉じることができます。

```swift
scrollView.keyboardDismissMode = .interactive
```

ツールバー自体も含め、全体で30行ちょっとの実装で期待通りに動くチャット画面の入力が実装できました。

https://github.com/hayashi311/InputAccessoryViewSample/blob/main/InputAccessoryViewSample/ViewController.swift

フレームワークの使い方を覚えるだけでなく、フレームワークがどう動いているのかを知ることでしか辿り着けない品質というものがあると信じています。bitFlyerでは本物のiOSエンジニアを募集しています！

https://bitflyer.com/ja-jp/recruit/offers/212

ぜひご応募ください。