拖动可组合项

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

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

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

选择正确的拖动 API

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

  • 在以下情况下,请选择标准 Material 组件(例如 SwipeToDismissBox 或抽屉式导航栏):您需要默认的 Material 3 滑动或拖动 行为(例如,删除电子邮件、归档任务或打开侧边 菜单 / 底部动作条)。它们提供内置行为,而无需自定义状态或布局管理。
  • 在以下情况下,请选择 Modifier.draggable :您只需要原始拖动 增量(例如,拖动自定义滑块)。如需了解详情,请参阅 使用 draggable 沿单个轴拖动。
    • 在以下情况下,请选择 Modifier.draggable2D :您需要一个组件同时在 X 轴和 Y 轴上连续移动(例如,平移地图或创建用户可以在屏幕上任意位置拖动的悬浮操作按钮)。如需了解详情,请参阅 使用 Modifier.draggable2D 在二维空间中拖动。
  • 在以下情况下,请选择 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 由以下主要部分组成:

  • 锚点 (DraggableAnchors):一个构建器,用于定义自定义状态(例如,Start, Center, End 的枚举类)及其对应的像素偏移值之间的映射。
  • 状态容器 (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. 在灰色背景上重新放置一个小紫色框,演示直接二维拖动,即在用户的手指抬起时元素立即停止移动。

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

  • 使用 offset 状态跟踪框的位置。
  • 使用 offset 修饰符根据拖动增量移动组件的位置。
  • 由于没有轻拂支持,因此在用户抬起手指时,框会立即停止移动。

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

此示例演示了如何使用 draggable2D 创建二维输入区域,其中选择器旋钮受限于特定 surface。与移动组件本身的可拖动元素示例不同,此实现使用二维增量在颜色选择器上移动子级可组合项“选择器”:

@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. 具有白色圆形选择器旋钮的颜色渐变,该旋钮可以向任意方向拖动,演示了如何将二维增量固定到容器的边界以更新所选颜色值。

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

  • 它使用 onSizeChanged 修饰符捕获渐变容器的实际尺寸。选择器确切知道边缘在哪里。
  • 在 graphicsLayer 内,它会调整 translationX 和 translationY,以确保选择器在拖动时保持居中。