Jetpack Compose 里的「自定义View」:布局、绘制、触摸

286 阅读52分钟

自定义绘制

在 Jetpack Compose 中,“自定义 View” 大致对应于在传统 Android View 里重写 onDraw() 的场景。Compose 提供了更灵活、更声明式的绘制接口,主要有两种用法:


1. 使用 Canvas 组件

最直接的方式就是在布局中放一个 Canvas,然后在它的 onDraw 回调里做绘制。

@Composable
fun MyCustomCanvas(modifier: Modifier = Modifier) {
    Canvas(modifier = modifier
        .size(200.dp)        // 指定画布大小
        .background(Color.White)
    ) {
        // DrawScope 在这里
        // this.size: IntSize,this.center: Offset
        drawCircle(
            color = Color.Blue,
            radius = size.minDimension / 2f,
            center = center
        )
        drawLine(
            color = Color.Red,
            start = Offset(0f, 0f),
            end = Offset(size.width, size.height),
            strokeWidth = 4.dp.toPx()
        )
        // 更多绘制:drawRect, drawPath, drawIntoCanvas { … } 等
    }
}
  • DrawScopeCanvas 回调里可访问 sizelayoutDirectiondensity 等属性。
  • 单位:所有与像素有关的参数(如 strokeWidthradius)都要用 toPx() 转换。
  • Path:可构建任意复杂路径,配合 drawPath(path, paint) 使用。

2. 在常规模板里用 Modifier.drawBehinddrawWithContent

如果想在已有布局组件(如 BoxRow)后面/前面插入自定义绘制,或者在内容之上再绘制,Modifier 提供了两个常用扩展:

2.1 drawBehind { … }

在元素内容之后绘制(内容在前,绘制在后)。

Box(
    modifier = Modifier
        .size(150.dp)
        .background(Color.LightGray)
        .drawBehind {
            // this: DrawScope
            drawRect(
                color = Color.Green,
                topLeft = Offset(10f, 10f),
                size = Size(size.width - 20f, size.height - 20f),
                style = Stroke(width = 8.dp.toPx())
            )
        }
) {
    Text("Hello", modifier = Modifier.align(Alignment.Center))
}

2.2 drawWithContent { … }

可以在绘制内容前后自定义时机:

Box(
    modifier = Modifier
        .size(120.dp)
        .drawWithContent {
            // 先画默认内容
            drawContent()
            // 再绘制内容之上
            drawCircle(
                color = Color.Red.copy(alpha = 0.3f),
                radius = size.minDimension / 2f,
                center = center
            )
        }
) {
    // Box 默认背景/子项会先被 drawContent() 绘制
    Text("Overlay", modifier = Modifier.align(Alignment.Center))
}

3. 性能优化——使用 drawWithCache

如果有些绘制(例如复杂 Path、渐变 Shader、图像位图)依赖于固定参数,不需要每次重组(Recompose)时都重新构建,可以用 drawWithCache 将耗时操作缓存起来:

Canvas(modifier = Modifier
    .size(200.dp)
    .drawWithCache {
        // 这个 block 只在 size 或关键参数变化时执行
        val path = Path().apply {
            moveTo(0f, 0f)
            lineTo(size.width, size.height)
            lineTo(size.width, 0f)
            close()
        }
        onDrawBehind {
            drawPath(path, color = Color.Magenta, style = Fill)
        }
    }
) { /* Canvas content 可为空 */ }

4. 结合动画与状态

Compose 的声明式特性让我们可以很容易地将动画与自定义绘制结合:

@Composable
fun AnimatedCircle() {
    // radius 会在 0–100.dp 之间不断往返
    val infinite = rememberInfiniteTransition()
    val animatedRadius by infinite.animateFloat(
        initialValue = 0f,
        targetValue = 100f,
        animationSpec = infiniteRepeatable(
            animation = tween(1000, easing = LinearEasing),
            repeatMode = RepeatMode.Reverse
        )
    )

    Canvas(modifier = Modifier.size(200.dp)) {
        drawCircle(
            color = Color.Cyan,
            radius = animatedRadius,
            center = center
        )
    }
}

5. 结合动画与状态

通过 drawIntoCanvas { … } 把绘制“下沉”到原生的 Android Canvas,从而可以用到旧版 View 绘制时常用的三维变换(android.graphics.Camera

@Preview
@Composable
fun CustomImage() {
  val image = ImageBitmap.imageResource(R.drawable.avatar)
  val paint by remember { mutableStateOf(Paint()) }
  val rotationAnimatable = remember { Animatable(0f) }
  val camera by remember { mutableStateOf(Camera()) }
  LaunchedEffect(Unit) {
    rotationAnimatable.animateTo(360f, infiniteRepeatable(tween(2000)))
  }
  Box(Modifier.padding(50.dp)) {
    Canvas(Modifier.size(200.dp)/*.graphicsLayer {// 底层使用的是RenderNode
      // 是不能直接使用这样的几何变化的,这是因为,这个方法实际上是对坐标系的旋转,
      // 所以,实际上, rotationX = 45f 运行之后,Y轴也是旋转之后的状态了。
      rotationX = 45f
      rotationY = 45f
    }*/) {
      drawIntoCanvas { canvas ->
        // 1. 将坐标系平移到视图中心
        canvas.translate(size.width/2, size.height/2)
        // 2. 先把坐标系绕 Z 轴转 -45°
        canvas.rotate(-45f)
        // 3. 调用 Camera 做 X 轴的三维旋转, 这也是原生特有的功能,配合camera做三维旋转。
        camera.save()
        camera.rotateX(rotationAnimatable.value)
        // applyToCanvas 接受原生 Canvas
        camera.applyToCanvas(canvas.nativeCanvas)
        camera.restore()
        // 4. 把 Z 轴旋转回去
        canvas.rotate(45f)
        // 5. 把坐标系移回左上角
        canvas.translate(-size.width/2, -size.height/2)
        // 6. 绘制图片到目标矩形
        canvas.drawImageRect(
          image,
          dstSize = IntSize(size.width.roundToInt(), size.height.roundToInt()),
          paint = paint
        )
      }
    }
  }
}

Camera 不支持 设置轴旋转,它默认的旋转位置是图片左上角原点:

image.png

因此我们需要利用canvas来移动坐标轴来实现整体的效果。我们这里先把图像往左上角移动,让图像的中心处于原点的位置,然后在做三维旋转。做完之后再把图像移动回来,在做绘制。 这里的知识涉及到了安卓原生的几何变化,更多内容可以参考范围裁切和⼏何变换

上述的效果:

20250503164332_rec_.gif


小结

  1. Canvas:最灵活,类似 View.onDraw()
  2. Modifier.drawBehind / drawWithContent:适合在已有组件前后插画。
  3. drawWithCache:缓存昂贵计算,提升性能。
  4. 结合动画:Compose 的动画 API 与 DrawScope 天然契合。

自定义布局和 Layout()

在 Jetpack Compose 中,如果我们需要实现标准布局(如 Row、Column、Box)无法满足的排版,就可以通过 自定义布局(Custom Layout) 来完成。Compose 提供了两种主要手段:

  1. Modifier.layout
  2. Layout API(以及更强大的 SubcomposeLayout)

下面分步介绍它们的用法和原理。


1. Modifier.layout

Modifier.layout 适用于对单个可组合项的测量与放置做“微调”。它接收一个 lambda,你可以在其中获得子节点的测量结果,然后自行决定放置位置。

Box(
  Modifier
    .size(200.dp)
    .background(Color.LightGray)
    .layout { measurable, constraints ->
      // 测量子元素
      val placeable = measurable.measure(constraints)

      // 本布局大小:直接使用父级给的 constraints.maxWidth/Height
      val width = constraints.maxWidth
      val height = constraints.maxHeight

      // 设置布局尺寸,并在 (10px, 10px) 处放置子元素
      layout(width, height) {
        placeable.placeRelative(10, 10)
      }
    }
) {
  Text("Hello", Modifier.background(Color.Magenta))
}
  • measurable.measure(constraints) :对当前可组合子节点进行测量,得到 Placeable
  • layout(width, height) { … } :指定此节点的最终尺寸,并在 lambda 中对所有子节点做放置。

这种方式简单,但一般只对单一子项或少量子项进行微调。


2. Layout API

当我们需要完全控制一组子节点的测量与排列时,应使用 Layout 可组合函数:

@Composable
fun MyRow(
  modifier: Modifier = Modifier,
  horizontalSpacing: Dp = 8.dp,
  content: @Composable () -> Unit // 外部传入的布局内容。
) {
  // Layout 是一个 “原子” 可组合,接受 content 和一个 measurePolicy
  Layout(
    content = content,
    modifier = modifier
  ) { measurables, constraints ->
    // 1. 测量阶段:对每个子节点调用 measure(), lambda最后一行就是返回值。
    val placeables = measurables.map { measurable ->
      measurable.measure(constraints)
    }

    // 2. 计算自身尺寸:宽度累加所有子项宽度 + 间隔,高度取最大值
    val totalWidth = placeables.sumOf { it.width } +
        ((placeables.size - 1).coerceAtLeast(0) * horizontalSpacing.roundToPx())
    val maxHeight = placeables.maxOfOrNull { it.height } ?: 0

    // 3. 布局阶段:调用 layout(...) 并在 lambda 中放置每个子项
    layout(totalWidth, maxHeight) {
      var xPosition = 0
      placeables.forEach { placeable ->
        placeable.placeRelative(x = xPosition, y = 0)
        xPosition += placeable.width + horizontalSpacing.roundToPx()
      }
    }
  }
}

用法示例:

MyRow(modifier = Modifier.padding(16.dp)) {
  Text("A")
  Text("B")
  Text("C")
}

显示效果:

image.png

核心流程

  1. Measure

    • 对所有 measurables 调用 measure(),传入相同或不同的 constraints;
    • 得到一组 Placeable,它们包含自身的宽高信息。
  2. Determine Size

    • 通过汇总 Placeable.widthPlaceable.height 及间隔,决定这个自定义布局的最终尺寸;
  3. Placement

    • layout(width, height) { … } 中,遍历 Placeable 并调用 placeRelative(x, y),指定子项在父布局中的位置。

3. SubcomposeLayout

当子布局的测量依赖于内容本身(例如:先测一个子内容再决定另一个子内容怎么放置)时,可以使用更灵活的 SubcomposeLayout。它支持“分阶段”地向布局中“再添加”子内容。让部分的组合过程(Composition)可以被允许延后到测量和布局阶段再进行。

  • 独立维护的 Slot Table,无法参与整体的重组优化——弱点;
  • 重复测量也会导致强制的重组——弱点;
  • 动态的组合让实际参与到界面中的节点数可以最小化——优点。

系统中使用的场景:

Scaffold {
  // ActionBar(title = {  })
  // Bottom Bar
  // Snack Bar
  // floating action button.
}

LazyColumn {
  
}

LazyRow {
  
}

一、原理与应用场景

  • 多阶段测量需求
    在常规的 Layout 中,所有子项都是在一次测量(measure)中被“批量”放入 Placeable,无法在测量前获取其他子项的尺寸信息。而某些场景下,需要先测量某一部分内容(比如标题、最大宽度、动态文本高度等),再基于测量结果来测量剩余内容。

  • SubcomposeLayout 的作用
    SubcomposeLayout 允许我们在一次布局流程中“分段”地 subcompose(子组合)不同的内容块,并在知道前一阶段测量结果后,再决定下一阶段的测量和布局。

  • 典型场景

    • 动态折叠面板:先测量标题高度,再测量内容显示区域的最大高度
    • 文字截断:先测量全文高度,若超出限定行数,再测量截断后的“…展开”按钮
    • 异步加载时占位布局:先测量占位尺寸,再测量真正内容

二、API 结构
@Composable
fun SubcomposeLayout(
    modifier: Modifier = Modifier,
    content: @Composable SubcomposeLayoutScope.() -> Unit
)
  • SubcomposeLayoutScope

    • subcompose(slotId: Any, content: @Composable () -> Unit): List<Measurable>
      将一段可组合内容注册在某个 slotId 下,返回对应的 Measurable 列表
    • layout(…) 阶段,通过 measurable.measure(constraints) 获得 Placeable
  • measurePolicy
    Compose 会根据我们在 content 中实现的测量、布局逻辑,动态决定子项的测量与放置。


三、典型用法示例

下面以“先测量标题,再测量内容高度,最后整体布局” 为例:

@Composable
fun HeaderContentColumn(
    header: @Composable () -> Unit,
    content: @Composable () -> Unit
) {
    SubcomposeLayout { constraints ->
        // 1. 测量 header 部分
        val headerPlaceables = subcompose("header", header).map {
            it.measure(constraints.copy(minWidth = 0)) // header 可灵活换行
        }
        val headerHeight = headerPlaceables.sumOf { it.height }

        // 2. 基于 headerHeight,测量 content 部分,限定最大高度
        val contentConstraints = constraints.copy(
            maxHeight = (constraints.maxHeight - headerHeight).coerceAtLeast(0)
        )
        val contentPlaceables = subcompose("content", content).map {
            it.measure(contentConstraints)
        }

        // 3. 计算最终布局高度
        val totalHeight = headerHeight + contentPlaceables.sumOf { it.height }

        layout(width = constraints.maxWidth, height = totalHeight) {
            // 放置 header
            var yOffset = 0
            headerPlaceables.forEach { placeable ->
                placeable.place(x = 0, y = yOffset)
                yOffset += placeable.height
            }
            // 放置 content
            contentPlaceables.forEach { placeable ->
                placeable.place(x = 0, y = yOffset)
                yOffset += placeable.height
            }
        }
    }
}
使用方法
HeaderContentColumn(
    header = {
        Text(text = "这是标题", style = MaterialTheme.typography.h6)
    },
    content = {
        Text(text = "这里是正文内容,根据标题高度自动调整剩余空间。".repeat(10))
    }
)

四、注意事项与性能考量

  1. 性能开销
    每次 subcompose 都会进行一次组合(compose)与测量,切勿在滚动列表等高频调用场景中滥用。
  2. slotId 复用
    对于同一阶段反复调用 subcompose("header",…),Compose 会复用之前的组合树,避免完全重建。务必为不同阶段使用不同的 slotId
  3. 协调布局顺序
    子组合发布的顺序决定了测量与布局的先后,应合理设计各阶段内容的依赖关系。
  4. 替代方案
    如果仅需简单的单向尺寸依赖(如单个子项依赖父级约束),可考虑在普通 Layout 中使用 onGloballyPositioned 或先测量一次后 remember 尺寸再重组,但这些方式通常不如 SubcomposeLayout 直观。

小结

  • 简单微调:对单个子组件位置做调整,用 Modifier.layout { measurable, constraints → … }
  • 完整自定义:对一组子组件做完全控制,使用 Layout(content) { measurables, constraints → … },并在其中实现测量与放置三步法。
  • 分阶段测量:当测量需要“先有部分内容,再测其他内容”时,选用 SubcomposeLayout

通过这些 API,我们可以在 Compose 中实现任意复杂的排版逻辑。任何标准布局(Row/Column/Box/Flow)都只是基于它们封装的常用模式,一旦掌握原理,自定义布局便不再神秘。

LookaheadScope()

主要是用来做过渡动画的(例如做两个界面共享元素的过渡动画,类似于安卓中的Transition动画。)。Lookahead过程 用来提供目标值,而 approachLayout 则负责提供过程中的动态值,配合动画的API,实现了过渡动画。 与传统的动画API相比,它的目标值是随时动态改变的,而传统的动画则不是。需要明确的目标值。LookaheadScope 是对动画功能的一种强力拓展,对先测量,然后才能得到目标值的情况进行了支持。

另外我们知道,在布局组件的布局算法中(也就是有多个组件的时候),对同一个 Measurable 调用两次 measure(...),会直接抛出:

Layout({
    Text(text = "example")
}) { measurables, constraints ->
    // 每个 measurable 只测量一次
    val placeables = measurables.map { 
        it.measure(constraints) 
        it.measure(constraints) // 多次调用会报错。
    }

    val width = placeables.maxOf { it.width }
    val height = placeables.maxOf { it.height }

    layout(width, height) {
        placeables.forEach { it.placeRelative(0, 0) }
    }
}

// 异常信息:
// java.lang.IllegalStateException: measure() may not be called multiple times on the same Measurable

如果只是一个组件的时候,不是 measurable list 的情况,也就是单个组件,这种写法不会导致异常:

CourseComposeLookaheadLayoutTheme {
    Text(
        text = "扔物线",
        modifier = Modifier.layout { measurable, constraints ->
            // 1. 只测量一次
            measurable.measure(constraints)
            val placeable = measurable.measure(constraints)
            // 2. 返回 MeasureResult
            layout(placeable.width, placeable.height) {
                placeable.placeRelative(0, 0)
            }
        }
    )
}

一、原理与应用场景

  • 未来布局预测(Lookahead Pass)
    LookaheadLayout 会在正常的测量与布局流程前,再执行一次“预测”测量和布局(lookahead pass),并将此阶段计算出的尺寸和位置缓存下来,供后续的真实测量和布局使用,从而让子节点能够“预见”到最终的位置和大小并据此驱动动画。
    来源:[Introducing LookaheadLayout][1]

  • 自动化布局动画
    基于预计算的最终状态,子节点可调用如 Modifier.animatePlacement()Modifier.animateContentSize() 等 API,在普通布局更新时自动执行平滑过渡,无需手动指定目标尺寸或位置。
    来源:[Animations with Lookahead in Jetpack Compose][2]

  • 典型应用场景

    • 容器变换(Container Transform):在两个界面共享元素的切换中,自动获取元素在目标界面的最终位置和大小并执行过渡动画
    • 动态列表重排:在 LazyColumn/LazyRow 内部对数据增删改造成重新布局时,让项目平滑移动到它们的新位置。
    • 响应式尺寸调整:根据状态切换而改变子组件尺寸时,自动补间宽高变化,避免布局抖动。

二、API 结构

@ExperimentalLayoutApi
@Composable
fun LookaheadLayout(
    modifier: Modifier = Modifier,
    content: @Composable LookaheadScope.() -> Unit
)
  • LookaheadScope
    content lambda 中,可像普通 MeasureScope/LayoutScope 一样进行 subcompose(...)measure(...)layout(...) 操作。同时,所有 Measurable 都会先接受“预测”约束进行测量,并在后续布局中可通过 Placeable.placeWithLayer() 等方法,结合 animatePlacement() 等 modifier,驱动动画。

  • Modifier.animatePlacement()
    当子组件的放置位置或尺寸发生变化时,自动根据 lookahead 结果补间过渡,实现无缝移动。

  • 依赖库

    implementation "androidx.compose.animation:animation:1.8.0"
    implementation "androidx.compose.foundation:foundation-layout:1.8.0"
    

三、典型用法示例

@OptIn(ExperimentalLayoutApi::class)
@Composable
fun LookaheadSizeExample() {
    var expanded by remember { mutableStateOf(false) }

    LookaheadLayout(
        modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp)
    ) {
        Box(
            modifier = Modifier
                .size(if (expanded) 200.dp else 100.dp)
                .background(Color.Red)
                .animatePlacement()
                .clickable { expanded = !expanded }
        )
    }
}
  1. Wrap:用 LookaheadLayout { ... } 包裹需要动画的组件。
  2. Measure/Layout:内部普通调用 BoxColumn 等布局,无需额外测量逻辑。
  3. Modifier.animatePlacement() :在组件尺寸或位置更新时,自动根据预测布局执行动画。

四、注意事项与性能考量

  1. 实验性 API
    目前该布局属于 @ExperimentalLayoutApi,可能会在未来版本调整。

  2. 性能开销
    每当布局树发生变化或所监控的 State 更新时,都会触发一次 lookahead pass(测量+布局),因此不宜在高频重组的场景(如深度嵌套的 LazyColumn 滚动)中滥用。

  3. Modifier 使用
    只有在子组件上添加了如 animatePlacement()animateContentSize() 等与 lookahead 结合的 Modifier,才会利用预测结果执行动画;否则该布局退化为普通布局。

  4. 与 SubcomposeLayout 区别

    • SubcomposeLayout 关注“分阶段组合”,用于在测量时条件性地构建子树;
    • LookaheadLayout 专注于“预测测量+布局”以驱动动画,内部采用更激进的缓存策略,避免不必要的重新计算。

五、approachLayout 的原理与使用

一、原理概述
  • 三阶段布局流程

    1. Lookahead Pass(预测阶段):Compose 首先对整个子树执行一次预测测量和布局,计算出每个子项在目标状态(比如展开后)的 lookaheadSizelookaheadPosition 并缓存。
    2. Approach Pass(渐进阶段):在预测和最终阶段之间,Compose 调用 approachLayout 提供的 lambda,让我们可以多次(通常由动画驱动)基于当前状态向目标状态“渐进”式地测量和布局。
    3. Final Pass(最终阶段):完成渐近过程后,Compose 做一次普通测量+布局,保证最终结果与预测阶段一致。
      此设计的好处在于,我们既能掌握目标状态,又能在中间阶段通过插值动画多次重测布局,生成平滑过渡效果,且在动画完成后可停止多余的通道调用,提升性能。 Composables UI

二、API 签名
@ExperimentalLayoutApi
fun Modifier.approachLayout(
    // 测量渐近是否继续
    isMeasurementApproachInProgress: (lookaheadSize: IntSize) -> Boolean,
    // 放置渐近是否继续(默认值可省略)
    isPlacementApproachInProgress:
        Placeable.PlacementScope.(lookaheadCoordinates: LayoutCoordinates) -> Boolean =
        defaultPlacementApproachInProgress,
    // 在 Approach Pass 中进行测量与放置
    approachMeasure:
        ApproachMeasureScope.(
            measurable: Measurable,
            constraints: Constraints
        ) -> MeasureResult
): Modifier
  • isMeasurementApproachInProgress:收到预测出的 lookaheadSize,返回 true 则继续在 Approach Pass 中测量,否则跳过后续测量。
  • isPlacementApproachInProgress:收到预测出的位置 (lookaheadCoordinates),返回 true 则继续放置,否则跳过后续放置。
  • approachMeasure:在每次 Approach Pass 调用,可访问 measurable、最终约束 constraints,并在其中测量、布局子项,实现渐近动画。 Composables UI

三、使用示例

下面示例演示一个 Box 在宽度由 100 dp 过渡到 200 dp 时,使用 approachLayout 驱动平滑动画,并在动画结束后自动停止多余的梯度测量/布局。

@Preview
@Composable
fun ApproachLayoutExample_Custom() {
    var expanded by remember { mutableStateOf(false) }
    val density = LocalDensity.current
    val widthAnim = remember { Animatable(0f) }

    // 1) 当 expanded 切换时,用 Animatable 驱动宽度动画
    LaunchedEffect(expanded) {
        val targetDp = if (expanded) 200.dp else 100.dp
        val targetPx = with(density) { targetDp.toPx() }
        widthAnim.animateTo(targetPx)
    }

    // 2) LookaheadScope 提供 lookaheadPass 和 approachPass
    LookaheadScope {
        // 3) 在 Box 上直接使用 approachLayout,无需 subcompose
        Box(
            modifier = Modifier
                // 这里定义“预测”目标宽度
                .width(if (expanded) 200.dp else 100.dp)
                .height(80.dp)
                .approachLayout(
                    // 只要当前动画值还没达到预测宽度,就继续测量渐近
                    isMeasurementApproachInProgress = { lookaheadSize ->
                        widthAnim.value.roundToInt() != lookaheadSize.width
                    },
                    // 位置不变,一直放置
                    isPlacementApproachInProgress = { true }
                ) { measurable, constraints ->
                    // 4) ApproachMeasureScope 中拿到 lookaheadSize
                    val targetW = lookaheadSize.width
                    // 用动画当前值测量子项
                    val w = widthAnim.value.roundToInt()
                        .coerceIn(constraints.minWidth, constraints.maxWidth)
                    val placeable = measurable.measure(
                        Constraints.fixedWidth(w).copy(
                            minHeight = constraints.minHeight,
                            maxHeight = constraints.maxHeight
                        )
                    )
                    // 5) 返回 MeasureResult 并放置
                    layout(placeable.width, placeable.height) {
                        placeable.place(0, 0)
                    }
                }
                .background(Color.Green)
                .clickable { expanded = !expanded }
        ) {
            Text("点我", color = Color.White, modifier = Modifier.padding(12.dp))
        }
    }
}

// 普通做法:
@Composable
fun SimpleAnimatedWidthExample() {
    var expanded by remember { mutableStateOf(false) }
    // 直接用 animateDpAsState 管理宽度动画
    val widthDp by animateDpAsState(
        targetValue = if (expanded) 200.dp else 100.dp,
        animationSpec = tween(durationMillis = 300)  // 可自定义时长、缓动
    )

    Box(
        Modifier
            .width(widthDp)
            .height(80.dp)
            .background(Color.Green)
            .clickable { expanded = !expanded }
    ) {
        Text("点我", color = Color.White, modifier = Modifier.padding(12.dp))
    }
}

20250508102038_rec_.gif

流程说明

  1. Lookahead Pass:测量 subcompose("slot") 中 Box 的宽度,得到目标 lookaheadSize.width
  2. Approach PassapproachLayout lambda 被多次调用,用 widthAnim.value 驱动测量/布局;当 widthAnim 达到目标宽度时,isMeasurementApproachInProgress 返回 false,后续跳过。
  3. Final Pass:Compose 最终进行一次普通测量+布局,确保结果和预测阶段一致。
  • 普通动画

    • 必须在代码里自己指定“从 100 dp 动画到 200 dp”这个目标值。
    • 只能针对 DpFloatColor 等属性做插值。
    • 如果布局中有多个元素要因为容器尺寸变化而联动动画,就要为每个元素都写类似的动画。
  • LookaheadScope+approachLayout

    • Compose 自动预测最终布局(lookahead pass),子树“知道”目标尺寸/位置。
    • 只写一次 approachLayout,就能在任意布局中平滑过渡测量(宽高)和放置(坐标)。
    • 在复杂场景(列表重排、ConstraintLayout 约束变化、共享元素切换…)下,写普通动画就非常繁琐,而 lookahead + approach 一行代码就能搞定。

四、注意事项
  1. 性能控制:合理编写 isMeasurementApproachInProgressisPlacementApproachInProgress,确保在动画完成后停止多余通道调用,避免高频布局造成性能瓶颈。

  2. 与 intermediateLayout(旧版本是这个方法) 区别

    • intermediateLayout 只在 Final Pass 调用一次,无法做多次渐进测量,也没有跳过通道机制;
    • approachLayout 专为渐进式动画设计,支持多次 Approach Pass、完成后跳过,并且更贴合 ModifierNode 架构。
movableContentOf / movableContentReceiverOf

movableContentReceiverOf 经常与 LookaheadScope 搭配,实现 容器变形(container-transform)动画Shared-element transition——先在目标位置预布局,再用 updateTransition 根据 look-ahead 结果驱动尺寸/位移动画。movableContentOf / movableContentReceiverOf = “记住这段 UI,随时剪切-粘贴”

下面是最小可运行范例:
只有一个 Task Avatar,点一下头像即可在「列表页 ⇄ 详情页」之间做平滑过渡。
核心只用了 3 个 API——

  • movableContentOf     让同一份 UI+状态在两处父节点之间“搬家”
  • LookaheadScope      让系统提前计算目标位置/尺寸
  • Modifier.animateBounds() 把旧 bounds → 新 bounds 变成动画

Compose 版本androidx.compose.foundation:foundation:1.8.0-alpha03(或更高)才有 animateBounds()

@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
@Preview
fun SimpleAvatarTransition() {

    var showDetail by remember { mutableStateOf(false) }

    /* ① scope 当作参数传递,而非 receiver */
    val avatar = remember {
        movableContentOf<LookaheadScope> { scope ->    // ⬅️ T = LookaheadScope
            Image(
                painter = painterResource(R.drawable.lanyuandan),
                contentDescription = null,
                modifier = Modifier
                    .size(if (showDetail) 160.dp else 64.dp)
                    .clip(CircleShape)
                    .animateBounds(lookaheadScope = scope)   // ⬅️ 显式传参
                    .clickable { showDetail = !showDetail }
            )
        }
    }

    /* ② Layout */
    LookaheadScope {
        val scope = this                               // 捕获当前 LookaheadScope
        if (showDetail) {
            Box(
                Modifier
                    .fillMaxSize()
                    .padding(top = 50.dp),                // ← 距顶 50 dp,
                contentAlignment = Alignment.TopCenter
            ) {
                Spacer(Modifier.height(48.dp))
                avatar(scope)                          // ⬅️ 把 scope 传进去
            }
        } else {
            Column(
                Modifier
                    .fillMaxSize()
                    .padding(top = 50.dp)                 // ← 同样距顶 50 dp
            ) {
                Row(
                    verticalAlignment = Alignment.CenterVertically,
                    modifier = Modifier
                        .fillMaxWidth()
                        .height(72.dp)
                        .padding(start = 16.dp, top = 30.dp)
                ) {
                    avatar(scope)
                    Text("Task title", Modifier.padding(start = 12.dp))
                }
            }
        }
    }
}

运行效果:

20250509224958_rec_.gif

只有一个 Task Avatar,点一下头像即可在「列表页 ⇄ 详情页」之间做平滑过渡。
核心只用了 3 个 API——

  • movableContentOf     让同一份 UI+状态在两处父节点之间“搬家”
  • LookaheadScope      让系统提前计算目标位置/尺寸
  • Modifier.animateBounds() 把旧 bounds → 新 bounds 变成动画。

LookaheadScope 的价值

普通的动画(animate*AsStateAnimatableupdateTransition 等)固然能让某个可组合项的某个属性(如 offsetsizealpha)平滑过渡,但它们有几个局限:

  1. 不知道最终布局状态
    普通动画只能在“当前状态”与“你自己配置的目标状态”之间过渡——但是对于复合布局(比如在列表中插入/删除、容器尺寸联动多子项、复杂的 ConstraintLayout 约束变化等),你往往不知道每个子项最终会落在什么位置、拥有什么尺寸,必须手动测量、计算,再把结果喂给动画,代码既繁琐又容易出错。
  2. 只作用于单一属性
    普通动画是针对某个或某组属性(DpFloatColor 等)做插值;而布局改变往往牵涉到测量 (measure) 和放置 (place) 两个阶段,你得分别写 animatePlacementanimateContentSize,并在不同布局中重复套路。

LookaheadScope 的价值

LookaheadScope(及其相关的 animateBoundsapproachLayout 等)引入了一个 “两阶段布局” 的概念:

  1. Lookahead Pass(预测阶段)
    Compose 先对整个子树做一次「试运行」测量和布局,算出每个子项在 新状态 下的目标 size 和目标 position,并缓存下来。

  2. Approach Pass(渐近阶段)
    在预测和最终布局之间,Compose 会多次(或一次)调用 approachLayoutanimateBounds,让我们可以拿到「当前状态」和「目标状态」的差值,自动做平滑过渡:

    • 测量渐近:以不同的中间 size 去测量子项
    • 放置渐近:以不同的中间 position 去放置子项
      直到动画结束,或你在回调里返回停止信号,Compose 就跳过后续多余通道。
  3. Final Pass(最终阶段)
    在动画完成后,Compose 再做一次普通测量+布局,保证最终 UI 与预测阶段保持一致。


为什么要用 LookaheadScope

  • 自动化“容器级”过渡
    ✔ 插入/删除列表元素时,其他项自动滑到新位置,无需你手动计算每个 offset
    ✔ 容器尺寸变化时,内部子项自动按新约束重新布局并过渡,无需手动给每个 widthheight animateDpAsState

  • API 更加简洁

    • 一行 Modifier.animateBounds() 就能搞定“尺寸+位置”双重动画。
    • 对于更细粒度需求,用 Modifier.approachLayout 也只要在一个 lambda 里写测量+放置逻辑。
  • 兼容任意布局
    LookaheadScope 并不局限于某个容器,任何 ColumnBoxConstraintLayoutSubcomposeLayout,甚至自定义的 Layout,都能在它的子树里享受 “预测→渐进→最终” 的三段式布局动画。


用例对比
场景普通动画做法LookaheadScope 做法
列表元素重排(项目插入/删除)每次更新要 remember 各项 targetOffset,然后 animateDpAsState包裹在 LookaheadScope+LazyColumn,一行 animateItemPlacement()
容器宽高变化,内部内容联动手动跟踪父容器宽度变化,给每个子项各写一个 animate*包裹在 LookaheadScope,在子项上调 .animateBounds()
共享元素切换(Container Transform)自己测量起点和终点坐标/尺寸,写 ValueAnimator + ModifierLookaheadScope + animateBounds 自动搞定起终点过渡

总结
虽然某些简单场景下,普通动画就够用,但一旦布局结构稍微复杂,手动管理起点/终点、测量/放置就会变得脆弱且难维护。LookaheadScope 则把“知道目标布局”与“渐进动画”合二为一,让开发者能以声明式、最少的代码,获得可靠且一致的过渡动画效果。

自定义触摸、可滚动与一维滑动监测


一、原理概览

  1. 事件分发

    • Compose 将原生 MotionEvent 转为每帧的 PointerInputEvent,分发给所有带 pointerInput 的节点。
    • pointerInput { awaitPointerEventScope { … } } 中,可以使用协程阻塞式地接收、消费手指的按下(down)、移动(move)和抬起(up)事件。
  2. 识别滑动(Touch Slop)

    • 为避免轻微抖动被误判为拖拽,Compose 提供 LocalViewConfiguration.current.touchSlop (≈8px)。
    • detectDragGesturesawaitHorizontalDrag 等 API 内部会在累计位移超过 slop 后才开始正式拖拽,并调用 change.consume() 阻止父级再消费。
  3. 惯性与回弹

    • 高层draggablescrollable 内置 VelocityTrackerFlingBehavior,拖拽结束后自动做惯性滑动或回弹。
    • 中/底层:用 animateDecayspring 等手动实现 fling 或回弹。
  4. 嵌套滚动协同

    • Modifier.scrollable() 本身集成了 nestedScroll,用来做滑动监测,基于 dragable,dragable提供了最基本的功能,也就是一维的滑动识别,而 scrollable 则是针对滑动布局的场景,也就是有子组件的情况。 它的实现Modifier.verticalScroll()Modifier.horizontalScroll() 已经完整的实现了滑动效果,类似于安卓中原生的 NestScrollView。 可与父/子滚动容器(如 LazyColumn)自动协同,避免滑动冲突。他主要有这三个特性:
      • 惯性滑动
      • 嵌套滑动
      • 滑动触边效果,overscroll

二、API 选型

级别API特点何时用
⭐⭐⭐⭐Modifier.scrollable(...)滑动检测+惯性+嵌套滚动协同需要「滚动」语义(滑块、滑条、自定义滚动容器)
⭐⭐⭐Modifier.draggable(...)拖拽位移+惯性+冲突处理简单拖拽、滑块、Swipe-to-Dismiss
⭐⭐pointerInput { detectDragGestures { … } }拖拽增量(dx/dy)+开始/结束回调,可自定义 consume()需要速度、开始/结束回调,自定义消费时机
pointerInput { awaitPointerEventScope { … } }底层自定义:手动 awaitDown、awaitTouchSlop、velocity、fling创新手势(双指、旋转、复杂冲突),或自行实现惯性回弹

三、最小化示例

1. draggable:一行搞定拖拽+惯性

@Composable
fun DraggableBox() {
    val offsetX = remember { Animatable(0f) }
    val scope   = rememberCoroutineScope()

    Box(
        Modifier
            .size(100.dp)
            .offset { IntOffset(offsetX.value.roundToInt(), 0) }
            .background(Color.Magenta)
            .draggable(
                orientation = Orientation.Horizontal,
                state = rememberDraggableState { delta ->
                    scope.launch { offsetX.snapTo(offsetX.value + delta) }
                },
                onDragStopped = { velocity ->
                    scope.launch {
                        offsetX.animateDecay(velocity, exponentialDecay())
                    }
                }
            )
    )
}

20250510170701_rec_.gif


2. detectDragGestures:可控消费时机+开始/结束钩子

@Composable
fun DetectDragBox() {
    var totalDrag by remember { mutableStateOf(0f) }

    Box(
        Modifier
            .size(100.dp)
            .offset { IntOffset(totalDrag.roundToInt(), 0) }
            .background(Color.Cyan)
            .pointerInput(Unit) {
                detectDragGestures(
                    onDragStart = { /* downPosition */ },
                    onDrag = { change, dragAmount ->
                        change.consume()           // 拦截父级
                        totalDrag += dragAmount.x
                    },
                    onDragEnd = {
                        totalDrag = 0f            // 简易回弹
                    }
                )
            }
    )
}

20250510172515_rec_.gif


3. awaitPointerEventScope:完全底层可定制

@Composable
@Preview
fun LowLevelSwipeBox() {
    val offsetX = remember { Animatable(0f) }
    val scope = rememberCoroutineScope()
    val touchSlop = LocalViewConfiguration.current.touchSlop

    Box(
        Modifier
            .size(100.dp)
            .offset { IntOffset(offsetX.value.roundToInt(), 0) }
            .background(Color.Yellow)
            .pointerInput(Unit) {
                awaitPointerEventScope {
                    while (true) {
                        // 等待按下
                        val down = awaitFirstDown()
                        var pastSlop = false

                        // 监听水平拖拽(含 slop & 取消),直到抬手
                        val change = awaitHorizontalDragOrCancellation(down.id)
                        if (change != null) {
                            val dx = change.positionChange().x
                            if (!pastSlop && abs(dx) > touchSlop) pastSlop = true
                            if (pastSlop) {
                                change.consume()    // 标记已消费,防止父级收到
                                scope.launch {
                                    offsetX.snapTo(offsetX.value + dx)
                                }
                            }
                            // 拖拽结束后自动获得 fling 速度?或自定义多指逻辑
                            scope.launch {
                                // 这里可用 animateDecay 或 spring 回弹
                            }
                        }
                    }
                }
            }
    )
}

20250510221346_rec_.gif

  • 这是一个演示 底层 pointerInput + 协程 方式检测一维(水平)拖拽的最简示例。

  • 它绕开了 draggable/scrollable 等高层封装,手动控制 slop 判断事件消费位置更新

  • 如果在注释处用 offsetX.animateDecay(velocity, exponentialDecay()),就可以再加上抬手时的 惯性滑动 效果。


4. scrollable:滚动检测 + 嵌套滚动 + 惯性

@Composable
fun ScrollableBoxDemo() {
    var offset by remember { mutableStateOf(0f) }
    val scrollState = rememberScrollableState { delta ->
        offset = (offset + delta).coerceIn(0f, 300f)  // 限制范围
        delta
    }

    Box(
        Modifier
            .size(100.dp)
            .offset { IntOffset(offset.roundToInt(), 0) }
            .background(Color.Blue)
            .scrollable(
                state       = scrollState,
                orientation = Orientation.Horizontal,
                flingBehavior = ScrollableDefaults.flingBehavior()
            )
    )
}

20250510172809_rec_.gif

  • 滑动过程:每帧将横向 delta 传给 scrollState,累加到 offset 并更新位置。
  • 惯性:松手后自动按当前速度做 fling 并自然减速。
  • 嵌套滚动:自动与父/子滚动容器协同,避免冲突。

5. SwipeToDismiss

一、核心原理
  1. 锚点(Anchors)+ 阈值
    底层用 AnchoredDraggable(取代了旧的 swipeable)定义了一系列偏移值(锚点),如:

    • 原位 0f
    • 向右滑出列表宽度 width
    • 向左滑出列表宽度 -width
      当用户拖拽超过某个阈值(典型是 50% 宽度)或松手时,内容会自动动画到最近的锚点,并更新 SwipeToDismissBoxState 中的 dismissValueAndroid Developers
  2. 状态管理 (SwipeToDismissBoxState)

    • rememberSwipeToDismissBoxState(confirmValueChange: (SwipeToDismissBoxValue) → Boolean)
    • SwipeToDismissBoxValue 有三种:DefaultStartToEndEndToStart,代表未滑出、从左向右滑出、从右向左滑出。
    • confirmValueChange 用于拦截 / 确认「dismiss 后」的业务逻辑(如从列表中移除项)。Composables UI
  3. 背景层 vs 内容层
    SwipeToDismissBox 会把你的 content 放在最上层,在它下面还有一个 backgroundContent,在滑动时会逐渐露出,可在此展示「删除」或「标记已读」等反馈。


二、快速上手示例
@Composable
@Preview
fun SwipeToDeleteList() {
    // 列表状态,别名不要叫 items
    val tasks = remember { mutableStateListOf("A", "B", "C", "D") }

    LazyColumn(
        modifier = Modifier
            .fillMaxSize()
            .padding(top = 100.dp)    // ← 整个列表下移 100dp
    ) {
        // ▶️ 用 count/key 重载,去掉 contentType(有默认值)
        items(
            count = tasks.size,
            key = { index -> tasks[index] }
        ) { index ->
            // 拿到当前项
            val task = tasks[index]

            // 1️⃣ 只在 confirmValueChange 中同意滑出,不做删除
            val dismissState = rememberSwipeToDismissBoxState(
                confirmValueChange = { newValue ->
                    newValue == SwipeToDismissBoxValue.StartToEnd
                }
            )

            // 2️⃣ 监听状态切换到 StartToEnd,再真正删除
            LaunchedEffect(dismissState.currentValue) {
                if (dismissState.currentValue == SwipeToDismissBoxValue.StartToEnd) {
                    tasks.remove(task)
                }
            }

            // 3️⃣ SwipeToDismissBox 本体
            SwipeToDismissBox(
                state = dismissState,
                backgroundContent = {
                    if (dismissState.dismissDirection == SwipeToDismissBoxValue.StartToEnd) {
                        Box(
                            Modifier
                                .fillMaxSize()
                                .background(Color.Red),
                            contentAlignment = Alignment.CenterStart
                        ) {
                            Icon(
                                Icons.Default.Delete,
                                contentDescription = "Delete"
                            )
                        }
                    }
                },
                enableDismissFromEndToStart = false, // 只允许左→右
                modifier = Modifier.fillMaxWidth()
            ) {
                Row(
                    Modifier
                        .fillMaxWidth()
                        .background(Color.White)
                        .padding(16.dp),
                    verticalAlignment = Alignment.CenterVertically
                ) {
                    Text(text = task.toString())
                }
            }
        }
    }
}

20250510224813_rec_.gif

  1. 左向右滑动:滑动过程中逐渐露出红色背景和删除图标;
  2. 达到阈值并松手:项自动滑出并从 items 列表中移除;

三、进阶与注意
  • 双向滑动enableDismissFromEndToStart = true,并在 confirmValueChange 里判断 SwipeToDismissBoxValue.EndToStart 做不同处理。
  • 自定义阈值:通过底层的 AnchoredDraggable 可以自定义 anchorsthresholdsvelocityThreshold
  • 嵌套滚动SwipeToDismissBox 内部已集成 nestedScroll,自动与父/子滚动协调。

四、注意事项

  • draggable vs scrollable

    • draggable 更适合“拖拽”交互,如卡片移动、滑块。
    • scrollable 更贴合“滚动”语义,如滑动条、自定义滚动容器。
  • 事件消费:在调用 change.consume() 前,应确认已超过 slop,避免误拦截点击或父滚动。

  • 嵌套滚动:你也可手动实现 nestedScroll 接口,更精细地控制父/子滚动协调。

  • 自定义惯性:若内置 FlingBehavior 不满足需求,可实现自己的 FlingBehavior.performFling


五、选型建议

  1. 场景简单(单轴拖拽+惯性)Modifier.draggable
  2. 需要速度或开始/结束通知detectDragGestures
  3. 创新或复杂手势awaitPointerEventScope
  4. 需要「滚动」语义+嵌套滚动Modifier.scrollable
  5. 需要横向滑动效果SwipeToDismissBox

嵌套滑动和 nestedScroll()

1. 嵌套滑动(Nested Scrolling)概述
在 Compose 中,是由最下层的去负责触摸事件的处理的,而它的父级View并不会直接负责触摸事件的处理,而是通过收到子组件的滑动通知,然后处理传递过来的滑动事件,具体处理方式就是每一个子组件进行滑动事件处理之前都会去询问父组件,是否需要先消费这一段距离,父组件不消费,或者没有消费完,剩下的部分由子组件继续处理。如果子组件处理完成之后还有一段距离,子组件就会再次通知父组件,是否继续消费没有处理完的滑动距离? 整个流程就是这样一个递归的调用。嵌套滑动的本质是什么?其实是一个订阅和通知的机制,每一个父组件开放出子组件滑动前和滑动后这两个回调接口。子组件就在滑动前和滑动完成之后调用父组件的回调。

在复杂的 UI 场景中,我们经常会遇到“父容器”和“子容器”都能滚动的情况,例如:LazyColumn 内部嵌套了一个可横向滑动的 LazyRow,或是一个可滑动的顶部栏+内容区。Android 原生 View 层面有 NestedScrollingParent/NestedScrollingChild 接口,Compose 则统一通过 nested scroll API 实现。

源码解释

fun Modifier.nestedScroll(
    connection: NestedScrollConnection,
    dispatcher: NestedScrollDispatcher? = null
): Modifier = composed(
    inspectorInfo = debugInspectorInfo {
        name = "nestedScroll"
        properties["connection"] = connection
        properties["dispatcher"] = dispatcher
    }
) {
    val scope = rememberCoroutineScope()
    // provide noop dispatcher if needed
    val resolvedDispatcher = dispatcher ?: remember { NestedScrollDispatcher() }
    remember(connection, resolvedDispatcher, scope) {
        resolvedDispatcher.originNestedScrollScope = scope
        NestedScrollModifierLocal(resolvedDispatcher, connection)
    }
}

它的主要作用是:

让当前组件能够参与 嵌套滑动 的传递和响应,无论是作为父组件还是子组件

🔍 参数作用详解:

connection: NestedScrollConnection必传

  • 这是一个接口,定义了如何处理来自子组件的滑动事件。

  • 也就是说,它决定了 "父组件" 如何对来自子组件的滑动做出反应。

  • 主要用于拦截子组件滑动前/后的操作,如:

    override fun onPreScroll(...) // 子组件滑动前
    override fun onPostScroll(...) // 子组件滑动后
    override suspend fun onPreFling(...) // 惯性滑动前
    override suspend fun onPostFling(...) // 惯性滑动后
    

💡 用于父组件参与处理滑动行为(如滚动偏移、拦截等)


dispatcher: NestedScrollDispatcher? = null可选

  • 这是一个派发器,用于子组件将滑动事件派发给父组件

  • 当我们在使用一个子组件并希望把滑动交给父组件继续处理(例如当子组件滚不动时),我们就需要使用这个 dispatcher

  • 在代码中通过调用:

    dispatcher.dispatchPreScroll(...)
    dispatcher.dispatchPostScroll(...)
    dispatcher.dispatchPreFling(...)
    dispatcher.dispatchPostFling(...)
    

💡 用于子组件主动把滑动事件派发给父组件,实现“滑动传递”。


✅ 组合使用说明:

场景connectiondispatcher
父组件想拦截/响应子组件滑动✅ 必传❌ 可选(通常不需要)
子组件想把滑动交给父组件❌ 可以为空✅ 必传
父子都想参与滑动✅ 必传✅ 必传

🧠 举个例子:

父组件定义一个 NestedScrollConnection

val connection = object : NestedScrollConnection {
    override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
        // 父组件消费掉 available 中的一部分滑动
        return Offset(0f, available.y / 2)
    }
}

子组件使用 dispatcher 来派发事件:

val dispatcher = remember { NestedScrollDispatcher() }

Modifier
  .draggable(...)
  .nestedScroll(connection, dispatcher) // 子组件传入 dispatcher,让父组件接管一部分滑动

🧾 总结一图:

+-------------------------+
|  Parent Composable      |
|  - Has connection       |
|  - Can consume scroll   |
+-------------------------+
          ▲
          | (dispatcher.dispatchXXX)
          ▼
+-------------------------+
|  Child Composable       |
|  - Has dispatcher       |
|  - Sends scroll up      |
+-------------------------+

在下图中,就是第二个参数为空的情况下的效果,此时父组件只是根据子组件的滑动距离做出调整,但是子组件不需要将滑动事件派发给父组件。

20250513225610_rec_.gif


2. Compose 中的 nestedScroll API

  • NestedScrollConnection:用于接收并处理滚动/惯性事件的回调接口。
  • NestedScrollDispatcher:用于将子组件的滚动事件派发给父组件。
  • Modifier.nestedScroll(...):将以上两者或其中之一与 Composable 关联起来。
interface NestedScrollConnection {
  // 子组件开始滑动前,父组件可先消耗一部分 delta
  fun onPreScroll(available: Offset, source: NestedScrollSource): Offset = Offset.Zero
  // 子组件滑动后,父组件可再消耗剩余 delta
  fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset = Offset.Zero
  // 同理对 fling(惯性)也有 onPreFling/onPostFling
}

3. Modifier.nestedScroll() 的两种常见用法

  1. 只用 NestedScrollConnection
    适用于父组件只需要监听并消耗子组件的滚动。

    val connection = remember {
      object : NestedScrollConnection {
        override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
          // 比如让 header 根据 available.y 收缩/展开
          headerHeight = (headerHeight - available.y).coerceIn(minHeight, maxHeight)
          return Offset(x = 0f, y = available.y) // 标记已消费
        }
      }
    }
    Box(
      Modifier
        .fillMaxSize()
        .nestedScroll(connection)
    ) {
      // 子组件,例如 LazyColumn
    }
    
  2. 同时用 NestedScrollDispatcher
    适用于“子组件主动将滚动事件通知父组件”场景。

    val dispatcher = remember { NestedScrollDispatcher() }
    // 父:关联 dispatcher,监听子事件
    Box(
      Modifier
        .fillMaxSize()
        .nestedScroll(connection = parentConnection, dispatcher = dispatcher)
    ) {
      // …
      // 子:在自己的 scrollable 或 draggable 中派发事件
      Box(
        Modifier
          .size(200.dp)
          .scrollable(
            orientation = Orientation.Vertical,
            state = rememberScrollState(),
            nestedScrollConnection = dispatcher // 将 dispatcher 当作 child
          )
      ) { /*…*/ }
    }
    

4. 典型示例:折叠标题栏 + 内容区

@Composable
fun CollapsingToolbarScreen() {
  var toolbarOffset by remember { mutableStateOf(0f) }
  val toolbarHeight = 200.dp.toPx()

  // 父级 connection:让内容区滚动时,先折叠标题栏
  val connection = remember {
    object : NestedScrollConnection {
      override fun onPreScroll(
        available: Offset, source: NestedScrollSource
      ): Offset {
        val delta = available.y.coerceAtMost(toolbarOffset)
        toolbarOffset -= delta
        return Offset(0f, delta)
      }
    }
  }

  Column(
    Modifier
      .fillMaxSize()
      .nestedScroll(connection)
  ) {
    Box(
      Modifier
        .height((toolbarHeight - toolbarOffset).coerceIn(0f, toolbarHeight).toDp())
        .fillMaxWidth()
        .background(Color.Blue)
    ) { Text("Toolbar", color = Color.White, Modifier.align(Alignment.Center)) }

    LazyColumn(Modifier.fillMaxSize()) {
      items(50) { idx -> Text("Item #$idx", Modifier.padding(16.dp)) }
    }
  }
}

20250514103143_rec_.gif

  • LazyColumn 试图向上滚动时,onPreScroll 会先消耗 Y 轴位移来折叠标题栏;
  • 标题栏完全折叠后,多余的滚动才作用于内容区本身。

5. 应用场景与注意事项

  • 横向+纵向嵌套:如 LazyRow 嵌套在 LazyColumn,Compose 已内建处理,大多数情况下无需自定义。
  • 自定义滚动行为:如折叠、拉伸、粘性头部,需自行实现 NestedScrollConnection
  • 性能:尽量仅在必要时重组,不要在回调内做重度计算或大量状态写入。
@Composable
@Preview
fun NestedScrollSample() {
  var offsetY by remember { mutableStateOf(0f) } // 外层 Column 的偏移量状态
  val dispatcher = remember { NestedScrollDispatcher() } // 派发嵌套滑动事件
  val connection = remember {
    object : NestedScrollConnection {
      override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
        // 处理子组件滑动之前的
        return super.onPreScroll(available, source) // 默认不消费,返回 Offset.Zero
      }

      override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
        // 处理子组件滑动之后的
        offsetY += available.y // 将子组件未消费的垂直滑动距离加到 offsetY 上
        return available // 父组件消费掉所有未被子组件消费的滑动距离
      }

      override suspend fun onPreFling(available: Velocity): Velocity {
        // 惯性滑动开始前的处理(可选)
        return super.onPreFling(available)
      }

      override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
        // 惯性滑动后的处理(可选)
        return super.onPostFling(consumed, available)
      }
    }
  }

  Column(
    Modifier
      .offset { IntOffset(0, offsetY.roundToInt()) } // 根据 offsetY 改变整体垂直位置
      .draggable(
        rememberDraggableState {
          // 1.滑动前调用父组件的消费函数
          val consumed = dispatcher.dispatchPreScroll(
            Offset(0f, it),
            NestedScrollSource.Drag // 拖拽滑动来源
          ) // 滑动之前的处理

          // 2. 父组件处理之后剩余的距离
          offsetY += it - consumed.y // 需要减掉父组件消费掉的滑动距离

          // 3. 子组件处理之后剩余的距离由父组件接着处理
          dispatcher.dispatchPostScroll(
            Offset(0f, it), // 原始滑动量
            Offset(0f, 0f), // 子组件已经消费完了(比如你自己没有再消耗了)
            NestedScrollSource.Drag
          ) // 滑动之后的处理
        },
        Orientation.Vertical // 支持垂直拖动
      )
      .nestedScroll(
        connection, // 必填,作为父组件,必须提供一个可以接收子组件的回调
        dispatcher  // 可空,不传递事件, 这个参数可以定位到正确的组件
      ) // 滑动功能的感受器,放到前边也可以
  ) {
    for (i in 1..10) {
      Text("第 $i 项")
    }

    LazyColumn(Modifier.height(50.dp)) { // 内部列表,支持滚动
      items(8) {
        Text("内部 List - 第 $it 项")
      }
    }
  }
}

整体效果说明:

  1. 最外层的 Column 组件可垂直拖动,拖动时其位置 (offsetY) 会改变,从而实现整个组件随着手势滑动。
  2. 内部的 LazyColumn 也可以滚动(垂直方向)。
  3. 通过 NestedScroll,内部滚动未消费的滑动会“冒泡”到外部 Column,从而形成父子嵌套滑动联动的效果。

20250513225255_rec_.gif

自定义触摸:二维滑动监测

在 Jetpack Compose 中,如果我们想实现自定义触摸逻辑并支持 二维滑动监测(即同时监测水平方向和垂直方向的手势),我们可以使用低层级的手势检测 API,比如:

  • Modifier.pointerInput { ... } 搭配

    • detectDragGestures (支持一维/二维拖动)
    • awaitPointerEventScope + awaitTouchSlopOrCancellation(更底层)

源码:

@OptIn(ExperimentalFoundationApi::class)
suspend fun PointerInputScope.detectDragGestures(
    onDragStart: (Offset) -> Unit = {},
    onDragEnd: () -> Unit = {},
    onDragCancel: () -> Unit = {},
    onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit
) =
    detectDragGestures(
        onDragStart = { _, slopTriggerChange, _ -> onDragStart(slopTriggerChange.position) },
        onDragEnd = { onDragEnd.invoke() },
        onDragCancel = onDragCancel,
        shouldAwaitTouchSlop = { true },
        orientationLock = null,
        onDrag = onDrag
    )
参数名类型含义
onDragStart(Offset) -> Unit当拖拽手势刚刚启动时调用,Offset 是初始触点的位置。可以用它来记录起始位置或执行动画。
onDragEnd() -> Unit当用户松手并且手势完成时调用,表示拖拽自然结束。可以在这里执行状态清理、触发动画或事件提交。
onDragCancel() -> Unit拖拽过程因其他因素中断(如系统手势拦截)时调用。
onDrag(PointerInputChange, Offset) -> Unit拖拽过程中持续回调,提供手指变化事件和拖动的 dragAmount(偏移量)。你大部分逻辑通常写在这里

Modifier.draggable()detectDragGestures()

1. Modifier.draggable()

特点
  • 封装性较高
    Modifier.draggable() 是 Compose 提供的一个高层次 API,用来处理拖动行为。它将手势识别、拖动状态更新和手势消费等封装在内部,一般只需要提供一个拖动状态或回调,就可以实现拖动效果。
  • 简单易用
    对于只需要检测单一方向的拖动(横向或纵向)或简单的滑块、进度条、滑动开关等控件,使用该方式代码更简洁。
  • 集成系统惯性(fling)
    通过结合 DraggableState 和系统预设的惯性动画,你可以轻松实现 fling 效果。
使用场景
  • 简单控件:例如滚动条、滑动选择器、左右或上下滑动的控件。
  • 状态驱动:当拖动逻辑和状态更新之间紧密绑定时(例如直接更新某个 state),使用 Modifier.draggable() 会更直接。
示例
val offset = remember { mutableStateOf(0f) }
Box(
    Modifier
        .draggable(
            orientation = Orientation.Horizontal,
            state = rememberDraggableState { delta ->
                offset.value += delta
            }
        )
        .offset { IntOffset(offset.value.roundToInt(), 0) }
        .size(100.dp)
        .background(Color.Red)
)

2. detectDragGestures()

特点
  • 灵活性高
    detectDragGestures() 是一个基于 pointerInput {} 的低层 API,它提供了更细粒度的控制,比如分别提供:

    • onDragStart
    • onDrag(包括手势变化及消费)
    • onDragEnd
    • onDragCancel

    我们可以在手势的各个阶段执行更多的自定义逻辑(例如在达到某个触摸门槛时开始记录、实时控制拖动时的其他业务逻辑等)。

  • 适合复杂交互
    当我们需要同时处理拖动和其他手势(如多指缩放、旋转、嵌套滚动)或者对拖动过程中的细节(起始点、方向锁定、取消等)进行精确控制时,使用 detectDragGestures() 更合适。

  • 需要手动消费事件
    在手势回调中我们需要调用 change.consume() 来表明我们已经消费了这一事件,否则可能会触发其他手势识别。

使用场景
  • 复杂控件:例如自定义的拖拽列表、拖动卡片、需要自定义手势响应或同时处理多个手势的场景。
  • 跨方向拖动:如果需要同时检测 X/Y 两个方向的拖动信息,可以让我们灵活拿到 dragAmount(二维偏移)。
示例
Box(
    Modifier
        .size(200.dp)
        .background(Color.LightGray)
        .pointerInput(Unit) {
            detectDragGestures(
                onDragStart = { offset ->
                    // 拖动开始,可以记录起点等
                },
                onDrag = { change, dragAmount ->
                    change.consume()  // 消费事件
                    // 根据拖动距离来更新状态,例如偏移量
                },
                onDragEnd = {
                    // 拖动结束时,可执行惯性或回弹动画
                },
                onDragCancel = {
                    // 拖动被中断时的处理
                }
            )
        }
)

如何选择?

  • 简单需求优先 Modifier.draggable()
    如果我们的需求仅仅是检测简单的单向拖动,或想快速实现一个基于状态的拖动效果,而不需要精细控制手势各阶段的细节,那么使用 Modifier.draggable() 就足够了。
  • 复杂交互使用 detectDragGestures()
    如果我们需要更复杂的拖动交互,如对拖动开始时、过程中的细分操作、拖动结束后的惯性(fling)、边界限制与回弹等定制化逻辑进行全面掌控,那么推荐使用 detectDragGestures() 结合 pointerInput {},这样可以灵活定制各个阶段的手势行为。

总体来说,二者各有侧重:

  • Modifier.draggable():更高层且简单;
  • detectDragGestures():更底层且灵活。

✅ 示例:自定义二维滑动检测

@Composable
fun TwoDimensionalDragBox() {
    var offsetX by remember { mutableStateOf(0f) }
    var offsetY by remember { mutableStateOf(0f) }

    Box(
        Modifier
            .size(300.dp)
            .background(Color.LightGray)
            .pointerInput(Unit) {
                detectDragGestures(
                    onDrag = { change, dragAmount ->
                        change.consume() // 表示我们消费了这个事件
                        offsetX += dragAmount.x
                        offsetY += dragAmount.y
                    }
                )
            }
    ) {
        Box(
            Modifier
                .offset { IntOffset(offsetX.roundToInt(), offsetY.roundToInt()) }
                .size(100.dp)
                .background(Color.Red)
        )
    }
}

效果:

20250515094156_rec_.gif

🧠 关键说明:

  • detectDragGestures:支持 x 和 y 两个方向的同时拖动。
  • dragAmount: Offset:就是你要的二维偏移值,x 表示水平方向的滑动,y 表示垂直方向。
  • change.consume():表示当前手势事件被消费掉了,不再传递给其他手势处理器。

🧪 如果需要更精细的触摸控制(如触摸按下、抬起、滑动方向判断):

可以用更底层的 awaitPointerEventScope

Modifier.pointerInput(Unit) {
    awaitPointerEventScope {
        while (true) {
            val down = awaitPointerEvent().changes.firstOrNull()?.also { it.consume() } ?: continue
            var pointerId = down.id

            val drag = awaitDrag(pointerId)
            drag?.let {
                // drag.positionChange() 是 Offset(x, y)
                // 你可以根据拖动方向计算是水平 or 垂直
            }
        }
    }
}

🎯 对于手势冲突处理:

如果这个二维滑动区域嵌套在其他可滚动组件中,可能会发生手势冲突。可能需要配合:

  • NestedScrollConnection

🚀 拓展:可以加惯性滑动(fling)

如果还想支持松手后惯性滚动(Fling) ,可以结合:

val flingBehavior = remember { ScrollableDefaults.flingBehavior() }

或者使用 Animatable 来模拟惯性运动。

以下是一个 完整的 Jetpack Compose 自定义二维滑动控件,它支持:

  • ✅ 拖动
  • ✅ 惯性滑动(Fling)
  • ✅ 边界限制
  • ✅ 回弹(Over-scroll bounce-back)

✅ 效果说明:

红色方块在灰色容器中拖动,手指松开后会有惯性滑动;如果滑出边界,会自动平滑回弹。


🧩 完整代码:

@Preview
@Composable
fun BouncyDraggableBox(
    modifier: Modifier = Modifier,
    boxSize: DpSize = DpSize(150.dp, 150.dp),
    containerSize: DpSize = DpSize(300.dp, 500.dp)
) {
    val coroutineScope = rememberCoroutineScope()
    val density = LocalDensity.current

    // 动画控制的偏移量
    val offsetX = remember { Animatable(0f) }
    val offsetY = remember { Animatable(0f) }

    // 惯性滑动的衰减动画
    val decay = rememberSplineBasedDecay<Float>()

    // 计算允许滑动的最大偏移(容器尺寸 - box 尺寸)
    val maxOffsetX: Float
    val maxOffsetY: Float
    with(density) {
        maxOffsetX = (containerSize.width - boxSize.width).toPx()
        maxOffsetY = (containerSize.height - boxSize.height).toPx()
    }

    // 边界阻尼效果:超出边界时返回带有缓冲的值
    fun Float.damped(min: Float, max: Float): Float {
        return when {
            this < min -> min + (this - min) / 3f  // 超出左/上边界
            this > max -> max + (this - max) / 3f  // 超出右/下边界
            else -> this
        }
    }

    Box(
        modifier = modifier
            .size(containerSize)
            .background(Color.LightGray)
            .pointerInput(Unit) {
                forEachGesture {
                    awaitPointerEventScope {
                        val down = awaitFirstDown()
                        val velocityTracker = VelocityTracker()
                        var pointerId = down.id
                        var pastTouchSlop = false

                        // 侦测是否拖动超出系统 touchSlop,超出才开始处理拖动逻辑
                        val drag = awaitTouchSlopOrCancellation(pointerId) { change, over ->
                            pastTouchSlop = true
                            change.consume() // 消费事件
                            coroutineScope.launch {
                                // 计算偏移并加阻尼
                                val newX = offsetX.value + over.x
                                val newY = offsetY.value + over.y
                                offsetX.snapTo(newX.damped(0f, maxOffsetX))
                                offsetY.snapTo(newY.damped(0f, maxOffsetY))
                            }
                        }

                        if (drag != null && pastTouchSlop) {
                            // 拖动中实时更新偏移
                            horizontalDrag(pointerId) { change ->
                                val delta = change.positionChange()
                                change.consume()
                                velocityTracker.addPosition(change.uptimeMillis, change.position)
                                coroutineScope.launch {
                                    val newX = offsetX.value + delta.x
                                    val newY = offsetY.value + delta.y
                                    offsetX.snapTo(newX.damped(0f, maxOffsetX))
                                    offsetY.snapTo(newY.damped(0f, maxOffsetY))
                                }
                            }

                            // 拖动结束后,计算手指释放时的速度
                            val velocity = velocityTracker.calculateVelocity()

                            coroutineScope.launch {
                                // X 方向:启动衰减动画模拟惯性滑动
                                try {
                                    offsetX.animateDecay(velocity.x, decay) {
                                        // 滑动过远时取消动画
                                        if (value < -maxOffsetX || value > 2 * maxOffsetX) cancel()
                                    }
                                    // 超出边界时回弹(弹簧动画)
                                    offsetX.animateTo(
                                        offsetX.value.coerceIn(0f, maxOffsetX),
                                        spring(stiffness = Spring.StiffnessLow)
                                    )
                                } finally {
                                    // 确保结束时回弹回来
                                    offsetX.animateTo(
                                        offsetX.value.coerceIn(0f, maxOffsetX),
                                        spring(stiffness = Spring.StiffnessLow)
                                    )
                                }

                                // Y 方向:惯性滑动 + 回弹
                                try {
                                    offsetY.animateDecay(velocity.y, decay) {
                                        if (value < -maxOffsetY || value > 2 * maxOffsetY) cancel()
                                    }
                                    offsetY.animateTo(
                                        offsetY.value.coerceIn(0f, maxOffsetY),
                                        spring(stiffness = Spring.StiffnessLow)
                                    )
                                } finally {
                                    offsetY.animateTo(
                                        offsetY.value.coerceIn(0f, maxOffsetY),
                                        spring(stiffness = Spring.StiffnessLow)
                                    )
                                }
                            }
                        }
                    }
                }
            }
    ) {
        // 红色 box,位置由 offset 控制
        Box(
            modifier = Modifier
                .offset {
                    IntOffset(offsetX.value.roundToInt(), offsetY.value.roundToInt())
                }
                .size(boxSize)
                .background(Color.Red, shape = RoundedCornerShape(16.dp))
        )
    }
}

img_v3_02ma_12d9bbef-c27e-4fba-952d-53ba4c4e90dl.gif

自定义触摸:多指手势

自定义多指触摸手势(如:双指缩放、旋转、平移、或者你自己的多指逻辑),可以通过 pointerInput 修饰符结合 awaitPointerEventScopePointerEvent API 实现。

🔧 实现多指触摸:双指缩放 + 平移 示例

这是一个典型的自定义多指手势识别示例(支持:双指缩放 + 平移):

@Composable
fun ZoomableBox(
    modifier: Modifier = Modifier,
    minScale: Float = 1f,
    maxScale: Float = 5f,
    content: @Composable BoxScope.() -> Unit
) {
    // 1⃣ 状态
    var scale by remember { mutableStateOf(1f) }
    var offset by remember { mutableStateOf(Offset.Zero) }

    // 2⃣ 手势处理
    val transformModifier = Modifier.pointerInput(Unit) {
        detectTransformGestures { _, pan, zoom, _ ->
            // —— 缩放 —— //
            val newScale = (scale * zoom).coerceIn(minScale, maxScale)

            // —— 平移 —— //
            // 缩放后再叠加位移能保持手指相对位置
            val newOffset = offset + pan

            scale = newScale
            offset = newOffset
        }
    }

    // 3⃣ 将变换应用到图层
    Box(
        modifier
            .then(transformModifier)
            .graphicsLayer {
                scaleX = scale
                scaleY = scale
                translationX = offset.x
                translationY = offset.y
            },
        content = content
    )
}

@Composable
@Preview
fun ZoomableImageDemo() {
    ZoomableBox(modifier = Modifier.fillMaxSize()) {
        Image(
            painter = painterResource(R.drawable.lanyuandan),
            contentDescription = null,
            contentScale = ContentScale.Fit,
            modifier = Modifier.fillMaxSize()
        )
    }
}

20250515221455_rec_.gif


📌 detectTransformGestures 是什么?

这是 Compose 提供的一个非常方便的 API(来自 foundation.gestures),用于识别常见的多指变换手势(缩放、平移、旋转):

suspend fun PointerInputScope.detectTransformGestures(
    panZoomLock: Boolean = false,
    onGesture: (centroid: Offset, pan: Offset, zoom: Float, rotation: Float) -> Unit
)
  • centroid:多指中心点
  • pan:拖动位移
  • zoom:缩放因子
  • rotation:旋转角度(可忽略)

✅ 使用方式示例

@Preview
@Composable
fun MultiTouchDemo() {
    MultiTouchGestureBox(
        modifier = Modifier
            .fillMaxSize()
            .background(Color.Gray)
    ) {
        Box(
            Modifier
                .size(200.dp)
                .background(Color.Red, RoundedCornerShape(16.dp))
        )
    }
}

🧠 如果想完全自定义多指行为(不使用 detectTransformGestures)

可以自己实现手势识别逻辑,比如读取 PointerEvent.changes 列表,获取多个指头的状态与位置:

.pointerInput(Unit) {
    forEachGesture {
        awaitPointerEventScope {
            val event = awaitPointerEvent()

            val touches = event.changes
            if (touches.size >= 2) {
                val first = touches[0]
                val second = touches[1]

                val distance = (first.position - second.position).getDistance()
                val center = (first.position + second.position) / 2f

                // 可以计算缩放因子、偏移等
            }
        }
    }
}

总结

方法适用场景特点
detectTransformGestures通用的多指缩放、拖动、旋转交互简洁、功能齐全
自定义 pointerInput高级自定义手势、多指事件逻辑控制灵活,需要自己计算坐标和变化

自定义触摸:最底层的 100% 定义触摸算法

一、Compose 触摸体系的“三层 API”

层级代表 API你能做什么典型场景
① 高层Modifier.clickableModifier.swipeableModifier.nestedScroll配置即可用列表滚动、按钮点击
② 中层detectTapGesturesdetectTransformGestures只关心手势特征,代码简单双击缩放、长按
③ 低层(100 % 自定义)pointerInput { awaitPointerEventScope { … } }完全拿到每一帧 PointerEvent,自行写算法游戏摇杆、矢量绘图、复杂多指手势

最底层的 100 % 定义触摸算法”指的就是第 ③ 层。


二、底层 PointerInput 的工作原理

  1. 事件来源
    Android 原生的 MotionEvent → Compose 转成 PointerEvent 并带上多指信息 (PointerId, pressed, position, changed, historicalPositions…)。

  2. 事件分发三阶段 (Pass 枚举)

    • Pass.Initial:父先看,有机会抢占。
    • Pass.Main:常规处理。
    • Pass.Final:收尾,可做冲突解决或惯性。
      事件沿着 Modifier 链 正序 / 逆序穿梭,每个 PointerInputFilter 都可消费事件。
  3. pointerInput = 一个挂在 Modifier 上的协程

    • 每个调用都会启动一条协程(PointerInputCoroutine)。
    • awaitPointerEventScope 内部你用挂起函数拿事件,Compose 会自动暂停/恢复,不阻塞主线程。
  4. 消费模型

    • change.consume() / change.consumePositionChange() 标记位移被用掉,后续 Pass 读到就不会再响应。
    • 保证不同手势检测器互斥协作

三、写 100 % 自定手势的套路

步骤Compose 实际调用栈说明
pointerInput(Unit) { … }Modifier.pointerInput一次重组 内启动一条协程,专门接收指针事件。
awaitEachGesture { … }(新版本 API 名为 forEachGesture内部先进入 awaitPointerEventScope { … },随后自动循环 → 每次「最后一根手指抬起」就重置手势状态帮你把“多帧组成的一次手势”包装成一个 block,下一轮会重新等 awaitFirstDown()
③ 在 block 里调用 awaitPointerEvent()(或更高阶函数如 awaitFirstDown()drag() 等)逐帧拿 PointerEvent,自己写算法、消耗事件这是“真正处理手势”的地方。

简洁模板

// 1.用pointerInput作用域包住全部事件
Modifier.pointerInput(Unit) {
    // 2.启动对手势的循环检查
    forEachGesture {                 // ← 旧版本叫 awaitEachGesture
        // 等第一根手指
        val down = awaitFirstDown()

        // 手势循环
        while (true) {
            // 3.去检查每一个手势,并对手势做处理。
            val event = awaitPointerEvent()
            // … 在这里处理位移 / 缩放 / 旋转 …

            // 全部手指抬起 → 跳出 forEachGesture,自动开始下一轮
            if (event.changes.all { !it.pressed }) break
        }
    }
}

关键点补充

  1. awaitPointerEventScope
    forEachGesture 本质就在它内部做循环;如果你想完全自管生命周期,可直接用 awaitPointerEventScope { … } 自己 while。

  2. 事件消费
    change.consume() / consumePositionChange() 抢占或协作,防止同层手势冲突。

  3. 高阶封装

    • 简单点击:直接用 Modifier.clickable
    • 拖动:draggable() / awaitTouchSlopOrCancellation()
    • 双指缩放:detectTransformGestures()

只有当这些封装无法满足需求时,才需要自己走 pointerInput → forEachGesture → awaitPointerEvent 这一整套“最底层三步曲”。

  • awaitFirstDown() :帮你等到第一根手指落下。
  • awaitPointerEvent() :挂起直到下一帧事件。
  • PointerInputChange:包含旧/新坐标、时间戳、是否已消费。
  • 循环退出:全部手指抬起后 pressedfalse

与原生相比,为什么 Compose 需要一次一次的取事件,取到了才能继续操作,而不是像原生那样,等着系统给回调就行了?

Compose 并不是“轮询”事件,而是在回调线程内用协程把回调 → 线性代码;所以“必须一次次 事件”只是写法上的表现,底层依旧是系统把 MotionEvent 推送上来。

  • 底层一样是系统回调;Compose 只是用 awaitPointerEvent()回调拆成顺序段
  • 本质是 Kotlin 协程与 Java 回调区别,是协程框架提供的挂起/恢复能力

1 | 原生 View 体系:回调式
@Override
public boolean onTouchEvent(MotionEvent e) {
    switch (e.getActionMasked()) { … }
}
  • 系统驱动,每帧触摸事件通过 ViewRootImpl 直接回调。
  • 逻辑往往要维护大量外部状态:
    isDragging? isLongPress? velocityTracker…​

我们知道,安卓原生中,抬起的事件有Action_pointer_up 和 Action_up。 Compose 无法对多指触控没有,它只有Release 事件, 因此,在Compose中如何判断最后一个手指被抬起了?还需要加上这样的判断:

if (event.type == PointerEventType.Release && event.changes.size == 1) // 最后抬起的手指。

当任何手指从屏幕上抬起后的一瞬间,它都会被从changes列表中移除。


2 | Compose PointerInput:协程式“顺序代码”
Modifier.pointerInput(Unit) {
    forEachGesture {
        val down = awaitFirstDown()
        if (down.isConsumed) return@forEachGesture   // 仍能抢占

        val longPress = withTimeoutOrNull(500) { awaitRelease() }
        if (longPress == null) onLongPress()
        else onClick()
    }
}
  • 同样在主线程回调里执行;只是改写成 挂起 → 恢复 的顺序流程:

    1. 触点落下 → 协程恢复。
    2. 没有新帧就挂起,不会自旋。
    3. 下一帧事件到 → 框架再把协程恢复。
  • 状态机更自然:不再手动存 flag/计时器,直接用挂起点表达阶段。


3 | “拉”与“推”其实是同一条链
层次触摸进入方式你写的代码表现
Android 内核 → ViewRoot100 % 回调 (push)
ViewRoot → Compose仍是 push
Compose PointerInputFilterpush → 转为协程 resume你在 awaitPointerEvent()“拉”事件

所以 Compose 并没有轮询硬件;只是把回调拆成能暂停/恢复的 逻辑片段,让手势流程写得像同步代码。


4 | 协程式写法的优势 / 代价
优势
  1. 顺序语义:复杂手势(长按 → 拖动 → 惯性)写成自然的流程控制。
  2. 结构化取消:Composable 销毁时协程自动 cancel(),不用手动解绑 OnTouchListener。
  3. 组合:可以把高阶检测器(drag() / tapOrDoubleTap())写成挂起函数复用。
代价
  1. 学习门槛:得理解协程、suspendcoroutineContext
  2. 遗漏事件风险:若在 awaitPointerEvent() 里做耗时计算,可能阻塞下一帧;需要 withContext(Dispatchers.Default) 或快速返回。
  3. 调试栈跟踪:挂起栈比同步栈阅读成本高。

小结
  • 回调模型没变;Compose 只是把 push 事件流包装成“看似 pull”的协程 API。
  • 因此我们看到的 awaitPointerEvent() 并非真正轮询,而是在等待下一次 resume。
  • 这么做带来更直观的手势状态机,也带来协程调试/设计的成本。

四、完整示例:自写“二指缩放 + 平移”

@Composable
fun FullCustomTransform(content: @Composable BoxScope.() -> Unit) {
    var scale by remember { mutableStateOf(1f) }
    var offset by remember { mutableStateOf(Offset.Zero) }

    Box(
        Modifier
            .fillMaxSize()
            .pointerInput(Unit) {
                awaitPointerEventScope {
                    var zoom = 1f
                    var pan = Offset.Zero

                    while (true) {
                        val event = awaitPointerEvent()
                        // 拿到两指信息
                        val changes = event.changes // 同Android原生的Action_move一样,这个值包含了所有手指的移动信息,而不是只有一个手指的。除了包含当前手指的信息,还包含了别的手指在上一瞬间的信息。
                        if (changes.size >= 2) {
                            val (p1, p2) = changes
                            // 本帧两个指尖的位移
                            val d1 = p1.positionChange()
                            val d2 = p2.positionChange()

                            // —— 缩放因子 —— //
                            val oldDist = (p1.previousPosition - p2.previousPosition).getDistance()
                            val newDist = (p1.position - p2.position).getDistance()
                            zoom *= newDist / oldDist

                            // —— 平移 —— //
                            pan += (d1 + d2) / 2f

                            // 消费掉位移,防止冒泡
                            p1.consume()
                            p2.consume()
                        }

                        scale = zoom.coerceIn(0.5f, 5f)
                        offset = pan
                    }
                }
            }
            .graphicsLayer {
                scaleX = scale
                scaleY = scale
                translationX = offset.x
                translationY = offset.y
            }
    ) { content() }
}

@Composable
@Preview
fun ZoomableImageScreen() {
    // 可用任何你想操作的内容包在 FullCustomTransform 里
    FullCustomTransform({
        Image(
            painter = painterResource(R.drawable.lanyuandan),
            contentDescription = null,
            contentScale = ContentScale.Fit,
            modifier = Modifier
                .fillMaxSize()
                .clip(RoundedCornerShape(8.dp)) // 随意加修饰符
        )
    })
}

img_v3_02ma_f9b62cf0-6a0f-4ec4-a63c-ea6bd45b4c3l.gif


Jetpack compose处理事件与安卓的一些对比

Jetpack Compose 设计成 两次从父节点到子节点(Initial 和 Final 阶段) ,一次从子节点到父节点(Main 阶段)的三阶段事件传递,是为了实现精确控制与灵活的事件消费逻辑:

① 为什么需要 第一次父→子 (Initial)

目的

  • 父节点有提前截获或预处理事件的机会

具体用途

  • 如一个滚动容器(例如 LazyColumn)可能需要提前判断触摸事件的方向(垂直或水平滑动),以决定是否消费掉事件,避免误触其内部的子控件。
  • 防止内部子控件(按钮等)抢占可能属于父控件的事件。例如,一旦滚动开始,就不希望按钮响应点击。

示例场景

  • 在滑动列表中手指移动时,滚动组件可在此阶段提前消费事件,避免按钮的误点击。

② 为什么需要 子→父 (Main)

目的

  • 让子控件优先处理交互事件(例如点击或拖拽)。

具体用途

  • 这是最主要的交互响应阶段。
  • 例如按钮在此阶段消费点击事件;拖拽组件在此阶段确认移动位置。

示例场景

  • 点击按钮时,按钮在此阶段消费事件并触发反馈(如波纹动画),再由父容器处理剩余事件。

③ 为什么又需要 第二次父→子 (Final)

目的

  • 让子控件获知之前阶段(Main阶段)父节点的处理情况,以决定自己下一步的响应动作。

具体用途

  • 子控件根据父节点是否消费事件,决定是否取消或继续响应后续事件。
  • 比如子控件可能已经开始了波纹动画,但父节点却消费了事件(如滚动),子控件可在此阶段中断动画或取消点击反馈。

示例场景

  • 一个按钮在触摸屏幕时开始播放波纹动画,但父容器在 Main 阶段消费了触摸事件(如开始滚动),此时按钮在 Final 阶段检查到事件被消费,就终止动画、取消响应。

📌 为什么不只用一次父→子?

  • 如果只用一次(Initial),子控件就无法得知父控件后续是否消费了事件。
  • 因此,子控件可能会错误地响应触摸事件(如按钮误判点击,产生错误反馈)。
  • 加入 Final 阶段,让子控件清楚了解父控件的消费情况,从而精确调整响应逻辑,确保交互的一致性。

🌰 整体事件传递过程简单示例

假设:

Scrollable(父节点)
└─ Button(子节点)
阶段方向操作示例场景说明
Initial父→子Scrollable 判断是否开始滚动提前拦截可能是滚动的触摸,防止按钮误响应
Main子→父Button 判断是否被点击Button 未被父消费前先响应触摸事件
Final父→子Button 检查父是否消费事件若父容器已消费事件,则按钮取消点击反馈

🚩 总结

Jetpack Compose设计三阶段(两次从父到子,一次从子到父)的事件传递流程,主要就是为了解决:

  • 父节点提前拦截事件的需求;
  • 子节点优先响应事件的需求;
  • 子节点精确得知父节点消费事件的情况,以合理地调整自身的响应状态;

这种精确控制触摸事件传递和消费的机制,保证了复杂交互场景下的逻辑清晰和用户体验的一致性。

对比安卓原生的步骤,他们之间有什么差异?

📌 一、Android 原生 View 事件传递流程:

原生 View 事件的处理流程一般分为三个主要方法:

  • dispatchTouchEvent(事件分发)
  • onInterceptTouchEvent(事件拦截,仅ViewGroup有)
  • onTouchEvent(事件处理)

完整流程如下:

Activity → ViewGroup → View

具体流程:

  1. dispatchTouchEvent (Activity → ViewGroup)

    • Activity 将触摸事件传递给根 ViewGroup。
  2. onInterceptTouchEvent (ViewGroup)

    • ViewGroup 可决定是否拦截事件。

      • 若拦截(返回true):事件交给自身的 onTouchEvent 处理,不再往下传递。
      • 若不拦截(返回false):继续向子 View 传递。
  3. dispatchTouchEvent (ViewGroup → View)

    • 子 View 接收事件。
  4. onTouchEvent (View)

    • 子 View 自己处理事件。

      • 若消费事件(返回true),传递终止,后续事件直接给该 View。
      • 若不消费(返回false),事件返回给父 View 的 onTouchEvent 处理。

原生事件流总结

Activity.dispatchTouchEvent 
→ ViewGroup.dispatchTouchEvent 
→ ViewGroup.onInterceptTouchEvent
  → View.dispatchTouchEvent 
  → View.onTouchEvent
→ ViewGroup.onTouchEvent
→ Activity.onTouchEvent

📌 二、Jetpack Compose 事件传递流程:

Jetpack Compose 中,事件传递被定义为 PointerEventPass 三个阶段:

  • Initial (父 → 子)
  • Main (子 → 父)
  • Final (父 → 子)

Compose 中的事件传递路径:

Initial 阶段:祖先 → 后代
Main 阶段:后代 → 祖先
Final 阶段:祖先 → 后代
  • Initial阶段:允许祖先(例如滚动容器)提前消费事件。
  • Main阶段:实际的交互响应发生在这里。
  • Final阶段:子节点获知父节点消费情况,确定自身最终的响应。

🚩 三、Compose 和 原生View 事件处理的主要差异:

差异点Android 原生 ViewJetpack Compose
流程设计单次单向传递(下行),使用拦截返回模式三阶段传递(下→上→下),明确分阶段
事件拦截机制通过onInterceptTouchEvent()通过Initial 阶段提前消费
事件消费方式通过返回true/false的方式通过调用consume() 方法明确消费
消费通知机制隐式:靠返回值得知显式:Final阶段让子节点明确知晓
灵活性拦截与消费耦合,事件响应逻辑分散更清晰地划分消费时机,事件逻辑集中
责任划分ViewGroup 和 View 区分较明确所有 Composable 一视同仁,责任更统一

🌰 四、场景举例对比:

例如,按钮放置在滚动容器中:

  • 原生 Android

    • 滚动的父 ViewGroup 用 onInterceptTouchEvent 拦截事件。
    • 一旦拦截,子 View 完全不接收到事件,无法明确得知为何未收到事件。
  • Jetpack Compose

    • 滚动容器在Initial阶段可消费事件,避免子按钮在Main阶段误消费。
    • 若父容器消费了事件,子按钮在Final阶段能明确得知,调整自身状态(取消动画或点击反馈)。

⚖️ 五、优缺点比较:
原生 Android View:
  • 优点

    • 易于理解,事件流固定明确。
    • 灵活控制,细粒度拦截。
  • 缺点

    • 返回布尔值的消费方式隐晦且分散,调试困难。
    • 子 View 无法明确知道父 View 为什么消费事件。
Jetpack Compose:
  • 优点

    • 明确、显式消费事件,便于调试。
    • 子节点可明确知道父节点的消费情况,响应更精确。
  • 缺点

    • 三阶段流程增加了一定复杂性,初学者理解成本高。

🎯 六、两者设计思想的核心区别:
  • 原生 View 以事件拦截和消费为核心,通过返回值来确定事件流向。
  • Compose 以明确的三阶段模型实现精确、透明的事件消费。

本质上:

  • Android 原生 View 隐式化事件的消费与传播,导致消费状态不明确。
  • Compose 则更显式化,允许组件明确知晓消费状况,提升了交互准确性。

🔑 七、小结(核心差异点):
  • 原生 View:

    单向传递 + 拦截式消费(隐式)
    
  • Jetpack Compose:

    三阶段传递 + 显式消费
    

Jetpack Compose 的三阶段事件传递方式,相比于原生 View,能够提供更加清晰、精准的事件处理模型,但也稍微增加了学习复杂度。

六、常见坑与最佳实践

⚠️ 坑规避要点
事件丢失保证每帧都 awaitPointerEvent(),别阻塞循环。
冲突合理 consumePositionChange(),否则同级别手势会同时拿到事件。
协程取消组件移除或重组时,pointerInput 协程自动取消,要在下次重组重新初始化状态。
性能对频繁触发的运算避免创建临时对象;尽量用 graphicsLayer 做变换。

和传统的 View 系统混用

📌 一、Compose 与 View 混用时的架构

当混用时,一般架构如下:

传统 View 容器 (ViewGroup)
   ├── Android 原生 View
   └── ComposeView(作为View的容器)
         └── Composable内容

Compose 容器(如 AndroidView)
   └── AndroidView(承载传统View)
        └── 原生 View内容

Compose 和传统View通常通过:

  • Compose → View: 使用 AndroidView 组件

    val context = LocalContext.current
    var name by remember { mutableStateOf("Example") }
    Column {
      Text("Compose Text", Modifier.clickable { name += "New" })
      AndroidView(factory = {
        TextView(context).apply {
          text = "Android Text"
        }
      }) { // 添加需要更新的逻辑
        it.text = name
      }
    }
    
  • View → Compose: 使用 ComposeView 组件
    • 方式1,直接addView: image.png
    • 方式2,使用xml: image.png image.png

📌 二、事件传递流程的衔接细节

当事件从传统View传入到Compose,或从Compose传入传统View时,两套机制必须协调工作:

① 从 View → Compose

  • 事件首先以传统View系统的方式处理:

    dispatchTouchEvent → onInterceptTouchEvent → onTouchEvent
    
  • 当事件传递到 ComposeView 时:

    • ComposeView 作为传统 View 接收 dispatchTouchEvent

    • 事件通过内部 Compose 机制 (PointerInput) 进一步分发:

      ComposeView.dispatchTouchEvent
         → Initial (Compose祖先→Compose后代)
         → Main (Compose后代→Compose祖先)
         → Final (Compose祖先→Compose后代)
      
  • 如果 Compose 内部消费了事件,则传统View的事件传播链条就此终止,外层的View不会再收到后续触摸事件。

示例流程:

Activity.dispatchTouchEvent
   └→ ViewGroup.dispatchTouchEvent (传统View)
         └→ ComposeView.dispatchTouchEvent
               └→ Compose (Initial → Main → Final)

② 从 Compose → View

Compose 组件中使用传统View:

  • 通常使用 AndroidView 包装传统View。

  • 此时,Compose 事件传入到 AndroidView 内部时:

    • 首先以 Compose 三阶段方式传递到 AndroidView
    • 进入 AndroidView 后,转为传统的View事件传播方式(dispatch → onIntercept → onTouchEvent)。

示例流程:

Compose 容器 (Initial → Main → Final)
   └→ AndroidView(传统View)
        └→ dispatchTouchEvent → onInterceptTouchEvent → onTouchEvent

📌 三、混用时事件传递规则要点总结

传递方向流程事件传播机制
View → Compose传统View系统 → ComposeView → Compose事件机制传统View → Compose 三阶段
Compose → ViewCompose事件机制 → AndroidView → 传统View系统Compose三阶段 → 传统View事件传播

关键点:

  • ComposeView / AndroidView 是桥梁,事件经过它们时会自动进行转换。
  • Compose中明确的三阶段事件传递机制与传统View的隐式拦截机制无缝连接。
  • 一旦某个阶段的组件消费了事件,该事件不再继续传播到其他组件。

📌 四、事件消费后的影响(关键区别)

当 Compose 内部组件消费了触摸事件时:

  • 传统View层无法继续接收到后续触摸动作。
  • Compose明确的事件消费机制,使得事件的消费状态很容易掌控。

当传统View消费了触摸事件时:

  • Compose 层面则无法再获取事件;Compose内部也无法在 Final 阶段获取到父View的消费状态(因为传统View消费状态隐式表达,Compose无法显式知晓)。

🌰 五、混用场景示例(Button in ComposeView)

例如,一个 ComposeView 放在传统ScrollView里:

mathematica
复制编辑
传统ScrollView
  └ ComposeView
      └ Button(Compose实现)
  • 当触摸事件发生:

    1. ScrollView 先执行 dispatchTouchEventonInterceptTouchEvent,若未拦截事件传入 ComposeView。
    2. ComposeView 将事件转发给 Compose 的三阶段事件机制(Initial → Main → Final)。
    3. 如果 Compose 内部按钮消费事件,事件不再返回给 ScrollView。
    4. 如果 Compose 不消费事件,事件继续返回给 ScrollView。

若 Compose 中按钮点击后消费了事件:

  • ScrollView 将无法再进行滚动操作。

🚩 六、Compose 与 传统 View 事件机制核心差异(总结):

事件特性传统ViewJetpack Compose
传递方向单向,明确的View树层级三阶段(祖先→后代→祖先→后代)
拦截机制onInterceptTouchEvent()隐式拦截Initial阶段显式消费
事件消费状态隐式(返回true消费)显式(consume()消费)
消费通知无法明确告知子ViewFinal阶段明确通知子节点

当混用时:

  • ComposeView 和 AndroidView 在两套机制之间自动衔接转换。
  • Compose的三阶段机制让消费事件更加明确与透明。
  • Compose内消费事件后,传统View事件链条立刻终止,反之亦然。

🔑 七、小结(混用时的核心关注点):

  • ComposeView 和 AndroidView 是两种机制之间的桥梁

  • 事件传播方向:

    • View → ComposeView → Compose 内部
    • Compose → AndroidView → View 内部
  • Compose 内的事件消费状态显式且明确,而原生 View 则隐式且相对模糊。

  • 混用时的消费事件后果明确: 一方消费,另一方立即停止接收。