はじめに
こんちには。AmebaLIFE事業本部の原田伶央です。
この記事は2023/9/14 ~ 9/16に開催されたDroidKaigi 2023にて「Master of NestedScroll」というタイトルで登壇させていただいた内容を記事に書き起こしたものになります。
内容としては、Androidアプリ開発における「ネストスクロール」に着目し、「プロダクト開発においてネストスクロールで困らないようになる」を目指したものになります。
なお、当日のセッション動画はYoutube上で公開されており、視聴することが可能です。 また、当日使用したスライドも公開しています。
目次
1. プロダクト開発におけるネストスクロール問題
そもそもネストスクロールとはなんでしょう? ここでは「一つのスクロール動作に複数のコンポーネントが関わる挙動」をネストスクロールと定義します。 例えば、以下の画像に示すようにスクロールに応じてツールバーの高さが増減する「折りたたみ式のツールバー」が代表的な例としてあります。
似たような挙動をしているアプリを見たことがあるかもしれません。
そのような不具合に繋がってしまうことには理由があると思いますが、ここでは以下の3つを主な原因として取り扱います。
- スクロールの仕組み自体が複雑である
- Jetpack Compose / Android View / 相互運用でそれぞれスクロールの仕組みが異なる
- Jetpack ComposeだとAndroid Viewほどカスタムスクロールのコンポーネントが充実していない
※ Androidアプリ開発では、2021/07/21にJetpack Compose1.0がリリースされ新しいUI作成の方法が主流になってきています。
2 Jetpack Compose時代の解決策
それでは、Jetpack Composeにおける解決策を見てみましょう。 主に以下の4つの特徴を見ていきます。
- Modifierで簡単にスクロールを適用可能
- TopAppBarでcollapsing対応が可能
- デフォルトでネストスクロールをサポート
- NestedScrollConnectionの仕組み
2-1 Modifierで簡単にスクロールを適用可能
// ScrollViewのように振る舞う val scrollState = rememberScrollState() Column(modifier = Modifier.verticalScroll(scrollState)) { ... } ------------------------------------------------------------------ // offsetしないので注意 Column( modifier = Modifier.scrollable( orientation = Vertical, state = rememberScrollState { delta -> delta }, ) ) { ... }
Jetpack Composeではスクロール操作を適用するために修飾子Modifier #verticalScroll/horizontalScrollを使用して簡単にサポートすることが可能です。 似たような修飾子にModifier #scrollableがあります。 こちらは、適用したコンポーネント自体はスクロールはされませんが、rememberScrollStateからスクロール量を取得することは可能です。
// Runtimeエラーになる Column( modifier = Modifier.verticalScroll(scrollState) ) { LazyColumn {...} } ------------------------------------------------------------------ // DSLを用いる LazyColumn { item(...) { ... } item(...) { ... } item(...) { ... } }
注意点としては同一方向にスクロール可能なコンポーネントをネストするとRuntimeエラーが起きます。 この場合、LazyColumnやLazyRowといったリストコンポーネントを用いると良いです。
2-2 TopAppBarでcollapsing対応
@ExperimentalMaterial3Api
@Composable
fun TopAppBar(
title: @Composable () -> Unit,
modifier: Modifier = Modifier,
navigationIcon: @Composable () -> Unit = {},
actions: @Composable RowScope.() -> Unit = {},
windowInsets: WindowInsets = TopAppBarDefaults.windowInsets,
colors: TopAppBarColors = TopAppBarDefaults.topAppBarColors(),
scrollBehavior: TopAppBarScrollBehavior? = null
)
Material3のコンポーネントでTopAppBarというものがあります。 注目したいのは、最後の引数であるscrollBehavior: TopAppBarScrollBehaviorです。 TopAppBarScrollBehaviorを用いることで「折りたたみ式ツールバー」の挙動をサポートできます。 実際に実装しているコードを見てみましょう。
@Composable fun CollapsingToolBarSample() { val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() Scaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topAppBar = { TopAppBar( ... scrollBehavior = scrollBehavior, ) }, ...
最初にTopAppBarDefaultsを呼び出します。 TopAppBarDefaultsは、TopAppBarの実装詳細に関するクラスです。 その中にTopAppBarScrollBehaviorを返すComposable関数があり、ツールバー下のスクロールに対してどのように動作するかを定義します。 TopAppBarScrollBehaviorには pinnedScrollBehavior) / enterAlwaysScrollBehavior) / exitUntilCollapsedScrollBehavior) の3種類があります。 Modifier #nestedScrollについては後述しますが、親のコンポーネントであるScafoldにNestedScrollConnectionを渡してあげることでネストスクロールをサポートしてあげます。 そして、TopAppBarのscrollBehaviorにもscrollBehaviorを渡してあげます。
TopAppBarDefaultsで提供されている3種類のTopAppBarScrollBehaviorをもう少し見ていきます。 AndroidViewのCoordinatorLayoutを実装したことのある方であれば、お馴染みの言葉かもしれません。
enterAlways
existUntilCollapsed
pinned(ツールバーの更新をしています)
ここまでで、すでにGoogleによって提供されているコンポーネントを活用することによって実現できる挙動です。 こうしたコンポーネントは、メンテナンスが盛んで動作も安定しているので可能な限り活用していきたいです。
しかし、先ほど紹介したMaterial3のTopAppBarですが、レイアウトを自由に変えることが難しいです。 例えば、実際に高さを変えようとするもののTopAppBar自体の高さが固定なので、レイアウトの変更が綺麗に適用されません。
2-3 デフォルトでネストスクロールをサポート
Jetpack Composeではデフォルトでネストスクロールをサポートしており、子のコンポーネントで消費されなかったスクロール量を親に渡す実装がデフォルトでなされています。
具体例としてHorizontalPager + LazyRowの実装を見てみましょう。 下記gif画像を見るとLazyRowのスクロールとHorizontalPagerのスワイプが地続きになっていることがわかると思います。
一体どういう仕組みでそうなっているのでしょうか?
2-4 NestedScrollConnectionの仕組み
fun Modifier.nestedScroll( connection: NestedScrollConnection, dispatcher: NestedScrollDispatcher? = null ): Modifier
Jetpack ComposeにはModifier #nestedScrollという修飾子があります。 NestedScrollConnectionでは、スクロール消費量などの伝播を制御することを可能にします。 NestedScrollDispatcherでは、親のコンポーネントに対して消費量などを送ることをします。
// 余ったx軸方向のスクロールをLazyRowで吸収する val nestedScrollConnection = remember { object : NestedScrollConnection { override fun onPostScroll(...) = Offset(available.x, 0F) } } HorizontalPager { ... LazyRow(Modifier.nestedScroll(nestedScrollConnection)) { ... } }
実際にどのような挙動が実現できるのかを先ほどのHorizontalPager + LazyRowの実装を改善することを通じて見ていきましょう。 まずは、NestedScrollConnectionを実装してあげます。
rememberでラップしてあげることで、NestedScrollConnectionを毎回生成しないようにします。 onPostScrollをオーバーライドし、子であるLazyRowがスクロール量を全て消費するように実装してあげます。 あとは、LazyRowのModifierにNestedScrollConnectionを渡してあげます。
これにより先ほどの挙動が解消されます。 つまり、このスクロール量を全て消費し切ることでHorizontalPagerが連想してスワイプされることがなくなります。
それでは、NestedScrollConnectionの内部実装を見ていきましょう。
interface NestedScrollConnection { fun onPreScroll(available: Offset, source: NestedScrollSource): Offset = Offset.Zero fun onPostScroll(...):Offset = Offset.Zero fun onPostScroll( consumed: Offset, available: Offset, source: NestedScrollSource ): Offset = Offset.Zero suspend fun onPreFling(available: Velocity): Velocity = Velocity.Zero suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { return Velocity.Zero } }
以下のコードに示すようにonPreScroll/onPostScrollでは、伴に戻り値にOffset.Zeroを渡しています。 これは、先ほど紹介したように「デフォルトでネストスクロールをサポートしている」に関係しているものです。 Offset.Zeroを返すことで子で消費されなかった、スクロール量を親に渡すことをしています。
Scrollと同様な形でonPreFling/onPostFlingというものがあります。 これは、スクロールで指を離した後の慣性速度を制御するものです。 ここでも慣性速度をゼロ(Velocity.Zero)で渡すことによってフリング動作を親のコンポーネントに伝播させることができています。
それぞれの引数を見ていきましょう。
onPreScroll
- available 消費する前のスクロール量
- 活用事例としては、available.y == 0で縦スクロールが端まで到達したことが分かる
- source スクロールイベントの種別
onPostScroll
- consumed 消費されたスクロール量
- available 消費されなかったスクロール量
返値として返すことで親にスクロール量を伝播させないことが可能 - source スクロールイベントの種別
onPreFling
- consumed スクロールで指を離した時の慣性速度
- 返値としてconsumed.yを返すことで縦にフリングさせないようにする – 規約など慎重に読ませないユースケースで使えるかもしれません
onPostFling
- consumed 消費された慣性速度
- available 消費されなかった慣性速度
また、onPreScrollとonPostScrollで受け取ることができるNestedScrollSource(source)は、スクロールイベントの種別を取得することができます。 スクロールイベントの種別は主に3種類ありますが、よく用いられるのはDragとFlingです。 Dragは指を離さずにスクロールした時で、Flingは指を離した時のスクロール動作を意味します。
Drag
Fling
上記のメソッドを活用して状態を更新することで、以下のような挙動も実現することができます。
FABの表示切り替え
アニメーション
stickyレイアウト
// 初期値を定義する val paddingState by remember { mutableStateOf(16.dp) } // 描画時に初期値を取得する modifier = Modifier.onGloballyPositioned { heightState = it.size.height } modifier = Modifier.onSizeChanged { widthState = it.width }
具体的なやり方としては、可変にしたい部分を状態として定義してあげます。 そして、その状態をNestedScrollConnectionで取得できるスクロール量や慣性速度を用いて増減させることで実現できます。
例えば、高さをスクロールに応じて増減させる時は、固定値であれば適当な値を初期値としてrememberで保持してあげます。 端末依存を気にするケースでは、Modifier #onGloballyPositionedや Modifier #onSizeChangedといった初期値を動的に取る手法を取ることができます。
状況に応じてですが、スクロール量によって状態として保持している値を増減させる時はKotlin構文のcoerceInを用いて範囲を指定してあげると良いでしょう。 そうすることで例えば折りたたみ式ツールバーにてツールバーの高さよりも消費されたスクロール量が多くなり高さがマイナスになるといった、期待しない動作の原因となる値になることを防ぐことができます。
このようにJetpack ComposeではNestedScrollConnectionを用いることでスクロールに応じてコンポーネントの状態を更新することができます。 現時点でalpha版のMotion LayoutやExperimentalAPIのsticky headerといった複雑なネストスクロールも実装可能になっています。 しかし、スクロールを増減する時の計算や条件分岐は複雑になりがちです。 data classやsealed interafce(class)を用いてスクロール量の閾値などをモデルが持つことで実装者以外の人も理解しやすくする工夫は必要そうです。
また、NestedScrollConnection/NestedScrollDispatcherについてはDroidKaigi 2023のカンファレンスアプリにおいていくつかの箇所で使われており、実例としてとても参考になります。
3 AndroidView時代の解決策
続いてはAndroidView時代の解決策を見ていきます。 Jetpack Composeとは異なり運用の歴史が長いので、豊富なコンポーネントがあることが特徴です。 以下の3点を見ていきます。
- デフォルトでネストスクロールをサポートしていない
- onInterceptTouchEvent/NestedScrollViewでこのタッチイベントをインターセプトできる
- NestedScrollingParent3とNestedScrollingChild3を実装するとネストスクロールをカスタムできる
3-1 デフォルトでネストスクロールをサポートしていない
Jetpack Composeとは異なり、AndroidViewはネストスクロールをサポートしていません。 以下のようにScrollViewを並列で並べると通常通り動作します。
しかし、ScrollViewの中にScrollViewを配置するとどうなるでしょう?
結果としては、親のスクロールは動作しても子のスクロールは動作しません。
これは、子のScrollViewのタッチイベントが親のScrollViewのタッチイベントして動作してしまうからです。
3-2 onInterceptTouchEvent/NestedScrollViewでこのタッチイベントをインターセプトできる
そこでNestedScrollViewを用いることで子のスクロールをインターセプトできます。 先ほど並列に配置していたScrollViewをNestedScrollViewに変えると今度は正常に動作します。
どのように動作しているのか具合的に見てみましょう。
NestedScrollViewを中心に見ていきます。 詳細は後述しますが、まずは親がスクロール可能かどうかを確認します。 例えばCoordinatorLayoutといったネストスクロールをサポートしているViewが親に存在していれば、ネストスクロールを開始します。
AndroidViewにはMotionEventというクラスがあります。 主な役割としては、タッチの座標や指圧などによって特定のViewにイベントを通知することです。 スクリーンをタップした時のACTION_DONW(MotionEventクラスで定義している定数)の場合は、子のViewに対してスクロール量を伝播させます。 タップした状態から少しでもスクローン状を移動すると検知されるACTION_MOVEの場合は、子のViewに消費するスクロール量を奪います。 こうしたタッチイベントの流れを辿ることで、親のScrollViewのタッチイベントと競合せずに正常な動作を実現します。
もう少し具体的な実例と伴にAndrodi Viewにおけるタッチイベントを見ていきます。 Jetpack Composeでも例示した横スワイプのコンポーネント内に横並びのリストコンポーネントを配置する例です。 AndroidViewの場合、ViewPager2とRecyclerViewを配置して実現します。
前述した通りAndroid Veiewではネストスクロールをサポートしていないので、ViewPager2のタッチイベントが優先されてしまい、中に配置されているリストを正常に操作することができません。 ここでは、android/viewpager2にあるandroidx.viewpager2.integration.testapp.NestedScrollableHost.ktの実装を元に解消するための実装を見ていきます。 ※ コード簡略化のために一部コードを省略して紹介します。 ※ 以下はライセンス表示です。
/* * Copyright 2019 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */
使い方としては、ViewPager2内でRecyclerViewをラップするだけです。
<ConstraintLayout> <TabLayout/> <ViewPager2> <NestedScrollableHost> <RecyclerView/> <NestedScrollableHost/> <ViewPager2/> <ConstraintLayout/>
NestedScrollableHostはonInterceptTouchEventをオーバーライドしてタッチイベントの競合を解消する実装をしています。 具体的に見ていきましょう。 まずは、受け取ったMotionEventをhandleInterceptTouchEventという別の関数に切り出したものに渡してあげます。
class NestedScrollableHost : FrameLayout { ... override fun onInterceptTouchEvent(e: MotionEvent): Boolean { handleInterceptTouchEvent(e) return super.onInterceptTouchEvent(e) } private fun handleInterceptTouchEvent(e: MotionEvent) { ... } }
次のコードスニペットではhandleInterceptTouchEventを見ています。
class NestedScrollableHost : FrameLayout { ... private fun handleInterceptTouchEvent(e: MotionEvent) { if (e.action == MotionEvent.ACTION_DOWN) { ... parent.requestDisallowInterceptTouchEvent(true) } ... } }
MotionEventがACTION_DOWNだった時は親コンポーネントがタッチイベントを受け取らないようにします。 ViewGroup #requestDisallowInterceptTouchEventでは親がタッチイベントを受け取るか否かを子で決めることができます。 子の設定はタッチしている間ずっと適用されます。 ここではtrueを渡してあげることで、親がタッチイベントを受け取ることができなくなります。 つまり、今回の場合だとRecyclerViewのACTION_DOWNとACTION_MOVEを検知できるようになり、ViewPager2とRecyclerViewのタッチイベントの競合が解消されます。
NestedScrollableHostではもう少し手を加えた実装をしています。 それは、スクロールの端に到達した時に親にスクロール量を伝播させることです。
init { touchSlop = ViewConfiguration.get(context).scaledTouchSlop } --------------------------------------------------- if (e.action == MotionEvent.ACTION_MOVE) { val dx = e.x - initialX val dy = e.y - initialY val isVpHorizontal = orientation == ORIENTATION_HORIZONTAL // assuming ViewPager2 touch-slop is 2x touch-slop of child val scaledDx = dx.absoluteValue * if (isVpHorizontal) .5f else 1f val scaledDy = dy.absoluteValue * if (isVpHorizontal) 1f else .5f ...
最初にscaledDxとscaledDyというTouch Slopを定義してあげます。 Touch Slopとは、スクロールとみなされるまでの距離のことです。 ここでは、RecyclerViewのTouch Slopの2倍をViewPager2のTouch Slopと定義します。 これらの値をViewシステムがスクロールしたとみなす量と比較してViewPager2がスワイプしたかどうかを判定してあげます。
---------------------以下はACTION_MOVEを受け取った時------------------------------ if (scaledDx > touchSlop || scaledDy > touchSlop) { if (isVpHorizontal == (scaledDy > scaledDx)) { ... } else { if (canChildScroll(orientation, if (isVpHorizontal) dx else dy)) { ... } else { parent.requestDisallowInterceptTouchEvent(false) ...
そして、ViewPager2のTouch SlopとViewシステムのTouch Slopを比較してViewPager2がスワイプしても良いかどうかを判定します。 その後にisVpHorizontalを用いて、どちらの方向にスクロールされたかを判定します。 もし、横にスクロールしていた場合、canChildScrollを用いて子がスクロール可能かどうかを見てあげます。 もし、これ以上スクロールできない場合は、先ほど用いたViewGroup #requestDisallowInterceptTouchEventに今度はfalseを渡してあげます。 そうすることで、親はタッチイベントを受け取ることが可能になり、ViewPager2のスワイプが可能になります。
子がスクロール可能かどうかは以下の実装で確認しています。
private fun canChildScroll(orientation: Int, delta: Float): Boolean { val direction = -delta.sign.toInt() return when (orientation) { 0 -> child?.canScrollHorizontally(direction) ?: false 1 -> child?.canScrollVertically(direction) ?: false else -> throw IllegalArgumentException() } }
まず、引数のdeltaからスクロールの方向を取得します。 その後にRecyclerView #canScrollHorizontally #canScrollVerticallyを用いることでスクロールをサポートしているかを確認しています。
タッチイベントの流れは以下のようになります。
ここまで見てきましたが、AndroidViewではCoordinatorLayoutやMotionLayoutとといったものもありますが、スクロールをカスタムしようとした場合かなり難しい印象があります。 当然個人差はあると思いますが、理由は以下の二点かなと思います。
- ネストスクロールをサポートしていないので、タッチイベントの競合をまず解消する必要がある
- スクロールの端に到達した時に親にスクロールを伝播する例では、子がこれスクロール可能かどうかを確認する必要がある
3-3 NestedScrollingParent3とNestedScrollingChild3を実装するとネストスクロールをカスタムできる
最後にNestedScrollingParent3とNestedScrollingChild3を見ていきます。 NestedScrollingParent3とは、子と連携してネストスクロールを制御するためのinterfaceです。 CoordinatorLayoutやMotion Layoutで実装されています。
それと対を成す名称であるNestedScrollingChild3は、ネストスクロールを親に適切に通知するためのinterfaceです。 NestedScrollViewやRecyclerViewで実装されています。
ここでは、実装例としてtakahiromさんによるWebView-in-CoordinatorLayoutにおけるNestedWebViewと伴に見ていきます。 NestedWebViewは、WebViewスクロールによるネストスクロールをサポートする実装になっています。
さて、ここまで見てきたようにAndroid Viewではネストスクロールのサポートを自作する場合オーバーライドするメソッドが多かったり、ネストスクロールが有効かどうかの確認やタッチイベントが終了した時などより具体的なシーンを見る必要があったりする点でJetpack Composeと比較して複雑な印象があります。 Jetpack Composeの方がよりネストスクロールを簡単にカスタムできるという点で、Jetpack Composeへの移行も視野に入れた方が良いかなと感じます。
4 相互運用上の解決策
では、Jetpack ComposeとAndroidを両方用いるハイブリッドな事例だとどうでしょうか?
例えば親のコンポーネントがAndroid ViewのCoordinatorLayoutで、子がJetpack ComposeのLazyColumnの場合です。 Jetpack Composeへの置き換えをする時に子のコンポーネントをボトムアップで置き換えていくのが一つの方法としてありますから、こういったケースは実際に起きうるでしょう。 ネストスクロールを特に考慮せずに実装すると、ツールバーの高さが増減するネストスクロールは機能しません。
親がNestedScrollingParent3を実装しているAndroidViewであれば、NestedScrollInteropConnectionを渡してあげることで動作します。
AndroidViewでは、以下の4つのクラスが主にNestedScrollingParent3を実装しています。
- NestedScrollView
- CoordinatorLayout
- MotionLayout
- SwipeRefreshLayout
では、NestedScrollInteropConnectionは一体何をしているのでしょうか? 具体的には、親がネストスクロールをサポートしているのかを確認し、サポートしていればスクロール量を送ります。
では、親がJetpack Composeで子がAndroidViewの場合だとどうでしょうか? 逆のパターンだと多く見られますが、このケースは少し珍しいかもしれません。 ただ、子のAndroidViewコンポーネントがJetpack Composeで再現するのが難しく、既に安定した動作をしてきたという実績のあるコンポーネントを使った方が安全と判断した場合にはこのようなケースが発生するかもしれません。
実例として親がJetpack ComposeのBoxと同じ階層にTopAppBarで、子がAndroidViewのRecyclerViewを実装する場合を見てみましょう。 RecyclerViewのスクロールに応じてTopAppBarのScrollBehaviorが動作するようにします。
実装はかなりシンプルです。 AndroidViewBindingを用います。
第一引数のfactoryでxmlファイルのDataBindingのinflateを行います。 そして、第二引数のupdateの中でRecyclerViewの初期化処理を行います。
これで、RecyclerViewのスクロールに応じてTopAppBarのScrollBehaviorが動作します。
Box(Modifier.nestedScroll(nestedScrollConnection)) { AndroidViewBinding( factory = InteropNestedScrollBinding::inflate, update = { recyclerview.layoutManager = LinearLayoutManager(root.context) recyclerview.adapter = AndroidViewViewPagerAdapter(...) }, ... ) TopAppBar(...) }
仕組みとしてはComposable関数のAndroidViewにカラクリがあります。
このComposable関数は、NestedScrollingParent3によって子のAndroidViewのスクロールを受け取ることができ、親に対してはJetpack ComposeのNestedScrollConnectionの説明で登場したNestedScrollDispatcherでスクロール量を伝播させることをしています。 この実装によってネストスクロールをサポート可能になっています。
ここまでみてきましたが、Jetpack ComposeとAndroid Viewの接続する実装を用いることでネストスクロール問題を解決できることがわかりました。 また、Composable関数のAndroid Viewでも見たようにネストスクロールをサポートする実装が内部でなされている点でとても便利だなと思います。 より新しい宣言的UIの移行のモチベーションになると感じました。
おわりに
いかがだったでしょうか? Androidアプリ開発では、AndroidViewからJetpack Composeの宣言的UIへとパラダイムシフトが起きました。 それによりリスト表示の仕方やネストスクロールのサポートが簡略化されたように感じます。 また、今後よりAPIによるサポートが充実していくかもしれません。 しかし、いくら汎用的なコンポーネントが充実しても柔軟なユーザー体験に対応するにはスクロールをカスタムする必要が出てくるでしょう。 スクロールの仕組みは決して簡単ではないので、実装する際はどのような仕組みでスクロール動作が成り立っているのかを整理しておくと便利です。 そこで、本記事がその一助になればと思い執筆しました。
ここまで読んでいただき、ありがとうございました。 プロダクト開発においてネストスクロールで困らなくなりますように。