グレー背景に穴を開けてフォーカスっぽい表現をする方法

アプリのチュートリアルなどで、ダイアログを表示したときのように全体をグレーで覆いつつ、強調したいところに穴を開けたくなることがあります。

要するにこんな感じ

|||
|:-:|:-:|
|![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/567f78f2-7138-1529-7ffb-e9d919cd5031.png)|![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/1d5c2f12-050c-4328-a86f-af19fca871f6.png)|

ダイアログを表示したとき背景がグレーになるのは、`WindowManager.LayoutParams.dimAmount`で制御可能ですが、これに穴を開けることはできないですね。上で示したように実際は矢印とか説明なども一緒に表示する必要があるでしょうから、それらを含んだレイアウトをDecorViewに追加するのが安直な実装方法ではないでしょうか？

# DecorViewとは

DecorViewはレイアウト全体の親に当たるViewですね。Layout Inspectorで見るとルートがDecorViewになっていますね。
ActivityのsetContentViewするレイアウトの親に当たるのが、content（ContentFrameLayout）ですね。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/750c5c03-2cb3-e435-d290-3da2c0837c81.png)

DecorViewは、`Window#getDecorView()` でアクセスすることができます。`Window#getDecorView()`の戻り値の型はViewですが、[DecorView](https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/com/android/internal/policy/DecorView.java)はFrameLayoutのサブクラスです。ViewGroupにキャストすればaddViewをコールしてViewを追加することができます。

# DecorViewにViewを貼り付ける

まずは適当なViewを貼り付けてみます。

```kotlin
val view = View(this)
view.setBackgroundColor(Color.RED)
(window.decorView as ViewGroup).addView(view)
```

|||
|:-:|:-:|
|![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/567f78f2-7138-1529-7ffb-e9d919cd5031.png)|![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/243d03b7-926f-5587-b641-aac4ac156d2f.png)|

このように、DecorViewはStatusBarからNavigationBarまで含めて全体を覆う領域を持っています。
そのため、contentViewと同じレイアウトを使って、その座標を使おう、とすると以下のように盛大にズレてしまいます。

|||
|:-:|:-:|
|![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/567f78f2-7138-1529-7ffb-e9d919cd5031.png)|![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/32406b86-c97c-ff24-1223-84ffe3115675.png)|


# 指定の場所に穴を開ける

特定の領域を塗りつぶすというのは簡単ですが、穴を開けるような描画は通常のレイアウトでは難しいです。
なので、そういう描画を行うViewを自作することになります。

穴を開ける場所については、先に示したようにDecorViewは画面全体を覆う大きさを持っています。この座標系でのViewの描画位置を取得する必要があります。そのためには`View.getGlobalVisibleRect()`を使って、対象となるViewの絶対座標系でのRectを取得します。

```kotlin
private val rectList: MutableList<RectF> = mutableListOf()

fun setTarget(start: View, top: View, end: View, bottom: View) {
    val rect = Rect()
    rectList.clear()
    listOf(start, top, end, bottom).forEach { view ->
        view.getGlobalVisibleRect(rect)
        rectList += RectF(rect).also {
            it.inset(-radius, -radius)
        }
    }
}
```

Canvasへの描画で`Paint#.xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR)`としたPaintを使うと消去を行うことができるので、作業領域を塗りつぶしてから、このPaintを使って穴を開け、作業領域を書き出せば穴を開けることができます。

```kotlin
private val erasePaint = Paint().also {
    it.color = Color.BLACK
    it.xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR)
    it.isAntiAlias = true
}

override fun dispatchDraw(canvas: Canvas) {
    ensureBuffer()
    val buffer = buffer ?: return
    val bufferCanvas = bufferCanvas ?: return
    bufferCanvas.drawColor(backgroundColor, PorterDuff.Mode.SRC)
    rectList.forEach {
        bufferCanvas.drawRoundRect(it, radius, radius, erasePaint)
    }
    canvas.drawBitmap(buffer, 0f, 0f, null)
}

private fun ensureBuffer() {
    if (buffer?.let { it.width == width && it.height == height } == true) {
        return
    }
    buffer = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).also {
        bufferCanvas = Canvas(it)
    }
}
```

|||
|:-:|:-:|
|![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/567f78f2-7138-1529-7ffb-e9d919cd5031.png)|![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/17d70c88-eef6-d148-b187-1117c9abb1d5.png)|

# 穴以外の座標調整を行う

穴を開けるだけなら前述の処理までで良いですが、その穴に合わせて別のViewを配置するのが通常ですね。

この座標調整を行う方法はいろいろあると思いますが、比較的簡単だと思うのは穴に該当する場所に見えないView（Space）を配置して、それに対する相対位置で他のViewを配置するというものです。
以下のように、穴の位置を示すSpaceを配置し、そのSpaceに対する相対座標で各Viewを配置しておきます。

```xml
<?xml version="1.0" encoding="utf-8"?>
<merge
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout"
    >

    <Space
        android:id="@+id/top"
        android:layout_width="200dp"
        android:layout_height="32dp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        />

    <Space
        android:id="@+id/start"
        android:layout_width="32dp"
        android:layout_height="200dp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        />

    <Space
        android:id="@+id/end"
        android:layout_width="32dp"
        android:layout_height="200dp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        />

    <Space
        android:id="@+id/bottom"
        android:layout_width="200dp"
        android:layout_height="32dp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        />

    <ImageView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:rotation="270"
        app:layout_constraintEnd_toEndOf="@id/top"
        app:layout_constraintStart_toStartOf="@id/top"
        app:layout_constraintTop_toBottomOf="@id/top"
        app:srcCompat="@drawable/ic_forward"
        />

    <ImageView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:rotation="180"
        app:layout_constraintBottom_toBottomOf="@id/start"
        app:layout_constraintStart_toEndOf="@id/start"
        app:layout_constraintTop_toTopOf="@id/start"
        app:srcCompat="@drawable/ic_forward"
        />

    <ImageView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toBottomOf="@id/end"
        app:layout_constraintEnd_toStartOf="@id/end"
        app:layout_constraintTop_toTopOf="@id/end"
        app:srcCompat="@drawable/ic_forward"
        />

    <ImageView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:rotation="90"
        app:layout_constraintBottom_toTopOf="@id/bottom"
        app:layout_constraintEnd_toEndOf="@id/bottom"
        app:layout_constraintStart_toStartOf="@id/bottom"
        app:srcCompat="@drawable/ic_forward"
        />
</merge>
```

GlobalVisibleRectで座標を取得していますので、そのときにこのレイアウトにも反映させるようにします。（ここではサイズは決め打ちでXMLに書いてしまっていますが、サイズもここで設定するようにしても良いでしょう）

```kotlin
fun setTarget(start: View, top: View, end: View, bottom: View) {
    val rect = Rect()
    rectList.clear()
    listOf(
        start to binding.start,
        top to binding.top,
        end to binding.end,
        bottom to binding.bottom,
    ).forEach { pair ->
        pair.first.getGlobalVisibleRect(rect)
        rectList += RectF(rect).also {
            it.inset(-radius, -radius)
        }
        pair.second.updateLayoutParams<LayoutParams> {
            updateMarginsRelative(start = rect.left, top = rect.top)
        }
    }
}
```

そうすると、画面サイズなどでViewの位置などが変わったとしてもきちんと適切な位置に他のViewを配置することができます。

|||
|:-:|:-:|
|![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/567f78f2-7138-1529-7ffb-e9d919cd5031.png)|![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/1d5c2f12-050c-4328-a86f-af19fca871f6.png)|

全体のコードとしてはこんな感じになっています。

```kotlin
class OverlayView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr) {
    private val binding: ViewOverlayBinding =
        ViewOverlayBinding.inflate(LayoutInflater.from(context), this)
    private val erasePaint = Paint().also {
        it.color = Color.BLACK
        it.xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR)
        it.isAntiAlias = true
    }
    private val backgroundColor = Color.argb(0x80, 0, 0, 0)

    private var buffer: Bitmap? = null
    private var bufferCanvas: Canvas? = null
    private val radius = 16 * resources.displayMetrics.density
    private val rectList: MutableList<RectF> = mutableListOf()

    fun setTarget(start: View, top: View, end: View, bottom: View) {
        val rect = Rect()
        rectList.clear()
        listOf(start, top, end, bottom).forEach { view ->
            view.getGlobalVisibleRect(rect)
            rectList += RectF(rect).also {
                it.inset(-radius, -radius)
            }
        }
    }

    override fun dispatchDraw(canvas: Canvas) {
        ensureBuffer()
        val buffer = buffer ?: return
        val bufferCanvas = bufferCanvas ?: return
        bufferCanvas.drawColor(backgroundColor, PorterDuff.Mode.SRC)
        rectList.forEach {
            bufferCanvas.drawRoundRect(it, radius, radius, erasePaint)
        }
        canvas.drawBitmap(buffer, 0f, 0f, null)
    }

    private fun ensureBuffer() {
        if (buffer?.let { it.width == width && it.height == height } == true) {
            return
        }
        buffer = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).also {
            bufferCanvas = Canvas(it)
        }
    }

    override fun onTouchEvent(event: MotionEvent?): Boolean {
        (parent as? ViewGroup)?.removeView(this)
        performClick()
        return true
    }
}
```

以上です。