なんとなく作っていたAndroidのカスタムViewを正しく実装する

Androidアプリを作っていれば基底のViewを拡張した独自のViewクラスを作ることが多々あると思いますが、いざゼロから書くとなると色々と取りこぼしがありそうだったのでまとめました

##CircleView
今回は以下の様な2重の円弧を描画するViewを作成していきます。

![639483c5-710f-f296-0131-74232138a46f.png](https://qiita-image-store.s3.amazonaws.com/0/129872/9472f5fe-d1ce-c27d-3d48-f90bbff21f96.png)

`View#onDraw`で描画していきますが、Viewの外側からはシンプルに使えることを意識して作成します。
設定できる独自プロパティとして以下を宣言していきます。

- 上の円弧の開始角度（StartAngle）
- 上の円弧の描画角度（DrawAngle）
- 上の円弧の色（PrimaryLineColor）
- 下の円弧の色（SecondaryLineColor）
- 線の太さ（StrokeWidth）

##1. 独自Viewクラス作成
まずはViewクラスをextendした空の`CircleView`クラスを作成していきます。

```java:CircleView.java
public class CircleView extends View {

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

    public CircleView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public CircleView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }
}
```

##2. XMLでの独自プロパティ実装

次にLayoutのXMLで指定できるように`attrs.xml`を作成していきます。
それぞれプロパティ名と型宣言を行います。


```xml:attrs.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="CircleView">
        <attr name="start_angle" format="float|reference"/>
        <attr name="draw_angle" format="float|reference"/>
        <attr name="primary_line_color" format="color|reference"/>
        <attr name="secondary_line_color" format="color|reference"/>
        <attr name="stroke_width" format="dimension|reference"/>
    </declare-styleable>
</resources>
```

これで`CircleView`クラスをLayoutファイル上に配置した際にXML上で独自プロパティを以下のように指定できます
※`com.kazakago.test.CircleView`は各自の配置したパッケージのフルパスに置き換えて記載して下さい

```xml:activity_main.xml
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
             xmlns:app="http://schemas.android.com/apk/res-auto"
             android:layout_width="match_parent"
             android:layout_height="match_parent">

    <com.kazakago.test.CircleView
        android:id="@+id/view_circle"
        android:layout_width="300dp"
        android:layout_height="300dp"
        android:layout_gravity="center"
        app:draw_angle="300"
        app:primary_line_color="#ff00ddff"
        app:secondary_line_color="#f0f0f0"
        app:start_angle="-90"
        app:stroke_width="10dp"/>

</FrameLayout>
```

`app`名前空間のプロパティが独自に追加したものになります。
ルートレイアウトの要素に`xmlns:app="http://schemas.android.com/apk/res-auto"`を追加するのも忘れずに！

##3. XMLから値を取得する
上記で宣言したXML要素を独自ViewクラスのJavaソースから取得します。
今回はonDrawで描画を行うためその部分で必要なPaint変数等もここで宣言しています。
XMLで宣言した各プロパティはJavaソースからセット、取得することも想定してgetter、setterを必ず用意します。

```java:CircleView.java
public class CircleView extends View {

    private float mStartAngle;
    private float mDrawAngle;
    private final Paint mFirstPaint = new Paint();
    private final Paint mSecondPaint = new Paint();
    private final RectF mOval = new RectF();

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

    public CircleView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public CircleView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        initView(context, attrs, defStyleAttr);
    }

    private void initView(Context context, AttributeSet attrs, int defStyle) {
        mFirstPaint.setAntiAlias(true);
        mFirstPaint.setStyle(Paint.Style.STROKE);

        mSecondPaint.setAntiAlias(true);
        mSecondPaint.setStyle(Paint.Style.STROKE);

        TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.CircleView, defStyle, 0);
        setStartAngle(typedArray.getFloat(R.styleable.CircleView_start_angle, -90.0f));
        setDrawAngle(typedArray.getFloat(R.styleable.CircleView_draw_angle, 270.0f));
        setPrimaryLineColor(typedArray.getColor(R.styleable.CircleView_primary_line_color, Color.BLACK));
        setSecondaryLineColor(typedArray.getColor(R.styleable.CircleView_secondary_line_color, Color.LTGRAY));
        setStrokeWidth(typedArray.getDimension(R.styleable.CircleView_stroke_width,
                TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 10.0f, getResources().getDisplayMetrics())));
        typedArray.recycle();
    }
}
```

TypedArrayをClass名を指定して取得し、宣言した`attrs.xml`の内容を一つ一つ取り出しています。
レイアウト側でプロパティが宣言がされていない時用に必ず`TypedArray#get***`の第2引数で初期値を宣言しておきましょう。
getter、setterについては今回は以下のように記載しました。※長くなるので行を詰めてます

```java:CircleView.java
    public float getStartAngle() { return mStartAngle; }
    public void setStartAngle(float startAngle) { mStartAngle = startAngle; }
    public float getDrawAngle() { return mDrawAngle; }
    public void setDrawAngle(float drawAngle) { mDrawAngle = drawAngle; }
    public int getPrimaryLineColor() { return mFirstPaint.getColor(); }
    public void setPrimaryLineColor(int color) { mFirstPaint.setColor(color); }
    public int getSecondaryLineColor() { return mSecondPaint.getColor(); }
    public void setSecondaryLineColor(int color) { mSecondPaint.setColor(color); }
    public float getStrokeWidth() { return mFirstPaint.getStrokeWidth(); }
    public void setStrokeWidth(float width) { mFirstPaint.setStrokeWidth(width); mSecondPaint.setStrokeWidth(width); }
```

ちなみに初期描画後に`mStartAngle`等にセットした値を実際のUIに反映させるためには`View#invalidate()`をコールして描画更新を促す必要があります。
場合によっては`setStartAngle`メソッド内で最後に呼び出してしまってもいいかもしれません。

##4.実際の描画処理
保持された変数に従って描画を行います。
今回は`View#onDraw`を使って独自に描画していますが、
場合によってはonDrawを使わないこともあると思うので状況に応じて読み替えて下さい。

```java:CircleView.java
    @Override
    protected void onDraw(Canvas canvas) {
        mOval.set(getStrokeWidth(),
                getStrokeWidth(),
                canvas.getWidth() - getStrokeWidth(),
                canvas.getHeight() - getStrokeWidth());
        canvas.drawArc(mOval, mStartAngle, 360, false, mSecondPaint);
        canvas.drawArc(mOval, mStartAngle, mDrawAngle, false, mFirstPaint);
    }
```

今回はこれだけですね。シンプル！

##5. 画面回転等のActivity再生成対応
忘れちゃいけないのが画面回転対応、正確にはActivityの再生成対応です。
これがないと画面回転させた時などに設定したメンバー変数が全て初期化されてしまいます。
基本的にActivityが保持しているメンバ変数はActivity側でBundleに持たせるなどの対策が必要ですが、独自Viewを使っている場合はView側で対応させたいですね。

そのためには`View#onSaveInstanceState`と`View#onRestoreInstanceState`を使います。

```java:CircleView.java
    @Override
    protected Parcelable onSaveInstanceState() {
        Parcelable parent = super.onSaveInstanceState();
        SavedState saved = new SavedState(parent);
        saved.startAngle = getStartAngle();
        saved.drawAngle = getDrawAngle();
        saved.primaryLineColor = getPrimaryLineColor();
        saved.secondaryLineColor = getSecondaryLineColor();
        saved.strokeWidth = getStrokeWidth();
        return saved;
    }

    @Override
    protected void onRestoreInstanceState(Parcelable state) {
        SavedState saved = (SavedState) state;
        super.onRestoreInstanceState(saved.getSuperState());
        setStartAngle(saved.startAngle);
        setDrawAngle(saved.drawAngle);
        setPrimaryLineColor(saved.primaryLineColor);
        setSecondaryLineColor(saved.secondaryLineColor);
        setStrokeWidth(saved.strokeWidth);
    }

    private static class SavedState extends BaseSavedState {
        public float startAngle;
        public float drawAngle;
        public int primaryLineColor;
        public int secondaryLineColor;
        public float strokeWidth;

        public SavedState(Parcel in) {
            super(in);
            startAngle = in.readFloat();
            drawAngle = in.readFloat();
            primaryLineColor = in.readInt();
            secondaryLineColor = in.readInt();
            strokeWidth = in.readFloat();
        }

        public SavedState(Parcelable superState) {
            super(superState);
        }

        @Override
        public void writeToParcel(final Parcel out, final int flags) {
            super.writeToParcel(out, flags);
            out.writeFloat(startAngle);
            out.writeFloat(drawAngle);
            out.writeInt(primaryLineColor);
            out.writeInt(secondaryLineColor);
            out.writeFloat(strokeWidth);
        }

        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];
            }
        };

    }
```

格納用に`BaseSavedState`をextendしたクラスを宣言します。
Parcelableに対応させるためCREATERも記述して下さい。
`View#onSaveInstanceState`内でSavedStateへメンバー変数を一時退去させ、
`View#onRestoreInstanceState`で取り出してメンバー変数を復元させる流れになります。

※細かいParcelableの実装方法は[公式](https://developer.android.com/reference/android/os/Parcelable.html)か↓辺りがわかりやすいです（丸投げ）
http://y-anz-m.blogspot.jp/2010/03/androidparcelable.html

縦画面固定にしていると画面回転しないためActivity再生成が起きにくく忘れがちですが、
フォント変更時やメモリ不足時など様々な状況で発生します。
個人的には、いかなる場合でも対応は絶対条件だと思っています。

## おわり
上記まで記載すればXMLからもJavaからも全てのプロパティにアクセスでき、
Activityの再生成にも対応した独自Viewクラスの完成です。

コードの全文は以下のGistにおいておきます。
https://gist.github.com/KazaKago/81123f712ab36bfa77cf