All Downloads are FREE. Search and download functionalities are using the official Maven repository.

commonMain.androidx.compose.ui.input.pointer.HitPathTracker.kt Maven / Gradle / Ivy

Go to download

Compose UI primitives. This library contains the primitives that form the Compose UI Toolkit, such as drawing, measurement and layout.

There is a newer version: 1.8.0-alpha01
Show newest version
/*
 * Copyright 2020 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package androidx.compose.ui.input.pointer

import androidx.collection.LongSparseArray
import androidx.collection.MutableLongObjectMap
import androidx.collection.MutableObjectList
import androidx.collection.mutableObjectListOf
import androidx.compose.runtime.collection.MutableVector
import androidx.compose.runtime.collection.mutableVectorOf
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.util.PointerIdArray
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.node.InternalCoreApi
import androidx.compose.ui.node.Nodes
import androidx.compose.ui.node.dispatchForKind
import androidx.compose.ui.node.has
import androidx.compose.ui.node.layoutCoordinates
import androidx.compose.ui.util.fastFirstOrNull
import androidx.compose.ui.util.fastForEach

/**
 * Organizes pointers and the [PointerInputFilter]s that they hit into a hierarchy such that
 * [PointerInputChange]s can be dispatched to the [PointerInputFilter]s in a hierarchical fashion.
 *
 * @property rootCoordinates the root [LayoutCoordinates] that [PointerInputChange]s will be
 * relative to.
 */
internal class HitPathTracker(private val rootCoordinates: LayoutCoordinates) {

    /*@VisibleForTesting*/
    internal val root: NodeParent = NodeParent()

    private val hitPointerIdsAndNodes = MutableLongObjectMap>(10)

    /**
     * Associates a [pointerId] to a list of hit [pointerInputNodes] and keeps track of them.
     *
     * This enables future calls to [dispatchChanges] to dispatch the correct [PointerInputChange]s
     * to the right [PointerInputFilter]s at the right time.
     *
     * If [pointerInputNodes] is empty, nothing will be added.
     *
     * @param pointerId The id of the pointer that was hit tested against [PointerInputFilter]s
     * @param pointerInputNodes The [PointerInputFilter]s that were hit by [pointerId].  Must be
     * ordered from ancestor to descendant.
     * @param prunePointerIdsAndChangesNotInNodesList Prune [PointerId]s (and associated changes)
     * that are NOT in the pointerInputNodes parameter from the cached tree of ParentNode/Node.
     */
    fun addHitPath(
        pointerId: PointerId,
        pointerInputNodes: List,
        prunePointerIdsAndChangesNotInNodesList: Boolean = false
    ) {
        var parent: NodeParent = root
        hitPointerIdsAndNodes.clear()
        var merging = true

        eachPin@ for (i in pointerInputNodes.indices) {
            val pointerInputNode = pointerInputNodes[i]

            if (merging) {
                val node = parent.children.firstOrNull {
                    it.modifierNode == pointerInputNode
                }

                if (node != null) {
                    node.markIsIn()
                    node.pointerIds.add(pointerId)

                    val mutableObjectList =
                        hitPointerIdsAndNodes.getOrPut(pointerId.value) { mutableObjectListOf() }

                    mutableObjectList.add(node)
                    parent = node
                    continue@eachPin
                } else {
                    merging = false
                }
            }
            // TODO(lmr): i wonder if Node here and PointerInputNode ought to be the same thing?
            val node = Node(pointerInputNode).apply {
                pointerIds.add(pointerId)
            }

            val mutableObjectList =
                hitPointerIdsAndNodes.getOrPut(pointerId.value) { mutableObjectListOf() }

            mutableObjectList.add(node)

            parent.children.add(node)
            parent = node
        }

        if (prunePointerIdsAndChangesNotInNodesList) {
            hitPointerIdsAndNodes.forEach { key, value ->
                removeInvalidPointerIdsAndChanges(key, value)
            }
        }
    }

    // Removes pointers/changes that are not in the latest hit test
    private fun removeInvalidPointerIdsAndChanges(
        pointerId: Long,
        hitNodes: MutableObjectList
    ) {
        root.removeInvalidPointerIdsAndChanges(pointerId, hitNodes)
    }

    /**
     * Dispatches [internalPointerEvent] through the hierarchy.
     *
     * @param internalPointerEvent The change to dispatch.
     *
     * @return whether this event was dispatched to a [PointerInputFilter]
     */
    fun dispatchChanges(
        internalPointerEvent: InternalPointerEvent,
        isInBounds: Boolean = true
    ): Boolean {
        val changed = root.buildCache(
            internalPointerEvent.changes,
            rootCoordinates,
            internalPointerEvent,
            isInBounds
        )
        if (!changed) {
            root.cleanUpHover()
            return false
        }
        var dispatchHit = root.dispatchMainEventPass(
            internalPointerEvent.changes,
            rootCoordinates,
            internalPointerEvent,
            isInBounds
        )
        dispatchHit = root.dispatchFinalEventPass(internalPointerEvent) || dispatchHit

        return dispatchHit
    }

    fun clearPreviouslyHitModifierNodeCache() {
        root.clear()
    }

    /**
     * Dispatches cancel events to all tracked [PointerInputFilter]s to notify them that
     * [PointerInputFilter.onPointerEvent] will not be called again until all pointers have been
     * removed from the application and then at least one is added again, and removes all tracked
     * data.
     */
    fun processCancel() {
        root.dispatchCancel()
        clearPreviouslyHitModifierNodeCache()
    }

    /**
     * Removes detached Pointer Input Modifier Nodes.
     */
    // TODO(shepshapard): Ideally, we can process the detaching of PointerInputFilters at the time
    //  that either their associated LayoutNode is removed from the three, or their
    //  associated PointerInputModifier is removed from a LayoutNode.
    fun removeDetachedPointerInputNodes() {
        root.removeDetachedPointerInputModifierNodes()
    }
}

/**
 * Represents a parent node in the [HitPathTracker]'s tree.  This primarily exists because the tree
 * necessarily has a root that is very similar to all other nodes, except that it does not track any
 * pointer or [PointerInputFilter] information.
 */
/*@VisibleForTesting*/
@OptIn(InternalCoreApi::class, ExperimentalComposeUiApi::class)
internal open class NodeParent {
    val children: MutableVector = mutableVectorOf()

    open fun buildCache(
        changes: LongSparseArray,
        parentCoordinates: LayoutCoordinates,
        internalPointerEvent: InternalPointerEvent,
        isInBounds: Boolean
    ): Boolean {
        var changed = false
        children.forEach {
            changed = it.buildCache(
                changes,
                parentCoordinates,
                internalPointerEvent,
                isInBounds
            ) || changed
        }
        return changed
    }

    /**
     * Dispatches [changes] down the tree, for the initial and main pass.
     *
     * [changes] and other properties needed in all passes should be cached inside this method so
     * they can be reused in [dispatchFinalEventPass], since the passes happen consecutively.
     *
     * @param changes the map containing [PointerInputChange]s that will be dispatched to
     * relevant [PointerInputFilter]s
     * @param parentCoordinates the [LayoutCoordinates] the positional information in [changes]
     * is relative to
     * @param internalPointerEvent the [InternalPointerEvent] needed to construct [PointerEvent]s
     */
    open fun dispatchMainEventPass(
        changes: LongSparseArray,
        parentCoordinates: LayoutCoordinates,
        internalPointerEvent: InternalPointerEvent,
        isInBounds: Boolean
    ): Boolean {
        var dispatched = false
        children.forEach {
            dispatched = it.dispatchMainEventPass(
                changes,
                parentCoordinates,
                internalPointerEvent,
                isInBounds
            ) || dispatched
        }
        return dispatched
    }

    /**
     * Dispatches the final event pass down the tree.
     *
     * Properties cached in [dispatchMainEventPass] should be reset after this method, to ensure
     * clean state for a future pass where pointer IDs / positions might be different.
     */
    open fun dispatchFinalEventPass(internalPointerEvent: InternalPointerEvent): Boolean {
        var dispatched = false
        children.forEach {
            dispatched = it.dispatchFinalEventPass(internalPointerEvent) || dispatched
        }
        cleanUpHits(internalPointerEvent)
        cleanUpHover()
        return dispatched
    }

    /**
     * Dispatches the cancel event to all child [Node]s.
     */
    open fun dispatchCancel() {
        children.forEach { it.dispatchCancel() }
    }

    /**
     * Removes all child nodes.
     */
    fun clear() {
        children.clear()
    }

    open fun removeInvalidPointerIdsAndChanges(
        pointerIdValue: Long,
        hitNodes: MutableObjectList
    ) {
        children.forEach {
            it.removeInvalidPointerIdsAndChanges(pointerIdValue, hitNodes)
        }
    }

    /**
     * Removes all child [Node]s that are no longer attached to the compose tree.
     */
    fun removeDetachedPointerInputModifierNodes() {
        var index = 0
        while (index < children.size) {
            val child = children[index]

            if (!child.modifierNode.isAttached) {
                child.dispatchCancel()
                children.removeAt(index)
            } else {
                index++
                child.removeDetachedPointerInputModifierNodes()
            }
        }
    }

    open fun cleanUpHits(internalPointerEvent: InternalPointerEvent) {
        for (i in children.lastIndex downTo 0) {
            val child = children[i]
            if (child.pointerIds.isEmpty()) {
                children.removeAt(i)
            }
        }
    }

    open fun cleanUpHover() {
        children.forEach {
            it.cleanUpHover()
        }
    }
}

/**
 * Represents a single Node in the tree that also tracks a [PointerInputFilter] and which pointers
 * hit it (tracked as [PointerId]s).
 */
/*@VisibleForTesting*/
@OptIn(InternalCoreApi::class, ExperimentalComposeUiApi::class)
internal class Node(val modifierNode: Modifier.Node) : NodeParent() {

    // Note: pointerIds are stored in a structure specific to their value type (PointerId).
    // This structure uses a LongArray internally, which avoids auto-boxing caused by
    // a more generic collection such as HashMap or MutableVector.
    val pointerIds = PointerIdArray()

    private var isIn = true
    private var hasEntered = false

    /**
     * Cached properties that will be set before the main event pass, and reset after the final
     * pass. Since we know that these won't change within the entire pass, we don't need to
     * calculate / create these for each pass / multiple times during a pass.
     *
     * @see buildCache
     * @see clearCache
     */
    private val relevantChanges: LongSparseArray = LongSparseArray(2)
    private var coordinates: LayoutCoordinates? = null
    private var pointerEvent: PointerEvent? = null

    override fun removeInvalidPointerIdsAndChanges(
        pointerIdValue: Long,
        hitNodes: MutableObjectList
    ) {
        if (this.pointerIds.contains(pointerIdValue)) {
            if (!hitNodes.contains(this)) {
                this.pointerIds.remove(pointerIdValue)
                this.relevantChanges.remove(pointerIdValue)
            }
        }

        children.forEach {
            it.removeInvalidPointerIdsAndChanges(pointerIdValue, hitNodes)
        }
    }

    override fun dispatchMainEventPass(
        changes: LongSparseArray,
        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 {
        // 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.
        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
    }

    /**
     * Calculates cached properties that will be stored in this [Node] for the duration of both
     * [dispatchMainEventPass] and [dispatchFinalEventPass]. This allows us to avoid repeated
     * work between passes, and within passes, as these properties won't change during the
     * overall dispatch.
     *
     * @see clearCache
     */
    override fun buildCache(
        changes: LongSparseArray,
        parentCoordinates: LayoutCoordinates,
        internalPointerEvent: InternalPointerEvent,
        isInBounds: Boolean
    ): Boolean {
        val childChanged =
            super.buildCache(
                changes,
                parentCoordinates,
                internalPointerEvent,
                isInBounds
            )

        // Avoid future work if we know this node will no-op
        if (!modifierNode.isAttached) return true

        modifierNode.dispatchForKind(Nodes.PointerInput) {
            coordinates = it.layoutCoordinates
        }

        @OptIn(ExperimentalComposeUiApi::class)
        for (j in 0 until changes.size()) {
            val keyValue = changes.keyAt(j)
            val change = changes.valueAt(j)

            if (pointerIds.contains(keyValue)) {
                val prevPosition = change.previousPosition
                val currentPosition = change.position

                if (prevPosition.isValid() && currentPosition.isValid()) {
                    // And translate their position relative to the parent coordinates, to give us a
                    // change local to the PointerInputFilter's coordinates
                    val historical = ArrayList(change.historical.size)

                    change.historical.fastForEach {
                        val historicalPosition = it.position
                        // In some rare cases, historic data may have an invalid position, that is,
                        // Offset.Unspecified. In those cases, we don't want to include it in the
                        // data returned to the developer because the values are invalid.
                        if (historicalPosition.isValid()) {
                            historical.add(
                                HistoricalChange(
                                    it.uptimeMillis,
                                    coordinates!!.localPositionOf(
                                        parentCoordinates,
                                        historicalPosition
                                    ),
                                    it.originalEventPosition
                                )
                            )
                        }
                    }

                    relevantChanges.put(keyValue, change.copy(
                        previousPosition = coordinates!!.localPositionOf(
                            parentCoordinates,
                            prevPosition
                        ),
                        currentPosition = coordinates!!.localPositionOf(
                            parentCoordinates,
                            currentPosition
                        ),
                        historical = historical
                    ))
                }
            }
        }

        if (relevantChanges.isEmpty()) {
            pointerIds.clear()
            children.clear()
            return true // not hit
        }

        // Clean up any pointerIds that weren't dispatched
        for (i in pointerIds.lastIndex downTo 0) {
            val pointerId = pointerIds[i]
            if (!changes.containsKey(pointerId.value)) {
                pointerIds.removeAt(i)
            }
        }

        val changesList = ArrayList(relevantChanges.size())
        for (i in 0 until relevantChanges.size()) {
            changesList.add(relevantChanges.valueAt(i))
        }
        val event = PointerEvent(changesList, internalPointerEvent)

        val activeHoverChange = event.changes.fastFirstOrNull {
            internalPointerEvent.activeHoverEvent(it.id)
        }

        if (activeHoverChange != null) {
            if (!isInBounds) {
                isIn = false
            } else if (!isIn && (activeHoverChange.pressed || activeHoverChange.previousPressed)) {
                // We have to recalculate isIn because we didn't redo hit testing
                val size = coordinates!!.size
                @Suppress("DEPRECATION")
                isIn = !activeHoverChange.isOutOfBounds(size)
            }
            if (event.type == PointerEventType.Move ||
                event.type == PointerEventType.Enter ||
                event.type == PointerEventType.Exit
            ) {
                event.type = when {
                    !hasEntered && isIn -> PointerEventType.Enter
                    hasEntered && !isIn -> PointerEventType.Exit
                    else -> PointerEventType.Move
                }
            }

            if (event.type == PointerEventType.Enter) hasEntered = true
            if (event.type == PointerEventType.Exit) hasEntered = false
        }

        val changed = childChanged || event.type != PointerEventType.Move ||
            hasPositionChanged(pointerEvent, event)
        pointerEvent = event
        return changed
    }

    private fun hasPositionChanged(oldEvent: PointerEvent?, newEvent: PointerEvent): Boolean {
        if (oldEvent == null || oldEvent.changes.size != newEvent.changes.size) {
            return true
        }
        for (i in 0 until newEvent.changes.size) {
            val old = oldEvent.changes[i]
            val current = newEvent.changes[i]
            if (old.position != current.position) {
                return true
            }
        }
        return false
    }

    /**
     * Resets cached properties in case this node will continue to track different [pointerIds]
     * than the ones we built the cache for, instead of being removed.
     *
     * @see buildCache
     */
    private fun clearCache() {
        relevantChanges.clear()
        coordinates = null
    }

    /**
     * Calls [block] if there are relevant changes, and if [modifierNode] is attached
     *
     * @return whether [block] was called
     */
    private inline fun dispatchIfNeeded(
        block: () -> Unit
    ): Boolean {
        // If there are no relevant changes, there is nothing to process so return false.
        if (relevantChanges.isEmpty()) return false
        // If the input filter is not attached, avoid dispatching
        if (!modifierNode.isAttached) return false

        block()

        // We dispatched to at least one pointer input filter so return true.
        return true
    }

    // TODO(shepshapard): Should some order of cancel dispatch be guaranteed? I think the answer is
    //  essentially "no", but given that an order can be consistent... maybe we might as well
    //  set an arbitrary standard and stick to it so user expectations are maintained.
    /**
     * Does a depth first traversal and invokes [PointerInputFilter.onCancel] during
     * backtracking.
     */
    override fun dispatchCancel() {
        children.forEach { it.dispatchCancel() }
        modifierNode.dispatchForKind(Nodes.PointerInput) {
            it.onCancelPointerInput()
        }
    }

    fun markIsIn() {
        isIn = true
    }

    override fun cleanUpHits(internalPointerEvent: InternalPointerEvent) {
        super.cleanUpHits(internalPointerEvent)

        val event = pointerEvent ?: return

        event.changes.fastForEach { change ->
            // There are two scenarios where we need to remove the pointerIds:
            //   1. Pointer is released AND event stream doesn't have an active hover.
            //   2. Pointer is released AND is released outside the area.
            val released = !change.pressed
            val nonHoverEventStream = !internalPointerEvent.activeHoverEvent(change.id)
            val outsideArea = !isIn

            val removePointerId =
                (released && nonHoverEventStream) || (released && outsideArea)

            if (removePointerId) {
                pointerIds.remove(change.id)
            }
        }
    }

    override fun cleanUpHover() {
        super.cleanUpHover()
        isIn = false
    }

    override fun toString(): String {
        return "Node(pointerInputFilter=$modifierNode, children=$children, " +
            "pointerIds=$pointerIds)"
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy