Android CustomView/CustomLayoutの作り方

このエントリーをはてなブックマークに追加

AndroidのCustomView/CustomLayoutを作るには沼が深いのでそれなりの情報をまとめておきます

まだ完全に理解ができてないので詰めが甘いところがあるかもしれませんが、基本は抑えていきたいと思います

公式のドキュメントはここ

カスタムビュー コンポーネントを作成する  |  Views  |  Android Developers

Android には、基本的なレイアウト クラスである View と ViewGroup をベースとする高度で強力な UI 作成用コンポーネント化モデルが用意されています。プラットフォームには、ビルド済みのさまざまな View サブクラスと ViewGroup サブクラス(ウィジェットおよびレイアウト…

developer.android.com

カスタムビュー コンポーネントを作成する  |  Views  |  Android Developers
Go to カスタムビュー コンポーネントを作成する  |  Views  |  Android Developers

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クラスを使うことによってsizemodeを取得したり、sizemodeからMeasureSpecを作成することができます。
このmodeというのがCustomLayoutでは重要で、親Viewがどのようにサイズを指定してきてるかの情報になります。modeは以下の3種類です:

  • AT_MOST
    • sizeで指定した以下のサイズにしなければならない
  • EXACTLY
    • sizeで指定したサイズにしなければならない
  • UNSPECIFIED
    • 好きなようにサイズを決めてよい
    • sizeには0が設定されてることがあるので注意

このモードによってwrap_contentmatch_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.getMeasuredWidthView.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

platform_frameworks_base/core/java/android/widget/FrameLayout.java at 6d891937a38220b0c712a1927f969e74bea3a0f3 · aosp-mirror/platform_frameworks_base
Go to platform_frameworks_base/core/java/android/widget/FrameLayout.java at 6d891937a38220b0c712a1927f969e74bea3a0f3 · aosp-mirror/platform_frameworks_base

savedInstance系の実装

Activity再生成などでViewの状態を保持するにはsavedInstance系の実装が必要になります。

https://developer.android.com/reference/android/view/View.html#onSaveInstanceState()

https://developer.android.com/reference/android/view/View.html#onRestoreInstanceState(android.os.Parcelable)

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

GitHub - MeilCli/AndroidCustomViewSample
Go to GitHub - MeilCli/AndroidCustomViewSample







サンプルと言ったな、あれは嘘だ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_targetAnchorlayout_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.GravityLayoutParams.AnchorIdLayoutParams.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);

できたもの

Layout.xmlはここ

通常時

Rtl時

通常時の境界表示

とりあげてない細かいところ

onMeasureでは脳筋な感じにView.measureView.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の世界へ