手勢

Compose 提供多種 API,協助您偵測使用者互動而產生的手勢。這些 API 的用途廣泛,包括:

  • 其中一些是高階 API,專為最常用的手勢而設計。舉例來說,clickable 修飾符可以輕鬆偵測點擊,而且還會提供無障礙功能並在輕觸時顯示視覺指標 (例如波紋)。

  • 此外,還會提供比較不常使用的手勢偵測工具,針對較低階的功能提供更多彈性,例如 PointerInputScope.detectTapGesturesPointerInputScope.detectDragGestures但不包括額外功能。

輕觸並按下

clickable 輔助鍵可讓應用程式偵測已套用該輔助鍵的元素所獲得的點擊次數

@Composable
fun ClickableSample() {
    val count = remember { mutableStateOf(0) }
    // content that you want to make clickable
    Text(
        text = count.value.toString(),
        modifier = Modifier.clickable { count.value += 1 }
    )
}

回應輕觸動作的 UI 元素範例

如果需要更多彈性,可以透過 pointerInput 輔助鍵提供輕觸手勢偵測工具:

Modifier.pointerInput(Unit) {
    detectTapGestures(
        onPress = { /* Called when the gesture starts */ },
        onDoubleTap = { /* Called on Double Tap */ },
        onLongPress = { /* Called on Long Press */ },
        onTap = { /* Called on Tap */ }
    )
}

捲動

捲動輔助鍵

在元素的內容邊界超過尺寸上限時,使用 verticalScrollhorizontalScroll 輔助鍵可以讓使用者以最簡單的方式捲動元素。只要使用 verticalScrollhorizontalScroll 輔助鍵,就不必平移或偏移內容。

@Composable
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會偵測捲動手勢,但不會偏移其內容。需要 ScrollableState 才能讓這個輔助鍵正確運作。建構 ScrollableState 時,您必須提供 consumeScrollDelta 函式,以便在每個捲動步驟 (透過手勢輸入、流暢捲動或快速滑過) 叫用該函式,並以像素為單位呈現差異。此函式必須傳回使用的捲動距離量,以確保事件在含有 scrollable 輔助鍵的巢狀元素時能正確傳播。

下列程式碼片段會偵測手勢並顯示偏移量的數值,但不會偏移任何元素:

@Composable
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())
    }
}

UI 元素會偵測按下手指並顯示手指位置的數值

巢狀捲動

Compose 支援巢狀捲動,讓多個元素回應單一捲動手勢。巢狀捲動的另一個典型範例就是其他清單中的清單,而較複雜的一種情況則是收合工具列

自動巢狀捲動

簡易的巢狀捲動功能您不需執行任何動作。觸發捲動動作的手勢會自動從子項套用到父項,因此,當子項無法再捲動畫面時,就會由父項元素處理該手勢。

系統支援自動巢狀捲動功能,而這項可立即使用的功能是由部分 Compose 的元件和輔助鍵所提供:verticalScrollhorizontalScrollscrollableLazy API 與 TextField。這表示當使用者捲動巢狀元件的內部子項時,先前的輔助鍵會將捲動差異傳播至支援巢狀捲動功能的父項。

以下範例顯示容器中已套用了 verticalScroll 輔助鍵的元素,且容器也同時套用了 verticalScroll 輔助鍵。

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)
                )
            }
        }
    }
}

兩個巢狀垂直捲動 UI 元素,對內部元素內外的手勢做出回應

使用 nestedScroll 輔助鍵

如果您需要在多個元素之間建立進階的協調捲動功能,則 nestedScroll 輔助鍵會定義巢狀捲動階層,讓您擁有更多彈性。如上文所述,部分元件已內建支援巢狀捲動。不過,像是 BoxColumn 這類無法自動捲動的可組合元件,便無法在巢狀捲動系統中傳播捲動差異,且差異和父項元件都無法進入 NestedScrollConnection。如果想解決這個問題,您可以使用 nestedScroll 將這類支援權限授予其他元件,包括自訂元件。

巢狀捲動互通性 (實驗功能)

如果想在捲動式可組合元件中建立巢狀捲動 View 元素 (或相反情況),可能會發生問題。最容易判別的情況,就是當將子項捲動到開頭或尾端時,本應由父項接續捲動,但是並未發生預期的行為,或是實際運作方式和預期內容不同。

這可能是由於捲動式可組合元件內建的預期情況所導致的。捲動式可組合元件具有「預設建立巢狀捲動」的規則,因此任何可以捲動的容器都必須加入巢狀捲動鏈結,包括透過 NestedScrollConnection 方式作為父項,以及透過 NestedScrollDispatcher 方式作為子項。當子項抵達邊界時,就會對父項驅動巢狀捲動。舉例來說,這項規則可以讓您同時正常使用 Compose Pager 和 Compose LazyRow。不過,如果使用 ViewPager2RecyclerView 達成互通性捲動,則由於這些元件並未實作 NestedScrollingParent3,因此子項無法將捲動延續到父項。

如果想啟用可在捲動式 View 與捲動式可組合元件之間同時使用的巢狀捲動互通性 API,讓兩個方向皆使用巢狀結構,那麼在以下情況中,您可以使用巢狀捲動互通性 API 緩解這類問題。

含有子項 ComposeView 的合作執行 View 父項

合作執行父項 View 已經實作 NestedScrollingParent3,因此可以從合作執行的巢狀子項可組合元件接收捲動差異。在這種情況下,ComposeView 會作為子項,並需用間接方式實作 NestedScrollingChild3。合作執行父項的範例之一就是 androidx.coordinatorlayout.widget.CoordinatorLayout

如需在捲動式 View 父項容器和巢狀捲動式子項可組合元件之間建立巢狀捲動互通性,可使用 rememberNestedScrollInteropConnection()

rememberNestedScrollInteropConnection() 可允許並記憶在實作 NestedScrollingParent3View 父項和 Compose 子項之間啟用巢狀捲動互通性的 NestedScrollConnection。此內容應和 nestedScroll 修飾符一同使用。由於 Compose 端會預設啟用巢狀捲動,因此您可以利用這個連結,在 View 端啟用巢狀捲動,同時在 Views 和可組合元件之間新增必要的緊連邏輯。

常見的作法是使用 CoordinatorLayoutCollapsingToolbarLayout 和一個子項可組合元件,請看範例:

<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

@ExperimentalComposeUiApi
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(
            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)
                    }
            }
        )
    }
}

最後,這個範例可以說明如何透過 BottomSheetDialogFragment 使用巢狀捲動互通性 API,藉此成功達成拖曳和關閉行為:

@OptIn(ExperimentalComposeUiApi::class)
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() 會在您附加的元素中安裝 NestedScrollConnectionNestedScrollConnection 負責從 Compose 層級傳送差異到 View層級。這樣做即可讓元素加入巢狀捲動,但是無法自動捲動元素。像是 BoxColumn 這類無法自動捲動的可組合元件,便無法在巢狀捲動系統中傳播捲動差異,且差異無法進入 rememberNestedScrollInteropConnection() 提供的 NestedScrollConnection,因此,差異無法進入父項 View 元件。如果要解決這個問題,請務必同時將捲動修飾符設定為這些巢狀可組合元件的類型。欲知更多詳情,請參考上述的巢狀捲動一節。

含有子項 ComposeView 的非合作執行 View 父項

非合作執行的 View 並未在 View 端實作必要的 NestedScrolling 介面。請注意,這代表具有 Views 的巢狀捲動互通性無法供您馬上使用。非合作執行的 ViewsRecyclerViewViewPager2

拖曳

draggable 修飾符是高階進入點,可讓您循單一方向拖曳手勢,並回報拖曳距離 (以像素為單位)。

請注意,這個輔助鍵與 scrollable 很類似,因為兩者都只會偵測手勢。您必須保留該狀態並在螢幕上呈現,例如透過 offset 輔助鍵移動元素:

var offsetX by remember { mutableStateOf(0f) }
Text(
    modifier = Modifier
        .offset { IntOffset(offsetX.roundToInt(), 0) }
        .draggable(
            orientation = Orientation.Horizontal,
            state = rememberDraggableState { delta ->
                offsetX += delta
            }
        ),
    text = "Drag me!"
)

如果您需要控管整個拖曳手勢,請考慮透過 pointerInput 輔助鍵來改用拖曳手勢偵測工具。

Box(modifier = Modifier.fillMaxSize()) {
    var offsetX by remember { mutableStateOf(0f) }
    var offsetY by remember { mutableStateOf(0f) }

    Box(
        Modifier
            .offset { IntOffset(offsetX.roundToInt(), offsetY.roundToInt()) }
            .background(Color.Blue)
            .size(50.dp)
            .pointerInput(Unit) {
                detectDragGestures { change, dragAmount ->
                    change.consumeAllChanges()
                    offsetX += dragAmount.x
                    offsetY += dragAmount.y
                }
            }
    )
}

按下手指拖曳 UI 元素

滑動

swipeable 修飾符可讓您拖曳元素,一旦放開手指時,這些元素通常會朝方向中定義的兩個 (或更多) 錨點建立動畫。常見的做法是實作「滑動即可關閉」模式。

請注意,這個修飾符不會移動元素,只會偵測手勢。您必須保留該狀態並在螢幕上呈現,例如透過 offset 輔助鍵移動元素。

swipeable 輔助鍵中必須使用可滑動的狀態,這種狀態可以使用 rememberSwipeableState() 加以建立並儲存。這個狀態也會提供一組實用的方法,然後透過程式輔助,針對錨定標記 (請參閱 snapToanimateToperformFlingperformDrag) 及屬性建立動畫,以便觀察拖曳進度。

滑動手勢可設有不同的閾值類型,例如 FixedThreshold(Dp)FractionalThreshold(Float),而且每個錨點的「開始到結束」組合也可以設有不同的閾值。

如要享有更多彈性,您可以在滑動超過邊界時設定 resistance,也可以設定 velocityThreshold,以便在滑動至下一個狀態時建立動畫 (即使尚未觸及定位 thresholds)。

@Composable
fun SwipeableSample() {
    val width = 96.dp
    val squareSize = 48.dp

    val swipeableState = rememberSwipeableState(0)
    val sizePx = with(LocalDensity.current) { squareSize.toPx() }
    val anchors = mapOf(0f to 0, sizePx to 1) // Maps anchor points (in px) to states

    Box(
        modifier = Modifier
            .width(width)
            .swipeable(
                state = swipeableState,
                anchors = anchors,
                thresholds = { _, _ -> FractionalThreshold(0.3f) },
                orientation = Orientation.Horizontal
            )
            .background(Color.LightGray)
    ) {
        Box(
            Modifier
                .offset { IntOffset(swipeableState.offset.value.roundToInt(), 0) }
                .size(squareSize)
                .background(Color.DarkGray)
        )
    }
}

回應滑動手勢的 UI 元素

多點觸控:平移、縮放、旋轉

如要偵測用來平移、縮放及旋轉的多點觸控手勢,您可以使用 transformable 輔助鍵。這個輔助鍵本身不會轉換元素,只會偵測手勢。

@Composable
fun TransformableSample() {
    // set up all transformation states
    var scale by remember { mutableStateOf(1f) }
    var rotation by remember { mutableStateOf(0f) }
    var offset by remember { mutableStateOf(Offset.Zero) }
    val state = rememberTransformableState { zoomChange, offsetChange, rotationChange ->
        scale *= zoomChange
        rotation += rotationChange
        offset += offsetChange
    }
    Box(
        Modifier
            // apply other transformations like rotation and zoom
            // on the pizza slice emoji
            .graphicsLayer(
                scaleX = scale,
                scaleY = scale,
                rotationZ = rotation,
                translationX = offset.x,
                translationY = offset.y
            )
            // add transformable to listen to multitouch transformation events
            // after offset
            .transformable(state = state)
            .background(Color.Blue)
            .fillMaxSize()
    )
}

回應多點觸控手勢 (平移、縮放和旋轉) 的 UI 元素

如果您需要將縮放、平移和旋轉與其他手勢合併,可以使用 PointerInputScope.detectTransformGestures 偵測工具。