Compose 实践与探索十 —— 其他预先处理的 Modifier
1、PointerInputModifier
PointerInputModifier 用于定制触摸(包括手指、鼠标、悬浮)反馈算法,实现手势识别。
1.1 基本用法
最简单的使用方式就是通过 Modifier.clickable() 响应点击事件:
Box(Modifier.size(40.dp).background(Color.Blue).clickable { println("点击事件") })
稍微复杂一点,功能更全面的是使用 Modifier.combinedClickable(),支持单击、双击、长按事件 :
Box(Modifier.size(40.dp).background(Color.Blue).combinedClickable(onDoubleClick = { println("双击事件") },onLongClick = { println("长按事件") }) { println("单击事件") })
最后的 lambda 表达式 onClick 表示的单击事件必须要传:
@ExperimentalFoundationApi
fun Modifier.combinedClickable(enabled: Boolean = true,onClickLabel: String? = null,role: Role? = null,onLongClickLabel: String? = null,onLongClick: (() -> Unit)? = null,onDoubleClick: (() -> Unit)? = null,onClick: () -> Unit
)
再更加底层一点的 API 是 pointerInput(),在它内部调用 detectTapGestures() 可以检测到单击、双击、长按与按压:
Box(modifier = Modifier.size(40.dp).background(Color.Blue).pointerInput(Unit) {detectTapGestures(onDoubleTap = { println("双击事件") },onLongPress = { println("长按事件") },onPress = { println("检测到按压") }) {println("单击事件")}})
onPress() 是只要有屏幕触碰就会触发,比如分别进行单击、长按与双击,输出如下:
检测到按压
单击事件
检测到按压
长按事件
检测到按压
检测到按压
双击事件
detectTapGestures() 与 combinedClickable() 都提供了对单击、双击、长按的检测,二者有哪些区别?其实从 tap 与 click 这两个单词的含义中可以窥见一二:
- tab 一般是指物理上的触摸,在 detectTapGestures() 中就是真实发生在屏幕上的触摸事件才会触发相应的事件回调,而不会反馈通过鼠标触发的事件
- click 这个点击,除了在物理屏幕上的点击,也可以是系统指令发出的点击。也就是说它既能反馈物理屏幕触发的事件,也能响应诸如鼠标这类的,没有物理触碰,但由系统发出的指令事件
combinedClickable() 的底层是通过 detectTapGestures() 实现的,并且一些效果是通过 onPress 这个触摸反馈达成的。
PointerInputScope 还提供了更更底层的 API —— awaitPointerEventScope(),连事件监听都是自己做的:
Modifier.pointerInput(Unit) {// 循环监听事件,就是监听一个手势中的所有事件,不加的话检测到一个事件协程就退出了forEachGesture {awaitPointerEventScope {val down = awaitFirstDown()}}}
1.2 基本原理
fun Modifier.pointerInput(key1: Any?,block: suspend PointerInputScope.() -> Unit
): Modifier = composed(inspectorInfo = debugInspectorInfo {name = "pointerInput"properties["key1"] = key1properties["block"] = block}
) {val density = LocalDensity.currentval viewConfiguration = LocalViewConfiguration.currentremember(density) { SuspendingPointerInputFilter(viewConfiguration, density) }.also { filter ->LaunchedEffect(filter, key1) {filter.coroutineScope = thisfilter.block()}}
}
SuspendingPointerInputFilter 实现了 PointerInputModifier 接口:
@Suppress("DEPRECATION_ERROR")
internal class SuspendingPointerInputFilter(override val viewConfiguration: ViewConfiguration,density: Density = Density(1f)
) : PointerInputFilter(),PointerInputModifier,PointerInputScope,Density by density {
PointerInputModifier 除了 SuspendingPointerInputFilter 还有一个实现类 PointerInteropFilter 是负责 Compose 与 View 系统交互的触摸反馈的,与 Compose 本身的内部结构没什么关系,因此我们主要看 SuspendingPointerInputFilter。
原理主要分两部分,一是看遍历 Modifier 链时如何处理 PointerInputModifier,二是看 PointerInputModifier 本身是如何工作的。
遍历 Modifier 链时,对 PointerInputModifier 的处理与上篇讲过的 DrawModifier 几乎完全相同。在 LayoutNode 的 modifier 属性的 set() 内,通过 foldOut() 遍历 Modifier 链,调用 addBeforeLayoutModifier() 将 PointerInputModifier 添加到当前正在遍历的 LayoutNodeWrapper 的 entities 数组中:
override var modifier: Modifier = Modifierset(value) {...val outerWrapper = modifier.foldOut(innerLayoutNodeWrapper) { mod, toWrap ->if (mod is RemeasurementModifier) {mod.onRemeasurementAvailable(this)}// 将指定类型的 Modifier 添加到 toWrap 内的数组的指定类型的链表头上toWrap.entities.addBeforeLayoutModifier(toWrap, mod)if (mod is OnGloballyPositionedModifier) {getOrCreateOnPositionedCallbacks() += toWrap to mod}val wrapper = if (mod is LayoutModifier) {// Re-use the layoutNodeWrapper if possible.(reuseLayoutNodeWrapper(toWrap, mod)?: ModifiedLayoutNode(toWrap, mod)).apply {onInitialize()updateLookaheadScope(mLookaheadScope)}} else {toWrap}wrapper.entities.addAfterLayoutModifier(wrapper, mod)wrapper}setModifierLocals(value)outerWrapper.wrappedBy = parent?.innerLayoutNodeWrapperlayoutDelegate.outerWrapper = outerWrapper...}
PointerInputModifier 与 DrawModifier 一样,是 addBeforeLayoutModifier() 内处理的四种类型之一,只不过 DrawModifier 链表在数组的第 0 个位置,而 PointerInputModifier 在第 1 个位置:
fun addBeforeLayoutModifier(layoutNodeWrapper: LayoutNodeWrapper, modifier: Modifier) {if (modifier is DrawModifier) {add(DrawEntity(layoutNodeWrapper, modifier), DrawEntityType.index)}if (modifier is PointerInputModifier) {add(PointerInputEntity(layoutNodeWrapper, modifier), PointerInputEntityType.index)}if (modifier is SemanticsModifier) {add(SemanticsEntity(layoutNodeWrapper, modifier), SemanticsEntityType.index)}if (modifier is ParentDataModifier) {add(SimpleEntity(layoutNodeWrapper, modifier), ParentDataEntityType.index)}}
因此,PointerInputModifier 都是对它右侧,距离它最近的那个 LayoutModifier 生效的。并且,如果有多个 PointerInputModifier 作用于同一个 LayoutModifier,那么会按照从左到右的顺序逐个执行 PointerInputModifier。
因为遍历是从右至左,但是将 PointerInputModifier 放入链表时采用的是头插法,这样左侧虽然后被遍历到,但是它会被插入到链表头,执行时从链表头开始执行,从 Modifier 链的角度看就是从左到右的顺序执行。这些结论在讲 DrawModifier 时已经详细说明过。
接下来看被存起来的 PointerInputModifier 链表是如何响应触摸事件的。
在 LayoutNode 的 hitTest() 中:
internal fun hitTest(pointerPosition: Offset,hitTestResult: HitTestResult<PointerInputFilter>,isTouchEvent: Boolean = false,isInLayer: Boolean = true) {val positionInWrapped = outerLayoutNodeWrapper.fromParentPosition(pointerPosition)outerLayoutNodeWrapper.hitTest(LayoutNodeWrapper.PointerInputSource,positionInWrapped,hitTestResult,isTouchEvent,isInLayer)}
再次看到 outerLayoutNodeWrapper,也就是最外层的 LayoutNodeWrapper:
fun <T : LayoutNodeEntity<T, M>, C, M : Modifier> hitTest(// 因为有多种类型,目前是两种 EntityType:PointerInputEntityType 与 // SemanticsEntityType 都会有取链表头的需求,所以这里用了参数让调用者指定具体类型hitTestSource: HitTestSource<T, C, M>,pointerPosition: Offset,hitTestResult: HitTestResult<C>,isTouchEvent: Boolean,isInLayer: Boolean) {// hitTestSource 参数传的 PointerInputSource,因此这里取的是 PointerInputModifier 的链表头val head = entities.head(hitTestSource.entityType())if (!withinLayerBounds(pointerPosition)) {...} else if (head == null) {hitTestChild(hitTestSource, pointerPosition, hitTestResult, isTouchEvent, isInLayer)} else if (isPointerInBounds(pointerPosition)) {// A real hit// 重点head.hit(hitTestSource,pointerPosition,hitTestResult,isTouchEvent,isInLayer)} else {...}}
调用 PointerInputEntity 链表头节点的 hit():
private fun <T : LayoutNodeEntity<T, M>, C, M : Modifier> T?.hit(hitTestSource: HitTestSource<T, C, M>,pointerPosition: Offset,hitTestResult: HitTestResult<C>,isTouchEvent: Boolean,isInLayer: Boolean) {// this 指代调用者/接收者,在当前流程中就是 PointerInputEntity 链表头节点if (this == null) {hitTestChild(hitTestSource, pointerPosition, hitTestResult, isTouchEvent, isInLayer)} else {hitTestResult.hit(hitTestSource.contentFrom(this), isInLayer) {next.hit(hitTestSource, pointerPosition, hitTestResult, isTouchEvent, isInLayer)}}}
看头节点不为空的情况,调用参数 hitTestResult 的 hit(),它的第一个参数 hitTestSource.contentFrom(this),点进 contentFrom() 发现是一个接口函数,通过 ctrl + alt + B 找不到实现它的子类,这是因为当前 Android Studio 无法通过这种方式找到接口的匿名实现类,所以就在接口 HitTestSource 的名字上点 alt + F7 找到它的匿名对象,有两个 PointerInputSource 和 SemanticsSource,通过泛型参数可以确定 PointerInputSource 是我们要找的实现对象,看它的 contentFrom():
val PointerInputSource =object : HitTestSource<PointerInputEntity, PointerInputFilter, PointerInputModifier> {override fun entityType() = EntityList.PointerInputEntityType@Suppress("ModifierFactoryReturnType", "ModifierFactoryExtensionFunction")override fun contentFrom(entity: PointerInputEntity) = entity.modifier.pointerInputFilter}
}
entity 是 PointerInputEntity 链表的头节点,modifier 就是 PointerInputEntity 所在的 PointerInputModifier,最后的 pointerInputFilter 点进去看是接口属性:
interface PointerInputModifier : Modifier.Element {val pointerInputFilter: PointerInputFilter
}
接口有两个实现类,我们要的是处理 Compose 内部逻辑的 SuspendingPointerInputFilter:
internal class SuspendingPointerInputFilter(override val viewConfiguration: ViewConfiguration,density: Density = Density(1f)
) : PointerInputFilter(),PointerInputModifier,PointerInputScope,Density by density {override val pointerInputFilter: PointerInputFilterget() = this
}
这个属性就是返回自己,因此 hitTestSource.contentFrom(this)
就是拿到了头节点的 SuspendingPointerInputFilter。
然后再进入 HitTestResult 的 hit():
fun hit(node: T, isInLayer: Boolean, childHitTest: () -> Unit) {hitInMinimumTouchTarget(node, -1f, isInLayer, childHitTest)}fun hitInMinimumTouchTarget(node: T,distanceFromEdge: Float,isInLayer: Boolean,childHitTest: () -> Unit) {val startDepth = hitDepthhitDepth++ensureContainerSize()values[hitDepth] = nodedistanceFromEdgeAndInLayer[hitDepth] =DistanceAndInLayer(distanceFromEdge, isInLayer).packedValueresizeToHitDepth()childHitTest()hitDepth = startDepth}
node 就是 PointerInputEntity 链表头节点,hitDepth 累加说明 node 是按照顺序被存入 values 数组中的。放入数组后,执行参数的 childHitTest 函数,向上倒,找到给它传值的位置是在调用 PointerInputEntity 链表头节点的 hit() 的 else 情况:
private fun <T : LayoutNodeEntity<T, M>, C, M : Modifier> T?.hit(hitTestSource: HitTestSource<T, C, M>,pointerPosition: Offset,hitTestResult: HitTestResult<C>,isTouchEvent: Boolean,isInLayer: Boolean) {// this 指代调用者/接收者,在当前流程中就是 PointerInputEntity 链表头节点if (this == null) {hitTestChild(hitTestSource, pointerPosition, hitTestResult, isTouchEvent, isInLayer)} else {hitTestResult.hit(hitTestSource.contentFrom(this), isInLayer) {next.hit(hitTestSource, pointerPosition, hitTestResult, isTouchEvent, isInLayer)}}}
调用 next.hit(),next 是链表的下一个节点,调用 hit() 就形成了递归,不断地做下面两件事:
// 将 PointerInputEntity 链表的节点存入 values 数组
values[hitDepth] = node
// 调用下一个节点的 hit()
childHitTest()
这样一来,我们就能明确,PointerInputModifier 是按照从左至右的顺序被存储的,自然执行时也是按照从左至右的先后顺序执行的。至于执行的源码分析,暂时先不做,看看后续安排。
2、ParentDataModifier
ParentDataModifier 与 DrawModifier、PointerInputModifier 的底层存储方式相同,它是测量与布局过程中起到辅助作用的 Modifier。接下来我们会从它的作用、使用与原理三个方面来讲解这个 Modifier。
2.1 作用
我们会通过讲解 ParentDataModifier 接口的两个实现类,来说明 ParentDataModifier 的作用。
weight()
我们先来看一段代码:
@Composable
fun ParentDataModifierSample() {Row {Box(Modifier.size(40.dp).background(Color.Blue).weight(1f))Box(Modifier.size(40.dp).background(Color.Red))Box(Modifier.size(40.dp).background(Color.Green))}
}
当没有给蓝色 Box 添加 weight() 之前,红绿蓝三色 Box 是相同大小,都是 40dp。在给蓝色 Box 添加 weight() 之后,它会占满这一行剩余的空间:
说明 Compose 中的 weight() 与原生 View 体系中的 layout_weight 属性的作用是一样的。那么现在来思考,weight() 内部是使用哪一个 Modifier 实现的?看起来像是 LayoutModifier,但点进 weight() 的源码,你会先看到它是 RowScope 接口内定义的抽象函数:
@LayoutScopeMarker
@Immutable
interface RowScope {/*** 根据元素的[weight]相对于[Row]中其他带权重的兄弟元素的比例调整元素的宽度。父元素将在测量* 无权重子元素后,将水平剩余空间分配给子元素,比例由该权重决定。* 当[fill]为 true 时,元素将被强制占用分配给它的整个宽度。否则,允许元素更小 - 这将导致[Row]更小,* 因为未使用的分配宽度将不会重新分配给其他兄弟元素。* @param weight 给予该元素的比例宽度,相对于所有带权重兄弟元素的总和。必须为正数。* @param fill 当为 true 时,元素将占用分配的整个宽度。*/@Stablefun Modifier.weight(/*@FloatRange(from = 0.0, fromInclusive = false)*/weight: Float,fill: Boolean = true): Modifier
}
找到 RowScope 唯一的实现类 RowScopeInstance,发现 weight() 内部使用的是 LayoutWeightImpl:
internal object RowScopeInstance : RowScope {@Stableoverride fun Modifier.weight(weight: Float, fill: Boolean): Modifier {require(weight > 0.0) { "invalid weight $weight; must be greater than zero" }return this.then(LayoutWeightImpl(weight = weight,fill = fill,inspectorInfo = debugInspectorInfo {name = "weight"value = weightproperties["weight"] = weightproperties["fill"] = fill}))}
}
而 LayoutWeightImpl 实现的是 ParentDataModifier 而不是我们预计的 LayoutModifier:
internal class LayoutWeightImpl(val weight: Float,val fill: Boolean,inspectorInfo: InspectorInfo.() -> Unit
) : ParentDataModifier, InspectorValueInfo(inspectorInfo)
并且 ParentDataModifier 不是 LayoutModifier 的子接口,而是一个直接继承自 Modifier.Element 的独立接口:
/**
* 一个修饰符,向父布局提供数据。这可以在测量和定位期间通过 IntrinsicMeasurable.parentData
* 从布局内部读取。Parent data 通常用于告知父级子布局应如何测量和定位。
*/
@JvmDefaultWithCompatibility
interface ParentDataModifier : Modifier.Element {/*** 通过 Modifier 链提供的 [parentData] 向外提供 parentData*/fun Density.modifyParentData(parentData: Any?): Any?
}
通过 ParentDataModifier 接口上的注释,你应该了解了 ParentDataModifier 的用途了,就是向父布局提供子组件的数据,以便让父布局知道子组件应该如何测量和布局。比如我们举例的 weight(),虽然是设置在 Box 上的,但这个数据是会被父布局的 Row 获取到,从而让 Row 知道应该如何对其内部的 Box 进行测量与布局。所以这个 ParentData 可以理解为给父布局使用的数据。
相比于 LayoutModifier 这种直接影响组件的测量与布局过程的 Modifier,比如例子中的 Box 使用 size(40.dp),那么 LayoutModifier 就会让该 Box 的首选尺寸是 40dp。而 ParentDataModifier 并不是直接影响其所设置的组件,而是把该组件的相关“诉求”同步给它的父组件,父组件会收集其内部所有子组件的“诉求”,根据父组件的规则来处理每一个子组件的“诉求”。因此 ParentDataModifier 是一种间接影响组件测量与布局的 Modifier。
那为什么不使用 LayoutModifier 来做这件事?因为做不了,或者说,很难做。就以我们举得 Row 中有三个 Box 的例子,假如每个 Box 都设置了各自的 weight(),那么 LayoutModifier 这种专注于某个单一组件的 Modifier,就势必要获取该组件的所有兄弟组件的 Modifier 内设置的 weight() 值,这样才能计算出总的 weight 值,再计算出自己的 weight 所占的比例,最后再根据总的宽度计算出自己的宽度值。如果这样做的话,LayoutModifier 就有两个明显越权的地方:
- 额外获取了兄弟组件的 weight 值
- 获取了父组件的宽度
这样做会使得 LayoutModifier 的功能不再单一的只针对它所修饰的组件了,并且每个 LayoutModifier 都要掌握所有兄弟组件的 weight 使得 LayoutModifier 的功能更加混乱,并且这样实现也很麻烦。而像 Compose 设计的那样,让父组件来获取 ParentData 信息并进行计算测量,不仅可以在职权内获取到自己的内部宽度属性,也可以方便地计算出每个子组件的 weight(不用每个 LayoutModifier 内都保存一份 weight 数据,只需保存一份数据即可算出所有子组件的 weight),这就是不用 LayoutModifier 而使用独立的 ParentDataModifier 的原因。
实际上,weight 属性交由父级组件计算其实是一种通用设计,原生 View 体系下的权重 layout_weight 实际上也是给父布局使用的。包括 layout_width 与 layout_height 这些 layout 开头的属性都是用于父布局测量和布局使用的。
layoutId()
再看第二个例子,Modifier.layoutId():
@Stable
fun Modifier.layoutId(layoutId: Any) = this.then(LayoutId(layoutId = layoutId,inspectorInfo = debugInspectorInfo {name = "layoutId"value = layoutId})
)
使用它可以为组件指定一个标签(tag),便于父组件在测量和布局时针对该组件做一些特殊处理。比如自定义一个 CustomLayout,在内部通过 Layout() 进行测量过程时,可以通过 Measurable 获取到这个 layoutId,然后根据规格做出相应的处理:
@Composable
fun CustomLayout(modifier: Modifier = Modifier, content: @Composable () -> Unit) {Layout(content, modifier) { measurables, constraints ->// 对每个组件进行测量 measurables.forEach { // it:Measurable// 获取 Measurable 的 layoutId 做出对应的测量when (it.layoutId) {"big" -> it.measure(constraints.xxx)"small" -> it.measure(constraints.yyy)else -> it.measure(constraints)}}// 布局的模拟代码 layout(100, 100) {...}}
}
这里为了让代码更直观一些,就对 layoutId 的值做了硬编码,实际项目中一定不会直接像 “big”、“small” 这样写的,一旦写错单词错误会很难排查。
使用时在 CustomLayout 的子组件的 modifier 上调用 layoutId():
CustomLayout(Modifier.size(40.dp)) {Text("Jetpack", Modifier.layoutId("big"))Text("Compose", Modifier.layoutId("small"))
}
这样父布局 CustomLayout 在测量时,就会根据 Text 指定的 layoutId 内容做出不同的测量动作。这个例子也能看出,layoutId 背后的 ParentDataModifier 对父布局的测量起到了辅助作用。
综上,我们通过 weight() 与 layoutId() 两个例子,说明了 ParentDataModifier 是一个辅助测量与布局过程的 Modifier。
2.2 用法
本节介绍如果想自定义一个 ParentDataModifier 应该怎么写。
基本用法
根据以往的经验,最直接的使用方式就是在 Modifier 链中,用 then() 连接一个 ParentDataModifier 的实现类的对象即可:
Row {Box(Modifier.size(40.dp).background(Color.Red).then(object : ParentDataModifier {override fun Density.modifyParentData(parentData: Any?): Any? {TODO("Not yet implemented")}}))
}
但是这样的话,在实现 modifyParentData() 的具体内容时,你需要去查阅 Box 的父组件 Row 的内部都用到了哪些 parentData,这是一件很麻烦的事情。因此,这种根据“以往经验”推测出的使用方法是错误的。
那正确的使用方法是什么呢?参考现成的 weight() 即可:
internal object RowScopeInstance : RowScope {@Stableoverride fun Modifier.weight(weight: Float, fill: Boolean): Modifier {require(weight > 0.0) { "invalid weight $weight; must be greater than zero" }return this.then(LayoutWeightImpl(weight = weight,fill = fill,inspectorInfo = debugInspectorInfo {name = "weight"value = weightproperties["weight"] = weightproperties["fill"] = fill}))}
}
weight() 是父组件 RowScope 提供给子组件使用的 Modifier,因此子组件在使用 ParentDataModifier 时,直接调用 weight() 即可,没必要写成 then() + 匿名实现对象的形式。因为父组件能供你使用的 ParentDataModifier 已经通过weight() 这样的便捷函数给你了,如果你想增加某种父组件没提供的 ParentDataModifier 功能,只通过写一个匿名子类用 then() 连接上是没用的,因为父组件内部没有对于该 ParentDataModifier 实现类的相应处理,因此相当于你白写了。
上面说的是,如何使用父组件提供的已经实现好的 ParentDataModifier 的功能。假如,你想自定义一个提供 ParentDataModifier 功能的父组件,应该做如下三件事:
- 组件内部要使用 Layout(),因为只有使用它才能通过它的第三个参数 MeasurePolicy 接口内 measure() 的参数获得
measurables: List<Measurable>
参数,进而遍历 measurables 进行测量 - 在 Layout() 内遍历 measurables 时,去拿到并使用每一个子组件提供的 ParentDataModifier 信息
- 在组件内写一个提供 ParentDataModifier 的函数,类似 weight() 与 layoutId() 那样,方便开发者使用
基于以上三点,可以写个例子:
@Composable
fun CustomLayout(modifier: Modifier = Modifier, content: @Composable () -> Unit) {Layout(content, modifier) { measurables, constraints ->measurables.forEach {// 这个强转的类型需要根据你的业务需求做相应的转换,这里只是举例转成 Stringval data = it.parentData as? String... // 自定义布局的后续代码}layout(100, 100) {// 布局代码...}}
}
通过 forEach 遍历 measurables,Measurable 的 parentData 就是该组件获取到的 ParentDataModifier 数据,类型是 Any?,因此在实现具体的自定义 Composable 函数时,需要将它强转为你所需要的类型,比如上面的 String。转换时需要注意使用 as? 而不是 as,因为并不是所有的组件都会设置 ParentDataModifier,对于这样的组件,它的 parentData 就为空,如果使用 as 强转会引发 NPE。
以上就完成了前两点,最后一点可以直接实现一个 ParentDataModifier 填入 then() 中即可:
fun Modifier.stringData() = then(object : ParentDataModifier {override fun Density.modifyParentData(parentData: Any?): Any? {// 带上右侧 ParentDataModifier 的 parentData 数据return "Compose: $parentData"}
})
modifyParentData() 的参数 parentData 是 Modifier 链上位于当前 Modifier 右侧的 ParentDataModifier 提供的 parentData 数据,之所以出现在参数上,是为了不遗弃右侧 ParentDataModifier 的数据,融合在一起使用的。当然,它提供给你的初衷是希望你不要遗弃数据,但你也需要结合实际应用场景,比如对于 weight() 这样的修饰符,如果连用了两个:
Modifier.weight(1f).weight(2f)
这种情况你对它做融合就没有任何意义,直接用后处理的 1f 覆盖先处理的 2f 即可:
// weight() 内 LayoutWeightImpl 的实现
override fun Density.modifyParentData(parentData: Any?) =((parentData as? RowColumnParentData) ?: RowColumnParentData()).also {it.weight = weightit.fill = fill}
由于我们还没有到自定义布局的部分,所以现在举的例子只是为了说明 ParentDataModifier 的使用方式,完整的使用示例,会在后续介绍自定义布局时展示。
多 ParentDataModifier 的处理
以上我们说的是子组件只使用了一种 ParentDataModifier 的情况,假如像下面这样,子组件使用了一个以上的 ParentDataModifier:
setContent {CustomLayout(Modifier.size(40.dp)) {Text("Jetpack",Modifier.weightData(1f).bigData(true))}
}fun Modifier.bigData(big: Boolean) = then(object : ParentDataModifier {override fun Density.modifyParentData(parentData: Any?): Any? {return big}
})fun Modifier.weightData(weight: Float) = then(object : ParentDataModifier {override fun Density.modifyParentData(parentData: Any?): Any? {return weight}
})
那么 CustomLayout 内获取 parentData 数据的方式就需要重新考量:
@Composable
fun CustomLayout(modifier: Modifier = Modifier, content: @Composable () -> Unit) {Layout(content, modifier) { measurables, constraints ->measurables.forEach {val data = it.parentData as? Float... // 自定义布局的后续代码}layout(100, 100) {// 布局代码...}}
}
it.parentData 只能拿到 Modifier 链靠左侧的那个 ParentDataModifier 提供的 parentData 数据,也就是上例中 weightData() 的 Float 数据,但是没法拿到右侧 bigData() 的 Boolean 数据。这时候需要创建一个综合数据类承载这两个维度的 ParentDataModifier 的数据:
class LayoutData(var weight: Float = 0f, var big: Boolean = false)
bigData() 与 weightData() 在实现 modifyParentData() 时,结果也要融合到 LayoutData 中:
fun Modifier.bigData(big: Boolean) = then(object : ParentDataModifier {override fun Density.modifyParentData(parentData: Any?): Any? {// 如果 parentData 说明 bigData() 在最右侧,就创建 LayoutData 填入 big 属性,// 否则就在右侧传入的现有的 LayoutData 中添加 big 属性return if (parentData == null) {LayoutData(big = big)} else {(parentData as LayoutData).apply { this.big = big }}}
})fun Modifier.weightData(weight: Float) = then(object : ParentDataModifier {override fun Density.modifyParentData(parentData: Any?): Any? {// 如果 parentData 为空就创建 LayoutData,否则就沿用参数传入的 LayoutData,添加 weight 属性return ((parentData as? LayoutData) ?: LayoutData()).also { it.weight = weight }}
})
CustomLayout 中获取到的 parentData 就可以转成 LayoutData,再分开对 big 与 weight 属性进行处理:
@Composable
fun CustomLayout(modifier: Modifier = Modifier, content: @Composable () -> Unit) {Layout(content, modifier) { measurables, constraints ->measurables.forEach {val data = it.parentData as? LayoutDataval big = data.bigval weight = data.weight... // 自定义布局的后续代码}layout(100, 100) {// 布局代码...}}
}
解决 API 污染问题
上一小节创建出的 bigData() 与 weightData() 是给 CustomLayout 这个组件使用的,这两个函数只有在 CustomLayout() 的内部被调用才有意义,因此它不应该“随处可用”,只能在 CustomLayout 的环境中使用。
但按照当前代码,在 CustomLayout 之外,设置 Modifier 时会出现 bigData() 与 weightData() 的代码提示,并且可以在外部使用:
这就产生了 API 污染的问题。解决问题的方案可以参考 Row 中的 weight(),该函数是做了抗污染的。
首先,weight() 是被定义在 RowScope 接口中的:
@LayoutScopeMarker
@Immutable
interface RowScope {@Stablefun Modifier.weight(/*@FloatRange(from = 0.0, fromInclusive = false)*/weight: Float,fill: Boolean = true): Modifier
}
这样一来,就只有 RowScope 的实现类对象,或者在 RowScope 的环境下才能调用 weight()。而 Row 这个 Composable 函数的最后一个参数 content 指定了函数的接收者类型为 RowScope,因此可以在 Row 的尾随 lambda 表达式中使用 weight():
@Composable
inline fun Row(modifier: Modifier = Modifier,horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,verticalAlignment: Alignment.Vertical = Alignment.Top,content: @Composable RowScope.() -> Unit
)
这样我们效仿上面的做法应用到 CustomLayout 上即可。
首先创建一个 CustomLayoutScope 接口:
@LayoutScopeMarker
@Immutable
interface CustomLayoutScope {fun Modifier.bigData(big: Boolean): Modifierfun Modifier.weightData(weight: Float): Modifier
}
@LayoutScopeMarker 注解通常有两个作用:
- 限制 API 的可见性,被标记的接口或类的扩展函数与成员函数只在特定作用域内可见并调用
- DSL 作用域隔离,被标记的函数只能在特定代码块内访问
我们这里用到的是第一种,只能在直接的特定作用域内使用,在作用域之外不可见,在作用域的间接(嵌套的)内部不可调用。
@Immutable 注解可以减少重组过程中的不必要重组,一般用在接口上面。
然后实现 CustomLayoutScope 接口,把两个接口函数的具体实现拿进来:
// 使用 internal 限制 CustomLayoutScopeInstance 只在当前模块内获取
internal object CustomLayoutScopeInstance : CustomLayoutScope {override fun Modifier.bigData(big: Boolean) = then(object : ParentDataModifier {override fun Density.modifyParentData(parentData: Any?): Any? {// 如果 parentData 说明 bigData() 在最右侧,就创建 LayoutData 填入 big 属性,// 否则就在右侧传入的现有的 LayoutData 中添加 big 属性return if (parentData == null) {LayoutData(big = big)} else {(parentData as LayoutData).apply { this.big = big }}}})override fun Modifier.weightData(weight: Float) = then(object : ParentDataModifier {override fun Density.modifyParentData(parentData: Any?): Any? {// 如果 parentData 为空就创建 LayoutData,否则就沿用参数传入的 LayoutData,添加 weight 属性return ((parentData as? LayoutData) ?: LayoutData()).also { it.weight = weight }}})
}
最后在 CustomLayout() 中给它的 content 参数加上 CustomLayoutScope 类型的接收者:
@Composable
fun CustomLayout(modifier: Modifier = Modifier,content: @Composable CustomLayoutScope.() -> Unit
) {// Layout() 的第一个参数要的是 () -> Unit,因此要修改为下面的形式Layout({ CustomLayoutScopeInstance.content() }, modifier) { measurables, constraints ->measurables.forEach {val data = it.parentData as? LayoutData...}layout(100, 100) {// 布局代码...}}
}
这样一来,就只能在 CustomLayout 的 content 的直接内部使用 CustomLayoutScope 内的函数了:
setContent {// Cannot access 'CustomLayoutScopeInstance': it is internal in // 'com.jetpack.compose.scope'Modifier.weightData(1f)CustomLayout {// 这个位置可用Text("Jetpack", Modifier.weightData(1f))Box(Modifier.size(20.dp).background(Color.Red)) {// 'fun Modifier.weightData(weight: Float): Modifier' can't be called in this // context by implicit receiver. Use the explicit one if necessaryText("Compose", Modifier.weightData(2f))}}
}
上面演示了三处使用 weightData() 的代码,只有第二处是可以使用的:
- 示例代码在 CustomLayoutScopeInstance 所定义的模块之外,由于 CustomLayoutScopeInstance 是 internal 的,模块之外访问不到它,也就无法进一步使用它的 weightData() 了。实际上不能在 CustomLayoutScope 之外使用其内部的接口函数,是通过限定 CustomLayoutScope 的实现类的可见性(比如 RowScope 与 CustomLayoutScope 都是限定其在模块内可见)导致使用者拿不到 CustomLayoutScope 的实现类对象实现编译报错的
- 可以正常使用,因为 weightData() 是 CustomLayoutScope 的接口函数,且 CustomLayout 的尾随 lambda 函数提供了 CustomLayoutScope 这个接收者,因此在尾随 lambda 的直接内部可以使用 weightData()
- 在 CustomLayout 内又嵌套了一个 Box,在 Box 的直接内部,CustomLayout 的间接内部不能使用 weightData(),CustomLayoutScope 接口上用 @LayoutScopeMarker 做了限制,使得只能在 CustomLayout 的直接内部使用,不能穿透到 Box 这样的嵌套的内部。如果没加,就可以穿透到嵌套的内部
当然,你也可以直接用 object 来做这个 Scope 省去实现接口的麻烦:
@LayoutScopeMarker
object CustomLayoutScope {fun Modifier.bigData(big: Boolean) = then(object : ParentDataModifier {override fun Density.modifyParentData(parentData: Any?): Any? {// 如果 parentData 说明 bigData() 在最右侧,就创建 LayoutData 填入 big 属性,// 否则就在右侧传入的现有的 LayoutData 中添加 big 属性return if (parentData == null) {LayoutData(big = big)} else {(parentData as LayoutData).apply { this.big = big }}}})fun Modifier.weightData(weight: Float) = then(object : ParentDataModifier {override fun Density.modifyParentData(parentData: Any?): Any? {// 如果 parentData 为空就创建 LayoutData,否则就沿用参数传入的 LayoutData,添加 weight 属性return ((parentData as? LayoutData) ?: LayoutData()).also { it.weight = weight }}})
}
2.3 实现原理
所有的 Modifier 第一步都是在 LayoutNode 处理的,并且 ParentDataModifier 与 DrawModifier、PointerInputModifier 一样,都是先被添加到它右侧距离它最近的 LayoutNodeWrapper 的 entities 这个数组的对应类型的链表中:
@kotlin.jvm.JvmInline
internal value class EntityList(val entities: Array<LayoutNodeEntity<*, *>?> = arrayOfNulls(TypeCount)
) {fun addBeforeLayoutModifier(layoutNodeWrapper: LayoutNodeWrapper, modifier: Modifier) {if (modifier is DrawModifier) {add(DrawEntity(layoutNodeWrapper, modifier), DrawEntityType.index)}if (modifier is PointerInputModifier) {add(PointerInputEntity(layoutNodeWrapper, modifier), PointerInputEntityType.index)}if (modifier is SemanticsModifier) {add(SemanticsEntity(layoutNodeWrapper, modifier), SemanticsEntityType.index)}// 注意 ParentDataModifier 添加的链表类型是 SimpleEntityif (modifier is ParentDataModifier) {add(SimpleEntity(layoutNodeWrapper, modifier), ParentDataEntityType.index)}}
}
与其他几个类型稍有不同的是,ParentDataModifier 对应的链表类型为 SimpleEntity,不像其他三种都是 XxxModifier 对应 XxxEntitity。
然后我们来看一下,ParentDataModifier 提供的数据 parentData 是被如何使用的。前面我们讲 CustomLayout 这个示例时,是通过 Layout() 参数的 MeasurePolicy 接口的唯一抽象函数 measure() 的参数拿到 measurables: List<Measurable>
,然后遍历 measurables 通过 Measurable 拿到 parentData 这个数据。
实际上 parentData 是 IntrinsicMeasurable 接口中的属性:
interface IntrinsicMeasurable {/*** Data provided by the [ParentDataModifier].*/val parentData: Any?...
}
LayoutNodeWrapper 实现的是 IntrinsicMeasurable 的子接口 Measurable,因此 LayoutNodeWrapper 的内部有 parentData 的实现:
override val parentData: Any?get() = entities.head(EntityList.ParentDataEntityType).parentDataprivate val SimpleEntity<ParentDataModifier>?.parentData: Any?get() = if (this == null) {wrapped?.parentData} else {with(modifier) {/*** ParentData provided through the parentData node will override the data provided* through a modifier.*/measureScope.modifyParentData(next.parentData)}}
对接口内 parentData 属性的实现就是 SimpleEntity 的链表头中的 parentData,而链表头的 parentData 是 SimpleEntity 的扩展属性,它根据 SimpleEntity 是否为空,有两种处理方式:
- 当 SimpleEntity 为空时,返回 wrapped 的 parentData。前面已经说过很多次 LayoutNodeWrapper 的 wrapped 就是它内部包含的另一个 LayoutNodeWrapper,因此
wrapped?.parentData
就是返回其内部包含的 LayoutNodeWrapper 的 parentData。意思就是当前 LayoutNodeWrapper 内的 SimpleEntity 链表已经遍历完尾节点或者链表干脆就是空的,该去遍历下一个 LayoutNodeWrapper 的 SimpleEntity 链表了 - 当 SimpleEntity 不为空时,调用该 SimpleEntity 内封装的 modifier 的 modifyParentData() 获取 parentData,该函数参数上的 next.parentData 是 SimpleEntity 链表的下一个节点的 parentData 属性,这里就形成了属性的递归调用,即当前节点在调用 modifyParentData() 前,需要先获取下一个节点的 parentData,而获取下一个节点的 parentData 时,假如下一个节点不为空则继续调用下下一个节点的 get()(如为空则去获取下一个 LayoutNodeWrapper 内的 parentData)
举一个例子帮助理解,假如有如下形式的 Modifier 链:
Modifier.then(ParentDataModifier1).then(ParentDataModifier2).then(LayoutModifier1).then(ParentDataModifier3).then(ParentDataModifier4).then(LayoutModifier2).then(ParentDataModifier5).then(ParentDataModifier6)
那么最外层的 LayoutNodeWrapper 的结构应该是:
ModifiedLayoutNode(entities = [null,null,null,ParentDataModifier1 -> ParentDataModifier2,null,null,null],modifier = LayoutModifier1,wrapped = ModifiedLayoutNode(entities = [null,null,null,ParentDataModifier3 -> ParentDataModifier4,null,null,null],modifier = LayoutModifier2,wrapped = InnerPlaceable(entities = [null,null,null,ParentDataModifier5 -> ParentDataModifier6,null,null,null]))
)
当访问外层 LayoutNodeWrapper 的 parentData 属性时,它会取 entities 数组中索引为 3 的链表 SimpleEntity<ParentDataModifier>
的表头,再访问表头的 parentData 属性。
访问表头 parentData 属性的过程是一个递归过程,递归访问下一个 SimpleEntity<ParentDataModifier>
节点的 parentData 属性,如果到了链表尾部就或链表本身就是空的,就访问当前 ModifiedLayoutNode 的 wrapped 包装的下一个 ModifiedLayoutNode,这样一直递归下去直到 InnerPlaceable 的 SimpleEntity<ParentDataModifier>
链表的尾部。
递归返回时带着下一个节点的 parentData 作为参数调用 modifyParentData(),也就是 ParentDataModifier 的接口函数,在这个函数中对下一个节点获取到的 parentData 作为参数进行数据融合,返回的结果将作为它上一个节点的 modifyParentData() 的参数,直到最外层 ModifiedLayoutNode 的 parentData 得出一个计算结果。
2.4 总结
对于 ParentDataModifier 可以总结以下几点:
- 作用:ParentDataModifier 用于给子组件附加一些属性,让父组件可以利用
- 用法:只有在通过 Layout() 写自定义函数时才会用到 ParentDataModifier,在测量与布局的算法中通过 Measurable 的 parentData 属性拿到这个数据后根据具体需求使用即可,最后要提供一个 Modifier 函数实现 ParentDataModifier,在 modifyParentData() 中根据需求提供附加数据
- 在同一个组件上交换调用 ParentDataModifier 函数的位置,UI 显示效果不会发生变化,因为使用 ParentDataModifier 提供的数据的是该组件的父组件
3、SemanticsModifier
SemanticsModifier 用于提供 SemanticsTree —— 语义树。本节将解释什么是语义树,SemanticsModifier 的使用与原理。
3.1 语义树
在 Jetpack Compose 中,语义树(Semantics Tree) 是组合树(Composition Tree)的“增强版”,用于描述 UI 元素的含义和交互属性。
语义树是对组合树进行修剪(对节点进行删除或合并)简化为实际有意义的节点,实际有意义是指可以供用户查看和操作的独立组件。比如一个 LinearLayout 在用户的角度看,它不像一个图片那样可以查看或一个按钮那样可以点击,它只是负责布局,对用户而言不可见也没有意义,不被用户关注,这也可被称为没有语义。同理,Compose 中的 Column 如果本身没有设置监听器,那么它所创建出的 LayoutNode 也就是无语义的,这样的节点会被合并或删除,最终形成一个有语义的树,也就是 Semantics Tree —— 语义树。
语义树的核心作用是为无障碍服务(如 TalkBack)、自动化测试框架(如 Espresso)和 UI 分析工具提供结构化信息。
在进行传统的 UI 开发时,一些组件,比如 ImageView、TextView 等,都会有一个 contentDescription 属性,这个属性就是在开启 TalkBack 功能后,视障用户点击到某个组件后,会选中该组件(可以被选中的组件就是语义树中的节点)并以语音方式读出 contentDescription 设置的内容,以帮助视障用户方便地使用 Android 设备。
3.2 基本使用
setContent {Column {Text("Jetpack Compose")Box(Modifier.width(100.dp).height(60.dp).background(Color.Magenta))}
}
上面是一个 Text 和一个 Box,开启 TalkBack 功能后,Text 组件是可被选中的,选中时语音会读出 Text 的文字内容,但 Box 不可被选中:

现在用 semantics() 为 Box 增加 contentDescription 属性:
setContent {Column {Text("Jetpack Compose")Box(Modifier.width(100.dp).height(60.dp).background(Color.Magenta).semantics {contentDescription = "品红色方块"})}
}
这样 Box 也可以在点击时被选中了,并且 TalkBack 会读出 contentDescription 设置的内容:

semantics() 有两个参数:
/**
* 向布局节点添加语义键值对(key/value pairs),用于测试、无障碍服务等场景。
* 在提供的 lambda 接收者作用域(SemanticsPropertyReceiver)中,可以通过 key = value 的形式
* 为任何 SemanticsPropertyKey 赋值。此外,也支持链式调用多个 semantics 修饰符的写法。
* 最终会生成两棵语义树:
* 未合并的树(Unmerged Tree): 根节点为 SemanticsOwner.unmergedRootSemanticsNode,每个带有
* SemanticsModifier 的布局节点会生成一个对应的 SemanticsNode,该 SemanticsNode 包含该节点上
* 所有 SemanticsModifier 设置的属性
* 合并的树(Merged Tree):根节点为 SemanticsOwner.rootSemanticsNode,节点数量更少
*(基于 mergeDescendants 和 clearAndSetSemantics 进行简化)。大多数场景(尤其是无障
* 碍服务或无障碍测试)应使用合并后的语义树。
*
* 参数说明:
* mergeDescendants(合并子节点语义):是否将当前组件及其子组件的语义信息合并为一个逻辑实体。
* 通常用于可被屏幕阅读器聚焦的组件(如按钮、表单字段)。在合并树中:所有子节点(除非子节点自身也标记了
* mergeDescendants)会从树中移除。子节点的属性会通过特定合并算法合并到父节点(例如,文本属性用逗号拼接)。
* 在未合并树中:仅标记 SemanticsConfiguration.isMergingSemanticsOfDescendants。
* properties(语义属性):通过 SemanticsPropertyReceiver 作用域添加语义属性,支持访问常用属性及
* 其值(如 contentDescription、role 等)。
*/
fun Modifier.semantics(mergeDescendants: Boolean = false,properties: (SemanticsPropertyReceiver.() -> Unit)
): Modifier
我们需要了解第一个参数 mergeDescendants 的含义与用法。从名字上能看出,它表示是否合并后代,也就是它的子组件。我们来看个例子:
Button(onClick = { /*TODO*/ }) {Text("测试文字")
}
上面这个按钮,在开启 TalkBack 点击它后,会语音播放“测试文字,按钮”,它会把 Text 内的文字作为按钮内容被读出,并且你想点击到 Text 上也不行,TalkBack 的选中框只能选中 Button:
发生这样情况的原因是,对于 Button 这种可点击的组件,Compose 在底层实现时,会自动给该组件调用 Modifier.semantics() 并给第一个参数传 true 使得其子组件被合并到该组件中。那假如我先在就想选中 Button 中的 Text,只读取 Text 的内容,那么可以给 Text 设置 Modifier.semantics(true):
Button(onClick = { /*TODO*/ }) {Text("测试文字", Modifier.semantics(true) { })
}
这样 TalkBack 就可以选中 Text 并读出它的内容了:
与 semantics() 类似的还有一个 clearAndSetSemantics(),它会清除当前组件的所有后代节点的语义,并设置新的语义。比如说:
setContent {Box(Modifier.width(100.dp).background(Color.Magenta).semantics(true) {contentDescription = "Jetpack"}) {Text("Compose")}
}
当前,点击按钮,TalkBack 会读出 “Jetpack Compose”,而不设置 semantics() 的第一个参数为 true 时,即使用默认的 false 时,Box 与 Text 可以分开点击,TalkBack 会读出它们各自的内容,这是我们讲 semantics() 时已经说过的。
现在,将 semantics() 替换成 clearAndSetSemantics():
setContent {Box(Modifier.width(100.dp).background(Color.Magenta).clearAndSetSemantics {contentDescription = "Jetpack"}) {Text("Compose")}
}
那么你就只能选中按钮,无法通过点击选中 Text,并且,选中时只读出 Box 的 contentDescription 属性的内容 “Jetpack”,这意味着 Box 内的后代节点都因为 clearAndSetSemantics() 而从语义树中被移除了,现在语义树中只有 Box 节点本身,语义内容为它设置的 contentDescription 的内容,相当于后代组件的语义被父组件设置的语义吞掉了。
3.3 原理简析
我们前面在讲 PointerInputModifier 的时候,其实有提到过 SemanticsModifier,说它们两个用的是一套结构,如果 PointerInputModifier 的原理你看明白了,那就意味着 SemanticsModifier 的原理也基本拿下了。
还是从两个角度来分析:SemanticsModifier 的底层是如何存储的,以及取出 SemanticsModifier 后如何工作的。
底层的存储又要再看一遍 EntityList 的 addBeforeLayoutModifier():
fun addBeforeLayoutModifier(layoutNodeWrapper: LayoutNodeWrapper, modifier: Modifier) {if (modifier is DrawModifier) {add(DrawEntity(layoutNodeWrapper, modifier), DrawEntityType.index)}if (modifier is PointerInputModifier) {add(PointerInputEntity(layoutNodeWrapper, modifier), PointerInputEntityType.index)}if (modifier is SemanticsModifier) {add(SemanticsEntity(layoutNodeWrapper, modifier), SemanticsEntityType.index)}if (modifier is ParentDataModifier) {add(SimpleEntity(layoutNodeWrapper, modifier), ParentDataEntityType.index)}}
就是将 SemanticsModifier 封装进 SemanticsEntity,然后把 SemanticsEntity 存入到 EntityList 的成员属性 entities 数组的 SemanticsEntity 链表的头部。
使用是在 LayoutNodeWrapper 的 SemanticsSource 中:
val SemanticsSource =object : HitTestSource<SemanticsEntity, SemanticsEntity, SemanticsModifier> {override fun entityType() = EntityList.SemanticsEntityTypeoverride fun contentFrom(entity: SemanticsEntity) = entityoverride fun interceptOutOfBoundsChildEvents(entity: SemanticsEntity) = falseoverride fun shouldHitTestChildren(parentLayoutNode: LayoutNode) =parentLayoutNode.outerSemantics?.collapsedSemanticsConfiguration()?.isClearingSemantics != trueoverride fun childHitTest(layoutNode: LayoutNode,pointerPosition: Offset,hitTestResult: HitTestResult<SemanticsEntity>,isTouchEvent: Boolean,isInLayer: Boolean) = layoutNode.hitTestSemantics(pointerPosition,hitTestResult,isTouchEvent,isInLayer)}
SemanticsSource 与 PointerInputSource 都用了 HitTestSource,也就是 HitTest 进行触摸点测试,通过触摸的点判断触摸到的是哪一个组件。
SemanticsSource 在 LayoutNode 的 hitTestSemantics() 被使用:
@Suppress("UNUSED_PARAMETER")internal fun hitTestSemantics(pointerPosition: Offset,hitSemanticsEntities: HitTestResult<SemanticsEntity>,isTouchEvent: Boolean = true,isInLayer: Boolean = true) {val positionInWrapped = outerLayoutNodeWrapper.fromParentPosition(pointerPosition)// hitTest() 用于判断摸到了哪一个组件outerLayoutNodeWrapper.hitTest(LayoutNodeWrapper.SemanticsSource,positionInWrapped,hitSemanticsEntities,isTouchEvent = true,isInLayer = isInLayer)}
再向上,AndroidComposeViewAccessibilityDelegateCompat 的 hitTestSemanticsAt() 用到了 hitTestSemantics():
@OptIn(ExperimentalComposeUiApi::class)@VisibleForTestinginternal fun hitTestSemanticsAt(x: Float, y: Float): Int {view.measureAndLayout()val hitSemanticsEntities = HitTestResult<SemanticsEntity>()view.root.hitTestSemantics(pointerPosition = Offset(x, y),hitSemanticsEntities = hitSemanticsEntities)val wrapper = hitSemanticsEntities.lastOrNull()?.layoutNode?.outerSemanticsvar virtualViewId = InvalidIdif (wrapper != null) {// The node below is not added to the tree; it's a wrapper around outer semantics to// use the methods available to the SemanticsNodeval semanticsNode = SemanticsNode(wrapper, false)val wrapperToCheckAlpha = semanticsNode.findWrapperToGetBounds()// Do not 'find' invisible nodes when exploring by touch. This will prevent us from// sending events for invisible nodesif (!semanticsNode.unmergedConfig.contains(SemanticsProperties.InvisibleToUser) &&!wrapperToCheckAlpha.isTransparent()) {val androidView = view.androidViewsHandler.layoutNodeToHolder[wrapper.layoutNode]if (androidView == null) {virtualViewId = semanticsNodeIdToAccessibilityVirtualNodeId(wrapper.modifier.id)}}}return virtualViewId}
hitTestSemanticsAt() 在同一个类的 dispatchHoverEvent() 中被调用,而后者在原生的 AndroidComposeView 中被调用:
public override fun dispatchHoverEvent(event: MotionEvent): Boolean {if (hoverExitReceived) {// Go ahead and send it nowremoveCallbacks(sendHoverExitEvent)sendHoverExitEvent.run()}if (isBadMotionEvent(event) || !isAttachedToWindow) {return false // Bad MotionEvent. Don't handle it.}if (event.isFromSource(InputDevice.SOURCE_TOUCHSCREEN) &&event.getToolType(0) == MotionEvent.TOOL_TYPE_FINGER) {// Accessibility touch explorationreturn accessibilityDelegate.dispatchHoverEvent(event)}...}
dispatchHoverEvent() 主要用于处理悬浮事件的,无障碍的触摸也在这个地方进行处理。
相关文章:
Compose 实践与探索十 —— 其他预先处理的 Modifier
1、PointerInputModifier PointerInputModifier 用于定制触摸(包括手指、鼠标、悬浮)反馈算法,实现手势识别。 1.1 基本用法 最简单的使用方式就是通过 Modifier.clickable() 响应点击事件: Box(Modifier.size(40.dp).backgro…...
基于Python的天气预报数据可视化分析系统-Flask+html
开发语言:Python框架:flaskPython版本:python3.8数据库:mysql 5.7数据库工具:Navicat11开发软件:PyCharm 系统展示 系统登录 可视化界面 天气地图 天气分析 历史天气 用户管理 摘要 本文介绍了基于大数据…...
“消失的中断“
“消失的中断” 1. 前言 在嵌入式开发过程中,中断必不可少。道友们想必也经常因为中断问题头疼不已,今天来说说一个很常见的问题,“消失的中断”。最近项目在使用第三方MCAL的时候,就遇到了I2C中断丢失的问题,排查起…...
对C++面向对象的理解
C的面向对象编程(OOP)是其核心特性之一,通过类(Class)和对象(Object)实现数据和行为的封装,支持继承、多态和抽象等核心概念。以下是关键点解析: 1. 类(Class…...
代码随想录-训练营-day52
97. 小明逛公园 (kamacoder.com) #include<iostream> #include<vector> using namespace std; int main(){int n,m,u,v,w;cin>>n>>m;vector<vector<vector<int>>> grid(n1,vector<vector<int>>(n1,vector<int>(n1…...
Java File 类详解
1. 概述 File 类是 Java 提供的用于文件和目录路径名的抽象表示。它能够用于创建、删除、查询文件和目录的信息,但不用于读写文件内容。如果需要对文件进行读写,可以结合 FileReader、FileWriter、BufferedReader 等类来完成。 2. File 类的构造方法 …...
JS实现省份地级市的选择
JS实现省份地级市的选择 效果展示: 代码实现 <!DOCTYPE html> <html lang"en"> <head><meta charset"UTF-8"><meta name"viewport" content"widthdevice-width, initial-scale1.0"><ti…...
【鸿蒙开发】Hi3861学习笔记-Visual Studio Code安装(New)
00. 目录 文章目录 00. 目录01. Visual Studio Code概述02. Visual Studio Code下载03. Visual Studio Code安装04. Visual Studio Code插件05. 附录 01. Visual Studio Code概述 vscode是一种简化且高效的代码编辑器,同时支持诸如调试,任务执行和版本管…...
记录致远OA服务器硬盘升级过程
前言 日常使用中OA系统突然卡死,刷新访问进不去系统,ping服务器地址正常,立马登录服务器检查,一看磁盘爆了。 我大脑直接萎缩了,谁家OA系统配400G的空间啊,过我手的服务器没有50也是30台,还是…...
计算机网络-网络规划与设计
基本流程 需求分析—》通信规范分析—》逻辑网络设计—》物理网络设计—》实施阶段 需求分析: 确定需求,包括:业务需求、用户需求、应用需求、计算机平台需求、网络通信需求等。 产物:需求规范 通信规范分析: 现有…...
C#opencv 遍历图像中所有点 不在圆范围内的点变为黑色,在圆范围内的保持原色
C#opencv 遍历图像中所有点 不在圆范围内的点变为黑色,在圆范围内的保持原色 安装 Install-Package OpenCvSharp4 Install-Package OpenCvSharp4.Windows 普通实现 using System; using System.Collections.Generic; using System.Linq; using OpenCvSharp; // 添加OpenCV引用…...
精通游戏测试笔记(持续更新)
第一章、游戏测试的两条规则 不要恐慌 不要将这次发布当作最后一次发布 不要相信任何人 把每次发布当作最后一次发布 第二章:成为一名游戏测试工程师...
Linux内核,mmap_pgoff在mmap.c的实现
1. mmap_pgoff的系统调用实现如下 SYSCALL_DEFINE6(mmap_pgoff, unsigned long, addr, unsigned long, len,unsigned long, prot, unsigned long, flags,unsigned long, fd, unsigned long, pgoff) {return ksys_mmap_pgoff(addr, len, prot, flags, fd, pgoff); }2. ksys_mma…...
深度揭秘:蓝耘 Maas 平台如何重塑深度学习格局
目录 前言 深度学习:技术基石与发展脉络 蓝耘 Maas 平台:深度学习的强大助推器 1. 高性能算力支撑 2. 丰富的模型支持 3. 便捷的开发体验 4. 完善的安全保障 代码示例:蓝耘 Maas 平台上的深度学习实践 1. 注册与登录 2. 代码实现 …...
深入解析操作系统进程控制:从地址空间到实战应用
引言 想象这样一个场景: 你的游戏本同时运行着《赛博朋克2077》、Chrome浏览器和Discord语音 突然游戏崩溃,但其他应用依然正常运行 此时你打开任务管理器,发现游戏进程已经消失,但内存占用却未完全释放 这背后涉及的关键机制…...
网络空间安全(33)MSF漏洞利用
前言 Metasploit Framework(简称MSF)是一款功能强大的开源安全漏洞利用和测试工具,广泛应用于渗透测试中。MSF提供了丰富的漏洞利用模块,允许安全研究人员和渗透测试人员利用目标系统中的已知漏洞进行攻击。 一、漏洞利用模块&…...
《Electron 学习之旅:从入门到实践》
前言 Electron 简介 Electron 是由 GitHub 开发的一个开源框架,基于 Chromium 和 Node.js。 它允许开发者使用 Web 技术(HTML、CSS、JavaScript)构建跨平台的桌面应用程序。 Electron 的优势 跨平台:支持 Windows、macOS 和 Linux…...
通达信软件+条件选股+code
在通达信软件中,你的选股公式需要放在 "公式管理器" 的 "条件选股公式" 分类中。以下是详细操作步骤: 一、打开公式管理器 打开通达信软件,按快捷键 Ctrl + F (或点击顶部菜单栏:"公式" → "公式管理器") 二、创建新公式 选择分…...
【2025】基于springboot+vue的汽车销售试驾平台(源码、万字文档、图文修改、调试答疑)
基于 Spring Boot Vue 的汽车销售试驾平台通过整合前后端技术,实现了汽车销售和试驾预约的信息化和智能化。系统为管理员和用户提供了丰富的功能,提升了客户体验和销售效率,增强了数据分析能力,为汽车销售行业的发展提供了新的途…...
Spring Web MVC入门
一、什么是SpringMVC 首先,MVC是一种架构设计模式,也是一种思想,而SpringMVC是对MVC思想的具体实现,除此之外,SpringMVC还是一个Web框架。 总的来说,SpringMVC就是一个实现MVC模式的Web框架。 而MVC可以…...
5G核心网实训室搭建方案:轻量化部署与虚拟化实践
5G核心网实训室 随着5G技术的广泛应用,行业对于5G核心网人才的需求日益增长。高校、科研机构和企业纷纷建立5G实训室,以促进人才培养、技术创新和行业应用研究。IPLOOK凭借其在5G核心网领域的深厚积累,提供了一套高效、灵活的5G实训室搭建方…...
IMX6ULL学习整理篇——Linux驱动开发的基础2 老框架的一次实战:LED驱动
IMX6ULL学习整理篇——Linux驱动开发的基础2 老框架的一次实战:LED驱动 在上一篇博客中,我们实现了从0开始搭建的字符设备驱动框架,但是这个框架还是空中楼阁,没有应用,很难说明我们框架的正确性。这里,…...
网络空间安全(32)Kali MSF基本介绍
前言 Metasploit Framework(简称MSF)是一款功能强大的开源安全漏洞检测工具,被广泛应用于渗透测试中。它内置了数千个已知的软件漏洞,并持续更新以应对新兴的安全威胁。MSF不仅限于漏洞利用,还包括信息收集、漏洞探测和…...
零基础上手Python数据分析 (3):Python核心语法快速入门 (下) - 程序流程控制、函数与模块
写在前面 还记得上周我们学习的 Python 基本数据类型、运算符和变量吗? 掌握了这些基础知识,我们已经能够进行一些简单的数据操作了。 但是,在实际的数据分析工作中,仅仅掌握基本语法是远远不够的。 我们需要让程序能够 根据条件做出判断,重复执行某些操作,组织和复用代…...
C++【类和对象】(超详细!!!)
C【类和对象】 1.运算符重载2.赋值运算符重载3.日期类的实现 1.运算符重载 (1).C规定类类型运算符使用时,必须转换成调用运算符重载。 (2).运算符重载是具有特殊名字的函数,名字等于operator加需要使用的运算符,具有返回类型和参数列表及函数…...
Windows-PyQt5安装+PyCharm配置QtDesigner + QtUIC
个人环境 Windows 11 pycharm 2024.2 Anaconda2024.6python 3.9 1)先使用pip命令在线安装 1)pip install PyQt5 2)pip install PyQt5-tools2)配置环境变量 1:安装成功后可以在python的安装目录Lib\site-packahes目录下看到安装包。比如我的路径是E:\anaconda3…...
qq音乐 webpack 补环境
网址: aHR0cHM6Ly95LnFxLmNvbS9uL3J5cXEvcGxheWVy 1.接口分析 接口:cgi-bin/musics.fcg 参数:sign是加密的 2.代码分析 进入调用栈 先在send位置打上断点,页面刷新 往上一个栈找 可以看到上面就有一个关键词sign是从…...
【蓝桥杯】省赛:神奇闹钟
思路 python做这题很简单,灵活用datetime库即可 code import os import sys# 请在此输入您的代码 import datetimestart datetime.datetime(1970,1,1,0,0,0) for _ in range(int(input())):ls input().split()end datetime.datetime.strptime(ls[0]ls[1],&quo…...
计算机的结构形式
微机的机构形式 台式个人微机 最开始的微机(计算机)都是台式的,到目前为止仍是个人微机的主要形式。台式机按照电脑机箱的放置形式,分为卧式和立式两种。台式机需要放在桌面上或者留有专门放置机箱位置,他的主机、键…...
C语言【内存函数】详解
目录: 1. memcpy使用和模拟实现 2. memmove使用和模拟实现 3. memset函数的使用 4. memcmp函数的使用 以上函数均包含在一个头文件<string.h>里面 一、memcpy的使用和模拟实现。 memcpy函数介绍: 函数原型: void * memcpy ( void…...
软考网络安全专业
随着信息技术的迅猛发展,网络安全问题日益凸显,成为社会各界普遍关注的焦点。在这样的背景下,软考网络安全专业应运而生,为培养高素质的网络安全人才提供了有力支撑。本文将对软考网络安全专业进行深入剖析,探讨其在信…...
Altium Designer——CHIP类元器件PCB封装绘制
文章目录 PCB封装组成元素:焊盘的属性 SS34肖特基二极管SMA(DO-214AC)封装绘制资料:步骤:1.绘制焊盘:用到的快捷键:资料: 2.绘制丝印:用到的快捷键:资料: PCB封装组成元素…...
C++ unordered_map unordered_set 模拟实现
1. 关于unordered_map 和 unordered_set 区别于C的另外两个容器map和set,map和set的底层是红黑树;而unordered_map和unordered_set的底层是哈希 因为unordered_map和unordered_set的底层是哈希,因此他们存储的数据是没有顺序unordered…...
Java使用自定义类加载器实现插件动态加载
虚拟机类加载子系统 Java虚拟机的⼀个重要子系统,主要负责将类的字节码加载到JVM内存的⽅法区,并将其转换为JVM内部的数据结构。 一个类从被加载到虚拟机开始,一直到卸载出内存为止,会经历七个阶段:加载,…...
【初级篇】如何使用DeepSeek和Dify构建高效的企业级智能客服系统
在当今数字化时代,企业面临着日益增长的客户服务需求。使用Dify创建智能客服不仅能够提升客户体验,还能显著提高企业的运营效率。关于DIfy的安装部署,大家可以参考之前的文章: 【入门级篇】Dify安装+DeepSeek模型配置保姆级教程_mindie dify deepseek-CSDN博客 AI智能客服…...
Java开发之数据库应用:记一次医疗系统数据库迁移引发的异常:从MySQL到PostgreSQL的“dual“表陷阱与突围之路
记一次医疗系统数据库迁移引发的异常:从MySQL到PostgreSQL的"dual"表陷阱与突围之路 一、惊魂时刻:数据库切换引发的系统雪崩 某医疗影像系统在进行国产化改造过程中,将原MySQL数据库迁移至PostgreSQL。迁移完成后,系…...
Langchian构建代理
文章目录 概要ReAct 代理 ReAct 使用ReAct基本用法提示词模板内存使用迭代使用返回执行每一步情况限制输出行数设置运行超时时间 不使用代理下LLM如何结合工具案例案例2 概要 单靠语言模型无法采取行动 - 它们只输出文本。 LangChain 的一个重要用例是创建 代理。 代理是使用大…...
Vim软件使用技巧
目录 Demo Vim怎么看一个文件的行号,不用打开文件的前提下?进入文件后怎么跳转到某一行? 不打开文件查看行号(查看文件的方法) 方法1、使用命令行工具统计行数 方法2、通过vim的 - 参数查看文件信息 进入文件后跳转到指定行…...
SQL与NoSQL的区别
以下是SQL与NoSQL数据库的详细对比,涵盖核心特性、适用场景及技术选型建议: 一、核心区别对比 特性SQL(关系型数据库)NoSQL(非关系型数据库)数据模型基于表格,严格预定义模式(Schem…...
1191:流感传染--BFS
这里写目录标题 题目 解析代码BFS代码 题目 解析 在同一天对一个病原体进行处理时,如果直接更改数组,将直接影响到后续的遍历 方法一:那么我们可以定义一个数组用来存储坐标:vectoir<pair<int,int>>,遍历…...
gfortran编译器调试功能选项
在使用 gfortran 编译器进行调试时,以下选项可以帮助你更好地定位和解决问题: 1. 生成调试信息 -g:生成调试信息,供调试器(如 gdb)使用。-ggdb:生成更详细的调试信息,优化 gdb 的使…...
小程序配置
注册小程序账号和安装开发工具 参考文档:注册小程序账号和安装开发工具https://blog.csdn.net/aystl_gss/article/details/127878658 HBuilder新建项目 填写项目名称,选择UNI-APP,修改路径,点击创建 manifest.json 配置 需要分别…...
【Linux】进程(1)进程概念和进程状态
🌟🌟作者主页:ephemerals__ 🌟🌟所属专栏:Linux 目录 前言 一、什么是进程 二、task_struct的内容 三、Linux下进程基本操作 四、父进程和子进程 1. 用fork函数创建子进程 五、进程状态 1. 三种重…...
MySQL(事物下)
目录 一 多版本并发控制( MVCC )是一种用来解决 读-写冲突 的无锁并发控制 1. 前置知识 示例: 二 Read View 1. 当事物进行快照读(读历史数据)会MySQL会创建一个Read Vidw类对象,用来记录和当前一起并发的事物(活跃的事物)&a…...
Springboot+mybatis实现增删改查效果
我们前面实现了增加效果,现在来写一下修改~我们首先在controller里面写update 接着在service和mapper写方法 最后我们测试一下 没问题~需要注意的是mapper的sql别写错了!...
【“以退为进“、“不得已而为之“与“风险对冲“的协同机制】
深度解析:“以退为进”、"不得已而为之"与"风险对冲"的协同机制 一、“以退为进”:空间重构的博弈艺术 1. 三维战略坐标系 权力维度:唐太宗"玄武门之变"后跪哭李渊,通过降维姿态化解道德危机&am…...
AUTOSAR 网络安全 架构
实现AUTOSAR网络安全架构的步骤指南 在当今汽车电子系统中,AUTOSAR(AUTomotive Open System ARchitecture)正在成为业界标准。结合网络安全要求,我们可以确保汽车在网络通信中保持安全。接下来,我们将讨论如何实现AUT…...
洛谷 P2801 教主的魔法 题解
之前学过 莫队 算法,其运用了分块思想;但是我居然是第一次写纯种的分块题目。 题意 给你一个长度为 n n n 的序列 a a a(一开始 ∀ a i ∈ [ 1 , 1000 ] \forall a_i\in[1,1000] ∀ai∈[1,1000])。要求执行 q q q 次操作&…...
Google最新生图模型Gemini-2.0-Flash-Exp免费用
Google发布新生图模型 Google释放出最新生图模型,在发布说明中提到: 2025年3月12日 在 Gemini-2.0-Flash-Exp 中发布原生图像输出功能 Gemini 2.0 Flash Experimental 模型发布,支持原生图像输出功能。开发者能够使用 Gemini 进行图像输出和…...
windows安装Elasticsearch
下载 下载最新版 https://www.elastic.co/downloads/elasticsearch 下载历史版本 安装 进入bin目录中 成功启动 访问 http://localhost:9200...