拖动可组合项

让用户能够在屏幕上实际拖动组件是现代 Android 应用的基础。无论您是要构建自定义滑块、滑动关闭组件还是交互式画布,Jetpack Compose 都能提供一套全面的 API,让您轻松处理拖动事件。

Compose 拖动 API 抽象化了原始触摸事件的复杂性。 您可以使用直接与 Compose 的状态管理和动画系统关联的内置修饰符,而无需使用 pointerInput 手动计算触摸坐标和管理手势生命周期。

本页介绍了如何以及何时使用每个拖动 API。

选择正确的拖动 API

您可以使用多个 API 来实现可拖动内容。

  • 在以下情况下,请选择标准 Material 组件(例如 SwipeToDismissBox 或抽屉式导航栏):您需要默认的 Material 3 滑动或拖动行为(例如,删除电子邮件、归档任务或打开侧边菜单 / 底部动作条)。它们提供内置行为,无需自定义状态或布局管理。
  • 在以下情况下选择 Modifier.draggable:您只需要原始拖动增量(例如,拖动自定义滑块)。如需了解详情,请参阅使用 draggable 沿单个轴拖动。
  • 在以下情况下 Modifier.anchoredDraggable 选择:您正在构建需要在两个或多个离散位置之间贴靠的自定义互动组件(例如,底部动作条、抽屉式导航栏或滑动显示操作)。如需了解详情,请参阅使用 anchoredDraggable 在锚点之间拖动组件。

使用 draggable 沿单个轴拖动

draggable 修饰符是单个方向拖动手势的高级入口点,并以像素为单位报告拖动距离。

请务必注意,此修饰符与 scrollable 类似,仅检测手势。您需要保留状态并在屏幕上表示该状态,例如,通过 offset 修饰符移动元素:

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

如果您需要控制整个拖动手势,请考虑改用拖动手势检测器,方法是使用 pointerInput 修饰符。

@Composable
private fun DraggableTextLowLevel() {
    Box(modifier = Modifier.fillMaxSize()) {
        var offsetX by remember { mutableFloatStateOf(0f) }
        var offsetY by remember { mutableFloatStateOf(0f) }

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

在此处插入替代文本
图 1. 通过手指拖动的界面元素

使用 anchoredDraggable 在锚点之间拖动组件

借助 anchoredDraggable 修饰符,您可以在一组预定义位置(称为锚点)之间拖动组件。当用户松开拖动手势时,组件会停在最近的锚点。anchoredDraggable 可用于创建自定义底部动作条、复杂滑块或抽屉式面板等组件。

API Surface

AnchoredDraggable API 包含以下主要部分:

  • Anchors (DraggableAnchors):一个构建器,用于定义自定义状态(例如 Start, Center, End 的枚举类)与其对应的像素偏移值之间的映射。
  • State (AnchoredDraggableState<T>):一个状态容器,用于管理组件的当前位置(偏移量)、其固定的锚点以及拖动进度。应使用 rememberSaveable 和 AnchoredDraggableState.Saver<T> 创建它,以便在配置更改后保留其状态。
  • 手势修饰符 (Modifier.anchoredDraggable):一种修饰符,用于检测沿 Orientation 的拖动手势,使用拖动增量更新状态,并处理快速滑动手势,以便在释放时将组件动画化到最近的锚点。

示例:构建自定义 SwipeToDismiss 组件

此示例演示了如何构建自定义 SwipeToDismiss 组件(白色横幅),用户可以向左滑动以关闭该组件,也可以向右拖动以查看滚动回弹拉伸效果。

  1. 定义锚点值。定义一个枚举,用于表示可拖动内容可以停靠的离散位置:

    private enum class AnchoredDraggableSwipeDismissValue { DismissedStart, Resting }

  2. 实例化并保存 AnchoredDraggableState。将 rememberSaveable 与 AnchoredDraggableState.Saver() 搭配使用,以便在 Activity 重新创建和配置更改(例如屏幕旋转或主题更改)期间持久保存组件的当前位置:

    val state =
        rememberSaveable(saver = AnchoredDraggableState.Saver()) {
            AnchoredDraggableState(initialValue = AnchoredDraggableSwipeDismissValue.Resting)
        }

  3. 应用 layout、offset 和 draggable 修饰符。如需使组件可拖动并确保其锚点适应其大小,请将以下修饰符应用于应在视觉上移动的内容:

    • Modifier.layout:锚点位置通常取决于组件的大小,而这在初始组合期间是未知的。使用 layout 修饰符测量组件,根据其宽度动态计算 DraggableAnchors 映射,并使用 state.updateAnchors() 更新状态。

    • Modifier.anchoredDraggable:连接到状态,在指定方向上分派拖动增量,并处理快速滑动手势,以便在释放时将组件动画化到最近的锚点。将此修饰符添加到应响应拖动手势的组件中,并向其传递 state 和拖动 orientation(以及可选的 overscrollEffect)。
    • Modifier.offset:在偏移量 lambda 中从 state.requireOffset() 读取偏移量。在此 lambda 中读取偏移量可确保仅在放置阶段读取该值,从而避免在拖动过程中进行不必要的重组。将此修饰符应用于应在视觉上移动的内容。

    Box(
        modifier = Modifier
            .fillMaxWidth()
            // Read state.requireOffset() inside the offset lambda to defer reading to the placement phase
            .offset { IntOffset(state.requireOffset().roundToInt(), 0) }
            .layout { measurable, constraints ->
                val placeable = measurable.measure(constraints)
                val anchors = DraggableAnchors {
                    AnchoredDraggableSwipeDismissValue.DismissedStart at -placeable.width.toFloat()
                    AnchoredDraggableSwipeDismissValue.Resting at 0f
                }
                state.updateAnchors(anchors)
                layout(placeable.width, placeable.height) { placeable.placeRelative(0, 0) }
            }
            // Pass overscrollEffect to anchoredDraggable so deltas beyond the bounds (min and max anchor) are dispatched to overscrollEffect, and render the visual effect with Modifier.overscroll.
            .overscroll(overscrollEffect)
            .anchoredDraggable(
                state,
                Orientation.Horizontal,
                overscrollEffect = overscrollEffect
            )
    ) {
        dismissContent()
    }

  4. [可选] 集成滚动回弹。overscrollEffect API 可实现一种视觉效果,当滚动或快速滑动到达滚动容器的边缘时,就会显示这种效果。当用户拖动超出定义的锚点边界时,您可以通过滚动回弹效果(通常是拉伸或发光)提供视觉反馈。

    1. 分派:将 overscrollEffect 实例传递给 anchoredDraggable 修饰符。这会将拖动增量分派到 AnchoredDraggable 状态或滚动回弹效果,具体取决于手势是在锚点边界内还是边界外。
    2. 渲染:将具有相同 OverscrollEffect 实例的 Modifier.overscroll 应用于应直观呈现效果的组件。

以下代码段展示了如何添加标准拉伸效果:

val overscrollEffect = rememberOverscrollEffect()
Box(
    modifier.fillMaxWidth()
) {
    Box(Modifier.fillMaxWidth()) {
        background()
    }
    Box(
        modifier = Modifier
            .fillMaxWidth()
            // Read state.requireOffset() inside the offset lambda to defer reading to the placement phase
            .offset { IntOffset(state.requireOffset().roundToInt(), 0) }
            .layout { measurable, constraints ->
                val placeable = measurable.measure(constraints)
                val anchors = DraggableAnchors {
                    AnchoredDraggableSwipeDismissValue.DismissedStart at -placeable.width.toFloat()
                    AnchoredDraggableSwipeDismissValue.Resting at 0f
                }
                state.updateAnchors(anchors)
                layout(placeable.width, placeable.height) { placeable.placeRelative(0, 0) }
            }
            // Pass overscrollEffect to anchoredDraggable so deltas beyond the bounds (min and max anchor) are dispatched to overscrollEffect, and render the visual effect with Modifier.overscroll.
            .overscroll(overscrollEffect)
            .anchoredDraggable(
                state,
                Orientation.Horizontal,
                overscrollEffect = overscrollEffect
            )
    ) {
        dismissContent()
    }
}

结果

上述代码段会生成一个白色横幅,其中包含文本“Drag me to dismiss!”,用户可以向左滑动以关闭该横幅(或向右拖动以查看滚动回弹拉伸效果):

视频 2.自定义滑动关闭组件实现。

示例:创建自定义的手势驱动型抽屉式导航栏

此示例演示了如何构建自定义的手势驱动型抽屉式导航栏。当用户水平拖动时,前景屏幕 (ScreenContents) 会向右滑动,从 1.0f 缩放到 0.8f,并将其边角圆角化,以显示底层的抽屉式导航栏。

  1. 定义锚点状态和位置。在此示例中,锚点是抽屉式导航栏的打开和关闭状态。为 Open 和 Closed 状态定义一个枚举,并使用 DraggableAnchors 将它们映射到像素偏移量:

    enum class DrawerState {
        Open,
        Closed
    }
    
        val anchors = DraggableAnchors {
            DrawerState.Open at drawerWidth
            DrawerState.Closed at 0f
        }
        val state = remember {
            AnchoredDraggableState(
                initialValue = DrawerState.Closed,
                anchors = anchors,
            )
        }

  2. 在点击某个项时为 anchoredDraggableState 添加动画效果。将 HomeScreenDrawer 放在 Box 的底部,将 ScreenContents 放在顶部。抽屉式导航栏保持静止,而顶层在其上方平移和缩放。

    当用户选择抽屉式导航栏中的某个项时,更新活动屏幕状态,并使用 state.animateTo(DrawerState.Closed) 自动关闭抽屉式导航栏:

    @Composable
    private fun AnchoredDraggableDrawerOnClickSample(
        modifier: Modifier = Modifier,
        state: AnchoredDraggableState<DrawerState>,
        coroutineScope: CoroutineScope
    ) {
        var screenState by remember { mutableStateOf(ScreenState.Home) }
    
        Box(modifier = modifier.fillMaxSize()) {
            HomeScreenDrawer(
                selectedScreen = screenState,
                onScreenSelected = { screen ->
                    screenState = screen
                    // Dismiss drawer when a destination is selected
                    coroutineScope.launch { state.animateTo(DrawerState.Closed) }
                }
            )
    
            // ScreenContents(...)
        }
    }

  3. 使用 state.requireOffset() 更改多个视觉属性。 您可以使用 Modifier.graphicsLayer 更改多个属性,以便根据拖动进度同时为平移、缩放和圆角半径添加动画效果,而不仅仅是偏移内容:

    • 使用 state.requireOffset() 可获取当前拖动偏移量。
    • 使用 lerp()(线性插值)计算相对于偏移量和抽屉式导航栏宽度的比例缩放(1f 到 0.8f)和圆角半径(0.dp 到 32.dp)。
    • Modifier.graphicsLayer 会在绘制阶段执行更改,从而确保高性能动画,而不会在拖动期间触发重组。

    @Composable
    private fun AnchoredDraggableDrawerGraphicsLayerSample(
        state: AnchoredDraggableState<DrawerState>,
        drawerWidth: Float,
        selectedScreen: ScreenState,
        onDrawerClicked: () -> Unit
    ) {
        ScreenContents(
            selectedScreen = selectedScreen,
            onDrawerClicked = onDrawerClicked,
            modifier = Modifier
                .graphicsLayer {
                    this.translationX = state.requireOffset()
                    val scale = lerp(1f, 0.8f, state.requireOffset() / drawerWidth)
                    this.scaleX = scale
                    this.scaleY = scale
                    val cornerRadius = lerp(0f, 32f, state.requireOffset() / drawerWidth)
                    this.shape = RoundedCornerShape(cornerRadius.dp)
                    this.clip = true
                }
                .anchoredDraggable(state, Orientation.Horizontal)
        )
    }

  4. 使用 animateTo 触发程序化动画。通过在协程作用域内启动 state.animateTo(),以编程方式控制抽屉式导航栏(例如点按菜单图标或选择抽屉式导航栏项时):

    fun toggleDrawerState(
        state: AnchoredDraggableState<DrawerState>,
        coroutineScope: CoroutineScope
    ) {
        coroutineScope.launch {
            if (state.currentValue == DrawerState.Open) {
                state.animateTo(DrawerState.Closed)
            } else {
                state.animateTo(DrawerState.Open)
            }
        }
    }

结果

视频 3. 前景屏幕(睡眠)被拖动到右侧,显示底层的抽屉式导航栏。

使用 Modifier.draggable2D 在两个维度上拖动

使用 draggable2D 修饰符可同时沿 X 轴和 Y 轴移动单个界面元素。

拖动可组合元素

此示例展示了 draggable2D 最常见的用例:允许用户拾取界面元素并将其重新放置在父级容器内的任意位置。

@Composable
private fun DraggableComposableElement() {
    // 1. Track the position of the floating window
    var offset by remember { mutableStateOf(Offset.Zero) }

    Box(modifier = Modifier.fillMaxSize().background(Color(0xFFF5F5F5))) {
        Box(
            modifier = Modifier
                // 2. Apply the offset to the box's position
                .offset { IntOffset(offset.x.roundToInt(), offset.y.roundToInt()) }
                // ...
                // 3. Attach the 2D drag logic
                .draggable2D(
                    state = rememberDraggable2DState { delta ->
                        // 4. Update the position based on the movement delta
                        offset += delta
                    }
                ),
            contentAlignment = Alignment.Center
        ) {
            Text("Video Preview", color = Color.White, fontSize = 12.sp)
        }
    }
}

视频 4. 一个小紫色框在灰色背景上重新定位,演示了直接 2D 拖动,其中元素在用户手指抬起的瞬间停止移动。

上述代码段包含以下内容:

  • 使用 offset 状态跟踪盒子的位置。
  • 使用 offset 修饰符根据拖动增量移动组件的位置。
  • 由于不支持快速滑动,因此当用户抬起手指时,方框会立即停止移动。

根据父级可组合项的拖动区域拖动子级可组合项

此示例演示了如何使用 draggable2D 创建一个 2D 输入区域,其中选择器旋钮被限制在特定表面内。与可拖动元素示例(移动组件本身)不同,此实现使用 2D 增量在颜色选择器中移动子级可组合项“选择器”:

@Composable
private fun ExampleColorSelector(
    // ...
)  {
    // 1. Maintain the 2D position of the selector in state.
    var selectorOffset by remember { mutableStateOf(Offset.Zero) }

    // 2. Track the size of the background container.
    var containerSize by remember { mutableStateOf(IntSize.Zero) }

    Box(
        modifier = Modifier
            .size(300.dp, 200.dp)
            // Capture the actual pixel dimensions of the container when it's laid out.
            .onSizeChanged { containerSize = it }
            .clip(RoundedCornerShape(12.dp))
            .background(
                brush = remember(hue) {
                    // Create a simple gradient representing Saturation and Value for the given Hue.
                    Brush.linearGradient(listOf(Color.White, Color.hsv(hue, 1f, 1f)))
                }
            )
    ) {
        Box(
            modifier = Modifier
                .size(24.dp)
                .graphicsLayer {
                    // Center the selector on the finger by subtracting half its size.
                    translationX = selectorOffset.x - (24.dp.toPx() / 2)
                    translationY = selectorOffset.y - (24.dp.toPx() / 2)
                }
                // ...
                // 3. Configure 2D touch dragging.
                .draggable2D(
                    state = rememberDraggable2DState { delta ->
                        // 4. Calculate the new position and clamp it to the container bounds
                        val newX = (selectorOffset.x + delta.x)
                            .coerceIn(0f, containerSize.width.toFloat())
                        val newY = (selectorOffset.y + delta.y)
                            .coerceIn(0f, containerSize.height.toFloat())

                        selectorOffset = Offset(newX, newY)
                    }
                )
        )
    }
}

视频 5. 一个颜色渐变,其中有一个白色圆形选择器旋钮,可以向任何方向拖动,演示了如何将 2D 增量限制在容器边界内,以更新所选颜色值。

上述代码段包含以下内容:

  • 它使用 onSizeChanged 修饰符来捕获渐变容器的实际尺寸。选择器可以准确识别边缘。
  • 在 graphicsLayer 中,它会调整 translationX 和 translationY,以确保选择器在拖动时保持居中。