巢狀捲動是指多個捲動元件彼此包含,並透過回應單一捲動手勢及傳達捲動增量 (變更) 共同運作的系統。
巢狀捲動系統可協調可捲動且階層連結的元件 (通常是共用同一個父項)。這個系統會連結捲動容器,並允許與在兩者之間傳播及共用的捲動增量互動。
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。
巢狀捲動週期的階段
當可捲動元件偵測到觸發事件 (例如手勢) 時,在實際觸發捲動動作之前,系統會將產生的差異傳送至巢狀捲動系統,並經過三個階段:捲動前、節點消耗和捲動後。

在第一個捲動前階段,收到觸發事件增量的元件會透過階層樹狀結構,將這些事件向上傳送至最上層的父項。接著,差異事件會向下傳遞,也就是說,差異會從最根層級的父項向下傳遞至啟動巢狀捲動週期的子項。

這讓巢狀捲動父項 (使用 nestedScroll 或可捲動修飾符的可組合函式) 有機會在節點本身可取用差異前,對差異執行某些動作。
在節點消耗階段,節點本身會使用父項未使用的任何差異。此時捲動動作已實際完成並顯示。
在這個階段,孩子可以選擇消耗全部或部分剩餘的捲動量。其餘內容則會傳回,進入捲動後階段。
最後,在捲動後階段,節點本身未消耗的任何內容都會再次傳送至其祖系進行消耗。

捲動後階段的運作方式與捲動前階段類似,任何父項都可以選擇是否要取用。

與捲動類似,當拖曳手勢完成時,使用者的意圖可能會轉換為速度,用於快速滑動 (使用動畫捲動) 可捲動的容器。快速滑動也是巢狀捲動週期的一部分,拖曳事件產生的速度會經過類似的階段:快速滑動前、節點消耗和快速滑動後。請注意,快速滑過動畫只會與觸控手勢相關聯,不會由其他事件觸發,例如無障礙功能或硬體捲動。
參與巢狀捲動週期
參與週期表示要攔截、消耗及回報階層中增量的消耗量。Compose 提供一系列工具,可影響巢狀捲動系統的運作方式,以及如何直接與該系統互動。舉例來說,您可能需要在捲動式元件開始捲動前,對捲動增量執行某些操作。
如果巢狀捲動週期是系統對節點鏈執行的動作,則 nestedScroll 修飾符可攔截並插入這些變更,以及影響鏈中傳播的資料 (捲動增量)。這個修飾符可以放在階層中的任何位置,並與樹狀結構中的巢狀捲動修飾符例項通訊,因此可以透過這個管道分享資訊。這個修飾符的建構區塊為 NestedScrollConnection 和 NestedScrollDispatcher。
NestedScrollConnection 可讓您回應巢狀捲動週期的各個階段,並影響巢狀捲動系統。它由四個回呼方法組成,每個方法代表一個消耗階段:捲動前/後和快速滑動前/後:
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。
在捲動時調整圖片大小
使用者捲動時,您可以建立動態視覺效果,讓圖片根據捲動位置變更大小。
根據捲動位置調整圖片大小
這個程式碼片段示範如何根據垂直捲動位置,調整 LazyColumn 內圖片的大小。使用者向下捲動時,圖片會縮小;向上捲動時,圖片會放大,但仍會維持在定義的最小和最大尺寸範圍內:
@Composable fun ImageResizeOnScrollExample( modifier: Modifier = Modifier, maxImageSize: Dp = 300.dp, minImageSize: Dp = 100.dp ) { var currentImageSize by remember { mutableStateOf(maxImageSize) } var imageScale by remember { mutableFloatStateOf(1f) } val nestedScrollConnection = remember { object : NestedScrollConnection { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { // Calculate the change in image size based on scroll delta val delta = available.y val newImageSize = currentImageSize + delta.dp val previousImageSize = currentImageSize // Constrain the image size within the allowed bounds currentImageSize = newImageSize.coerceIn(minImageSize, maxImageSize) val consumed = currentImageSize - previousImageSize // Calculate the scale for the image imageScale = currentImageSize / maxImageSize // Return the consumed scroll amount return Offset(0f, consumed.value) } } } Box(Modifier.nestedScroll(nestedScrollConnection)) { LazyColumn( Modifier .fillMaxWidth() .padding(15.dp) .offset { IntOffset(0, currentImageSize.roundToPx()) } ) { // Placeholder list items items(100, key = { it }) { Text( text = "Item: $it", style = MaterialTheme.typography.bodyLarge ) } } Image( painter = ColorPainter(Color.Red), contentDescription = "Red color image", Modifier .size(maxImageSize) .align(Alignment.TopCenter) .graphicsLayer { scaleX = imageScale scaleY = imageScale // Center the image vertically as it scales translationY = -(maxImageSize.toPx() - currentImageSize.toPx()) / 2f } ) } }
程式碼重點
- 這段程式碼會使用
NestedScrollConnection攔截捲動事件。 onPreScroll會根據捲動差異計算圖片大小的變化。currentImageSize狀態變數會儲存圖片的目前大小,並限制在minImageSize和maxImageSize. imageScale之間,而 則衍生自currentImageSize。LazyColumn會根據currentImageSize進行偏移。Image會使用graphicsLayer修飾符套用計算出的縮放比例。graphicsLayer中的translationY可確保圖片在縮放時仍保持垂直置中。
結果
上述程式碼片段會產生捲動時的圖片縮放效果:
巢狀捲動互通性
如果想在捲動式可組合元件中建立巢狀捲動 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>
您需要在 Activity 或 Fragment 中設定子項可組合函式,以及必要的 NestedScrollConnection:
open class MainActivity : ComponentActivity() { 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( title = {}, 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(R.layout.list_item, null) .apply { // Nested scrolling interop is enabled when // nested scroll is enabled for the root View ViewCompat.setNestedScrollingEnabled(this, true) } } ) } }
最後,這個範例可以說明如何透過 BottomSheetDialogFragment 使用巢狀捲動互通性 API,藉此成功達成拖曳和關閉行為:
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 介面。請注意,這代表具有 Views 的巢狀捲動互通性無法供您馬上使用。非合作執行的 Views 為 RecyclerView 和 ViewPager2。
其他資源
為您推薦
- 注意:系統會在 JavaScript 關閉時顯示連結文字
- 瞭解手勢
- 遷移
CoordinatorLayout至 Compose - 在 Compose 中使用 View