深入浅出Compose HitTest 机制

0 阅读16分钟

本章是深入浅出Compose系列的第二篇,由于最近工作太忙了直到春节假期最后一天才有时间更新一下博客,本章我们将目光聚焦在Compose框架对于事件交互的处理

UI 框架中的HitTest

在UI框架中,事件的传递是非常重要的事情,它决定了UI元素的交互部分。

在Android中View之间的事件传递部分中,View的dispatchTouchEvent,onTouchEvent 是两个关键的事件传递方法,常常用于ViewGroup与View之间的事件传递以及消费,在Android View体系下,事件的传递模型是以一个View为整体纬度的,相当于事件属于View的一部份。

而在Compose中,事件传递变得更加简单,Compose把事件的传递抽象为一个节点,它不归属于任何一个View,它只属于一个模块部分,这个节点就叫做PointerInput,属于Compose中对于功能抽象中NodeKind中的一类,因此对于常见的点击来说,我们只关注PointerInput节点就可以掌握事件派发的整体流程了

internal object Nodes {
    .... 
    @JvmStatic
    inline val PointerInput
        get() = NodeKind<PointerInputModifierNode>(0b1 shl 4)

对于UI框架来说,事件派发有三个是值得我们关注的,它们分别是:

  1. 事件的消费顺序:事件是怎么进行上到下之前的传递,以及怎么被最终消费
  2. 具备TRS 的事件判断:即物体变换情况下,即具备TRS = 平移 - 旋转 - 缩放 下的事件是如何进行判断的,UI框架怎么知道点击点在哪?
  3. 不规则区域的判断: 不规则区域下,事件是如何被处理的
image.png

接下来我们会围绕着这三部分,来介绍一些Compose这个UI框架是如何进行事件的传递以及处理

事件的消费顺序

在Compose中,PointerInputModifierNode就是一个NodeKind为PointerInput 的节点,它是一个点击行为的最小例子的参与单元,我们有一个用的非常多的Modifier,clickable,clickable它其实就是一个PointerInputModifierNode

fun Modifier.clickable(
    enabled: Boolean = true,
    onClickLabel: String? = null,
    role: Role? = null,
    interactionSource: MutableInteractionSource? = null,
    onClick: () -> Unit,
): Modifier {
    return this.then(
        ClickableElement(
            interactionSource = interactionSource,
            indicationNodeFactory = null,
            useLocalIndication = true,
            enabled = enabled,
            onClickLabel = onClickLabel,
            role = role,
            onClick = onClick,
        )
    )
}
internal open class ClickableNode(
    interactionSource: MutableInteractionSource?,
    indicationNodeFactory: IndicationNodeFactory?,
    useLocalIndication: Boolean,
    enabled: Boolean,
    onClickLabel: String?,
    role: Role?,
    onClick: () -> Unit,
) :
    AbstractClickableNode(
        interactionSource = interactionSource,
        indicationNodeFactory = indicationNodeFactory,
        useLocalIndication = useLocalIndication,
        enabled = enabled,
        onClickLabel = onClickLabel,
        role = role,
        onClick = onClick,
    ) 
internal abstract class AbstractClickableNode(
    private var interactionSource: MutableInteractionSource?,
    private var indicationNodeFactory: IndicationNodeFactory?,
    private var useLocalIndication: Boolean,
    enabled: Boolean,
    private var onClickLabel: String?,
    private var role: Role?,
    onClick: () -> Unit,
) :
    DelegatingNode(),
    PointerInputModifierNode,
    KeyInputModifierNode,
    SemanticsModifierNode,
    TraversableNode,
    CompositionLocalConsumerModifierNode,
    ObserverModifierNode,
    IndirectPointerInputModifierNode 

事件的传递其实就是在PointerInputModifierNode 与PointerInputModifierNode之前进行的,在进行下一步分析之前,我们可以看一下Compose是怎么介入事件的产生的。在进入Compose中,AndroidComposeView其实承担着View下的MotionEvent派发到Compose中的事件枢纽,因为Android系统中,它只认识View,并不认识其他东西,因此在进入Compose的世界中,由View产生的MotionEvent会在dispatchTouchEvent阶段,被转换为Compose的PointerInputEvent

AndroidComposeView 中
override fun dispatchTouchEvent(motionEvent: MotionEvent): Boolean {
   .....

    val processResult = handleMotionEvent(motionEvent)

    ....
    return processResult.dispatchedToAPointerInputModifier
}

dispatchTouchEvent -> handleMotionEvent -> sendMotionEvent

private fun sendMotionEvent(motionEvent: MotionEvent): ProcessResult {
    ....
    val pointerInputEvent = motionEventAdapter.convertToPointerInputEvent(motionEvent, this)
    val action = motionEvent.actionMasked
return if (pointerInputEvent != null) {
        // Cache the last position of the last pointer to go down so we can check if
        // it's in a scrollable region in canScroll{Vertically|Horizontally}. Those
        // methods use semantics data, and because semantics coordinates are local to
        // this view, the pointer _position_, not _positionOnScreen_, is the offset that
        // needs to be cached.
        pointerInputEvent.pointers
            .fastLastOrNull {
it.down &&
                    (action == ACTION_DOWN ||
                        action == ACTION_POINTER_DOWN ||
                        !isCanScrollUsingLastDownEventFixEnabled)
            }
?.position
            ?.let { lastDownPointerPosition = it }

val result =
            pointerInputEventProcessor.process(pointerInputEvent, this, isInBounds(motionEvent))

这里是因为Compose不仅仅被设计在Android系统中运行,因此数据类上它做了一次数据结构之前的转换,这里它利用的是View下的MotionEvent 最终转换为一个叫做PointerInputEvent的数据类。这里我们可以简单认识一下这两个数据类,它其实就具备了一次点击下的所有信息,比如点击的位置等关键内容

internal expect class PointerInputEvent {
    val uptime: Long
    val pointers: List<PointerInputEventData>
}
internal data class PointerInputEventData(
    val id: PointerId,
    val uptime: Long,
    val positionOnScreen: Offset,
    val position: Offset,
    val down: Boolean,
    val pressure: Float,
    val type: PointerType,
    val activeHover: Boolean = false,
    val historical: List<HistoricalChange> = mutableListOf(),
    val scrollDelta: Offset = Offset.Zero,
    val originalEventPosition: Offset = Offset.Zero,
)

我们知道,PointerInputEvent是代表着一次点击的事件的信息,然而,在正式进行HitTest时,事件派发的PointerInputEvent会被作为输入产生另一个数据类,InternalPointerEvent

// Gets a new PointerInputChangeEvent with the PointerInputEvent.
@OptIn(InternalCoreApi::class)
val internalPointerEvent =
    pointerInputChangeEventProducer.produce(pointerEvent, positionCalculator)

InternalPointerEvent记录着事件之前的变化,即上一次的PointerInputEvent 与本次的PointerInputEvent之间的变化差异,每根手指的变化都被存储在PointerInputChange这个数据类中,大家可以想象一下,多根手指触摸的场景

fun produce(
    pointerInputEvent: PointerInputEvent,
    positionCalculator: PositionCalculator,
): InternalPointerEvent {
    // Set initial capacity to avoid resizing - we know the size the map will be.
    val changes: LongSparseArray<PointerInputChange> =
        LongSparseArray(pointerInputEvent.pointers.size)
    pointerInputEvent.pointers.fastForEach {
val previousTime: Long
        val previousPosition: Offset
        val previousDown: Boolean

        val previousData = previousPointerInputData[it.id.value]
        if (previousData == null) {
            previousTime = it.uptime
            previousPosition = it.position
            previousDown = false
        } else {
            previousTime = previousData.uptime
            previousDown = previousData.down
            previousPosition = positionCalculator.screenToLocal(previousData.positionOnScreen)
        }

        changes.put(
            it.id.value,
            PointerInputChange(
                it.id,
                it.uptime,
                it.position,
                it.down,
                it.pressure,
                previousTime,
                previousPosition,
                previousDown,
                false,
                it.type,
                it.historical,
                it.scrollDelta,
                it.originalEventPosition,
            ),
        )
        if (it.down) {
            previousPointerInputData.put(
                it.id.value,
                PointerInputData(it.uptime, it.positionOnScreen, it.down),
            )
        } else {
            previousPointerInputData.remove(it.id.value)
        }
    }

return InternalPointerEvent(changes, pointerInputEvent)
}

那么,为什么还要一个数据类呢?其实是为了后续处理位移形变等操作提供了更多的信息,比如(滑动的时候自身的位置也改变这种场景,为了offset这个信息,后面我们会继续分析)

@Immutable
class PointerInputChange(
    val id: PointerId,
    val uptimeMillis: Long,
    val position: Offset,
    val pressed: Boolean,
    val pressure: Float,
   val previousUptimeMillis:  Long,
 val previousPosition:  Offset,
 val previousPressed:  Boolean,
    isInitiallyConsumed: Boolean,
    val type: PointerType = PointerType.Touch,
    val scrollDelta: Offset = Offset.Zero,
) 

命中测试

继续回到我们的事件流程,这里我们将会看到重要的一步,即命中测试。命中测试是为了解决哪些PointerInputModifierNode将会被加入到事件的派发列表的流程

val root: LayoutNode

@OptIn(InternalCoreApi::class)
val internalPointerEvent =
    pointerInputChangeEventProducer.produce(pointerEvent, positionCalculator)
.....
// Add new hit paths to the tracker due to down events.
for (i in 0 until internalPointerEvent.changes.size()) {
    val pointerInputChange = internalPointerEvent.changes.valueAt(i)
    if (isHover || pointerInputChange.changedToDownIgnoreConsumed()) {
        // 命中测试阶段
       root.hitTest(pointerInputChange.position, hitResult, pointerInputChange.type)
        ....
    }
}

在这里我们可以看到,最开始会由跟节点root这个LayoutNode 开始进行(如果对LayoutNode不太熟悉的读者可以参考上一篇

internal fun hitTest(
    pointerPosition: Offset,
    hitTestResult: HitTestResult,
    pointerType: PointerType = PointerType.Unknown,
    isInLayer: Boolean = true,
) {
    // 转换为当前NodeCoordinator的局部坐标
    val positionInWrapped = outerCoordinator.fromParentPosition(pointerPosition)
    outerCoordinator.hitTest(
        NodeCoordinator.PointerInputSource,
        positionInWrapped,
        hitTestResult,
        pointerType,
        isInLayer,
    )
}

这里我们再回顾一下Compose的树组织结构,NodeCoordinator下有两种,一种是LayoutModifierNode产生的,另一种是归属于LayoutNode本身的InnerNodeCoordinator

这里就是说,一个PointerInputModifierNode 能不能被点到,其实是取决于它当前的NodeCoordinator的区域

我们举个例子

Box(modifier = Modifier.background(Color.Black).requiredSize(200.dp) .clickable{} .background(Color.Red) .requiredSize(300.dp) .background(Color.Green).requiredSize(400.dp)) {

}
image.png

这个clickable 所属的PointerInputModifierNode能否被响应,就取决于它的右侧的LayoutModifierNode(因为NodeCoordinator生成取决于LayoutModifierNode)。即范围是300dp,并不是200dp也不是400dp

hitTest 做了几件事情,即进入真的的区域判断时,比如是不是在范围内,都转换为了本地坐标

val positionInWrapped = outerCoordinator.fromParentPosition(pointerPosition)

这里的本地坐标就是以当前NodeCoordiantor左上角为原点的坐标

image.png

进行转换之后,对于NodeCoordinator是否命中的信息就可以不用考虑offset(偏移)的影响了,因为都做了本地坐标转换的计算,因此只需要判断点击是否在区域内即可

NodeCoordinator

fun hitTest(
    hitTestSource: HitTestSource,
    pointerPosition: Offset,
    hitTestResult: HitTestResult,
    pointerType: PointerType,
    isInLayer: Boolean,
) {
    // head就是当前NodeCoordinator下所控制的PointerInputModifierNode节点
    val head = head(hitTestSource.entityType())
    // 如果不在点击区域内,尝试进行最小点击区域的查看,比如点击中心在人手指范围内也算是命中
    if (!withinLayerBounds(pointerPosition)) {
        // This missed the clip, but if this layout is too small and this is within the
        // minimum touch target, we still consider it a hit.
        if (pointerType == PointerType.Touch) {
            val distanceFromEdge =
                distanceInMinimumTouchTarget(pointerPosition, minimumTouchTargetSize)
            if (
                distanceFromEdge.fastIsFinite() &&
                    hitTestResult.isHitInMinimumTouchTargetBetter(distanceFromEdge, false)
            ) {
                head.hitNear(
                    hitTestSource,
                    pointerPosition,
                    hitTestResult,
                    pointerType,
                    false,
                    distanceFromEdge,
                )
            } // else it is a complete miss.
        }
    } else if (head == null) {
        // 如果当前没有PointerInput节点,那么就继续走子节点
        hitTestChild(hitTestSource, pointerPosition, hitTestResult, pointerType, isInLayer)
    } else if (isPointerInBounds(pointerPosition)) {
        // A real hit
        // 当前位置在范围内,则加入点击列表
        head.hit(hitTestSource, pointerPosition, hitTestResult, pointerType, isInLayer)
    } else {
        // 尝试是否存在在外部也能被点击的情况
        val distanceFromEdge =
            if (pointerType != PointerType.Touch) Float.POSITIVE_INFINITY
            else {
                distanceInMinimumTouchTarget(pointerPosition, minimumTouchTargetSize)
            }
        val isHitInMinimumTouchTargetBetter =
            distanceFromEdge.fastIsFinite() &&
                hitTestResult.isHitInMinimumTouchTargetBetter(distanceFromEdge, isInLayer)

        head.outOfBoundsHit(
            hitTestSource,
            pointerPosition,
            hitTestResult,
            pointerType,
            isInLayer,
            distanceFromEdge,
            isHitInMinimumTouchTargetBetter,
        )
    }
}

NodeCoordinator 会依次处理以下几种情况,即对应着上面的几个ifelse条件

  1. 如果当前点击的坐标不在NodeCoordinator范围,但是如果点击满足在minimumTouchTargetSize范围中,也算是命中了点击
val minimumTouchTargetSize: Size
    get() = with(layerDensity) { layoutNode.viewConfiguration.minimumTouchTargetSize.toSize() } 

这个场景大家可以想象就是人手点击时,点击到边缘上,即使中心点不在只要满足系统定义的点击范围内,也算是能够被点击成功,默认是48.dp

  1. 如果当前NodeCoordinator不存在PointerInput节点,因为点击时都是自上而下遍历整颗NodeCoordinator树的,因此不存在就直接派发到下一个NodeCoordinator中,当然NodeCoordinator下LayoutModifierNodeCoordinator与InnerNodeCoordinator有不同的处理逻辑,原因是LayoutModifierNodeCoordinator只处理一个孩子的情况,相反InnerNodeCoordinator需要处理多个孩子

  2. 如果点击事件在NodeCoordinator范围内,就会把当前的PointerInput节点加入到列表中,并记录额外的深度信息等

inline fun hitInMinimumTouchTarget(
    node: Modifier.Node,
    distanceFromEdge: Float,
    isInLayer: Boolean,
    isInExpandedBounds: Boolean,
    childHitTest: () -> Unit,
) {
    val startDepth = hitDepth
    removeNodesInRange(hitDepth + 1, size)
    hitDepth++
    values.add(node)
    distanceFromEdgeAndFlags.add(
        DistanceAndFlags(distanceFromEdge, isInLayer, isInExpandedBounds).packedValue
    )
    childHitTest()
    hitDepth = startDepth
}

4. 对于在事件外的情况,也需要考虑处理,因为Compose可以支持扩大UI元素的点击区域,在Android开发中我们常常有扩大padding的方式用于进行热区的扩大,或者重写onTouchEvent等手段去进行热区外的点击,而在Compose中UI框架本身就支持了这种情况,因此处理起来就非常容易了

interface PointerInputModifierNode : DelegatableNode {
    ... 
val touchBoundsExpansion: TouchBoundsExpansion
        get() = TouchBoundsExpansion.None
}

开发者可以重写PointerInputModifierNode 下touchBoundsExpansion,就可以实现点击热区扩大的效果,因此在处理点击时,也需要考虑touchBoundsExpansion的影响

在整个命中测试阶段,只要是在范围内的PointerInputNode,都会被加入到事件派发列表中,并被保存在一开始的HitTestResult对象中

这里我们可以看到,Compose的点击并不会判断当前元素是否可见,比如一个空白的Box存在PointerInputNode,依旧会被加入到列表中,比如这里看不到任何东西,也没有任何绘制指令,依然会响应点击


Box(modifier = Modifier.requiredSize(400.dp).clickable {
Log.e("hello","1111")

} )

同时,如果我们写了多个PointerInputNode,比如多个clickable,他们都会被依次加入到命中测试的列表中

Box(modifier = Modifier.requiredSize(400.dp).clickable { 
 Log.e("hello","1111")

 } .clickable { 
 Log.e("hello","2222")

 } )

[clickable1 , clickable2]

事件派发

命中测试决定了哪些PointerInputNode能够感知事件的传递,到了事件派发阶段,

// Dispatch to PointerInputFilters
val dispatchedToSomething =
    hitPathTracker.dispatchChanges(internalPointerEvent, isInBounds)
fun dispatchChanges(
    internalPointerEvent: InternalPointerEvent,
    isInBounds: Boolean = true,
): Boolean {
    val changed =
        root.buildCache(
            internalPointerEvent.changes,
            rootCoordinates,
            internalPointerEvent,
            isInBounds,
        )
    if (!changed) {
        return false
    }

    // In some rare cases, a cancel or a request to remove a pointer input node might come in
    // during an event. To avoid problems, we use `dispatchingEvent` to guard against that and
    // if a cancel or request to remove nodes comes in, we delay it until after the event has
    // been dispatched.
    dispatchingEvent = true
    var dispatchHit =
        root.dispatchMainEventPass(
            internalPointerEvent.changes,
            rootCoordinates,
            internalPointerEvent,
            isInBounds,
        )
    dispatchHit = root.dispatchFinalEventPass(internalPointerEvent) || dispatchHit
    dispatchingEvent = false

    return dispatchHit
}

dispatchChanges 顾名思义,派发事件的改变,这里分为预处理与3次事件的派发

预处理是指,每个PointerInputModifierNode前都会在onPointerEvent事件处理阶段收到一个PointerEvent,真是进行派发前,需要对一些数据进行更新操作。PointerEvent中关键的数据changes: List,这里的PointerInputChange列表都需要对事件进行坐标本地化处理,因为一开始我们只能拿到全局坐标,而PointerInputChange需要对前后两次事件进行本地化处理

大家可以想象拖拽一个物体的场景,每次拖拽时,红色NodeCoodinator的offset都会产生偏移,因此前后两次事件的坐标原点其实是会发生改变的,因此如果没有PointerInputChange进行本地化处理,那么这种坐标原点发生变化的时候进行增量处理就会变得非常麻烦

image.png
expect class PointerEvent
internal constructor(
    changes: List<PointerInputChange>,
    internalPointerEvent: InternalPointerEvent?,
) 

通过预处理之后,每个PointerInputModiferNode收到的PointerInputChange都是在本地坐标系内了,因此后续的操作判断就会变得简单

预处理之后,每个在命中测试列表的PointerInputModiferNode都会在onPointerEvent收到3次事件,分别是Initial,Main,Final事件

override fun dispatchMainEventPass(
    changes: LongSparseArray<PointerInputChange>,
    parentCoordinates: LayoutCoordinates,
    internalPointerEvent: InternalPointerEvent,
    isInBounds: Boolean,
): Boolean {
    // TODO(b/158243568): The below dispatching operations may cause the pointerInputFilter to
//  become detached. Currently, they just no-op if it becomes detached and the detached
//  pointerInputFilters are removed from being tracked with the next event. I currently
//  believe they should be detached immediately. Though, it is possible they should be
//  detached after the conclusion of dispatch (so onCancel isn't called during calls
//  to onPointerEvent). As a result we guard each successive dispatch with the same check.
return dispatchIfNeeded {
val event = pointerEvent!!
        val size = coordinates!!.size

        // Dispatch on the tunneling pass.
        modifierNode.dispatchForKind(Nodes.PointerInput) {
it.onPointerEvent(event, PointerEventPass.Initial, size)
        }

// Dispatch to children.
        if (modifierNode.isAttached) {
            children.forEach {
it.dispatchMainEventPass(
                    // Pass only the already-filtered and position-translated changes down to
                    // children
                    relevantChanges,
                    coordinates!!,
                    internalPointerEvent,
                    isInBounds,
                )
            }
}

        if (modifierNode.isAttached) {
            // Dispatch on the bubbling pass.
            modifierNode.dispatchForKind(Nodes.PointerInput) {
it.onPointerEvent(event, PointerEventPass.Main, size)
            }
}
    }
}
override fun dispatchFinalEventPass(internalPointerEvent: InternalPointerEvent): Boolean {
   val result = dispatchIfNeeded {
val event = pointerEvent!!
        val size = coordinates!!.size
        // Dispatch on the tunneling pass.
        modifierNode.dispatchForKind(Nodes.PointerInput) {
it.onPointerEvent(event, PointerEventPass.Final, size)
        }

// Dispatch to children.
        if (modifierNode.isAttached) {
            children.forEach { it.dispatchFinalEventPass(internalPointerEvent) }
}
    }
cleanUpHits(internalPointerEvent)
    clearCache()
    return result
}
image.png

有了这3个事件阶段,很多父子之间的事件派发的场景就会变得简单。在Initial阶段,最先加入命中测试列表的阶段会最先收到这个事件,因此这个节点可以最先获得消费事件的机会, 注意这里只是机会,有了这个机制就可以实现ViewGroup的onInterceptTouchEvent类似的机制,它可以控制把这个事件要不要被消费掉。

这里值得注意的是,这里的消费并不是说PointerInputModifierNode能够中断事件的派发流程,只是说PointerInputChange的一些标记被设置为了true

fun consume() {
    if (consumedDelegate == null) {
        downChange = true
        positionChange = true
    } else {
        consumedDelegate?.consume()
    }
}

因此下一个PointerInputModifierNode还是能够PointerInputChange,只是说它能够知道PointerInputChange有没有被上一个节点所消费

 /**
* Indicates whether the change was consumed or not. Note that the change must be consumed in
* full as there's no partial consumption system provided.
*/
val isConsumed: Boolean
    get() = consumedDelegate?.isConsumed ?: (downChange || positionChange)

因此,在Compose中,事件的消费只是一种协议,并不是在机制上进行事件的中断。通常情况下,我们写的PointerInputModifierNode都会在Main阶段判断PointerInputChange有没有被消费,从而再决定要不要自身消费这个事件,因为最后加入到命中测试列表的节点是用户在画家算法中能看到最顶层的节点

比如Clickable的实现

override fun onPointerEvent(
    pointerEvent: PointerEvent,
    pass: PointerEventPass,
    bounds: IntSize,
) {
    super.onPointerEvent(pointerEvent, pass, bounds)
    if (isSuspendingPointerInputEnabled) {
        return
    }
    if (pass == PointerEventPass.Main) {
        val downEvent = this.downEvent
        if (downEvent == null) {
            if (pointerEvent.isChangedToDown(requireUnconsumed = true)) {
                val change = pointerEvent.changes[0]
                change.consume()
                this.downEvent = change
                if (enabled) {
                    handlePressInteractionStart(change.position, indirectPointer = false)
                }
            }
        } else if (pointerEvent.changes.fastAll { it.changedToUp() } ) {
            // All pointers are up
            val up = pointerEvent.changes[0]
            up.consume()
            if (enabled) {
                handlePressInteractionRelease(downEvent.position, indirectPointer = false)
                onClick()
            }
            this.downEvent = null
        } else {
            val touchPadding = getExtendedTouchPadding(bounds)
            if (
                pointerEvent.changes.fastAny {
it.isConsumed || it.isOutOfBounds(bounds, touchPadding)
                }
) {
                // Canceled
                this.downEvent = null
                handlePressInteractionCancel(indirectPointer = false)
            }
        }
    } else if (pass == PointerEventPass.Final && downEvent != null) {
        // Check for cancel by position consumption. We can look on the Final pass of the
        // existing pointer event because it comes after the pass we checked above.
        if (pointerEvent.changes.fastAny { it.isConsumed && it != downEvent } ) {
            // Canceled
            downEvent = null
            handlePressInteractionCancel(indirectPointer = false)
        }
    }
}

这里我们可以知道的是,在Compose中事件一定是从命中列表从头到尾派发的,并不存在中断流程,判断事件有没有消费是一个协议,因此开发者如果想要实现一种特殊的一次事件多次消费的场景,是完全可以做到忽略消费条件的,当然最好不要这么做避免带来其他麻烦。其他监听事件的Modifier就是利用这个事件派发机制做到的

回到上文中,两个clickable中,因为clickable 是在main阶段判断事件有没有被消费从而响应点击处理,因此最后被加入命中测试的clickable2能够先获取mian事件并消费掉,clickable1 能在依旧能感知事件的存在,但是它知道了PointerInputChange已经被标记为消费状态,从而忽略

Box(modifier = Modifier.requiredSize(400.dp).clickable { 
 Log.e("hello","1111")

 } .clickable { 
 Log.e("hello","2222")

 } )

[clickable1 , clickable2]

Initialclickable1 clickable2
Mainclickable2 clickable1
Finalclickable1 clickable2

具备TRS 的事件判断

在日常开发中,我们常常会遇到物体变换情况下,即具备TRS = 平移 - 旋转 - 缩放,那么UI框架怎么处理这种情况的,比如

Column {
Box(modifier = Modifier.clickable {
Log.e("hello","1111")

    } .background(Color.Green).requiredSize(400.dp))
    
    Box(modifier = Modifier.graphicsLayer {
scaleX = 0.5f
        scaleY = 0.5f
    } .clickable {
Log.e("hello","1111")

    } .background(Color.Green).requiredSize(400.dp))
} 

我们以缩放为例子,Compose是怎么知道缩放后的事件点击区域应该是在红色圈的内部而不是响应原本的大小区域呢?

答案就藏在fromParentPosition 这个函数

internal fun hitTest(
    pointerPosition: Offset,
    hitTestResult: HitTestResult,
    pointerType: PointerType = PointerType.Unknown,
    isInLayer: Boolean = true,
) {
    val positionInWrapped = outerCoordinator.fromParentPosition(pointerPosition) 
    outerCoordinator.hitTest(
        NodeCoordinator.PointerInputSource,
        positionInWrapped,
        hitTestResult,
        pointerType,
        isInLayer,
    )
}

fromParentPosition 会充分考虑内部的位移以及变化关系

这里分为布局的偏移跟TRS变化

open fun fromParentPosition(
    position: Offset,
    includeMotionFrameOfReference: Boolean = true,
): Offset {
    // 先处理布局的偏移
    val relativeToPosition =
        if (!includeMotionFrameOfReference && this.isPlacedUnderMotionFrameOfReference) {
            position
        } else {
            position - this.position
        }
    val layer = layer
    // TRS变化
    return layer?.mapOffset(relativeToPosition, inverse = true) ?: relativeToPosition
}

布局偏移是指只改变canvas的绘制偏移,并不会生成新的RenderNode处理,它只是做了位置的移动

相反,TRS变化都会被UI框架记录在一个Matrix中,上面的graphicsLayer最终会修改GraphicsLayer 的Matrix

override fun mapOffset(point: Offset, inverse: Boolean): Offset {
    val matrix =
        if (inverse) {
            getInverseMatrix() ?: return Offset.Infinite
        } else {
            getMatrix()
        }
    return if (isIdentity) {
        point
    } else {
        matrix.map(point)
    }
}

在Android中,进行TRS变化时需要对RenderNode级别进行修改,在sdk 29后,每个GraphicsLayer都会生成一个RenderNode进行Transform变化的处理,之前会之间生成一个View进行(View本身也具备一个RenderNode)

private fun updateMatrix() {
    if (isMatrixDirty) {
        with(graphicsLayer) {
val (x, y) =
                if (pivotOffset.isUnspecified) {
                    this@GraphicsLayerOwnerLayer.size.toSize().center
} else {
                    pivotOffset
                }

            matrixCache.resetToPivotedTransform(
                pivotX = x,
                pivotY = y,
                translationX = translationX,
                translationY = translationY,
                rotationX = rotationX,
                rotationY = rotationY,
                rotationZ = rotationZ,
                scaleX = scaleX,
                scaleY = scaleY,
            )
        }
isMatrixDirty = false
        isIdentity = matrixCache.isIdentity()
    }
}
@RequiresApi(Build.VERSION_CODES.Q)
internal class GraphicsLayerV29(
    override val ownerId: Long,
    private val canvasHolder: CanvasHolder = CanvasHolder(),
    private val canvasDrawScope: CanvasDrawScope = CanvasDrawScope(),
) : GraphicsLayerImpl {
    private val renderNode: RenderNode = RenderNode("graphicsLayer")

因此坐标的本地化其实处理了两件事,一个RenderNode下的布局偏移以及不同RenderNode之间的矩阵变化转换,从而在事件处理的时候,坐标都是基于一个NodeCoordinator的左上角的本地坐标处理

image.png

在Compose中,NodeCoordinator并不会一定生成Layer(Layer会生成RenderNode),因此可以存在多个NodeCoordinator属于一个Layer(属于一个RenderNode,用一个canvas绘制)的情况,因此这种情况下本地坐标转换只考虑本地的布局偏移即可

而遇到类似graphicsLayer 这些Modifer时,会生成新的Layer,因此坐标的转换需要考虑跨RenderNode的变化,是否生成Layer取决于Measure阶段是否调用了placeWithLayer 函数

override fun MeasureScope.measure(
    measurable: Measurable,
    constraints: Constraints,
): MeasureResult {
    val placeable = measurable.measure(constraints)
    return layout(placeable.width, placeable.height) {
 placeable.placeWithLayer(0, 0, layerBlock = layerBlock)
    }
}

不规则区域的判断

Android ViewGroup中,默认情况下ViewGroup会判断TRS的变化,但是不会判断Path对点击区域的影响

@UnsupportedAppUsage
protected boolean isTransformedTouchPointInView(float x, float y, View child,
        PointF outLocalPoint) {
    final float[] point = getTempLocationF();
    point[0] = x;
    point[1] = y;
    transformPointToViewLocal(point, child);
    final boolean isInView = child.pointInView(point[0], point[1]);
    if (isInView && outLocalPoint != null) {
        outLocalPoint.set(point[0], point[1]);
    }
    return isInView;
}

/**
* @hide
*/
@UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
public void transformPointToViewLocal(float[] point, View child) {
    point[0] += mScrollX - child.mLeft;
    point[1] += mScrollY - child.mTop;

    if (!child.hasIdentityMatrix()) {
        child.getInverseMatrix().mapPoints(point);
    }
}

因此想要实现不规则Path对点击区域的影响往往需要重写dispatchTouchEvent 进行自定义的处理,而在Compose中,Path可以很好的被支持

点击区域在圆形
Box(modifier = Modifier.clip(CircleShape).clickable {
Log.e("hello","1111")

} .background(Color.Green).requiredSize(400.dp))

关键在hitTest 的第一个判断中withinLayerBounds

protected fun withinLayerBounds(pointerPosition: Offset): Boolean {
    if (!pointerPosition.isFinite) {
        return false
    }
    val layer = layer
    return layer == null || !isClipping || layer.isInLayer(pointerPosition)
}

当遇到具备裁剪的path时,会进入path区域的判断

override fun isInLayer(position: Offset): Boolean {
    val x = position.x
    val y = position.y

    if (graphicsLayer.clip) {
        return isInOutline(graphicsLayer.outline, x, y)
    }

    return true
}
internal fun isInOutline(
    outline: Outline,
    x: Float,
    y: Float,
    tmpTouchPointPath: Path? = null,
    tmpOpPath: Path? = null,
): Boolean =
    when (outline) {
        is Outline.Rectangle -> isInRectangle(outline.rect, x, y)
        is Outline.Rounded -> isInRoundedRect(outline, x, y, tmpTouchPointPath, tmpOpPath)
        is Outline.Generic -> isInPath(outline.path, x, y, tmpTouchPointPath, tmpOpPath)
    }

因此即便Path是一个五角星或者其他不规则的形状,是否响应事件派发依旧可以被很好支持

总结

本章我们从UI框架的HitTest流程出发,探索了Compose对于事件派发的处理机制,我们了解了事件派发是如何产生的,事件的命中测试与事件的派发流程。相信大家通过对本篇的学习,能够更加得心应手的使用Compose,实现更多更好的交互