Androidでは独自Viewを作ることで、そのViewの描画を自由にコントロールすることができ、どんな描画もできてしまいます。しかし、既存のViewの描画はそのまま使いつつ、それを加工したような描画をしたくなることがあります。
要するに↓のようなことですね。こういうのはどうやって実装すれば良いかということを説明します。
実装方法
といってもそんな難しいことではなく、ViewGroupのOverrideをすればよいだけです。
AndroidでのViewの描画は親が子の領域にあわせてCanvasをclip/transrateして渡して描画させ、その子がさらに子の~という形で、Viewの階層を走査する形で描画されていきます。
ですので、細工をしたい階層のViewGroupを自作してやれば、その子の描画をコントロールすることができます。
早速冒頭のような円で囲った部分だけを描画するようにしてみましょう。
canvasをclipする
class ClipView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
private var progress: Int = 0
private val path = Path()
fun setProgress(progress: Int) {
this.progress = progress
invalidate()
}
override fun dispatchDraw(canvas: Canvas) {
canvas.save()
val radius = hypot(width.toFloat(), height.toFloat()) * progress / 1000
path.reset()
path.addCircle(0f, 0f, radius, Path.Direction.CCW)
canvas.clipPath(path)
canvas.drawColor(Color.WHITE)
super.dispatchDraw(canvas)
canvas.restore()
}
}
やっていることはすごく単純で、dispatchDraw
をoverrideし、superをコールする前に、canvasをclipしておきます。
そうすると、その子はここでclipされたcanvasにそのまま描画しようとしますので、冒頭のように円で囲まれた部分だけに描画されるような動作となります。
canvasはView階層の他のところの描画にも受け継がれていきますので、save
/restore
でメソッドコールが終わる前に元の状態に戻しておく必要があります。
キャンバスの一部を消去する
もう一つの方法として、消しゴムのように一度描画された部分を消すという加工を行うこともできます。
PaintのxfermodeにPorterDuffXfermode(PorterDuff.Mode.CLEAR)
を設定すると、このPaintで描画した領域を消すことができるようになります。
ではやってみましょう。
class ClipView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
private var progress: Int = 0
private val holePaint = Paint().apply {
isAntiAlias = true
color = Color.BLACK
xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR)
}
fun setProgress(progress: Int) {
this.progress = progress
invalidate()
}
override fun dispatchDraw(canvas: Canvas) {
canvas.drawColor(Color.argb(0x80, 0xFF, 0xFF, 0xFF))
super.dispatchDraw(canvas)
val radius = hypot(width.toFloat(), height.toFloat()) * progress / 1000
canvas.drawCircle(0f, 0f, radius, holePaint)
}
}
違う、そうじゃない
はい、そうですね。canvasは親Viewから子Viewへと受け継がれ描画されていくものと説明したように、途中で消去すると、その親が描画した部分もまとめて消してしまうのでこうなってしまいます。
まあ、こういう描画がしたい場合もなきにしもあらずかもしれませんが、こうするだけなら単に黒で上書きしちゃえばいいですね。。
作業用バッファへの描画を利用する
こういう場合にどうすれば良いかですが、canvasへの直接の描画ではなく、作業用のバッファへ子Viewを描画させ、それを加工してからcanvasへ反映させればよいです。
class ClipView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
private var buffer: Bitmap? = null
private var bufferCanvas: Canvas? = null
private var progress: Int = 0
private val holePaint = Paint().apply {
isAntiAlias = true
color = Color.BLACK
xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR)
}
fun setProgress(progress: Int) {
this.progress = progress
invalidate()
}
override fun dispatchDraw(canvas: Canvas) {
if (buffer?.let { it.width == canvas.width && it.height == canvas.height } != true) {
buffer = Bitmap.createBitmap(canvas.width, canvas.height, Bitmap.Config.ARGB_8888).also {
bufferCanvas = Canvas(it)
}
}
val buffer = buffer ?: return
val bufferCanvas = bufferCanvas ?: return
bufferCanvas.drawColor(Color.argb(0x80, 0xFF, 0xFF, 0xFF), PorterDuff.Mode.SRC)
super.dispatchDraw(bufferCanvas)
val radius = hypot(width.toFloat(), height.toFloat()) * progress / 1000
bufferCanvas.drawCircle(0f, 0f, radius, holePaint)
canvas.drawBitmap(buffer, 0f, 0f, null)
}
}
dispatchDrawで受け取ったCanvasの(というか自身の)サイズ分のBitmapを作成し、そのBitmapへ描画するめのCanvasを作成します。super.dispatchDraw()にそのCanvasを渡すと、子Viewの描画先がここで作成したBitmapに行われます。
最後にBitmapに消しゴムをかけてから、そのBitmapをcanvasに書き出すということを行います。
その結果が以下になります。
本来説明したかった内容は以上なのですが、もう一つ、
なぜ突然、オーバーレイの背景を半透過にしたのかですが、↓の部分の説明をしたかったからです。
bufferCanvas.drawColor(Color.argb(0x80, 0xFF, 0xFF, 0xFF), PorterDuff.Mode.SRC)
ViewのdispatchDrawで渡ってくるCanvasはすでに親Viewの背景などが描画されているため、クリア処理は不要と言うかやってはダメですね。透過なら透過の描画を上書きするだけです。
しかし、ここでは自前で用意したBitmapなので、前回描画した内容が残ったままなのでクリアする必要があります。
不透明な色であればそのまま描画してしまって問題ないですが、透過色の場合は、前回書かれた内容に透過色がかぶることになって、前回の内容が残ってしまいます。そこで第二引数でPorterDuff.Mode.SRC
を指定することで、透過色を既存の内容にブレンドするのではなく、描画するないようで上書きさせることができるようになります。
以上です。