Jetpack Compose 之 Modifier(下)

Modifier

书接上篇 Jetpack Compose 之 Modifier(上)

OnRemeasuredModifierOnPlacedModifier的对比

OnRemeasuredModifierOnPlacedModifier 都是在自定义布局流程里"插钩"用的接口,但它们跑的时机、能拿到的数据,以及典型用途都不一样:


1. 触发时机不同

Modifier 触发时机
OnRemeasuredModifier 测量阶段 结束之后,measure(...) 完成时
OnPlacedModifier 放置阶段 结束之后,place(...) 完成时
  • OnRemeasured :在给定的 Constraints 下,子节点测量出 width×height 后回调一次。
  • OnPlaced :在父布局里把子节点定位放置(place(x,y))后回调一次,可以拿到最终的 LayoutCoordinates

2. 拿到的数据不同

Modifier 回调参数 能做什么
OnRemeasuredModifier IntSize(size) 读取测量出的尺寸
OnPlacedModifier LayoutCoordinates(coords) 读取放置后的位置、尺寸、父子关系、全局/本地坐标等
  • onRemeasured(size) 只给一个尺寸:size.widthsize.height

  • onPlaced(coords) 则给一个完整的 LayoutCoordinates,能查询:

    • coords.positionInParent()positionInWindow()
    • coords.size
    • coords.parentcoords.children
    • coords.isAttachedToRoot()coords.globalPosition() 等。

3. 典型用例对比

  • 测量驱动动画(OnRemeasured)

    kotlin 复制代码
    Modifier.onRemeasured { size ->
      launch {
        // 根据 size.width 启动一次宽度适配动画
        animateDpAsState(targetValue = size.width.toDp())
      }
    }
  • 获取绝对屏幕位置(OnPlaced)

    kotlin 复制代码
    Modifier.onPlaced { coords ->
      val screenPos = coords.positionInWindow()
      // 把 screenPos 传给外部,做拖拽放置或弹窗定位
    }

4. 底层实现区别

  • OnRemeasuredModifier

    • 属于 RemeasureEntityType,当某层 LayoutNodeWrappermeasure() 完成并生成 Placeable 后,框架会遍历所有挂在该层的 OnRemeasuredModifier 实例,按添加顺序依次调用 onRemeasured(size)
  • OnPlacedModifier

    • 属于 PositioningEntityType,当该层 LayoutNodeWrapper 的所有子 Placeable.place() 都调用完毕后,框架会遍历所有挂在该层的 OnPlacedModifier 实例,依次调用 onPlaced(coordinates)

总结

  • 测量结束想拿到「尺寸」就用 OnRemeasuredModifier
  • 放置结束想拿到「位置/坐标/层级」就用 OnPlacedModifier

LookaheadOnPlacedModifier 的作用、写法和原理

LookaheadOnPlacedModifier 是在 LookaheadScope 内对组件"被摆放"(placed)完成时的回调,它不仅会在正常布局完成时触发,还会把预先测量(lookahead pass)阶段得到的布局信息一并传给你,从而可以拿到未来 布局状态下的位置信息与当前布局状态下的位置信息,常用于基于前瞻信息做平滑过渡动画(例如共享元素动画) ,其写法与原理与 OnPlacedModifier基本一致,只是多了一个参数,以及必须要在 LookaheadScope 中调用


典型写法

LookaheadScope 里,我们直接给子组件加上一个双参数的 onPlaced 回调,例如:

kotlin 复制代码
LookaheadScope {
  Row(
    Modifier
      .onPlaced { lookaheadCoordinates, actualCoordinates ->
        // lookaheadCoordinates:预测完成后的 LayoutCoordinates
        // actualCoordinates:正式布局完成后的 LayoutCoordinates
        // 你可以基于两者的差值来驱动动画
      }
  ) {
    // ...子组件...
  }
}

这个 API 与普通的 Modifier.onPlaced { coordinates -> ... } 用法几乎一模一样,区别在于回调签名多了一个 lookaheadCoordinates 参数。


底层原理

  1. 两次测量与布局

    • Lookahead PassLookaheadLayout 会先做一次"前瞻"测量和摆放,将目标状态下的尺寸和位置缓存起来;
    • Normal Pass :接着按常规测量和摆放,这时组件在布局过程中就能拿到那次前瞻的结果。
      这两轮过程由 LookaheadDelegateMeasurePassDelegate 分别驱动,前者只对 LookaheadLayout 子树执行一次,后者执行真正的布局
  2. Modifier 存储与回调触发

    • LayoutNode.modifier 的 setter 中,所有实现了 LookaheadOnPlacedModifier 的元素都会通过 addAfterLayoutModifier 附加到相应的 ModifiedLayoutNode(或 LayoutNodeWrapper)的 entities 列表中;
    • 布局完成后,Compose 会依次调用每层 wrapper 的 onLayoutComplete(),其中会遍历 entities,针对 LookaheadOnPlacedModifier 类型调用回调,并把预先缓存的"lookahead"坐标和真实坐标都传给我们
  3. 为何与普通 OnPlacedModifier 同时机
    LookaheadOnPlacedModifier 的触发时机与普通的 OnPlacedModifier 完全一致------都在布局流程结束后调用,只是因为它是"后置"(addAfterLayoutModifier)存储的,所以能够见到所有 LayoutModifier 已经处理完的最终位置;不同点仅在于它回调时额外携带了第一次前瞻布局的结果。

kotlin 复制代码
// androidx.compose.ui.layout.LookaheadOnPlacedModifier
@OptIn(ExperimentalComposeUiApi::class)
internal class LookaheadOnPlacedModifier(
    // callback 就是我们实现的回调。
    val callback: (
        lookaheadScopeRootCoordinates: LookaheadLayoutCoordinates,
        coordinates: LookaheadLayoutCoordinates
    ) -> Unit,
    val rootCoordinates: () -> LookaheadLayoutCoordinates,
) : Modifier.Element {

    fun onPlaced(coordinates: LookaheadLayoutCoordinates) {
        callback(rootCoordinates(), coordinates)
    }
}

// androidx.compose.ui.node.BackwardsCompatNode#onLookaheadPlaced
@OptIn(ExperimentalComposeUiApi::class)
override fun onLookaheadPlaced(coordinates: LookaheadLayoutCoordinates) {
    val element = element
    if (element is LookaheadOnPlacedModifier) {
        element.onPlaced(coordinates)
    }
}

// androidx.compose.ui.node.NodeCoordinator#onPlaced
@OptIn(ExperimentalComposeUiApi::class)
fun onPlaced() {
    val lookahead = lookaheadDelegate
    if (lookahead != null) {
        visitNodes(Nodes.LayoutAware) {
            // LookaheadOnPlacedModifier调用时机。
            it.onLookaheadPlaced(lookahead.lookaheadLayoutCoordinates)
        }
    }
    visitNodes(Nodes.LayoutAware) {
        // OnPlacedModifier 调用时机
        it.onPlaced(this)
    }
}

综上,LookaheadOnPlacedModifier 就是专门为 LookaheadLayout 设计的双参数 placed 回调,能让你在做布局过渡动画时,既能拿到"未来"布局信息,也能拿到当前布局信息,大幅简化动画构造的工作。另外,可以看到,OnPlacedModifier(布局) OnRemeasuredModifier(测量) LookaheadOnPlacedModifier(布局) 都被归为了 Nodes.LayoutAware 类型。LayoutAware 代表的是对于布局过程有感知的,布局过程包含测量与布局。

kotlin 复制代码
@OptIn(ExperimentalComposeUiApi::class)
internal fun calculateNodeKindSetFrom(element: Modifier.Element): Long {
    // 省略其他代码......
    if (
        element is OnPlacedModifier ||
        element is OnRemeasuredModifier ||
        element is LookaheadOnPlacedModifier
    ) {
        mask = mask or Nodes.LayoutAware
    }
    return mask
}

OnGloballyPositionedModifier 的作用、写法和原理

Modifier.onGloballyPositioned {layoutCoordinates -> ... } 会在该组件及所有其上层布局完成 放置 (placement)之后、在每次重新布局后立即 回调一次,给我们一个完整的 LayoutCoordinates 对象(这里没有使用 NodeCoordinates 是为了避免一些api 污染的问题。)。有点类似于android.view.View#addOnLayoutChangeListener 。我们可以通过它:

  • 拿到该组件在 窗口 坐标系(coords.positionInWindow())或 屏幕 坐标系(coords.localToWindow(Offset.Zero))中的绝对位置;

  • 查询它的大小(coords.size)、父子层级(coords.parent / coords.children)等;

  • 以此驱动后续的动画定位、弹窗对齐、测试断言或其他依赖"视图真实位置"的逻辑。

  • 触发的方式:当它右边的LayoutModifier所控制的区域 size 之类的发生变化,当它所在的 Composable 函数所控制的区域的位置或者尺寸发生变化,会回调。并且它的回调函数中的 layoutCoordinates对象 就是 它所在区域的 LayoutCoordinates 的对象,也就是 NodeCoordinator。

    kotlin 复制代码
    Modifier.onGloballyPositioned {}.size(currrentSize) // 当它右边的LayoutModifier所控制的区域size之类的发生变化
    Modifier.onGloballyPositioned {} // 当它所在的 Composable 函数所控制的区域的位置或者尺寸发生变化

当我们想得到一个区域的位置或者尺寸的回调的时候,就可以已使用这个函数。


写法

kotlin 复制代码
Box(
  Modifier
    .size(120.dp)
    .onGloballyPositioned { layoutCoordinates ->// 这个参数其实就是它所属的 NodeCoordinator 的对象,与 Modifier.onPlaced{} 的参数是一样的
      // 组件已经被放到了父布局中,这里可以拿到它的全局位置与大小
      val topLeft = coords.positionInWindow()          // 相对于 Window 左上角
      val bounds  = coords.boundsInWindow()            // Rect(Offset, Size)
      Log.d("Demo", "Placed at $topLeft; size=${coords.size}")
    }
) {
  Text("Hello")
}
  • 任何实现了 LayoutModifier(如 paddingsize)的放置完成后,都会触发靠它最"靠内" 的 onGloballyPositioned 回调。
  • 它会在 每次布局(首次、尺寸/位置变化后)都执行一次。

原理

onGloballyPositioned 原理也是分为两部分,一是存储, 二是回调,存储与其余大部分的 Modifier 一样,都是在 LayoutNode 里边的nodes.updateFrom(value)完成的。

回调 则是从MeasureAndLayoutDelegate#dispatchOnPositionedCallbacks这里发起的:

  1. PositioningModifier
    onGloballyPositioned 底层就是一个实现了 OnPlacedModifier 接口的包装器(OnGloballyPositionedModifier)。Compose 在把我们的 Modifier 链拆成 LayoutNodeWrapper 时,会把这个包装器注册到相应的布局节点上。

  2. 插入到放置阶段

    由于它是一个 后置 布局修饰符(addAfterLayoutModifier),Compose 会在每层 LayoutNodeWrapper 完成所有子节点的 place(x,y) 之后 ,调用它的 onPlaced(coordinates)

    kotlin 复制代码
    // 伪代码:在每个 wrapper 完放置子项后
    wrapper.entities.forEach(PositioningEntityType) { entity ->
      (entity.modifier as OnPlacedModifier).onPlaced(wrapper.coordinates)
    }
  3. LayoutCoordinates 提供全局映射

    回调给你的 coords: LayoutCoordinates 不仅包含子树的测量结果,也持有:

    • coords.localToWindow(Offset.Zero):把本地(0,0)映射到 Window 坐标
    • coords.parent / coords.children:节点树层级
    • coords.size:测量出的最终 IntSize
      因此你可以直接通过它计算"视图在屏幕上到底在哪里"。

小结

  • 什么时候用 :当你需要确切知道一个 Compose 元素在屏幕/窗口中的位置与大小时,如做拖拽、定位弹窗、或 UI 自动测试。
  • 怎么用 :在 Modifier 链中调用 .onGloballyPositioned { coords -> ... },每次布局完成后都会回调。
  • 底层实现 :它是一个 OnPlacedModifier,Compose 在放置阶段自动触发,并把完整的 LayoutCoordinates 交给你。
  • onPlaced 的区别:两者回调的实际不同。 onPlaced 是在测量和布局的过程中,每一个 LayoutModifier 或者说是 Composable 函数 在 它的外层去摆放它的时候,这个函数就会被调用。它的内层是还没有被调用的,例如我们可以在此时 调用 .offset { IntOffset(offsetX, offsetY) }(注意这个是一个LayoutMoidifer 函数,所以和其他的LayoutModifier有先后顺序。) 去影响内部的 布局。而onGloballyPositioned 则是在自己所对应的 NodeCoordinator 的相对整个窗口,对于窗口的位置被更新的时候,以及尺寸改变的时候,会等到整个 Compose 完成放置、节点附着到 Window才会触发。虽然理论上 onPlaced 回调的时候onGloballyPositioned 可能不回调,但是实际上,一般的onPlaced 触发后 onGloballyPositioned也会被触发。
    • onPlaced { coords -> ... }

      • 回调给你的 coords: LayoutCoordinates相对于它的父布局 的局部坐标和尺寸。
      • 只能通过 coords.positionInParent()coords.sizecoords.parent/children 等 API 得到局部信息。
      • 如果你调用 positionInWindow(),它会抛异常,因为它并不保证已经附着到 Window。
    • onGloballyPositioned { coords -> ... }

      • 底层也是一个 OnPlacedModifier,但它附着的是 全局 (在 AndroidComposeView 最外层)的位置监听器。
      • coords.positionInWindow()coords.boundsInWindow()coords.windowToLocal() 都可用,你拿到的是组件在整个 窗口 / 屏幕 中的绝对位置和大小。

ModifierLocal、ModifierLocalProvider、ModifierLocalConsumer 的原理与运用

1. 原理与机制

原来与其余的Modifier类似,也是两部分,存储和回调。存储也是在 LayoutNode#updateFrom(value) 中做的。

回调流程代码:

kotlin 复制代码
// NodeChain#updateFrom
internal fun updateFrom(m: Modifier) {
    attach()
}

// androidx.compose.ui.node.NodeChain#attach
fun attach() {
    headToTail {
        if (!it.isAttached) it.attach()
    }
}

// Modifier#attach
internal fun attach() {
    check(!isAttached)
    check(coordinator != null)
    isAttached = true
    onAttach()
    // TODO(lmr): run side effects?

// androidx.compose.ui.node.BackwardsCompatNode#onAttach
override fun onAttach() {
    onModifierUpdated(true)
}

最终是发生在 BackwardsCompatNode#onModifierUpdated中:

  • ModifierLocal 的本质

    Compose 为了在一条 Modifier 链内传递上下文信息,引入了与 CompositionLocal 类似的机制------ModifierLocal。底层维护了一组节点(ProviderNode、ConsumerNode),在链合并(merge)阶段,这些节点会注册到一个"ModifierLocal 栈"中,保证每个 Consumer 都能在组合时读到距离自己最近的 Provider 值,否则回退到默认值 droidconAndroid Developers

  • 作用域

    • CompositionLocal:作用于整个 Composition 树,可跨多个组件传递信息。
    • ModifierLocal:仅限于单条 Modifier 链,更加轻量、局部化,适合与特定 UI 行为或布局属性绑定。

2. 核心 API

kotlin 复制代码
// 1. 定义一个 ModifierLocal,提供默认值
val MyLocal = modifierLocalOf { defaultValue }

// 2. 在 Modifier 链上"提供"新值(Provider)
Modifier.modifierLocalProvider(MyLocal) { newValue }

// 3. 在后续同一链上的某处"消费"该值(Consumer)
Modifier.modifierLocalConsumer {
    // 这里可以通过 MyLocal.current 读取到 newValue
}
  • modifierLocalOf { ... } 定义一个可提供(ProvidableModifierLocal)Local,并指定默认值;
  • modifierLocalProvider 会在链上插入 ProviderNode,将 MyLocal.current 设为 value()
  • modifierLocalConsumer 会插入 ConsumerNode,组合阶读取时在 ModifierLocalReadScope 中执行 lambda,可安全访问所有 ProvidableModifierLocal.current Android Developers

3. Provider & Consumer 的工作流程

  1. 定义

    kotlin 复制代码
    // 在文件顶层或 Companion 对象中定义
    val LocalFoo = modifierLocalOf { Foo() }
  2. 注入(Provider)

    kotlin 复制代码
    Box(
      Modifier
        .modifierLocalProvider(LocalFoo) { Foo(config) }
        .then(/* 其他 Modifier */)
    )
    • 系统将创建一个 ProviderNode 并记录在当前链的上下文中。
  3. 消费(Consumer)

    kotlin 复制代码
    Modifier.modifierLocalConsumer {
      val foo = LocalFoo.current
      // 在此处可以基于 foo 做测量、布局或绘制
    }
    • 当框架 merge Modifier 链并在组合阶段应用时,ConsumerNode 会回调你提供的 lambda,此时 LocalFoo.current 已被解析为最近 Provider 提供的值或默认值。

4. 与 CompositionLocal 的比较

特性 CompositionLocal ModifierLocal
定义方法 compositionLocalOf { ... } modifierLocalOf { ... }
提供/消费 CompositionLocalProvider + Local.current modifierLocalProvider + modifierLocalConsumer + Local.current
作用域 整棵组合树 单条 Modifier 链
典型用途 主题、布局方向、语言环境等跨组件状态 局部触摸反馈、测量参数、布局配置、分析埋点等与 Modifier 强相关的场景

5. 典型场景示例

  • 统一内边距

    kotlin 复制代码
    val LocalPadding = modifierLocalOf { 0.dp }
    Modifier
      .modifierLocalProvider(LocalPadding) { 16.dp }
      .modifierLocalConsumer {
        val pad = LocalPadding.current
        then(Modifier.padding(pad))
      }
  • 点击埋点

    kotlin 复制代码
    private val ScreenNameLocal = modifierLocalOf<String?> { null }
    
    @Composable
    fun Modifier.analyticsScreen(screenName: String): Modifier {
      // 在链上写入 screenName,供后续 Consumer 获取
      return this
        .then(
          Modifier.modifierLocalProvider(ScreenNameLocal) { screenName }
        )
        .then(
          Modifier.modifierLocalConsumer {
            val name = ScreenNameLocal.current
            // 这里可以上报埋点:LaunchEvent(name)
          }
        )
    }

    这样,无需在每个点击事件里手动传递 screenName,就能在自定义的 Consumer 中拿到当前页面标识并上报。

  • 两个LayoutModifier传递sharedString

    kotlin 复制代码
    val sharedWidthKey = modifierLocalOf { "0" }
    Modifier
        .then(object : LayoutModifier, ModifierLocalProvider<String> { // 同时实现连个Modifier
            lateinit var widthString: String
            override fun MeasureScope.measure(
                measurable: Measurable,
                constraints: Constraints
            ): MeasureResult {
                val placeable = measurable.measure(constraints)
                return layout(placeable.width, placeable.height) {
                    placeable.placeRelative(0, 0)
                }
            }
    
            override val key: ProvidableModifierLocal<String>
                get() = sharedWidthKey
            override val value: String
                get() = widthString
        })
        .then(object : LayoutModifier, ModifierLocalConsumer {// 同时实现连个Modifier
            lateinit var sharedString: String
            override fun MeasureScope.measure(
                measurable: Measurable,
                constraints: Constraints
            ): MeasureResult {
                sharedString // 可以使用该变量了。
                val placeable = measurable.measure(constraints)
                return layout(placeable.width, placeable.height) {
                    placeable.placeRelative(0, 0)
                }
            }
    
            // 开始测量和布局之前调用,并且是整个Modifier链条从上到下每一个ModifierLocalConsumer 依次调用这个函数,
            // 它并不会主动把上游共享给我们的数据都传递给我们,但是我们函数的内部可以手动的去获取那些数据。
            override fun onModifierLocalsUpdated(scope: ModifierLocalReadScope) = with(scope) {
                sharedString = sharedWidthKey.current
            }
        })
        .layout { measurable, constraints ->
            val placeable = measurable.measure(constraints)
            val widthString = placeable.width.toString()
            layout(placeable.width, placeable.height) {
                placeable.placeRelative(0, 0)
            }
        }
        .layout { measurable, constraints ->
            val placeable = measurable.measure(constraints)
            layout(placeable.width, placeable.height) {
                placeable.placeRelative(0, 0)
            }
        }
  • 实现多级连续消费的效果

kotlin 复制代码
// windowInsetsPadding 给界面的组件加上边距,让我们组件不会被系统组件盖住。
Modifier
    .windowInsetsPadding(WindowInsets(4.dp, 4.dp, 4.dp, 4.dp)) // 给界面的组件加上边距,让我们组件不会被系统组件盖住。
    .windowInsetsPadding(WindowInsets(4.dp, 6.dp, 4.dp, 6.dp)) // 这一层作为上层数据的消费者,又做为下层数据的提供者。
    .windowInsetsPadding(WindowInsets(4.dp, 2.dp, 4.dp, 2.dp))

@Stable
fun Modifier.windowInsetsPadding(insets: WindowInsets): Modifier = this.then(
    InsetsPaddingModifier(insets, debugInspectorInfo { // 同时实现了ModifierLocalConsumer与ModifierLocalProvider接口
        name = "windowInsetsPadding"
        properties["insets"] = insets
    })
)

internal class InsetsPaddingModifier(
    private val insets: WindowInsets,
    inspectorInfo: InspectorInfo.() -> Unit = debugInspectorInfo {
        name = "InsetsPaddingModifier"
        properties["insets"] = insets
    }
) : InspectorValueInfo(inspectorInfo), LayoutModifier,
    ModifierLocalConsumer, ModifierLocalProvider<WindowInsets> {

小结

  • ModifierLocal 为我们提供了一条"轻量级"从父级 modifier 向子 modifier 传递数据的途径。
  • modifierLocalOf → 定义键;Modifier.modifierLocalProvider → 提供值;Modifier.modifierLocalConsumer → 消费值。
  • 适用于需要在 layout 或 draw 逻辑里读取上游提供的参数,而不想新增一大堆函数参数的场景。

通过这种方式,我们可以让自定义 Modifier 之间灵活协作,实现更可扩展、可复用的 UI 构建组件。

相关推荐
vvilkim1 小时前
全面解析React内存泄漏:原因、解决方案与最佳实践
前端·javascript·react.js
vvilkim2 小时前
React批处理(Batching)更新机制深度解析
前端·javascript·react.js
Bayi·2 小时前
前端面试场景题
开发语言·前端·javascript
程序猿熊跃晖2 小时前
Vue中如何优雅地处理 `<el-dialog>` 的关闭事件
前端·javascript·vue.js
进取星辰2 小时前
12、高阶组件:魔法增幅器——React 19 HOC模式
前端·javascript·react.js
拉不动的猪2 小时前
前端低代码开发
前端·javascript·面试
程序员张32 小时前
Vue3集成sass
前端·css·sass
夜跑者3 小时前
axios 在请求拦截器中设置Content-Type无效问题
前端
知识分享小能手3 小时前
JavaScript学习教程,从入门到精通,Ajax与Node.js Web服务器开发全面指南(24)
开发语言·前端·javascript·学习·ajax·node.js·html5
烛阴3 小时前
Swizzling--OpenGL的向量的灵活组合
前端·webgl