LoginSignup
4
6

More than 3 years have passed since last update.

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

Last updated at Posted at 2021-04-11

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

要するにこんな感じ

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

DecorViewとは

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

DecorViewは、Window#getDecorView() でアクセスすることができます。Window#getDecorView()の戻り値の型はViewですが、DecorViewはFrameLayoutのサブクラスです。ViewGroupにキャストすればaddViewをコールしてViewを追加することができます。

DecorViewにViewを貼り付ける

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

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

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

指定の場所に穴を開ける

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

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

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を使って穴を開け、作業領域を書き出せば穴を開けることができます。

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)
    }
}

穴以外の座標調整を行う

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

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

<?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に書いてしまっていますが、サイズもここで設定するようにしても良いでしょう)

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を配置することができます。

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

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

以上です。

4
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
6