AndroidのCustomView/CustomLayoutを作るには沼が深いのでそれなりの情報をまとめておきます
まだ完全に理解ができてないので詰めが甘いところがあるかもしれませんが、基本は抑えていきたいと思います
公式のドキュメントはここ
カスタムビュー コンポーネントを作成する | Views | Android Developers
Android には、基本的なレイアウト クラスである View と ViewGroup をベースとする高度で強力な UI 作成用コンポーネント化モデルが用意されています。プラットフォームには、ビルド済みのさまざまな View サブクラスと ViewGroup サブクラス(ウィジェットおよびレイアウト…
developer.android.com
CustomViewを作る際に必要になるもの
以下の要素が必要になってきたりならなかったりします
- クラス(Viewやその派生クラスを継承)
- コンストラクター
- onDrawの実装
- onMeasureの実装
- savedInstance系の実装
- attrs.xmlで属性の用意
ただ、実際にはViewGroupの派生クラスとしてCustomViewを作ることが多いと思うので、CustomLayoutの作り方に触れていきます
CustomLayoutを作る際に必要になるもの
以下の要素が必要になってきたりならなかったりします
- クラス(ViewGroupやその派生クラスを継承)
- コンストラクター
- onDrawの実装
- onMeasureの実装
- onLayoutの実装
- LayoutParamsの用意
- LayoutParams系メソッドの実装
- savedInstance系の実装
- attrs.xmlで属性の用意
要素の説明をしていきます
コンストラクター
https://developer.android.com/reference/android/view/View.html#View(android.content.Context)
基底クラスとなるViewにはコンストラクターが4つ用意されています。
その中で必ず必要となってくるのはView(Context)
コンストラクターで、その他はLayout.xmlからViewを生成する際に必要とされます。
基本的には4つのコンストラクターすべてを派生してやればいいです。(Layout.xmlから生成することがないのであれば、Contextのみのコンストラクターだけでもいいし、引数を増やしたコンストラクターを用意してもいいでしょう)
onDrawの実装
滅多にオーバーライドして実装しないのであまり詳しくはありませんが、Drawableを操作する場合や、Canvasで描画する際に使います。
ImageViewやTextViewを再利用しておけば基本的には実装することはないと思いますので(割愛
onMeasureの実装
https://developer.android.com/reference/android/view/View.html#onMeasure(int,%20int)
onMeasureは自身と子のViewのサイズを確定させるのに使います。既存クラスのサイズ計算ロジックを再利用しない場合に使うことになります。
引数のonMeasure(int, int)
は、親Viewから指示されたwidthとheightのMeasureSpec
となっています。
sizeとmodeが合わさった値なので注意
https://developer.android.com/reference/android/view/View.MeasureSpec
MeasureSpec
クラスを使うことによってsize
とmode
を取得したり、size
とmode
からMeasureSpec
を作成することができます。
このmode
というのがCustomLayoutでは重要で、親Viewがどのようにサイズを指定してきてるかの情報になります。mode
は以下の3種類です:
- AT_MOST
size
で指定した以下のサイズにしなければならない
- EXACTLY
size
で指定したサイズにしなければならない
- UNSPECIFIED
- 好きなようにサイズを決めてよい
size
には0
が設定されてることがあるので注意
このモードによってwrap_content
やmatch_parent
が実現されています。
https://developer.android.com/reference/android/view/View.html#measure(int,%20int)
子のサイズを決めるには、子Viewのmeasure
メソッドを呼び出す必要があります。このメソッドの引数はMeasureSpecなので親Viewから指示されたMeasureSpecから子Viewに渡すMeasureSpecを作らなければなりません。
https://developer.android.com/reference/android/view/View.html#setMeasuredDimension(int,%20int)
自身のサイズを決めるには、setMeasuredDimension
メソッドを呼び出す必要がありますが、こちらはMeasureSpecではないので注意。
onLayoutの実装
onMeasureの後に呼ばれ、子Viewを配置していくメソッドです。基底クラスがFrameLayoutだったりする場合は既定クラスのonLayoutの実装を使うことで実装する必要がなくなったりしますが、自前で配置したいときは実装する必要があります。
配置するには子ViewのView.layout
メソッドを呼び出せばいいだけですが、配置箇所の計算にはView.getMeasuredWidth
やView.getMeasuredHeight
を利用する必要があります。
LayoutParamsの実装とLayoutParams系メソッドの実装
LinearLayoutやFrameLayoutなどの、android:layout_marginTop
などのlayout_***
属性はLayoutParams系統の実装をすることによって実現しています。子Viewに属性を書くことによって親View(CustomLayout)内での配置などを変えたい場合はLayoutParams系統を実装するとよいでしょう。
実装する場合に必要になるのは以下のものです
- ViewGroup.LayoutParamsから派生したLayoutParamsクラス
android:layout_margin
に対応する場合はMarginLayoutParamsから派生
ViewGroup.LayoutParams generateDefaultLayoutParams()
メソッドのオーバーライドViewGroup.LayoutParams generateLayoutParams(AttributeSet)
メソッドのオーバーライドViewGroup.LayoutParams generateLayoutParams(ViewGroup.LayoutParams)
メソッドのオーバーライドboolean checkLayoutParams(ViewGroup.LayoutParams)
メソッドのオーバーライド
LayoutParamsの実装についてはFrameLayoutが参考になるかなと思います
platform_frameworks_base/core/java/android/widget/FrameLayout.java at 6d891937a38220b0c712a1927f969e74bea3a0f3 · aosp-mirror/platform_frameworks_base
Contribute to aosp-mirror/platform_frameworks_base development by creating an account on GitHub.
github.com
savedInstance系の実装
Activity再生成などでViewの状態を保持するにはsavedInstance系の実装が必要になります。
https://developer.android.com/reference/android/view/View.html#onSaveInstanceState()
Parcelableのところに関してはBaseSavedStateを継承したものを用意するといった実装がありますが、Bundleでもいいんじゃないかなーーとも思ってたり(未検証
attrs.xmlで属性の用意
layout.xmlでカスタム属性を使いたい場合はattrs.xmlで宣言しておく必要があります。(正確に言えばファイル名はどうでもいいですけど)
<?xml version="1.0" encoding="utf-8" ?>
<resources>
<declare-styleable name="CustomView">
<attr name="customAttribute" format="reference"/>
</declare-styleable>
</resources>
attrの細かい定義方法は、ググればでてくるので(割愛
サンプル的な感じでCustomLayout作った
実際に作ったものを見たほうが理解しやすいと思うので、サンプル的なものを作ってみましたー
CoordinatorLayoutのanchor機能を実装してみましたが、中身は結構てきとーなので仕事などで採用する際はもうちょっと隅々まで実装してください;;
サンプル的なやつ↓
GitHub - MeilCli/AndroidCustomViewSample
Contribute to MeilCli/AndroidCustomViewSample development by creating an account on GitHub.
github.com
サンプルと言ったな、あれは嘘だXamarin.Androidでの実装です
AndroidとXamarin.Android APIの違い
純粋なAndroider向けに説明すると、UPPER_SNAKE_CASEが滅ぼされていたり、interfaceにprefix I
が付いていたりするユートピアです!!!
今回関係してくるところで言えば、gravityがint
ではなくちゃんとGravityFlags
列挙型になっていたりします。
作るものの仕様
サンプルなので細かいところは詰め込まず、CoordinatorLayoutのanchor機能に集中した感じにします。
そもそもanchor機能とはなんぞやを説明すると
app:layout_anchor
で起点となるViewを指定app:layout_anchorGravity
で起点となるViewのどこを起点とするかを指定android:layout_gravity
で起点に対してどこに配置するかを指定
といった感じです。
また、app:layout_anchor
が指定されていない子Viewのandroid:layout_gravity
はFrameLayoutと同じ配置になります。
あと名前はてきとーにAnchorLayout
としておきましょうか。
まずはLayoutParams系を準備する
LayoutParamsを準備することになりますが、今回は属性も宣言する必要があるので、Attributes.xml
から定義します
<?xml version="1.0" encoding="utf-8" ?>
<resources>
<declare-styleable name="AnchorLayout_LayoutParams">
<attr name="android:layout_gravity"/>
<attr name="layout_targetAnchor" format="reference"/>
<!-- Constant value defined in https://developer.android.com/reference/android/view/Gravity -->
<attr name="layout_targetAnchorGravity">
<flag name="top" value="48"/>
<flag name="bottom" value="80"/>
<flag name="left" value="3"/>
<flag name="right" value="5"/>
<flag name="start" value="8388611"/>
<flag name="end" value="8388613"/>
<flag name="center" value="17"/>
<flag name="center_horizontal" value="1"/>
<flag name="center_vertical" value="16"/>
</attr>
</declare-styleable>
</resources>
android:layout_gravity
は再利用で、layout_targetAnchor
とlayout_targetAnchorGravity
を宣言します。
(本当はlayout_anchor
にしたかったけどSupportLibraryのほうとコンフリクトして、コンフリクトが解決できなかったので断念)
また、layout_targetAnchorGravity
でもXamarin.Android特有のGravityFlags
を再利用したかったので、値はコピってます。
ちなみにandroid:layout_gravityの定義はここにあったりします。
そして、その宣言した属性を実際に使えるようにLayoutParamsを実装します。(実装したコード)
public LayoutParams(Context c, IAttributeSet attrs) : base(c, attrs)
{
TypedArray a = c.ObtainStyledAttributes(attrs, Resource.Styleable.AnchorLayout_LayoutParams);
Gravity = (GravityFlags)a.GetInt(Resource.Styleable.AnchorLayout_LayoutParams_android_layout_gravity, (int)GravityFlags.NoGravity);
AnchorId = a.GetResourceId(Resource.Styleable.AnchorLayout_LayoutParams_layout_targetAnchor, 0);
AnchorGravity = (GravityFlags)a.GetInt(Resource.Styleable.AnchorLayout_LayoutParams_layout_targetAnchorGravity, (int)GravityFlags.NoGravity);
a.Recycle();
}
このようにコンストラクターを作ることで属性を取得できますが、基底クラスのLayoutParamsをソースとすることがあったり、コード上でLayoutParamsを作成できるように、コンストラクターはこれ以外にも用意しておく必要があります。
onMeasureの実装
実装するものはたいした機能のないCustomLayoutなので、onMeasureでサイズを決め、onLayoutで配置するコードを実装すればいいです。
onMeasureのコードはここですが、簡単に説明をすると
int width = MeasureSpec.GetSize(widthMeasureSpec);
int height = MeasureSpec.GetSize(heightMeasureSpec);
MeasureSpecMode widthMode = MeasureSpec.GetMode(widthMeasureSpec);
MeasureSpecMode heightMode = MeasureSpec.GetMode(heightMeasureSpec);
最初に親Viewからの指示を分解して
child.Measure(
makeChildMeasureSpec(child, width, widthMode, childLayoutParameters.Width),
makeChildMeasureSpec(child, height, heightMode, childLayoutParameters.Height)
);
子Viewに対してLayoutParamsや親Viewからの指示を加味して、MeasureSpecを作成し、View.Measure
を呼び出して子Viewにサイズを計算させ
maxChildWidth = Math.Max(maxChildWidth, child.MeasuredWidth);
maxChildHeight = Math.Max(maxChildHeight, child.MeasuredHeight);
子Viewで一番大きいサイズを計算しておき
int contentWidth = widthMode == MeasureSpecMode.Exactly ? width : maxChildWidth;
int contentHeight = heightMode == MeasureSpecMode.Exactly ? height : maxChildHeight;
SetMeasuredDimension(contentWidth, contentHeight);
親Viewからの指示を加味しながら、自身のサイズを確定させる
onLayoutの実装
onMeasureでサイズが確定されたので、あとは子Viewを配置するだけです
onLayoutのコードはここですが、簡単に説明すると
int parentTop = PaddingTop;
int parentBottom = b - t - PaddingBottom;
int parentLeft = PaddingLeft;
int parentRight = r - l - PaddingRight;
まず自身のコンテンツ領域の境界値を求めておいて、子ViewのサイズとLayoutParams.Gravity
とLayoutParams.AnchorId
とLayoutParams.AnchorGravity
を利用して位置を決めます。
そのままだとロジックがややこしくなるので、gravityの計算と、gravityと位置情報からViewのleft/topの位置を計算する処理に分けます。
gravityの計算を抜粋するとこんなかんじです:
private GravityFlags convertRelativeHorizontalGravity(GravityFlags gravity)
{
GravityFlags absoluteGravity = Gravity.GetAbsoluteGravity(gravity, (GravityFlags)LayoutDirection);
switch (absoluteGravity & GravityFlags.HorizontalGravityMask)
{
case GravityFlags.CenterHorizontal:
return GravityFlags.Center;
case GravityFlags.Right:
return GravityFlags.End;
case GravityFlags.Left:
default:
return GravityFlags.Start;
}
}
水平方向はRtlの対応が必要なのでGravity.GetAbsoluteGravity
メソッドを利用しています。また、gravityはフラグ値なのでabsoluteGravity & GravityFlags.HorizontalGravityMask
で水平方向の値を算出してます。
算出したら水平方向の値をCenter
/Start
/End
に分類し、垂直方向も同じように3分類し、このあとの位置計算メソッドの共通化をします。
位置計算メソッドはanchorが存在するときとしないときでわけることにしました、ここではより複雑なanchorが存在するときをとりあげます:
private int calculateLayoutPositionWithAnchor(int anchorStart, int anchorEnd, int size, GravityFlags gravity, GravityFlags anchorGravity)
{
int basePoint;
switch (anchorGravity)
{
case GravityFlags.Center:
basePoint = (anchorStart + anchorEnd) / 2;
break;
case GravityFlags.End:
basePoint = anchorEnd;
break;
case GravityFlags.Start:
default:
basePoint = anchorStart;
break;
}
switch (gravity)
{
case GravityFlags.Center:
return basePoint - size / 2;
case GravityFlags.End:
return basePoint;
case GravityFlags.Start:
default:
return basePoint - size;
}
}
anchorStart
にはleft
またはtop
の値が入りanchorEnd
にはright
またはbottom
の値が入ります。anchiorGravity
によって変わる起点(basePoint)を計算してから、gravity
の値で子Viewのleftまたはtopの値となるものを算出します。
ここまでやったらあとは子Viewに配置を指示するだけです。
child.Layout(left, top, left + width, top + height);
できたもの
通常時
Rtl時
通常時の境界表示
とりあげてない細かいところ
onMeasureでは脳筋な感じにView.measure
とView.setMeasuredDimension
を使ってごりごり実装しましたが、いちおう便利メソッド?があるようで、ガチな実装をする場合には使用することも考えたほうがよさそうです。
- ViewGroup.getChildMeasureSpec
- 親ViewからのMeasureSpecと子VewのLayoutParamsを加味した子ViewのMeasureSpec計算メソッドっぽい?
- ViewGroup.measureChild
- 子Viewのmeasureをpaddingを考慮しながらやってくれるみたい?
- ViewGroup.measureChildWithMargin
- ViewGroup.measureChildにmargin計算が混ざったもの?
- View.resolveSizeAndState
- 実はView.setMeasuredDimensionではMeasureStateという情報も付与(ビット演算)できるようだが、引数からそれらを加味したsizeを計算してくれるみたい?
CustomLayoutの沼ポイント
- onMeasureが複数回呼ばれることがある
- 親Layoutで子Viewのサイズを仮決定したいときがあるので、そういう場合に複数回onMeasureが走ることがあるので、その対応も考えないといけない
- 自身のmarginやpaddingを含めたspecがonMeasureで降ってくる?
- たぶん自分の実装考慮ができてないだけだと思うが、marginやpaddingに対応しようとすると計算がややこしくなるので沼
- …etc
最後に
ようこそCustomLayoutの世界へ