子孫ビューの相対Rect情報を取得してオーバーレイから切り抜く

# はじめに

[Android Advent Calendar 2019](https://qiita.com/advent-calendar/2019/android)の12日目です。

今日は箸休めに、最近の技術に関することではなくViewの操作に関することを書きます。

タイトルは「**子孫ビューの相対Rect情報を取得してオーバーレイから切り抜く**」で、今回はActivity側で、内包するFragentが持つViewの、特定の親ViewGroupからの相対Rect(矩形の座標)情報を取得して操作する例を、オーバーレイを切り抜く自作サンプルコードを元に説明します。

すごく抽象的な説明で分かりにくいと思うので、ちょっと図で説明を。

![clipMaskStructure_intro.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214755/b2aa5877-0f43-6ffb-0ba3-cf8eaeb9a8e5.png)


ActivityがMainFragmentと、Activity全体を覆うMaskView(半透明のオーバーレイ)をレイアウトとして持っているとします。

※MaskViewは独自定義のカスタムビューで、オーバーレイの色や特定のRect範囲を透明にするメソッドを持っています。後ほど触れます。

そしてFragmentが持っている特定のView(上記画像でいうPic1)の、Activityからの相対Rect情報を取得し、**その座標範囲のMaskViewを動的に透明にする**ことで、「**切り抜いた**」ように見せます。

gifイメージは以下です。ここでは、FABを押下した時に1枚目の画像の範囲のオーバーレイを切り抜いています。

https://user-images.githubusercontent.com/40135056/69638854-def9a000-109e-11ea-81a8-5ae9e00cea8d.gif

また、サンプルアプリは以下からどうぞ。

https://github.com/nichiyoshi/clip_mask_sample

# キーになるのはoffsetDescendantRectToMyCoords

この時キーになるのが、ViewGroupの[offsetDescendantRectToMyCoords](https://developer.android.com/reference/android/view/ViewGroup#offsetDescendantRectToMyCoords(android.view.View,%20android.graphics.Rect))メソッドです。

> Offset a rectangle that is in a descendant's coordinate space into our coordinate space.

**子孫ビューがマージンなどを指定していたり、ネストの階層で`wrap_content`を使っていたりしても、それらを踏まえて親ViewGroupの座標空間の中での座標を取得できます**。

今回の場合、以下のようにActivityのレイアウトに`container`と、その子`main_fragment`があります。この`main_fragment`が内包するViewの、**containerからの相対的なRect情報**を取得することで、container全体を覆う`mask`から対象範囲を切り抜くことができます。

```xml:レイアウト例
<androidx.constraintlayout.widget.ConstraintLayout 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:id="@+id/container"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/main_fragment"
        android:name="com.example.nichiyoshi.maskclipsample.MainFragment"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintStart_toStartOf="parent"/>

    <com.example.nichiyoshi.maskclipsample.MaskView
        android:id="@+id/mask"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintStart_toStartOf="parent"/>

    <com.google.android.material.floatingactionbutton.FloatingActionButton
        android:id="@+id/fab"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="@dimen/fab_margin"
        app:srcCompat="@drawable/ic_scissors"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"/>

</androidx.constraintlayout.widget.ConstraintLayout>
```

# 前準備：カスタムMaskView

以下のMaskViewは、半透明の黒(#80000000)でViewの範囲をpaintし、かつ `clipWithRect(rect: Rect)`メソッドを呼ばれると、引数で指定されたRectの範囲を透明にして再描画します。

```kotlin:MaskView
class MaskView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {


    private val backgroundPaint = Paint().apply {
        color = Color.parseColor("#80000000")
    }

    private val clearPaint = Paint().apply {
        xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR)
    }

    private var clipRect: Rect? = null

    fun clipWithRect(rect: Rect) {
        clipRect = rect
        postInvalidate()
    }

    override fun draw(canvas: Canvas) {
        super.draw(canvas)
        setLayerType(LAYER_TYPE_HARDWARE, null)

        canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), backgroundPaint)

        clipRect?.let { rect ->
            canvas.drawRect(rect, clearPaint)
        }
    }

}
```

なお、[こちらの記事](https://qiita.com/takusemba/items/33484bf45884d4f78d74)を参考にさせていただきました。

#全体像

![69766753-c54e7a80-11bc-11ea-9e81-fa1658edae01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214755/d80f06c5-d2d0-6702-22cb-92d9331980e2.png)

ActivityとMainFragmentでViewModelを共有し、ActivityのFABが押下された時にViewModelの`requestViewToClip`関数が呼ばれます。それを監視しているMainFragmentでは切り抜くべき対象のView(pic1)をViewModelの`setViewToClip`関数で指定し、それを監視しているActivityが先ほどの`offsetDescendantRectToMyCoords`関数でcontainerからの相対Rect範囲を取得し、その範囲をMaskViewから切り抜きます。

```kotlin:MainActivity
class MainActivity : AppCompatActivity() {

    private val viewModel by viewModels<MainViewModel>()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        fab.setOnClickListener {
            viewModel.requestViewToClip()
        }

        viewModel.viewToClip.observe(this) { targetView ->
            Rect().apply {
                targetView.getDrawingRect(this)
                container.offsetDescendantRectToMyCoords(targetView, this)
                mask.clipWithRect(this)
            }
        }
    }
}
```
※`observe(this){}`の箇所は[KTX](https://developer.android.com/reference/kotlin/androidx/lifecycle/package-summary#extension-functions-summary)を使っています。

```kotlin:MainViewModel
class MainViewModel: ViewModel() {

    private val _viewToClip = MutableLiveData<View>()
    val viewToClip: LiveData<View> = _viewToClip

    fun setViewToCLip(view: View) {
        _viewToClip.postValue(view)
    }

    private val _requestViewToClip = MutableLiveData<Unit>()
    val requestViewToClip: LiveData<Unit> = _requestViewToClip

    fun requestViewToClip() {
        _requestViewToClip.postValue(Unit)
    }

}
```
```kotlin:MainFragment
class MainFragment: Fragment(R.layout.fragment_main) {

    private val viewModel by activityViewModels<MainViewModel>()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        image1.scaleType = ImageView.ScaleType.FIT_CENTER
        image2.scaleType = ImageView.ScaleType.FIT_CENTER

        // loads images from free image provider "pakutaso"
        image1.load("https://www.pakutaso.com/shared/img/thumb/cat9302341_TP_V.jpg")
        image2.load("https://www.pakutaso.com/shared/img/thumb/cat9302331_TP_V.jpg")

        viewModel.requestViewToClip.observe(viewLifecycleOwner) {
            viewModel.setViewToCLip(image1)
        }
    }

}
```
※ [by activityViewModels](https://developer.android.com/reference/kotlin/androidx/fragment/app/package-summary#activityviewmodels)はViewModelの[デリゲートプロパティ](https://dogwood008.github.io/kotlin-web-site-ja/docs/reference/delegated-properties.html)です。
※`class MainFragment: Fragment(R.layout.fragment_main) `の箇所は、[Fragment LayoutId コンストラクタ](https://developer.android.com/jetpack/androidx/releases/fragment#1.1.0)を利用しています。

#おわりに

ちょっと分かりづらい説明になってしまいましたが、以上です。

ソースコートはGitHubにあげています。
https://github.com/nichiyoshi/clip_mask_sample