はじめに
iosの仕組みを概要的に説明することがモチベーションです。
想定読者はios〜中級者です。
仕組みをわかって実装するための、最低限の知識が体系的に得られるといいなあと思っております。
間違っていたらご指摘していただけると嬉しいです。
また、修正・追加して欲しい内容などございましたらこれもご指摘いただけるとありがたいです。
構成は以下になります。
1.View
2.Draw
3.Animation
4.Touch
Viewの根幹となる描画機能を3章まで説明し、最後にタッチ機能を説明します。
全体観
UIViewとはなんでしょうか?
ドキュメントを見てみると、たくさんのクラスやプロトコルから構成されているのがわかります。
これらの機能を取りまとめて管理するクラスがUIViewなのですが、
このうち根幹的な機能を担っているのは2番目のCALayerDelegateです。
つまり描画機能です。
画面に表示されるというのがViewとしての根幹機能であることは共感していただけるかと思います。
UIViewのほとんどメンバ変数メンバ関数が、どこに何を描画するのかに関するものでもあります。
この記事にわたって描画周りの仕組みを主に説明致します。
1.View
ヒエラルキー
UIViewのヒエラルキーには”親子関係”があります。
親viewは子viewをsubviews
パラメータに格納します。
この図の場合、①の子が②③、②の子が④です。
viewのヒエラルキーは、描画の順番に関連します。
描画
画面の表示は全て描画によるものです。
ざっくり説明すると、UIViewは描画キャンバスを1枚持っていて、frame
やbackgroundColor
などのパラメータをもとにキャンバスを描画し、画面に表示させます。
UITextViewのtext
やUIImageViewのimage
なども描画パラメータの一種と考えることができるかと思います。
また、このキャンバスは実はCALayerなのですが、後でまた説明します。
親viewから順番に上書き描画されていきます。subviewsは格納が早い順に描画されます。
上の図の場合、
描画の順番は
① -> ② -> ④ -> ③
になります。
自分で描画したい場合はdraw()
をoverrideします。
layout
1.Viewで主に説明したいのはこの章です。
少し長くなりますがご容赦ください。
frameとbounds
frame
が親座標系における原点と縦横幅、 bounds
が自分座標系における原点と縦横幅です。原点以外は一致します。
ただ原点は独立して変更可能です。bounds
はどこを切り取って表示するかを決めていて、デフォルトの原点は( 0.0, 0.0 )ですが、原点を変更すると表示される矩形がその方向にずれます。
(特別な理由がなければ必要ない操作とは思いますが)
※画像を入れる
指定方法
layoutの指定方法は3種類あります。
1.frame直指定
2.AutoResizing
3.AutoLayout
結論から言うと、3.AutoLayoutを普段使いにして、特別な場合に1.frameを使ったほうがいいのかなと思います。
理由と合わせて各layoutの説明します。
1.frame直指定
frameの値(+回転があるなら.transform)を参考にViewの描画位置が決められるのですが、frame(x/y/w/h)を直に設定します。
2と3は制約によってframeの値を指定する方法と言えます。端末サイズや親viewのframeサイズの変化にも動的に対応できるので便利です。
2.AutoResizing
親viewに対する辺の制約と自身のサイズ(w/h)を設定できます。
子view同士の制約はできないのでちょっと不便です。
AutoLayoutがなかった時代は主流だったようです。
3.AutoLayout
AutoResizingでは設定できなかった種類の制約も付けれるようになった制約です。
AutoResizingを補完するため(?)っぽい感じで後から生まれた制約です。
参考書ではAutoLayout推奨でした。
例えば、leadingAnchorなど設定するやつですね。
AutoLayoutは、
AutoResizingの上位互換であり、
画面サイズに動的に対応できるので、異なる端末間で同じレイアウトを作りたい時、回転させたい時、などにワンソースで対応できます。この点でframe指定よりも優れています。
なので、普段使いはAutoLayoutにして、配置後のviewの位置をドラッグなどで頻繁に動かすアプリなどではframeの方がやりやすいかも、といった感じです。これが先程の理由です。
注意すべき点は、frameやAutoResizing、とAutoLayoutを併用すると、
frameやAutoResizingの設定が内部で自動的にAutoLayoutに変換されることです。
結果、生のAutoLayoutとコンフリクトを起こす可能性があります。し、設定が実現すればいいやと言うスタンスで変換されるので、どんなAutoLayoutが出来上がってくるか予測がつきません。
さらに厄介なのは、そのView自身はAutoLayoutを使用していなくとも、他のViewからAutoLayoutの参照にされた場合も、自動的にAutoLayoutに変換されます。
これらの自動変換を止めるには、viewのtranslatesAutoresizingMaskIntoConstraints
をfalseに変更します。
なので、可能な限りlayout方法の併用は避けた方が良さそうです。
意図しないAutoLayoutが発生する可能性があります。
また、自動変換を止めた場合でも、AutoLayoutと他のlayoutのどちらが優先されるのかわからないという問題もあります。(frame指定であればAutoLayoutで上書きされますが、であれば不必要にframeを指定して混乱の元にする必要はないでしょう。AutoResizingを使う理由はもはやないですね)。
wrap_contentは実現できる
androidには、wrap_contentやmatch_parentといった便利な制約ショートカットが用意されていますが、iosにはないです。
でも、ショートカットが用意されてないだけで、iosでもほぼ任意の制約を作ることが可能です。
制約にはpriority
というパラメータがあります。
制約がバッティングした時、priority
が高い方が優先されます。
例えばwrap_contentで考えると、
親Viewのサイズを規定する制約のpriority
が低かったり、そもそも制約がなかったりする状態で、
子viewの制約が親viewに紐づいていると、子viewの制約が優先されて親viewが縮んだりします。
4辺全てにこれを適用するとwrap_contentが実現可能です。
intrinsic content size
特定のviewクラスはintrinsicContentSize
(本質的なサイズ)というメンバ変数を持っています。
例えば、UITextView文字列の大きさ、UIImageViewのimageの大きさなどがその本質的なサイズにあたります。
実は、デフォルトではこのサイズでviewが表示されるように制約がついています。
このデフォルト制約はpriorityが低いのですが、以下のメソッドで値の確認と上書きが可能です。
contentHuggingPriority(for:)
contentCompressionResistancePriority(for:)
setContentCompressionResistancePriority(_:for:)
setContentHuggingPriority(for:)
その他
・xibfileからでもcodeとほぼ同等のlayoutが実装可能です。
唯一UILayoutGuideだけxibからは使用不可だそうです
これは制約用viewのようなオブジェクトで、描画を行いません。スペーサーとして無色のviewを入れるしかないときに代用すると処理量を減らせます。
・xibfileからでも端末サイズごとに制約や色、文字列などのパラメータを設定可能です。
2.Draw
layer
全体観
iosではlayerという概念があり描画は全てlayerを通して行われます。
layerはそれぞれの描画環境(contextという)を持っていて、描画に必要な情報が全てこのcontextに渡されます。
そして、contextが描画されることで、端末画面にその結果が表示されます。
UIViewは1枚の専属layerを持っていて、layer
プロパティから参照できます。
UIViewの描画は全てこのlayer(とそのsublayer)による描画になります。
また、UIViewのパラメータから間接的にlayerのパラメータを設定できます。
(frame
やbackgroundColor
など)
これらの描画に必要な情報は最終的にCGContextに渡されます。
描画までの情報の流れはこんな感じ
UIView -> CALayer -> CGContext => 描画
context
contextとは、総合描画環境のようなもので、描画を行う本体です。
描画にはcontextが必須です。
iosではCGContextクラスのインスタンスとして提供されます。
AndroidでいうCanvasです。
画材道具一式と言い換えてもいいかと思います。
contextには描画範囲と描画方法を設定できます。
デフォルトの描画範囲は、そのcontextを持つlayerのframe
とbounds
プロパティで定義されます。
描画方法は、CAlayerやUIViewのパラメータを通して、背景色、画像、テキスト、pathやpathの色、塗りつぶし色、cornerRadius、影、、、などが設定することで設定できます。また、draw()をオーバライドすレバカスタムもできます。
ヒエラルキー
underlying layer
UIViewはlayer
プロパティにCALayerを一つ持っています。
参考書に倣ってこれをunderlying layerと呼称します。
UIViewのUIは全てこのunderlying layerの描画が司ります。
Viewとの関連性
underlying layerの描画順番と描画位置は、Viewの親子関係を反映します。
ただし、layerはsublayers
プロパティに子layerを格納できます。
上図の場合描画順は、1 -> 1-1 -> 1-1-1 -> 2 -> 3 -> 3-1 になります。
カスタム描画
viewやlayerのテンプレート的な描画を超えた描画をしたいときは、draw()をオーバライドする必要があります。
draw()はlayerの描画時に呼ばれるメソッドで、このメソッドの内で、contextの設定をして、それを描画するといった流れです。
draw()の設定方法
CAlayerのdraw()の設定方法は以下の3パターンです。
1.CALayerのサブクラスからdraw(in ctx: CGContext)
をoverrideする。
2.CALayerDelegateに設定したクラスからdraw(_ layer: CALayer, in ctx: CGContext)
を設定する。
3.UIViewのサブクラスからdraw(_ rect: CGRect)
をoverrideする。
12のパターンでは、contextがメソッドの引数として提供されます。
3のパターンでは、contextがメソッドの引数として提供されません。
これは描画方法に絡んでくるのですが、後述の描画例をみた方が早いでしょう。
Underlying layerの場合、デフォルトでUIViewがdelegateになっているので、3を使用します。
ただし注意として、生のUIViewを使用するべきです。例えば、UIImageViewのdraw()をoverrideすることは想定されていないため、描画が失敗する可能性があります。
野良CALayerの場合、1か2の方法のどちらかを選択します。
描画方法
描画方法は2種類あります。
Core Graphicを使う方法と、UIKitを使う方法です。
Core Graphicはiosの描画機能を全て提供します。
UIKitは多少機能が制限されますが、複数のCore Graphicメソッドをラップして簡略化して使いやすくしています。
なので、内部的にはどちらも同じことを行なっています。併用も可能です。
(個人的にはCore Graphicの方がわかりやすくて好みです。。)
各描画方法の詳しい例は他の記事に譲りますが、
基礎的な説明のために簡単な例として、実際に以下の青丸を描画してみましょう。
Core Graphicで描画
contextを直接操作する方法です。contextを取得する必要があります。
上記12のdraw()を使うときは引数のcontextを使用しますが、3のdraw()を使うときは、UIGraphicsGetCurrentContext()を使って、CurrentContextを取得する必要があります。
override func draw(in ctx: CGContext) {
ctx.addEllipse(in: CGRect(x: 0.0, y: 0.0, width: 100.0, height: 100.0) )// pathの追加
ctx.setFillColor(UIColor.blue.cgColor)// 塗りつぶし色の設定
ctx.fillPath()// パス内の塗りつぶし
}
override func draw(_ rect: CGRect) {
guard let ctx = UIGraphicsGetCurrentContext() else { assert(false) }
ctx.addEllipse(in: CGRect(x: 0.0, y: 0.0, width: 100.0, height: 100.0) )
ctx.setFillColor(UIColor.blue.cgColor)
ctx.fillPath()
}
UIKitで描画
CurrentContextに描画する方法です。contextを取得する必要はありません。
上記12のdraw()を使うときは、引数のcontextをUIGraphicsPushContext()によってCurrentContextに設定する必要があります。
3のdraw()を使うときは、自動的に対象contextがCurrentContextに設定されているので、何かする必要はありません。
layerサブクラスのdraw(_ rect: CGRect)
を使用する場合は、UIGraphicsGetCurrentContext()でcurrent contextを取得します。
delegateのdraw(_ layer: CALayer, in ctx: CGContext)
を使用する場合は、UIGraphicsPushContext()によって、引数のcontextをCurrentContextに設定する必要があります。
override func draw(in ctx: CGContext) {
UIGraphicsPushContext(ctx)
let p = UIBezierPath(ovalIn: CGRect(x: 0.0, y: 0.0, width: 100.0, height: 100.0))
UIColor.blue.setFill()
p.fill()
}
override func draw(_ rect: CGRect) {
let p = UIBezierPath(ovalIn: CGRect(x: 0.0, y: 0.0, width: 100.0, height: 100.0))
UIColor.blue.setFill()
p.fill()
}
Layerテンプレート
CALayerを継承したテンプレートLayerが用意されており、これを使うと、
わざわざdraw()を設定するためのクラスを作成しなくても、layerのcontextを設定することが可能です。
テンプレートごとに用途は限定されるのですが、context自己設定機能は便利なので使う時も多いかと思います。
CAShapeLayer
path
プロパティを持ち、pathの描画を登録できます。
一番使う頻度が高いカスタムlayerかもしれません。
CATextLayer
文字列の描画を登録できます。
CAGradientLayer
グラデーションの描画を登録できます。
例えば、CAShapeLayerで青丸を描画するコードは以下です。
override func viewWillAppear(_ animated: Bool) {
super .viewWillAppear(animated)
let sl = CAShapeLayer()
sl.path = CGPath(
ellipseIn: CGRect(x: 0.0, y: 0.0, width: 100.0, height: 100.0),
transform: nil)
sl.fillColor = UIColor.blue.cgColor
sl.frame = view.layer.bounds
view.layer.addSublayer(sl)
sl.setNeedsDisplay()// sublayerの描画
}
その他
clipToBoundと影の併用
layerのclipToBounds
パラメータは、bounds外への描画を許可するかどうかを決めます。
layerの影はshadowColor
,shadowOffset
,shadowRadius
,shadowOpacity
プロパティによって設定でき、alphaが0でないpixelに対して影の描画がそのlayerのcontextに追加されます。
そしてこの影は、bounds外にはみ出ることがあります。
clipToBounds
がtrueになっていると、はみ出た影は描画されません。
解決方法は、影をつけたいlayerのbounds
と同じ大きさの親layerを作り、sublayerとして持たせることです。親layerに対して影をつけることで、clipToBoundと影の併用が実現できます。
描画しない方法 clear/clip/mask ※保留
3.Animation ※保留
4.Touch
この章では、UIViewの機能の中で、描画の次にたくさん触れるであろうタッチ機能を説明します。
タップ情報取得までの流れ
ざっくりいうと、以下の流れでタップ情報がViewまで届きます。
デバイスがタップ情報を検知
↓
applicationに渡される
↓
該当のViewに渡される
以下もう少し詳細を説明します。
UITouchとUIEvent
UITouch
指一つあたりに一つインスタンスが生成されます。
その指が離れるまで同じインスタンスが保持され、離れるとデリートされます。
タップに関する情報を持っています。(タップ座標、開始時のview、今タップ中のviewなど)
UIEvent
全てのUITouchインスタンスを保持します。
UITouchの数が0->1になった時にインスタンスが生成され、1->0になった時にインスタンスがデリートします。
UITouchの状態
UITouchは、状態を格納するphase
パラメータを持ちます。
主に以下の値を取ります。
.began
: タッチ開始
.moved
: タッチが移動
.stationary
: タッチがその場にとどまる。(変化なし)
.ended
: タッチ終了
.canceled
: タッチ中断
UIViewがタップ情報を受け取る
UIEventを受け取って処理するためのUIResponderというクラスが存在します。
最初の写真からもわかりますが、UIViewはUIResponderを継承しています。
どれか一つでもUITouchインスタンスのphaseの値が変化すると、UIEventインスタンスが該当のview(phaseが変化したUITouch座標のview)に送られます。
UIResponderは、UITouchのphaseに対応した検知メソッドを持ちます。変化したphaseに対応する検知メソッドが一つだけ呼ばれます。
touchesBegan(Set<UITouch>, with: UIEvent?)
touchesMoved(Set<UITouch>, with: UIEvent?)
touchesEnded(Set<UITouch>, with: UIEvent?)
touchesCancelled(Set<UITouch>, with: UIEvent?)
タップ検知
方法1:生のタップ情報を使う(UIResponder)
UIResponderの上記検知メソッドを使用します。
自由度が高く、複雑なタップ検知を設定可能ですが、その反面簡単なタップ検知も設定が難しくなりがちです。
可能なら、UIGestureRecognizerのテンプレートを使った方が楽で良いかと思います。
3タップ後にドラッグして再度タップ、などの特殊なタップ検知などで必要になります。
以前簡単な例を記事を書きました。
方法2:UIGestureRecognizerを使う。
実は、viewが受け取ったタップ情報は、viewのもつUIGestureRecognizerインスタンスにも送られます。
ViewはgestureRecognizers
パラメータにインスタンスを格納しています。
UIGestureRecognizerはUIResponderではないのですが、UIResponderの検知メソッドとほぼ同じメソッドを持ち、UIRecognizerと同様の働きをします。(どうしてこの二つを別々のクラスにしたのか不思議ですが、Androidも同様な仕組みが存在した気がします。)
https://developer.apple.com/documentation/uikit/uigesturerecognizer
テンプレート
UIGestureRecognizerは豊富なテンプレートを持っています。
ワンタップ、ドラッグ、ピンチ、回転、ダブルタップ、、、
などなど典型的なタップ検知は大概用意してくれています。大概この範囲で済みそうですね。
こちらも以前簡単な例を記事を書きました。
参考文献