布景

看到 经过调用栈快速探究 Compose 中 touch 事情的处理原理 一文,想到之前踩过相关的坑,在此记载一下。

先想一个问题,在 Compose 组件外面包一层 Box 组件,会影响点击事情的分发吗?乍一想似乎不影响,咱们平时写 UI 的时候,Row、Column、Box 随手用,没有碰到会影响点击事情的情况。但凑巧的是,若干个条件一起发生,导致在包了一层 Box 之后,原本正常分发的点击事情不再正常工作了……

复现场景

在一个主体运用 Compose 编写的页面之上,有一个之前运用 View 写的浮层,经过 AndroidView 组件接入了当时页面。为了方便排布,浮层与页面其他主体元素,一起作为 ConstraintLayout 的子元素,进行约束布局。在升级到 1.4.0 之后,出现了 AndroidView 在设置部分 Modifier 时布局反常的问题(I4dc77, b/274797771)。为了解决布局问题,临时在 AndroidView 组件外包裹了一层 Box 组件。

简化今后的场景复现如下,首要运用 AndroidView 引入原有的 View,FloatingView 点击一次后切换 visibility 为 GONE,模仿点击叉叉封闭了这个浮层:

ps:编辑器解析后缩进都紊乱了,没精力一个个修改了。。

@Composable
fun FloatingView() {
    AndroidView(
        factory = { context -> FloatingView(context) }
)
}
// FloatingView
private fun init() {
    // 点击浮层后,弹出 Toast,而且切换 FloatingView 的 visibility 为 GONE
    setOnClickListener {
Toast.makeText(context, "Floating View Clicked!", Toast.LENGTH_SHORT).show()
        visibility = GONE
 }
}

在同一个 Box 中,别离放置了页面主体元素和浮层,预期在封闭浮层今后,再点击这个区域,能够显现布景被点击的 Toast,代表点击事情分发到了页面主体元素:

@Composable
fun FloatingDemo() {
Surface(modifier = Modifier.fillMaxSize()) {
        // 页面主体元素
Box(
            modifier = Modifier
                .fillMaxSize()
                .clickable {
Toast
                        .makeText(context, "Background View Clicked!", Toast.LENGTH_SHORT)
                        .show()
                }
)
        // 浮层
        FloatingView()
    }
}

后用 Box 包裹 FloatingView 组件,替换本来的 FloatingView 组件:

@Composable
fun WrappedFloatingView() {
    Box {
FloatingView()
    }
}
@Composable
fun FloatingDemo() {
Surface(modifier = Modifier.fillMaxSize()) { 
// 页面主体元素
Box(
            modifier = Modifier
                .fillMaxSize()
                .clickable {
Toast
                        .makeText(context, "Background View Clicked!", Toast.LENGTH_SHORT)
                        .show()
                }
)
        // 替换为包裹了 Box 之后的浮层
        WrappedFloatingView()
    }
}

别离点击看看效果。在切换 FloatingView 的 visibility 为 GONE 之后,再次点击浮层区域,未包裹 Box 的比如中,布景的 Box 能接收到点击事情,而包裹了 Box 的比如中却不能:

“`
包一层 Box 影响了事情分发?记 Compose 事情分发的一次踩坑
FloatingView 未包裹 Box FloatingView 包裹了 Box

Box 在这儿看起来只起到辅助布局的效果,没有对点击事情做任何额定的处理,为什么就影响了事情分发呢?

剖析

Compose 事情分发流程

首要简单介绍 Compose 事情分发的流程。Compose 事情分发的流程与 View 系统相差不多,遍历 LayoutNode 的 Modifier 判别是否有处理点击事情的 PointerInputModifierNode,若有,再判别 MotionEvent 的坐标是否在对应 LayoutNode 的规模内,若契合则收集起来,并做下一步的分发。

Compose 经过 ComposeView 挂接到传统 View 视图体系中,ComposeView 是一个 ViewGroup ,它的直接子 View 是一个 AndroidComposeView 目标(它也是一个 ViewGroup ),然后在 AndroidComposeView 中管理着一棵由 LayoutNode 组成的 UI 树,每个 Composable 终究都对应着 LayoutNode 树中的一个节点。

包一层 Box 影响了事情分发?记 Compose 事情分发的一次踩坑

咱们运用 setContent 办法将 Compose 布局设置到了 AndroidComposeView 中。AndroidComposeView 重写了 ViewGroup 的 dispatchTouchEvent 办法分发 Android 的点击事情给 Compose,经过 handleMotionEvent 办法、sendMotionEvent 办法,调用到 PointerInputEventProcessor 的 process 办法:

@OptIn(InternalCoreApi::class, ExperimentalComposeUiApi::class)
// 1. root 为 AndroidComposeView 传进来的根节点
internal class PointerInputEventProcessor(val root: LayoutNode) {
    fun process(
        pointerEvent: PointerInputEvent,
        positionCalculator: PositionCalculator,
        isInBounds: Boolean = true
    ): ProcessResult {
        internalPointerEvent.changes.values.forEach { pointerInputChange ->
            // 2. 是否为 down 事情,假如是的话,则需要记载射中途径
            if (isHover || pointerInputChange.changedToDownIgnoreConsumed()) {
                val isTouchEvent = pointerInputChange.type == PointerType.Touch
                // 3. 获取射中的 PointerInputModifierNode ,增加到 hitResult 调集
                root.hitTest(pointerInputChange.position, hitResult, isTouchEvent)
                if (hitResult.isNotEmpty()) {
                    // 4. 增加到射中途径,转换成链表 hitPathTracker
                    hitPathTracker.addHitPath(pointerInputChange.id, hitResult)
                    hitResult.clear()
                }
            }
        }
        // 5. 分发事情
        val dispatchedToSomething =
            hitPathTracker.dispatchChanges(internalPointerEvent, isInBounds)
        return ProcessResult(dispatchedToSomething, anyMovementConsumed)
    }
}

Compose 如何取得射中途径 hitResult

在 Compose 的时刻分发流程中,包裹一层 Box 导致哪里发生了变化,终究致使点击事情没有按照预期传递呢?经过调试发现,在进程3“获取射中的 PointerInputModifierNode ,增加到 hitResult 调集”之后,hitResult 的结果有所不同。包裹了 Box 时,hitResult 中少了两个 Node,而这两个 Node 恰好是 Compose 中调用 clickable 修饰符后,会增加的两个 Node。

  • 没有 Box 包裹

包一层 Box 影响了事情分发?记 Compose 事情分发的一次踩坑

  • 有 Box 包裹

包一层 Box 影响了事情分发?记 Compose 事情分发的一次踩坑

推测是这个原因导致布景 Box 没有被分发到点击事情,所以持续追踪进程3处的代码,能够看到持续调用了 hitTest 办法,能够猜到这是一个遍历操作,但是详细的流程是什么样的呢?

// LayoutNode
@OptIn(ExperimentalComposeUiApi::class)
internal fun hitTest(
    pointerPosition: Offset,
    hitTestResult: HitTestResult<PointerInputModifierNode>,
    isTouchEvent: Boolean = false,
    isInLayer: Boolean = true
) {
    val positionInWrapped = outerCoordinator.fromParentPosition(pointerPosition)
    // 从 outerCoordinator 开端遍历 NodeCoordinator,调用 NodeCoordinator.hitTest 检测
    outerCoordinator.hitTest(
        // 这儿传入 NodeCoordinator.PointerInputSource,之后用于筛选 PointerInputModifierNode
        NodeCoordinator.PointerInputSource,
        positionInWrapped,
        hitTestResult,
        isTouchEvent,
        isInLayer
    )
}

先看下遍历的大致流程:

  1. Modifier.Node 以 NodeChain 双向链表的方式挂在 LayoutNode 上
  2. 查找射中途径时,从 outerCoordinator 开端遍历 NodeCoordinator 链,假如有 PointerInputModifierNode 且点击坐标在当时 LayoutNode 规模内,将其参加 hitTestResult
  3. 遍历到 innerCoordinator 后,再持续查找子 LayoutNode 节点

包一层 Box 影响了事情分发?记 Compose 事情分发的一次踩坑

遍历的3个进程的对应代码如下:

  1. Modifier.Node 以 NodeChain 双向链表的方式挂在 LayoutNode 上
// LayoutNode
internal val nodes = NodeChain(this)
internal val innerCoordinator: NodeCoordinator
    get() = nodes.innerCoordinator
internal val outerCoordinator: NodeCoordinator
    get() = nodes.outerCoordinator
// 每次增加新的 Modifier 时,都会收拾 NodeChain
override var modifier: Modifier = Modifier
    set(value) { ..
        nodes.updateFrom(value)
    }
  1. 查找射中途径时,从 outerCoordinator 开端遍历 NodeCoordinator 链,假如有 PointerInputModifierNode 且点击坐标在当时 LayoutNode 规模内,将其参加 hitTestResult
// NodeCoordinator
fun <T : DelegatableNode> hitTest(
    hitTestSource: HitTestSource<T>,
    pointerPosition: Offset,
    hitTestResult: HitTestResult<T>,
    isTouchEvent: Boolean,
    isInLayer: Boolean
) {
    val head = headUnchecked(hitTestSource.entityType())
    if (!withinLayerBounds(pointerPosition)) { ...
    } else if (head == null) {
        // 2.1. 对应的 Modifier.Node 不是 PointerInputModifierNode,持续遍历 NodeCoordinator 链
        hitTestChild(hitTestSource, pointerPosition, hitTestResult, isTouchEvent, isInLayer)
    } else if (isPointerInBounds(pointerPosition)) {
        // 2.2. 对应的 Modifier.Node 是 PointerInputModifierNode,而且 pointer 坐标在 layoutNode 规模
        // 内,记载这一个 PointerInputModifierNode 到 hitTestResult
        head.hit(
            hitTestSource,
            pointerPosition,
            hitTestResult,
            isTouchEvent,
            isInLayer
        )
    } else { ... }
}
open fun <T : DelegatableNode> hitTestChild(
    hitTestSource: HitTestSource<T>,
    pointerPosition: Offset,
    hitTestResult: HitTestResult<T>,
    isTouchEvent: Boolean,
    isInLayer: Boolean
) {
    val wrapped = wrapped
    if (wrapped != null) {
        val positionInWrapped = wrapped.fromParentPosition(pointerPosition)
        // 2.3. 持续对下一个 NodeCoordinator 进行 hitTest 判别
        wrapped.hitTest(
            hitTestSource,
            positionInWrapped,
            hitTestResult,
            isTouchEvent,
            isInLayer
        )
    }
}
  1. 遍历到 innerCoordinator 后,再持续查找子 LayoutNode 节点
// InnerNodeCoordinator
@OptIn(ExperimentalComposeUiApi::class)
override fun <T : DelegatableNode> hitTestChild(
    hitTestSource: HitTestSource<T>,
    pointerPosition: Offset,
    hitTestResult: HitTestResult<T>,
    isTouchEvent: Boolean,
    isInLayer: Boolean
) {
    ...
    // 3.1. 按 z 轴放置的次序,逆序遍历子 LayoutNode,一旦回来 true 就停止遍历
    layoutNode.zSortedChildren.reversedAny { child ->
        if (child.isPlaced) {
            // 3.2. 调用 NodeCoordinator.PointerInputSource 目标的 childHitTest 办法
            // 即跳转到3.4进程
            hitTestSource.childHitTest(
                child,
                pointerPosition,
                hitTestResult,
                isTouchEvent,
                inLayer
            )
            val wasHit = hitTestResult.hasHit()
            val continueHitTest: Boolean
            if (!wasHit) {
                continueHitTest = true
            } else if (
                // 3.3. 检查方才射中的 LayoutNode 是否与其他兄弟 LayoutNode 同享点击事情,
                // 假如同享,则持续遍历,反之则完毕遍历
                child.outerCoordinator.shouldSharePointerInputWithSiblings()
            ) {
                hitTestResult.acceptHits()
                continueHitTest = true
            } else {
                continueHitTest = false
            }
            !continueHitTest
        } else {
            false
        }
    }
}
@OptIn(ExperimentalComposeUiApi::class)
val PointerInputSource =
    object : HitTestSource<PointerInputModifierNode> {
        override fun entityType() = Nodes.PointerInput
        override fun childHitTest(
            layoutNode: LayoutNode,
            pointerPosition: Offset,
            hitTestResult: HitTestResult<PointerInputModifierNode>,
            isTouchEvent: Boolean,
            isInLayer: Boolean
        // 3.4. 开端子 LayoutNode 的遍历
        ) = layoutNode.hitTest(pointerPosition, hitTestResult, isTouchEvent, isInLayer)
    }

是否和兄弟 LayoutNode 同享点击事情

梳理 Compose 取得射中途径 hitResult 的进程后,能够推断包裹 Box 之后,影响了遍历进程的 3.3 进程。3.3 进程决议是否持续遍历的关键在于 PointerInputModifier 中 sharePointerInputWithSiblings() 办法的回来值,该办法在 BackwardsCompatNode 被重写,回来值取决于 pointerInputFilter 的 shareWithSiblings 特点:

// NodeCoordinator
fun shouldSharePointerInputWithSiblings(): Boolean {
    val start = headNode(Nodes.PointerInput.includeSelfInTraversal) ?: return false
    start.visitLocalDescendants(Nodes.PointerInput) {
if (it.sharePointerInputWithSiblings()) return true
    }
return false
}
// PointerInputModifierNode
fun sharePointerInputWithSiblings(): Boolean = false
// BackwardsCompatNode
override fun sharePointerInputWithSiblings(): Boolean {
    return with(element as PointerInputModifier) {
        pointerInputFilter.shareWithSiblings
    }
}

PointerInputFilter 抽象类中界说 shareWithSiblings 变量为 false:

abstract class PointerInputFilter {
    @Suppress("OPT_IN_MARKER_ON_WRONG_TARGET")
    open val shareWithSiblings: Boolean
        get() = false
}

PointerInputModifier 接口有一个完成类 PointerInteropFilter,其中重写了 pointerInputFilter 目标,它的 shareWithSiblings 特点重写为 true:

// PointerInteropFilter
@ExperimentalComposeUiApi
internal class PointerInteropFilter : PointerInputModifier {
    override val pointerInputFilter =
        object : PointerInputFilter() {
            override val shareWithSiblings
                get() = true
        }
}

AndroidView 的点击事情会经过 pointerInteropFilter 修饰符创建一个 PointerInteropFilter 目标,增加到 BackwardsCompatNode:

// AndroidViewHolder
val layoutNode: LayoutNode = run {
    val layoutNode = LayoutNode()
    val coreModifier = Modifier
        // 运用 pointerInteropFilter 修饰符处理点击事情
        .pointerInteropFilter(this)
    layoutNode.modifier = modifier.then(coreModifier)
    layoutNode
}
// PointerInteropFilter
@ExperimentalComposeUiApi
internal fun Modifier.pointerInteropFilter(view: AndroidViewHolder): Modifier {
    // 创建了 PointerInputModifier 接口的完成类 PointerInteropFilter 目标
    val filter = PointerInteropFilter()
    filter.onTouchEvent = {...}
    return this.then(filter)
}

因而,在 Compose 遍历寻找射中途径 hitResult 的进程中,假如射中 AndroidView,将与其兄弟 LayoutNode 共享点击事情,持续进行遍历。

而 Compose 其他组件的点击事情,在 1.4.x 版别,都收拢到 pointerInput 修饰符,增加 PointerInputModifier 接口的另一个完成类 SuspendingPointerInputFilter 目标。SuspendingPointerInputFilter 中没有重写 shareWithSiblings 特点,因而并不会跟兄弟 LayoutNode 同享点击事情。在 1.5.0 版别及今后,取消了 SuspendingPointerInputFilter 完成类,Compose 组件的点击事情,在遍历取得射中途径 hitResult 的进程中,在进程 3.3 也不会进行同享,而是直接完毕遍历。

在这个场景里发生了什么

梳理完了事情分发进程、遍历取得射中途径、是否和兄弟 LayoutNode 同享点击事情,回头看看在这个详细场景里发生了什么:

@Composable
fun FloatingDemo() {
Surface(modifier = Modifier.fillMaxSize()) {
        // 页面主体元素
Box(
            modifier = Modifier
                .fillMaxSize()
                .clickable {
Toast
                        .makeText(context, "Background View Clicked!", Toast.LENGTH_SHORT)
                        .show()
                }
)
        // 浮层
        FloatingView()
        // 替换为包裹了 Box 之后的浮层
        // WrappedFloatingView()
    }
}
  • 假如 AndroidView 没有包裹 Box,首要射中 AndroidView,回来到父节点 Surface 时,因为 AndroidView 和兄弟节点共享点击事情,持续进入页面主体元素进行遍历。
  • 假如 AndroidView 包裹了 Box,首要射中 AndroidView,回来到父节点 Box 时,虽然 AndroidView 和兄弟节点共享点击事情,但该 Box 下没有其他子节点,所以持续回来;回来到父节点 Surface 时,因为 Box 不和兄弟节点共享点击事情,因而不会再进入页面主体元素进行遍历。

结语

剖析了这么久,不知道有没有朋友心里觉得有点古怪的?是的,设置 FloatingView 的 visibility 为 GONE 之后,为什么它的宽高不是0,反而还能看到它的布局边界呢?有人在 IssueTracker 提出了这个问题,看起来在 1.6.0 版别已经修正了(b/324429692)。所以总结起来,这一次预期之外的事情分发,是由两个 bug 扎堆引起的意外(另一个是前文提到的 AndroidView 在设置部分 Modifier 时布局反常的问题(I4dc77, b/274797771),从 1.4.0 开端出现,在 1.4.3 已经修正)。因而假如运用最新的 1.6.3,是无法复现本文的问题的。

虽然这个坑已经踩完了,但假如在运用 1.6.0 以前的版别,一起还有和 View 系统有互操作的情况,能够检查一下有没有类似的情况。以及升版别的时候,还是要检查一下有没有忽然坑了的当地,修正的时候也不要修了一个又搞出另一个。。。

相关内容跨过版别和时刻都好久,有问题欢迎讨论和纠正~