LoginSignup
20
14

More than 5 years have passed since last update.

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

Last updated at Posted at 2016-12-04

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

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

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

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

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

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

UIControlからはじめる

UIKeyInput.006.png

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

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

UIKeyInput.007.png
標準の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で自動で隠れる

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

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

入力のUI: UIKeyInput

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

UIKeyInput.014.png
必要だったのは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分で話す内容ではなかった
20
14
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
20
14