@hayashi311です。
Potatotips #35@トレタでカスタムUIコンポーネントを作る時の基本について話してきました。
この記事は、発表と懇親会での話題をまとめたものです。
話したこと、話したかったこと
カスタムUIコンポーネントを作る時のアプローチ、考えていることがあって、これをまとめてみました。
発表では練習不足と詰め込みすぎて論点が曖昧になったので、内容を削っています。
その代わり、考えに至った経緯や、「こういう時はこうしよう」ということが書いてあります。「なぜそうするか」という「理屈」を書きました。
UIControlからはじめる
レイアウト、レンダリング、イベントハンドリング、UIコンポーネントを作る時に考えることは少なくありません。
小さくて挙動を予想しやすいUIControlを拡張するところからはじめると、うまく実装できることが多いように思います。
たいていの場合、イベントの受け渡しはaddTarget
とsendAction
で十分です。
標準と同じインターフェースなら、コードを読まなくても使えるコンポーネントになります。
- 最小限のクラス/プロトコルからはじめる
- 標準の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を指定する仕組みです。
-
canBecomeFirstResponder
でtrue
を返すようにしておく - 他の
UIResponder
にfirstResponder
が移った時にresignFirstResponder
が呼ばれるので便利
UIView
もUIViewController
もUIWindow
もすべてUIResponder
のサブクラスです。
入力のUI: UIResponder.inputView
入力系のUIコンポーネントがfirstResonderになった時、キーボードの代わりにカスタムUIを表示する仕組みがあります。
UIResponder
がfirstResponder
になった時、firstResponder
からrootまでUIResponderチェーンを駆け上がって、最初に返されるinputView
&inputAccessoryView
を表示します。
-
inputView
(キーボードの部分) -
inputAccessoryView
(キーボード上のツールバーの部分) -
inputView
とinputAccessoryView
を返すUIResponder
は別々で良い -
UIScrollView
のkeyboardDismissMode
に対応 -
resignFirstResponder
で自動で隠れる
UIResponderなのでフォーカス状態の管理をUIKitに任せることができる
UIResponder
はUIView
である必要はないので、Viewの階層に含まれないUIResponder
のサブクラスを実装してもいいんですよ。
(その場合は、自前でnext: UIResponder
を返します)
入力のUI: UIKeyInput
カスタムUIを表示するinputView
に対して、UIKeyInputは標準のキーボードを使いながら、そのキーボードの入力を受け取るためのシンプルなプロトコルです。
必要だったのはUITextFieldではなく、キーボードの入力だった
UIKeyInput
を実装したUIResponder
がfirstResponder
になると、キーボードが表示されます。
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())
}
}
シンプルな実装なので、挙動を予測しやすい。
まとめ
https://t.co/tkCGZ4Y6XU #potatotips
— 所 友太 (@tokorom) November 29, 2016
- UIKitの各クラス/プロトコルの役割を知る
- 最小限/適切なレイヤーで実装する
- 標準のコンポーネントと同じように振る舞う
- 5分で話す内容ではなかった