Android SDKは様々なViewコンポーネント(TextView, Button, ImageViewなど)を含んでいます。
これだけで、十分に見栄えのあるアプリが開発できます。
ですが、全ての人やアプリの要望に対応することは難しく、アプリ開発中に「こんなViewが欲しい!」と思える場面があります。
そのような場合はカスタムビューの作成を検討してみましょう。「なければ作ってしまえ!」という訳です。
ここでは「Viewの継承とonDrawの役割」をまとめます。
※環境:Android Studio Electric Eel | 2022.1.1
Viewを継承
カスタムビューの作成方法は簡単です。「Viewクラス」または「既存のViewコンポーネント(TextViewなど)」を継承して作ります。
class CustomView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0, defStyleRes: Int = 0 ) : View(context, attrs, defStyleAttr, defStyleRes) { init { } override fun onDraw(canvas: Canvas?) { super.onDraw(canvas) // Viewの描画を行う // (図形の描画、画像の貼り付けなどを行うCanvasのメソッドを実行) } // ここから --- 必要に応じて実装 --- override fun onAttachedToWindow() { super.onAttachedToWindow() // ウィンドウへ追加された時に呼ばれる } override fun onDetachedFromWindow() { super.onDetachedFromWindow() // ウィンドウから削除された時に呼ばれる } override fun onVisibilityChanged(changedView: View, visibility: Int) { super.onVisibilityChanged(changedView, visibility) // Visibilityの変化で呼ばれる // (ウィンドウへ追加された時を含む、削除された時を含まない) } // ここまで --- 必要に応じて実装 --- }
class CustomView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : AppCompatTextView(context, attrs, defStyleAttr) { init { } override fun onDraw(canvas: Canvas?) { super.onDraw(canvas) // Viewの描画を行う // (図形の描画、画像の貼り付けなどを行うCanvasのメソッドを実行) } // ここから --- 必要に応じて実装 --- override fun onAttachedToWindow() { super.onAttachedToWindow() // ウィンドウへ追加された時に呼ばれる } override fun onDetachedFromWindow() { super.onDetachedFromWindow() // ウィンドウから削除された時に呼ばれる } override fun onVisibilityChanged(changedView: View, visibility: Int) { super.onVisibilityChanged(changedView, visibility) // Visibilityの変化で呼ばれる // (ウィンドウへ追加された時を含む、削除された時を含まない) } // ここまで --- 必要に応じて実装 --- }
オーバーライドしたonDraw( )メソッドで、新たなViewの表示内容を描画します。
描画はCanvasクラス(引数で渡される)が持つメソッドを実行することで行います。
ただし、onDraw( )のオーバーライドは必須ではありません。カスタムビューの目的が、描画を伴わない機能変更や追加のみであれば、必要ありません。
コンストラクタの扱い
Viewクラスは4つ(既存のAppCompatTextViewクラスは3つ)のコンストラクターを持ちます。Viewのドキュメントによれば、それぞれ次のような用途があります。
【1】View(Context context) Simple constructor to use when creating a view from code. コードからビューを作成するときに使用する単純なコンストラクター。 【2】View(Context context, AttributeSet attrs) Constructor that is called when inflating a view from XML. XML からビューをインフレートするときに呼び出されるコンストラクター。 【3】View(Context context, AttributeSet attrs, int defStyleAttr) Perform inflation from XML and apply a class-specific base style from a theme attribute. XML からインフレーションを実行し、テーマ属性からクラス固有の基本スタイルを適用します。 【4】View(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) Perform inflation from XML and apply a class-specific base style from a theme attribute or style resource. XML からインフレーションを実行し、テーマ属性またはスタイル リソースからクラス固有の基本スタイルを適用します。
すなおにViewクラスを継承すると、コンストラクタは次のように記述できます。
class Custom : View { constructor(context: Context?) : super(context) constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) init { コンストラクタ引数が参照できない } : : }
この記述は、次のことが言えます。
・セカンダリコンストラクタのみになってしまう
(Javaにプライマリとセカンダリコンストラクタを別々に扱う概念がないため)
・4つのコンストラクタの記述が冗長、見た目がシンプルでない
・初期化ブロック(init{ })からコンストラクタ引数が参照できない
ですので、デフォルト付き引数を用いて、プライマリコンストラクタへ集約した記述が良いです。
class Moon @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0, defStyleRes: Int = 0 ) : View(context, attrs, defStyleAttr, defStyleRes) { init { context, attrsなどが参照できる } : : }
デフォルト付きの引数は呼び出し時に省略できます。Viewの使用(インスタンスの作成、オーバーロード)感を損なうことはありません。
ちなみに、ドキュメント「Androidデベロッパー」で解説されている「カスタムビューの作成」は、【2】のコンストラクタのみを継承しています。【1】【3】【4】は捨て去れています。
使い方を限定するのであれば、良いのかも知れませんが…
また、コンストラクタに@JvmOverloadsアノテーションが必要になります。
レイアウトリソースにカスタムビューが存在すると、inflate( )からカスタムビューがインスタンス化されます。
つまり、JavaからKotlinのデフォルト付き引数を持ったトコンストラクタが呼ばれます。
そのため、@JvmOverloadsが必要です。
詳細は「アノテーション:@JvmOverloads」を参照してください。
onDrawの役割
onDraw( )でViewの描画を行いますが、記述がそのように見えるだけで、実際は描画する処理を行っていません。
onDraw( )の本来の役割は描画コマンドのリストを作成することです。
作成はViewツリー上のViewを巡回し、各ViewのonDraw( )を実行することで行われます。
この時にCanvas#drawArc( )や#drawRect( )などの関数が呼ばれると、関数に対応したコマンドがリストへ書き込まれます。
巡回が終わったら、リストの作成は完了です。
なお、実際の描画は「リスト作成完」のトリガ信号を受け取った描画ルーチンの仕事です。描画コマンドのリストを読み出しつつ、OpenGL ESを駆使して、GPUに行わせます。
表示内容の更新
「onDrawの役割」で述べた通り、描画コマンドのリストを作成する作業は、すべてのViewのonDraw( )を実行するので非常に重い処理になります。
ですので、Viewは描画コマンドのリストをキャッシュします。
キャッシュがある場合はキャッシュからリストを取得し、onDraw( )を実行しません。これにより、処理の大幅な軽量化を行っています。
フレーム落ちを回避することが狙いです。
このキャッシュ動作は、動的にViewの表示内容を変更したい時、問題になります。
表示内容の変更は、描画コマンドのリストを再作成する必要があります。しかし、キャッシュがあるとonDraw( )は実行されないからです。
ですので、このような時はView#invalidate( )を実行します。
class CustomView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0, defStyleRes: Int = 0 ) : View(context, attrs, defStyleAttr, defStyleRes) { var parameter: Float = 0.0f set(value) { field = value invalidate() // onDrawの再実行を依頼 } override fun onDraw(canvas: Canvas?) { super.onDraw(canvas) // parameterを使ったViewの描画を行う // (図形の描画、画像の貼り付けなどを行うCanvasのメソッドを実行) } : : }
View#invalidate( )を実行するとフラグが立って、次の画面リフレッシュでonDraw( )が再実行されます。
例:Moon
カスタムビューの例として、「Moon(月)」を作成してみました。
Monnはage(月齢)プロパティを持ち、月の欠け具合が変更できます。
※欠け具合の演算式は事実と合っていないかも知れません!
class Moon @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0, defStyleRes: Int = 0 ) : View(context, attrs, defStyleAttr, defStyleRes) { var age: Float = 0.0f set(value) { field = value invalidate() // onDrawの再実行を依頼 } override fun onDraw(canvas: Canvas?) { super.onDraw(canvas) if(canvas == null) return if((canvas.width <= 0) or (canvas.height <= 0)) return val _width = canvas.width.toFloat() val _heigjt = canvas.height.toFloat() val _centerX = _width / 2.0f val _centerY = _heigjt / 2.0f val _diameter = if(_width < height) _width else _heigjt val _radius = _diameter / 2.0f val _paintNoon = Paint().apply { setColor(Color.YELLOW) } val _paintNight = Paint().apply { setColor(Color.DKGRAY) } val _colors = intArrayOf(0x00000000, 0x00000000, 0x20000000,) val _stops = floatArrayOf(0.0f, 0.6f, 1.0f) val _shader = RadialGradient( _centerX, _centerY, _radius, _colors, _stops, Shader.TileMode.CLAMP ) val _paintEdge = Paint().apply {setShader(_shader) } val _fullMoon = RectF( _centerX - _radius, _centerY - _radius, // top, left _centerX + _radius, _centerY + _radius // bottom, right ) val _ageAngle = (PI.toFloat() * 2.0f) / 30.0f * age val _waneWidth = abs(cos(_ageAngle) * _radius) val _halfMoon = RectF( _centerX - _waneWidth, _centerY - _radius, // top, left _centerX + _waneWidth, _centerY + _radius // bottom, right ) when { (_ageAngle <= PI * 0.5f) -> { canvas.drawArc(_fullMoon, 90.0f,180.0f,false, _paintNight) // 左半分 canvas.drawArc(_fullMoon,270.0f,180.0f,false, _paintNoon) // 右半分 canvas.drawArc(_halfMoon, 0.0f,360.0f,false, _paintNight) // 右半分 } (_ageAngle <= PI) -> { canvas.drawArc(_fullMoon, 90.0f,180.0f,false, _paintNight) // 左半分 canvas.drawArc(_fullMoon,270.0f,180.0f,false, _paintNoon) // 右半分 canvas.drawArc(_halfMoon, 0.0f,360.0f,false, _paintNoon) // 左半分 } (_ageAngle <= PI * 1.5f) -> { canvas.drawArc(_fullMoon, 90.0f,180.0f,false, _paintNoon) // 左半分 canvas.drawArc(_fullMoon,270.0f,180.0f,false, _paintNight) // 右半分 canvas.drawArc(_halfMoon, 0.0f,360.0f,false, _paintNoon) // 右半分 } (_ageAngle <= PI * 2.0f) -> { canvas.drawArc(_fullMoon, 90.0f,180.0f,false, _paintNoon) // 左半分 canvas.drawArc(_fullMoon,270.0f,180.0f,false, _paintNight) // 右半分 canvas.drawArc(_halfMoon, 0.0f,360.0f,false, _paintNight) // 左半分 } else -> { canvas.drawArc(_fullMoon, 0.0f,360.0f,false, _paintNight) // 全周 } } canvas.drawArc(_fullMoon,0.0f,360.0f,false, _paintEdge) // 全周 } }
カスタムビューを作成するだけで、Android Studioのレイアウトエディタから参照できるようになります。とても便利です。
<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout ... tools:context=".MoonActivity" android:background="@color/black"> <カスタムビューのパッケージ名.Moon android:id="@+id/moon" android:layout_width="200dp" android:layout_height="200dp" android:visibility="visible" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> </androidx.constraintlayout.widget.ConstraintLayout>
プログラム上の扱いは既存のViewと全く変わりません。
属性はカスタムビューのプロパティをプログラムで操作し、動的な表示を行っています。
class MoonActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_moon) findViewById<Moon>(R.id.moon).apply { var _age = age setOnClickListener { _age = _age + 1.0f (it as Moon).age = if(_age >= 30.0f) 0.0f else _age } } } }
関連記事: