Edited at

Androidで独自Viewを作るときの4つのTips +3

More than 1 year has passed since last update.

独自のViewを作るときに困ったことがたくさんあったので、まとめておこうと思います。

(ところで独自Viewのことは何て呼ぶんでしょうか? Custom View? Custom Component? Custom Widget?)


コンストラクタの作りかた

Viewにはコンストラクタが3種類存在します。(この辺を見るとわかります

基本的に全部オーバーライドしておけば問題ありませんでした。

<追記 2016/01/19>

API Level 21 からコンストラクタが4種類に増えたようです。

引数が4つのコンストラクタを Lolipop 未満のOSから呼び出すと InvocationTargetException を起こすので、オーバーライドの際にはバージョン分岐などが必要そうです。

</追記 2016/01/19>

XMLで定義したLayoutからインスタンス化された時には、引数が2つのコンストラクタが呼ばれるようです。

また、AndroidのSDKのソースを見ると流儀があるようで、引数の多いコンストラクタに初期化処理を全部任せるようです。

具体的にはこんな感じ。


OriginalView.java


public class OriginalView extends View {

public OriginalView(Context context) {
this(context, null);
}

public OriginalView(Context context, AttributeSet attrs) {
this(context, attrs, R.attr.originalViewStyle);
}

public OriginalView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);

// このへんで初期化処理

}

// 以下略



独自のXML属性を作って、セットされた値を取得する

resourceのXML(attrs.xml)側とJavaのコード側の両方で作業が必要になります。


XML側

<declare-styleable>を使って、どんな属性があるかを定義します。

中身の書き方についてはこちらの記事が詳しい。

公式の方にも多少書かれてます

format属性の中身は|で区切れるらしいのですが、どうやって使えばいいのかイマイチよくわからないです。

詳しい人がいたら教えて下さい。


attrs.xml

<resources>

<declare-styleable name="OriginalView">
<attr name="hoge_int" format="integer" />
<attr name="fuga_color" format="color" />
<attr name="moge_str" format="string" />

<!-- 多分、colorとreferenceどちらも指定できるんだと思う -->
<attr name="foo" format="color|reference" />
</declare-styleable>

<declare-styleable name="Themes">
<attr name="originalViewStyle" format="reference" />
</declare-styleable>
</resources>



Javaコード側

引数が2つ、もしくは3つあるコンストラクタの第二引数があれば、XMLの属性値を取得できるようです。

TypedArrayとして値を取り出して使用します。


OriginalView.java

    public OriginalView(Context context, AttributeSet attrs, int defStyleAttr) {

super(context, attrs, defStyleAttr);

TypedArray a = context.obtainStyledAttributes(attrs, com.android.internal.R.styleable.View,
defStyleAttr, 0);

int hogeInt = a.getInteger(R.stylable.OriginalView_hoge_int, 0);
int fugaColor = a.getInteger(R.stylable.OriginalView_fuga_color, Color.BLACK);
String mogeStr = a.getInteger(R.stylable.OriginalView_moge_str);

a.recycle();
}



layoutで使うとき

実際にlayoutでオリジナルの属性を使うときは、xmlnsの拡張が必要です。


HogeHogeActivity.xml

<LinearLayout

xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:original="http://schemas.android.com/apk/res-auto">

これでoriginal名前空間使って独自要素を記述できます。


HogeHogeActivity.xml

<View

class="net.kikuchy.MyViewSample.OriginamView"
original:hoge_int="30"
original:fuga_color="#ff000000"
original:moge_str="定時だ帰るぞ!!!!!!" />


layer-listに入れたid付きのitemを取得したい

ProgressBarの見た目を変えたいときには、背景部分の見た目と動く棒の見た目を別個のDrawableにせず、

layer-listに入れたid付きのitemを使って一つのDrawableにまとめることができます。

文章だとわかりづらいのでソースで。


drawable/progress.xml

<layer-list xmlns:android="http://schemas.android.com/apk/res/android">

<!-- 背景の方の見た目 -->
<item android:id="@android:id/background">
<shape android:shape="rectangle">
<solid android:color="#efefef" />
</shape>
</item>

<!-- 動く棒の方の見た目 -->
<item android:id="@android:id/progress">
<shape android:shape="rectangle">
<solid android:color="#222222" />
</shape>
</item>
</layer-list>


こんな風に、一つのDrawableにいくつかの見た目を格納しておいて、後でidで取り出したいときにはどうしたらいいのか、という話。


取り出し方

findDrawableByLayerId()を使います。

事前に、どうにかしてlayer-listをソースコード側で取得しておきます。


OriginalView.java

LayerDrawable layer = (LayerDrawable)getResources().getDrawable(R.drawable.progress);

Drawable progress = layer.findDrawableByLayerId(android.R.id.progress);

引数のResource IDは適当なものにしておきます。

SDKでも使われているようなIDを使いたければandroid名前空間にあるidを使えば上記のようにできます。

独自のidが使いたければ、適当に定義して使ってください。


Drawableを使ってCanvasに描画したい

Drawableを取得できていれば、サイズの指定を行うだけですぐに描画できます。


OriginalView.java

@Override

protected void onDraw(Canvas canvas) {
Drawable d = (どうにかして取得してくる);
d.setBounds(x, y, width, height);
d.draw(canvas);
}


文字列がぴったり収まるような図形を描きたい

Paint.FontMetricsというクラスを使います。

こちらのページがとてもわかりやすいので参照のこと。


xmlで書いたlayoutを一つのViewとして扱いたい (2015/9/12追記、2017/11/22修正)

例えばこんな複雑なlayoutファイルがあったとき。


res/layout/complex_layout.xml

<FrameLayout

android:layout_width="match_parent"
android:layout_height="match_parent" >
<ImageView
android:id="@+id/face_picture" />
<TextView ... />
<Button ... />
...
</FrameLayout>

このレイアウトを一つのViewとして扱えるようにして、

他のlayoutから( includemerge を使わずに、単一Viewとして)使いたいとき、


res/layout/main_activity.xml

...

<net.kikuchy.MyViewSample.ComplexView
android:layout_width="match_parent"
android:layout_height="match_parent" />

あるいは独自のセッターを持たせたりしたいときにどうしたらいいか。


OriginalListAdapter.java

...

@Override
public View getView (int position, View convertView, ViewGroup parent) {
SomeEntity entityData = mEntities.get(position);
ComplexView singleRow = (ComplexView) convertView;
if (singleRow == null) singleRow = new ComplexView(mContext);
singleRow.setEntity(entityData); // <- こんなセッターが欲しい
return singleRow;
}

...


独自Viewクラスの引数が三つあるコンストラクタの中で、layoutファイルをinflateしてしまえばお行儀よく実現できます。


res/layout/main_activity.xml

<!-- root要素を merge に変更。 -->

<!-- 元のroot要素はtools:parentTag属性に記述しておきます -->
<marge
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:parentTag="LinearLayout" >
<ImageView
android:id="@+id/face_picture" />
<TextView ... />
<Button ... />
...
</merge>


ComplexView.java

public class ComplexView extends FrameLayout {   // layoutファイルの元のroot要素をextendするのがミソ

...
// 残りの2つのコンストラクタは、この記事上部の「コンストラクタの作り方」の通りになっているとします。

public ComplexView(ontext context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);

// 第3引数にtrueを渡すと、このComplexViewの直下にレイアウトのmergeの子要素を展開してくれる
LayoutInflater.from(context).inflate(R.layout.complex_layout, this, true);

// あらかじめ、後で使うViewのインスタンスはメンバとして掴んでおく
mFacePicture = (ImageView) findViewById(R.id.face_picture);
...
}

// エンティティの要素を独自ビューのパーツに流し込む
public void setEntity(SomeEntity entity) {
mFacePicture.setImageUri(entity.getFaceImageUri());
...
}

...


inflateの仕方はこちらの記事を、tools:parentTagの使い方はこちらのStackOverflowを参考にしました。

<追記 2017/11/22>

merge を使ってView階層がフラットになるようにしました。

</追記 2017/11/22>


画面回転とかしてもインスタンスの状態を保持したい (2016/04/08追記)

"Don't keep Activity" (アクティビティを保持しない)がONになっていたり低スペック端末だったりすると、画面を回転したりアプリをバックグラウンドに入れたりするとViewは破棄され、再生成されます。

再生成されても状態を保持したい場合は、 View.BaseSavedState を実装したクラスを用意し、Viewの onSaveInstanceState で状態の保存、 onRestoreInstanceState で状態の復元を行う必要があります。

AOSPのクラスを見ると、大まかに以下のように実装すれば良さそうです。


OriginalView.java

public class OriginalView extends View {

// 保持したいパラメーターたち
private int paramA;
private int paramB;
private String[] paramC;

...

@Override
protected Parcelable onSaveInstanceState() {
Parcelable superState = super.onSaveInstanceState();

// 以下で作成する SavedState クラスに保持したいパラメーターを移しておく。
SavedState ss = new SavedState(superState);
ss.a = paramA;
ss.b = paramB;
ss.c = paramC;
return ss;
}

@Override
public void onRestoreInstanceState(Parcelable state) {
// この辺はボイラープレート。以下で作成する SavedSate だったらそれにキャストする。
if (!(state instanceof SavedState)) {
super.onRestoreInstanceState(state);
return;
}
SavedState ss = (SavedState)state;
super.onRestoreInstanceState(ss.getSuperState());

// 保持していた値を取り出す
paramA = ss.a;
paramB = ss.b;
paramC = ss.c;
// 表示の更新とかが必要ならここでやってしまう
}

...

public static class SavedState extends BaseSavedState {
// 親クラス(対象のView)の保持したいパラメーターと同じ型のメンバを持っておく
int a;
int b;
String[] c;

// コンストラクタは二つ
// Parcelable が引数のコンストラクタはsuperを呼び出すだけ
SavedState(Parcelable superState) {
super(superState);
}

// Parcel が引数のコンストラクタでは、保存した値を取り出す
// 以下の CREATOR からしか使われないっぽいので、このコンストラクタは private でも良いらしい
SavedState(Parcel source) {
super(source);

// 読み出す順番は writeToParcel で書き込む順番と同じにする
a = source.readInt();
b = source.readInt();
// createXXXArray は、配列の「領域確保と読み出し」を行うメソッド
c = source.createStringArray();
// readXXXArray は、すでに領域確保済みの配列に対して読み出しを行うメソッド
// c = new String[10];
// source.readStringArray(c);
}

// writeToParcel で値を保存する
@Override
public void writeToParcel(Parcel out, int flags) {
super.writeToParcel(out, flags);

out.writeInt(a);
out.writeInt(b);
out.writeStringArray(c);
}

// BaseSavedState は Parcelable を継承しているので、non-null な Parcelable.Creator<SavedState> CREATOR をstaticフィールドに持っている必要がある
@SuppressWarnings("hiding")
public static final Parcelable.Creator<SavedState> CREATOR
= new Parcelable.Creator<SavedState>() {
public SavedState createFromParcel(Parcel in) {
return new SavedState(in);
}

public SavedState[] newArray(int size) {
return new SavedState[size];
}
};
}
}


Parcel に書き込める型は意外と種類が豊富なので(FileDescriptorとかも保存できる)、一度 ドキュメントのメソッド一覧 には目を通しておくと良いと思います。

ただドキュメントの説明はけっこう不親切で、 createStringArray()readStringArray() の違いなどが書かれていません。

createXXXArray 系は配列の作成と値の読み込みを同時に行ってくれるもので、

readXXXArray 系は、すでに作成済みの配列に値の読み込みを行ってくれます。

長さが足りない配列に readXXXArray を使おうとすると RuntimeException: bad array lengths の例外が出るのでご注意。

(参考: StackOverflow


スタイルで宣言されているあの値を使いたい(2017/11/23追記)

Toolbar など、スタイルで指定した colorPrimarycolorAccent

を自動的に使用してくれるViewがありますよね。

柔軟性を高めるためとか、自分でもそうしたスタイルに指定されたリソースを使用したいことがあります。

コードからは以下のようにして取得することができます。


ThemeUsingView.java

TypedValue d = new TypedValue();

TypedArray a = getContext().obtainStyledAttributes(
d.data,
new int[] { R.attr.colorPrimary }
);

// このprimaryColorが使いたい色
int primaryColor = a.getColor(0, 0);

// TypedArrayは忘れずにrecycleしましょう。
a.recycle();


R.attr にはスタイルで設定した属性名などが反映されています。

これを使えば好きな値を取得することが可能です。

色さえ得られれば、あとは背景色に反映するなりtintに使うなり、いろいろできます!

(参考:StackOverflow

他にも何かあったら書き足す予定。 荒木さんのスライドでだいたいのことが分かりそうな予感


あわせて読みたい