🔥

ViewPager2で作成した上タブの中のLazyRowで作ったカルーセルの横スクロールを完璧に動かす方法

に公開

概要

久しぶりの投稿です。最近では皆わからないことを(たぶん)ほとんどAIで調べてると思います。私はほとんどGoogle検索しなくなりました。そんな感じなので、技術記事を書くモチベーションが低下しておりました。

しかし、今回書く内容は随分前に実装してたのですが、いまだに、たとえばパープレさんでディープリサーチしたとて完璧な動作をさせる回答が出てきません。ということで、僕の知識を世の中のために書いときますw
そのうち、この記事が回答の参照先として入ると思います。

こんな感じで質問

ViewPager2で上タブを作りました。1つ1つのタブはFragmentでComposeで実装しています。FragmentではXMLでレイアウトはせずにComposeだけで実装しています。その中でLazyRowで横スクロールできるカルーセルを作りました。カルーセルを横スクロールするとViewPager2の横スワイプが動いてしまいます。どうすると確実にカルーセルを動かせますか?

いろんな聞き方で試しましたが、ほとんどの回答は同じでした。

現状の回答(どれも完璧じゃない)

2025年4月の段階では、ざっくりこんな回答でした。どれも完璧には動きません。

解決策1:NestedScrollInteropConnectionを使用する(Compose側)

こんな実装です。この方法では全然解決しません。ViewPager2にタッチイベントが持っていかれました。

    val nestedScrollInterop = rememberNestedScrollInteropConnection()
    
    LazyRow(
        modifier = Modifier
            .nestedScroll(nestedScrollInterop)
            .fillMaxWidth(),
        // その他のLazyRowの設定
    ) {
        // アイテムの設定
    }

解決策2:NestedScrollableHostを実装する

NestedScrollableHostを実装して、それを使ってLazyRowでラップする方法です。

NestedScrollableHostで、fun onInterceptTouchEvent(e: MotionEvent): Boolean を実装してますが、parent.requestDisallowInterceptTouchEvent(true)をしていますが、ViewPager2に対してrequestDisallowInterceptTouchEventをしても意味がありません。

解決策3:NestedScrollConnectionを実装する

ComposeでContextからFragmentを取得してViewPager2を取得して、recyclerView.requestDisallowInterceptTouchEvent(true)を呼びます。NestedScrollConnectiononPreScroll()メソッドなどで実装します。
そもそもカルーセル部分のタッチがViewPager2に取られるのでダメです。

解決策4:Compose UIの修飾子を使用してタッチイベントを制御

LazyRowで、Modifier.pointerInteropFilterを実装する方法です。
これもViewPager2にタッチイベントが持ってかれました。

解決策5:ViewPager2のisUserInputEnabledで制御する

ViewPager2の横スワイプ自体を無効にする方法でした。カルーセル部分をタッチしたときは無効にするという処理でしたが、そもそもカルーセル部分のタッチがViewPager2に取られるのでダメです。

他にも色々な案がありましたがどれも完璧に制御はできてませんでした。

完璧に制御できる方法

私の方法は解決策2に近いです。

まずは、次のNestedScrollableHostを実装をします。ViewPager2に対して、requestDisallowInterceptTouchEvent(true)を呼び出しても意味がありません。ViewPager2の中にあるRecyclerViewに対してrequestDisallowInterceptTouchEvent(true)をする必要があります。
そのためにparentを辿っていくために再帰していきます。

class CarouselScrollableHost @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {

    override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
        handleInterceptTouchEvent(ev)
        return super.onInterceptTouchEvent(ev)
    }

    private fun handleInterceptTouchEvent(e: MotionEvent) {
        when (e.action) {
            MotionEvent.ACTION_DOWN -> {
                requestDisallowInterceptTouchEventViewPager(parent, this, true)
            }
        }
    }

    private fun requestDisallowInterceptTouchEventViewPager(
        viewParent: ViewParent?,
        view: ViewGroup,
        disallowIntercept: Boolean
    ) {
        if (viewParent == null) {
            return
        }
        if (viewParent is ViewPager2) {
            // ViewPager2の内部実装のRecyclerView対して制御する.trueの場合は、上タブの横スワイプを禁止
            view.requestDisallowInterceptTouchEvent(disallowIntercept)
            return
        } else if (viewParent is ViewGroup) {
            // ViewPager2が出てくるか親がいなくなるまで再起する
            requestDisallowInterceptTouchEventViewPager(viewParent.parent, viewParent, disallowIntercept)
        }
    }
}

次にこのクラスの子ViewにLazyRowを追加します。

AndroidView(
    factory = { context ->
        CarouselScrollableHost(context).apply {
            addView(
                ComposeView(context).apply {
                    setContent {
                        LazyRow(
                            listState,
                        ) {
                            // items                                       
                        }                                
                    }
                }
            )
        }
    },
    modifier = Modifier.fillMaxWidth(),
)

これだけで完璧にLazyRowにタッチイベントがきます。
あとは、LazyRowの端っこまでスクロールしたら、ViewPager2の横スワイプを動かすだけです。(それはまた別の機会に)

感想

古いアプリをメンテしている場合はよく使うと思うのだけど、あんまりネット上に情報がないのはなんでなんでしょう。最初から完全コンポーズ化以外だったら、一時的にでもこの技術は必要だと思うので困っている人がいたらぜひ!

NewsPicks の Zenn

Discussion