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 构建组件。

相关推荐
编程猪猪侠8 分钟前
Tailwind CSS 自定义工具类与主题配置指南
前端·css
qhd吴飞11 分钟前
mybatis 差异更新法
java·前端·mybatis
YGY Webgis糕手之路33 分钟前
OpenLayers 快速入门(九)Extent 介绍
前端·经验分享·笔记·vue·web
患得患失94936 分钟前
【前端】【vueDevTools】使用 vueDevTools 插件并修改默认打开编辑器
前端·编辑器
ReturnTrue86836 分钟前
Vue路由状态持久化方案,优雅实现记住表单历史搜索记录!
前端·vue.js
UncleKyrie42 分钟前
一个浏览器插件帮你查看Figma设计稿代码图片和转码
前端
遂心_44 分钟前
深入解析前后端分离中的 /api 设计:从路由到代理的完整指南
前端·javascript·api
你听得到111 小时前
Flutter - 手搓一个日历组件,集成单日选择、日期范围选择、国际化、农历和节气显示
前端·flutter·架构
风清云淡_A1 小时前
【REACT18.x】CRA+TS+ANTD5.X封装自定义的hooks复用业务功能
前端·react.js
@大迁世界1 小时前
第7章 React性能优化核心
前端·javascript·react.js·性能优化·前端框架