Scroll 修飾子
verticalScroll
修飾子と horizontalScroll
修飾子は、コンテンツの境界が最大サイズ制約より大きい場合にユーザーが要素をスクロールできるようにする最も簡単な方法を提供します。verticalScroll
修飾子と horizontalScroll
修飾子では、コンテンツを変換またはオフセットする必要はありません。
@Composable private fun ScrollBoxes() { Column( modifier = Modifier .background(Color.LightGray) .size(100.dp) .verticalScroll(rememberScrollState()) ) { repeat(10) { Text("Item $it", modifier = Modifier.padding(2.dp)) } } }
ScrollState
を使用すると、スクロール位置を変更したり、現在の状態を取得したりできます。デフォルトのパラメータでこれを作成するには、rememberScrollState()
を使用します。
@Composable private fun ScrollBoxesSmooth() { // Smoothly scroll 100px on first composition val state = rememberScrollState() LaunchedEffect(Unit) { state.animateScrollTo(100) } Column( modifier = Modifier .background(Color.LightGray) .size(100.dp) .padding(horizontal = 8.dp) .verticalScroll(state) ) { repeat(10) { Text("Item $it", modifier = Modifier.padding(2.dp)) } } }
Scrollable 修飾子
scrollable
修飾子はスクロール修飾子とは異なり、scrollable
はスクロール操作を検出して差分を取得しますが、そのコンテンツを自動的にオフセットしません。これは代わりに ScrollableState
を介してユーザーに委任されます。これは、この修飾子が正常に機能するために必要です。
ScrollableState
を作成する際は、各スクロール ステップで(ジェスチャー入力、スムーズ スクロール、フリングによって)呼び出される consumeScrollDelta
関数を、ピクセル単位の差分で指定する必要があります。この関数は、scrollable
修飾子を持つネストされた要素がある場合にイベントが適切に伝播されるように、消費したスクロール距離の量を返す必要があります。
次のスニペットは、操作を検出してオフセットの数値を表示しますが、要素のオフセットは行いません。
@Composable private fun ScrollableSample() { // actual composable state var offset by remember { mutableStateOf(0f) } Box( Modifier .size(150.dp) .scrollable( orientation = Orientation.Vertical, // Scrollable state: describes how to consume // scrolling delta and update offset state = rememberScrollableState { delta -> offset += delta delta } ) .background(Color.LightGray), contentAlignment = Alignment.Center ) { Text(offset.toString()) } }
ネストされたスクロール
ネストされたスクロールは、相互に含まれる複数のスクロール コンポーネントが、単一のスクロール操作に反応してスクロールの差分(変更)を伝達することで連携するシステムです。
ネストされたスクロール システムでは、スクロール可能なコンポーネント間で階層的にリンクされているコンポーネントを調整できます(ほとんどの場合、同じ親を共有することによって行われます)。このシステムは、スクロール コンテナをリンクし、伝播および共有されるスクロール差分の操作を可能にします。
Compose には、コンポーザブル間のネストされたスクロールを処理する方法が複数用意されています。ネストされたスクロールの一般的な例としては、別のリスト内にあるリストや、より複雑なケースとして折りたたみツールバーがあります。
自動ネスト スクロール
シンプルなネスト スクロールでは、アプリ側のアクションは必要ありません。スクロール アクションを開始する操作は、子から親に自動的に伝播されます。これにより、子がそれ以上スクロールできなくなると、親要素によって操作が処理されます。
自動ネスト スクロールは、Compose の一部のコンポーネントと修飾子(verticalScroll
、horizontalScroll
、scrollable
、Lazy
API、TextField
)によってサポートされており、すぐに使用できます。つまり、ユーザーがネストされたコンポーネントの内側の子をスクロールすると、以前の修飾子によって、ネストされたスクロールをサポートする親にスクロールの差分が伝播されます。
次の例は、verticalScroll
修飾子が適用されたコンテナ内で、verticalScroll
修飾子が適用された要素を示しています。
@Composable private fun AutomaticNestedScroll() { val gradient = Brush.verticalGradient(0f to Color.Gray, 1000f to Color.White) Box( modifier = Modifier .background(Color.LightGray) .verticalScroll(rememberScrollState()) .padding(32.dp) ) { Column { repeat(6) { Box( modifier = Modifier .height(128.dp) .verticalScroll(rememberScrollState()) ) { Text( "Scroll here", modifier = Modifier .border(12.dp, Color.DarkGray) .background(brush = gradient) .padding(24.dp) .height(150.dp) ) } } } } }
nestedScroll
修飾子の使用
複数の要素間で高度な調整されたスクロールを作成する必要がある場合は、nestedScroll
修飾子を使用してネストされたスクロール階層を定義することで柔軟性を高めることができます。前のセクションで説明したように、一部のコンポーネントには、ネストされたスクロールのサポートが組み込まれています。ただし、自動的にスクロールできないコンポーザブル(Box
や Column
など)の場合、そのようなコンポーネントのスクロールの差分はネストされたスクロール システムに伝播されず、NestedScrollConnection
にも親コンポーネントにも到達しません。この問題を解決するには、nestedScroll
を使用して、カスタム コンポーネントを含む他のコンポーネントにそのようなサポートを提供します。
ネストされたスクロール サイクル
ネストされたスクロール サイクルとは、スクロール可能なコンポーネントと修飾子、nestedScroll
などを使用して、ネストされたスクロール システムを構成するすべてのコンポーネント(またはノード)を通じて階層ツリーを上下にディスパッチするスクロール差分のフローです。
ネストされたスクロール サイクルのフェーズ
スクロール可能なコンポーネントによってトリガー イベント(操作など)が検出されると、実際のスクロール操作がトリガーされる前に、生成された差分がネストされたスクロール システムに送信され、事前スクロール、ノードの消費、ポストスクロールという 3 つのフェーズを経ます。
最初の事前スクロール フェーズでは、トリガー イベントの差分を受信したコンポーネントは、階層ツリーを通じて、これらのイベントを最上位の親にディスパッチします。デルタイベントはバブルダウンします。つまり、デルタは最もルートの親から、ネストされたスクロール サイクルを開始した子に向かって下方向に伝播されます。
これにより、ネストされたスクロールの親(nestedScroll
またはスクロール可能な修飾子を使用するコンポーザブル)は、ノード自体が使用する前に、デルタで何かを行う機会が得られます。
ノードの使用フェーズでは、ノード自体は、親によって使用されなかった差分を使用します。これは、スクロールの移動が実際に実行され、表示される時点です。
このフェーズで、子は残りのスクロールの全部または一部を消費することを選択できます。残っているものは、ポスト スクロール フェーズに進むために戻されます。
最後のポストスクロール フェーズでは、ノード自体が消費しなかったデータは再び祖先に渡され、消費されます。
ポストスクロール フェーズは、事前スクロール フェーズと同様に動作します。このフェーズでは、親のいずれかが使用するかどうかを選択できます。
スクロールと同様に、ドラッグ操作が終了すると、ユーザーの意図が、スクロール可能なコンテナのフリング(アニメーションを使用したスクロール)に使用される速度に変換される場合があります。フリングもネストされたスクロール サイクルの一部であり、ドラッグ イベントによって生成される速度は、プリフリング、ノードの消費、ポストフリングで同様のフェーズを経ます。なお、フリング アニメーションはタッチ操作にのみ関連付けられ、a11y やハードウェアのスクロールなどの他のイベントではトリガーされません。
ネストされたスクロール サイクルに参加する
サイクルに参加するということは、階層に沿ったデルタの使用量をインターセプト、消費、報告することを意味します。Compose には、ネストされたスクロール システムの動作と、システムを直接操作する方法に影響を与えるツールのセットが用意されています。たとえば、スクロール可能なコンポーネントがスクロールを開始する前に、スクロールのデルタに関する操作を行う必要がある場合などです。
ネストされたスクロール サイクルがノードのチェーンで動作するシステムの場合、nestedScroll
修飾子はこれらの変更をインターセプトして挿入し、チェーンに伝播されるデータ(スクロールの差分)に影響を与えます。この修飾子は階層の任意の場所に配置できます。ツリーの上位にあるネストされたスクロール修飾子インスタンスと通信し、このチャネルを通じて情報を共有できます。この修飾子の構成要素は NestedScrollConnection
と NestedScrollDispatcher
です。
NestedScrollConnection
は、ネストされたスクロール サイクルのフェーズに応答し、ネストされたスクロール システムに影響を与える方法を提供します。これは 4 つのコールバック メソッドで構成され、それぞれが使用フェーズ(プリ/ポスト スクロールとプリ/フリング)のいずれかを表します。
val nestedScrollConnection = object : NestedScrollConnection { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { println("Received onPreScroll callback.") return Offset.Zero } override fun onPostScroll( consumed: Offset, available: Offset, source: NestedScrollSource ): Offset { println("Received onPostScroll callback.") return Offset.Zero } }
各コールバックは、伝播されているデルタに関する情報(その特定のフェーズの available
デルタと、前のフェーズで使用された consumed
のデルタ)も示します。階層への差分の伝播を停止したい場合は、ネストされたスクロール接続を使用できます。
val disabledNestedScrollConnection = remember { object : NestedScrollConnection { override fun onPostScroll( consumed: Offset, available: Offset, source: NestedScrollSource ): Offset { return if (source == NestedScrollSource.SideEffect) { available } else { Offset.Zero } } } }
すべてのコールバックは、NestedScrollSource
型に関する情報を提供します。
NestedScrollDispatcher
は、ネストされたスクロール サイクルを初期化します。ディスパッチャを使用してそのメソッドを呼び出すと、サイクルがトリガーされます。スクロール可能なコンテナには、操作中にキャプチャされた差分をシステムに送信する組み込みのディスパッチャがあります。このため、ネストされたスクロールをカスタマイズするほとんどのユースケースでは、ディスパッチャの代わりに NestedScrollConnection
を使用して、新しい差分を送信するのではなく、既存の差分に反応します。その他の使用方法については、NestedScrollDispatcherSample
をご覧ください。
ネストされたスクロールの相互運用
スクロール可能な View
要素をスクロール可能なコンポーザブルでネストしようとすると、問題が発生することがあります。また、その逆も同様です。特に顕著なのは、子をスクロールしてその開始境界または終了境界に達し、親にスクロールが引き継がれることを期待している場合です。ただし、この想定される動作は、実現しないか、期待どおりに動作しない可能性があります。
この問題は、スクロール可能なコンポーザブルに組み込まれている前提の結果です。スクロール可能なコンポーザブルには「デフォルトでネスト スクロール」というルールがあります。つまり、スクロール可能なコンテナはすべて、ネスト スクロール チェーンに、NestedScrollConnection
を介して親として参加し、NestedScrollDispatcher
を介して子として参加する必要があります。そうすることで、子が境界にあるときに、子が親のネスト スクロールを行います。このようなルールにより、たとえば Compose の Pager
と Compose の LazyRow
が適切に連携します。ただし、相互運用スクロールが ViewPager2
または RecyclerView
で行われている場合、これらは NestedScrollingParent3
を実装していないため、子から親への連続スクロールはできません。
スクロール可能な View
要素とスクロール可能なコンポーザブルが両方向でネストされており、それらの間でネスト スクロールの相互運用 API を有効にする場合、以下のシナリオでネスト スクロールの相互運用 API を使用することで、このような問題を軽減できます。
子 ComposeView
を含む、協力する親 View
連携する親View
とは、NestedScrollingParent3
をすでに実装しているため、連携するネストされた子コンポーザブルからスクロール差分を受け取ることができる親です。この場合、ComposeView
は子として機能し、NestedScrollingChild3
を(間接的に)実装する必要があります。連携する親の例として、androidx.coordinatorlayout.widget.CoordinatorLayout
があります。
スクロール可能な View
である親コンテナとネストされたスクロール可能な子コンポーザブルの間にネスト スクロールの相互運用が必要な場合は、rememberNestedScrollInteropConnection()
を使用できます。
rememberNestedScrollInteropConnection()
により、NestedScrollingParent3
を実装する親 View
と子 Compose との間でネスト スクロールの相互運用を有効にする NestedScrollConnection
の許可と保存が可能になります。これは nestedScroll
修飾子と組み合わせて使用する必要があります。Compose 側ではネストされたスクロールがデフォルトで有効になっているため、この接続を使用して、View
側で両方のネストされたスクロールを有効にし、Views
とコンポーザブルの間に必要なグルーロジックを追加できます。
よく使われるユースケースでは、次の例に示すように、CoordinatorLayout
、CollapsingToolbarLayout
、子コンポーザブルを使用します。
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent"> <com.google.android.material.appbar.AppBarLayout android:id="@+id/app_bar" android:layout_width="match_parent" android:layout_height="100dp" android:fitsSystemWindows="true"> <com.google.android.material.appbar.CollapsingToolbarLayout android:id="@+id/collapsing_toolbar_layout" android:layout_width="match_parent" android:layout_height="match_parent" android:fitsSystemWindows="true" app:layout_scrollFlags="scroll|exitUntilCollapsed"> <!--...--> </com.google.android.material.appbar.CollapsingToolbarLayout> </com.google.android.material.appbar.AppBarLayout> <androidx.compose.ui.platform.ComposeView android:id="@+id/compose_view" app:layout_behavior="@string/appbar_scrolling_view_behavior" android:layout_width="match_parent" android:layout_height="match_parent"/> </androidx.coordinatorlayout.widget.CoordinatorLayout>
アクティビティまたはフラグメントで、子コンポーザブルと必要な NestedScrollConnection
を設定する必要があります。
open class MainActivity : ComponentActivity() { @OptIn(ExperimentalComposeUiApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) findViewById<ComposeView>(R.id.compose_view).apply { setContent { val nestedScrollInterop = rememberNestedScrollInteropConnection() // Add the nested scroll connection to your top level @Composable element // using the nestedScroll modifier. LazyColumn(modifier = Modifier.nestedScroll(nestedScrollInterop)) { items(20) { item -> Box( modifier = Modifier .padding(16.dp) .height(56.dp) .fillMaxWidth() .background(Color.Gray), contentAlignment = Alignment.Center ) { Text(item.toString()) } } } } } } }
子 AndroidView
を含む親コンポーザブル
このシナリオでは、子 AndroidView
を含んでいる親コンポーザブルがある場合の Compose 側のネスト スクロールの相互運用 API の実装を扱います。AndroidView
は、スクロールにおける親である Compose に対しては子になるので NestedScrollDispatcher
を実装し、スクロールにおける子である View
に対しては親になるので NestedScrollingParent3
を実装します。これにより、Compose の親は、ネストされたスクロール可能な子 View
からネストされたスクロールの差分を受け取れるようになります。
次の例は、このシナリオでネストされたスクロールの相互運用を、Compose の折りたたみツールバーとともに実現する方法を示しています。
@Composable
private fun NestedScrollInteropComposeParentWithAndroidChildExample() {
val toolbarHeightPx = with(LocalDensity.current) { ToolbarHeight.roundToPx().toFloat() }
val toolbarOffsetHeightPx = remember { mutableStateOf(0f) }
// Sets up the nested scroll connection between the Box composable parent
// and the child AndroidView containing the RecyclerView
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
// Updates the toolbar offset based on the scroll to enable
// collapsible behaviour
val delta = available.y
val newOffset = toolbarOffsetHeightPx.value + delta
toolbarOffsetHeightPx.value = newOffset.coerceIn(-toolbarHeightPx, 0f)
return Offset.Zero
}
}
}
Box(
Modifier
.fillMaxSize()
.nestedScroll(nestedScrollConnection)
) {
TopAppBar(
modifier = Modifier
.height(ToolbarHeight)
.offset { IntOffset(x = 0, y = toolbarOffsetHeightPx.value.roundToInt()) }
)
AndroidView(
{ context ->
LayoutInflater.from(context)
.inflate(R.layout.view_in_compose_nested_scroll_interop, null).apply {
with(findViewById<RecyclerView>(R.id.main_list)) {
layoutManager = LinearLayoutManager(context, VERTICAL, false)
adapter = NestedScrollInteropAdapter()
}
}.also {
// Nested scrolling interop is enabled when
// nested scroll is enabled for the root View
ViewCompat.setNestedScrollingEnabled(it, true)
}
},
// ...
)
}
}
private class NestedScrollInteropAdapter :
Adapter<NestedScrollInteropAdapter.NestedScrollInteropViewHolder>() {
val items = (1..10).map { it.toString() }
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): NestedScrollInteropViewHolder {
return NestedScrollInteropViewHolder(
LayoutInflater.from(parent.context)
.inflate(R.layout.list_item, parent, false)
)
}
override fun onBindViewHolder(holder: NestedScrollInteropViewHolder, position: Int) {
// ...
}
class NestedScrollInteropViewHolder(view: View) : ViewHolder(view) {
fun bind(item: String) {
// ...
}
}
// ...
}
次の例は、scrollable
修飾子で、この API を使用する方法を示しています。
@Composable
fun ViewInComposeNestedScrollInteropExample() {
Box(
Modifier
.fillMaxSize()
.scrollable(rememberScrollableState {
// View component deltas should be reflected in Compose
// components that participate in nested scrolling
it
}, Orientation.Vertical)
) {
AndroidView(
{ context ->
LayoutInflater.from(context)
.inflate(android.R.layout.list_item, null)
.apply {
// Nested scrolling interop is enabled when
// nested scroll is enabled for the root View
ViewCompat.setNestedScrollingEnabled(this, true)
}
}
)
}
}
次にある最後の例では、ネスト スクロールの相互運用 API を BottomSheetDialogFragment
で使用して、ドラッグして閉じる動作を実現する方法を示しています。
class BottomSheetFragment : BottomSheetDialogFragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val rootView: View = inflater.inflate(R.layout.fragment_bottom_sheet, container, false)
rootView.findViewById<ComposeView>(R.id.compose_view).apply {
setContent {
val nestedScrollInterop = rememberNestedScrollInteropConnection()
LazyColumn(
Modifier
.nestedScroll(nestedScrollInterop)
.fillMaxSize()
) {
item {
Text(text = "Bottom sheet title")
}
items(10) {
Text(
text = "List item number $it",
modifier = Modifier.fillMaxWidth()
)
}
}
}
return rootView
}
}
}
rememberNestedScrollInteropConnection()
は、アタッチ先の要素に NestedScrollConnection
をインストールします。NestedScrollConnection
は、デルタを Compose レベルから View
レベルに送信します。これにより、要素はネストされたスクロールに参加できますが、要素のスクロールが自動的に有効になることはありません。自動的にスクロールできないコンポーザブル(Box
や Column
など)の場合、そのようなコンポーネントのスクロールのデルタは、ネストされたスクロール システムでは伝播されず、rememberNestedScrollInteropConnection()
によって提供される NestedScrollConnection
には到達しないため、これらのデルタは親の View
コンポーネントに到達しません。この問題を解決するには、これらのタイプのネストされたコンポーザブルにスクロール可能な修飾子も設定してください。詳しくは、前のネストされたスクロールのセクションをご覧ください。
子 ComposeView
を持つ非協力的な親 View
連携しない View とは、View
側で必要な NestedScrolling
インターフェースを実装していない View のことです。つまり、こうした Views
とのネスト スクロールの相互運用は、そのままでは機能しません。連携しない Views
は、RecyclerView
と ViewPager2
です。
あなたへのおすすめ
- 注: JavaScript がオフになっている場合はリンクテキストが表示されます
- ジェスチャーについて
CoordinatorLayout
を Compose に移行する- Compose でビューを使用する