UIコンポーネントの作り方 #potatotips @トレタ


@hayashi311です。
Potatotips #35@トレタでカスタムUIコンポーネントを作る時の基本について話してきました。

この記事は、発表と懇親会での話題をまとめたものです。

話したこと、話したかったこと

カスタムUIコンポーネントを作る時のアプローチ、考えていることがあって、これをまとめてみました。

発表では練習不足と詰め込みすぎて論点が曖昧になったので、内容を削っています。

その代わり、考えに至った経緯や、「こういう時はこうしよう」ということが書いてあります。「なぜそうするか」という「理屈」を書きました。

UIControlからはじめる

レイアウト、レンダリング、イベントハンドリング、UIコンポーネントを作る時に考えることは少なくありません。
小さくて挙動を予想しやすいUIControlを拡張するところからはじめると、うまく実装できることが多いように思います。

たいていの場合、イベントの受け渡しはaddTargetsendActionで十分です。
標準と同じインターフェースなら、コードを読まなくても使えるコンポーネントになります。


標準のAPIに合わせると、使い方に迷わない

  • 最小限のクラス/プロトコルからはじめる
  • 標準のAPIに合わせる

UIResponder, UIViewはすべての基本

UIコンポーネントを実装してaddSubViewするということは、UIViewの階層と、UIResponderチェーンにUIコンポーネントを組み込むということです。

基本的なことですが、すごく大事なことです。

階層やチェーンの中で、どうイベントが発生して、どう処理されるか。レイアウトはどう処理されて、レンダリングはどう実行されるか。

レンダリング結果だけを見て、つじつまを合わせようとするとたいていの場合破綻します。

hitTest

UIViewの階層は、親Viewが子Viewの参照のリストを持つ形で表現されています(subviews)。

スクリーンがタップされた時、UIWindowを起点に子ViewのhitTestを再帰的に呼び出して、UIViewの階層の中で どのViewがタップされたのかを特定します。

// hitTestのイメージ
func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
    if /*pointがboundsの中だったら*/ {
        for subview in subviews {
            if let hitView = subview./*再帰的にhitTestを呼び出す*/ {
                return hitView
            }
         }
        return self
    }
    return nil
}

hitTestや内部で呼び出されるpoint(inside:, with:) -> Boolをoverrideすることで、以下のような実装が可能です

  • 子Viewを強制的にhitさせる/させない
  • boundsより大きな領域をタップに反応させる

UIResponder、firstResponder

UIResponderチェーンは、子Responderが親Responderの参照を持つ形で表現されています。(next: UIResponder)

hitTestでタップされたViewを特定した後、今後は逆向きにUIResponderのチェーンを駆け上がって、UIEventに反応するべきUIResponderを特定します。

UIViewの階層 UIResponderチェーン
rootから末端へ 末端からrootへ

タッチイベントはhitTestを使って対応するViewを特定できますが、キーボードの入力や端末のシェイクなど対応するViewを特定できないイベントもあります。

firstResponderはこのようなグローバルなイベントを最初に受け取るUIResponderを指定する仕組みです。

  • canBecomeFirstRespondertrueを返すようにしておく
  • 他のUIResponderfirstResponderが移った時にresignFirstResponderが呼ばれるので便利

UIViewUIViewControllerUIWindowもすべてUIResponderのサブクラスです。

入力のUI: UIResponder.inputView

入力系のUIコンポーネントがfirstResonderになった時、キーボードの代わりにカスタムUIを表示する仕組みがあります。

UIResponderfirstResponderになった時、firstResponderからrootまでUIResponderチェーンを駆け上がって、最初に返されるinputView&inputAccessoryViewを表示します。

  • inputView(キーボードの部分)
  • inputAccessoryView(キーボード上のツールバーの部分)
  • inputViewinputAccessoryViewを返すUIResponderは別々で良い
  • UIScrollViewkeyboardDismissModeに対応
  • resignFirstResponderで自動で隠れる


UIResponderなのでフォーカス状態の管理をUIKitに任せることができる

UIResponderUIViewである必要はないので、Viewの階層に含まれないUIResponderのサブクラスを実装してもいいんですよ。
(その場合は、自前でnext: UIResponderを返します)

入力のUI: UIKeyInput

カスタムUIを表示するinputViewに対して、UIKeyInputは標準のキーボードを使いながら、そのキーボードの入力を受け取るためのシンプルなプロトコルです。


必要だったのはUITextFieldではなく、キーボードの入力だった

UIKeyInputを実装したUIResponderfirstResponderになると、キーボードが表示されます。
UITextFieldもまたUIKeyInputを実装したUIResponderですね。

発表では4桁のPINCodeを入力するUIコンポーネントを例に説明しました。

@IBDesignable
class PINCodeField: UIControl, UIKeyInput {

    var code: [Decimal] = []

    var hasText: Bool {
        get {
            return !code.isEmpty
        }
    }

    func insertText(_ text: String) {
        guard code.count < 4 else {
            return
        }
        guard let decimal = Decimal(string: text), decimal.isFinite else {
            return
        }
        code.append(decimal)
    }

    func deleteBackward() {
        code = Array(code.dropLast())
    }
}

シンプルな実装なので、挙動を予測しやすい。

まとめ

  • UIKitの各クラス/プロトコルの役割を知る
  • 最小限/適切なレイヤーで実装する
  • 標準のコンポーネントと同じように振る舞う
  • 5分で話す内容ではなかった