【Android】View が見えるかどうかを知る

Android の `View` がルートビューから一部でも見えているかどうかは、
`View.getGlobalVisibleRect(Rect)` の返値で判定できる。

この値が変わったときに通知を受けたければ、次のように状態変化を検知して、`View.getGlobalVisibleRect(Rect)` で現在の状態を再取得、前の状態と変わったかを判定すればよい。（不足があればご教示いただければ幸いだ。）
- `ViewTreeObserver.addGlobalLayoutListener` で、ビュー自身の `visible` プロパティの値やツリー構造が変わったことを検知
- `ViewTreeObserver.addScrollChangedListener` でスクロール状態が変わったことを検知

これを簡単に行えるように、拡張関数 `View.setGlobalVisibilityListener` 作成した。

```Kotlin
import android.graphics.Rect
import android.view.View
import android.view.ViewTreeObserver
import androidx.annotation.UiThread
import java.lang.ref.WeakReference
import java.util.WeakHashMap

/**
 * [View.setGlobalVisibilityListener] で使用するリスナーの型。
 */
typealias GlobalVisibilityListener = (view: View, isGloballyVisible: Boolean) -> Unit

/**
 * [View] の少なくとも一部がルートビューから可視かどうかのリスナーをセットする。
 *
 * 次のような場合にリスナーに通知される。
 * - リスナーをセットした。
 * - ビューのツリー構造が変わったことにより可視かどうか変わった。
 * - [View.setVisibility] により可視かどうかが変わった。
 * - スクロールによりルートビューから可視かどうかが変わった。
 *
 * リスナーは１つの [View] インスタンスに１つだけセットできる。
 * リスナーをセットすると古いリスナーは削除される。
 * null をセットした場合も古いリスナーは削除される。
 *
 * @receiver リスニング対象のビュー。
 * @param listener リスナー。
 *  引数 view にはリスニング対象のビューが渡される。
 *  引数 isGloballyVisible には現在可視かどうかが渡される。
 */
@UiThread
fun View.setGlobalVisibilityListener(
    listener: GlobalVisibilityListener?,
) {
    GlobalVisibilityListenerManager.setGlobalVisibilityListener(this, listener)
}

/**
 * [setGlobalVisibilityListener] で使用する、リスナーのマネージャー。
 *
 * 生成によりリスニングを開始し、[close] 関数の呼び出しによりリスニングを終了する。
 */
@UiThread
private class GlobalVisibilityListenerManager(
    /** リスニング対象のビュー */
    view: View,
    /** リスナー */
    private val listener: GlobalVisibilityListener,
) : AutoCloseable {
    /** リスニング対象のビューの弱参照 */
    // リスナーの削除を忘れてもメモリリークしないよう、弱参照で保持する。
    private val viewRef = WeakReference(view)

    /**
     * 直前に可視だったかどうか
     *
     * 初期状態のみ null。
     * */
    private var wasGloballyVisible: Boolean? = null

    /** [View.getVisibility] の変化と、ビューのツリー構造の変化を検出するためのリスナー */
    private val onGlobalLayoutListener: ViewTreeObserver.OnGlobalLayoutListener =
        ViewTreeObserver.OnGlobalLayoutListener { update() }

    /** スクロールを検知するためのリスナー */
    private val onScrollChangedListener: ViewTreeObserver.OnScrollChangedListener =
        ViewTreeObserver.OnScrollChangedListener { update() }

    init {
        view.viewTreeObserver.addOnGlobalLayoutListener(onGlobalLayoutListener)
        view.viewTreeObserver.addOnScrollChangedListener(onScrollChangedListener)

        update()
    }

    override fun close() {
        val view = viewRef.get()
            ?: return

        view.viewTreeObserver.removeOnGlobalLayoutListener(onGlobalLayoutListener)
        view.viewTreeObserver.removeOnScrollChangedListener(onScrollChangedListener)
    }

    /**
     * ビューが可視かどうかを確認し、変わっていれば通知を行う。
     */
    private fun update() {
        val view = viewRef.get()
            ?: return

        val isGloballyVisible = view.getGlobalVisibleRect(rect)
        if (wasGloballyVisible == isGloballyVisible) {
            return
        }

        wasGloballyVisible = isGloballyVisible

        listener(view, isGloballyVisible)
    }

    companion object {

        /** [View.getGlobalVisibleRect] の引数に使用する [Rect] */
        // インスタンス生成・破棄のコストを下げるため、クラスで共通のインスタンスを使用する。
        private val rect = Rect()

        /** ビューとそれをリスンしているマネージャーのマップ */
        // ビューが参照されなくなったらマネージャーも不要となるため、WeakHashMap にしている。
        private val viewManagerMap = WeakHashMap<View, GlobalVisibilityListenerManager>()

        /**
         * リスナーをセットする。
         *
         * @param view リスニング対象のビュー。
         * @param listener リスナー。
         *
         * @see View.setGlobalVisibilityListener
         */
        fun setGlobalVisibilityListener(
            view: View,
            listener: GlobalVisibilityListener?,
        ) {
            // 古いマネージャーがあればそれを閉じる。
            viewManagerMap[view]
                ?.close()
            // 新しいリスナーがあれば新しいマネージャーをセットする。
            // なければキーを削除する。
            listener?.let { GlobalVisibilityListenerManager(view, it) }
                ?.also { viewManagerMap[view] = it }
                ?: run { viewManagerMap.remove(view) }
        }
    }
}
```

／以上