Edited at

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

More than 1 year has passed since last update.

@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分で話す内容ではなかった