blob: 1be1cb3df2fc4b58e74aa13838fd82f2a507a12a [file] [log] [blame]
/*
* Copyright 2024 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.scrollcapture
import android.graphics.Point
import android.view.ScrollCaptureCallback
import android.view.ScrollCaptureTarget
import android.view.View
import androidx.annotation.RequiresApi
import androidx.compose.runtime.collection.mutableVectorOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.toAndroidRect
import androidx.compose.ui.internal.checkPreconditionNotNull
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.platform.isVisible
import androidx.compose.ui.semantics.SemanticsActions.ScrollByOffset
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.semantics.SemanticsOwner
import androidx.compose.ui.semantics.SemanticsProperties.Disabled
import androidx.compose.ui.semantics.SemanticsProperties.VerticalScrollAxisRange
import androidx.compose.ui.semantics.getOrNull
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.roundToIntRect
import java.util.function.Consumer
import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.CoroutineScope
/** Separate class to host the implementation of scroll capture for dex verification. */
@RequiresApi(31)
internal class ScrollCapture : ComposeScrollCaptureCallback.ScrollCaptureSessionListener {
var scrollCaptureInProgress: Boolean by mutableStateOf(false)
private set
/**
* Implements scroll capture (long screenshots) support for a composition. Finds a single
* [ScrollCaptureTarget] to propose to the platform. Searches over the semantics tree to find
* nodes that publish vertical scroll semantics (namely [ScrollByOffset] and
* [VerticalScrollAxisRange]) and then uses logic similar to how the platform searches [View]
* targets to select the deepest, largest scroll container. If a target is found, an
* implementation of [ScrollCaptureCallback] is created for it (see
* [ComposeScrollCaptureCallback]) and given to the platform.
*
* The platform currently only supports scroll capture for containers that scroll vertically.
* The API supports horizontal as well, but it's not used. To keep this code simpler and avoid
* having dead code, we only implement vertical scroll capture as well.
*
* See go/compose-long-screenshots for more background.
*/
// Required not to be inlined for class verification.
fun onScrollCaptureSearch(
view: View,
semanticsOwner: SemanticsOwner,
coroutineContext: CoroutineContext,
targets: Consumer<ScrollCaptureTarget>
) {
// Search the semantics tree for scroll containers.
val candidates = mutableVectorOf<ScrollCaptureCandidate>()
visitScrollCaptureCandidates(
fromNode = semanticsOwner.unmergedRootSemanticsNode,
onCandidate = candidates::add
)
// Sort to find the deepest node with the biggest bounds in the dimension(s) that the node
// supports scrolling in.
candidates.sortWith(
compareBy(
{ it.depth },
{ it.viewportBoundsInWindow.height },
)
)
val candidate = candidates.lastOrNull() ?: return
// If we found a candidate, create a capture callback for it and give it to the system.
val coroutineScope = CoroutineScope(coroutineContext)
val callback =
ComposeScrollCaptureCallback(
node = candidate.node,
viewportBoundsInWindow = candidate.viewportBoundsInWindow,
coroutineScope = coroutineScope,
listener = this
)
val localVisibleRectOfCandidate = candidate.coordinates.boundsInRoot()
val windowOffsetOfCandidate = candidate.viewportBoundsInWindow.topLeft
targets.accept(
ScrollCaptureTarget(
view,
localVisibleRectOfCandidate.roundToIntRect().toAndroidRect(),
windowOffsetOfCandidate.let { Point(it.x, it.y) },
callback
)
.apply { scrollBounds = candidate.viewportBoundsInWindow.toAndroidRect() }
)
}
override fun onSessionStarted() {
scrollCaptureInProgress = true
}
override fun onSessionEnded() {
scrollCaptureInProgress = false
}
}
/**
* Walks the tree of [SemanticsNode]s rooted at [fromNode] to find nodes that look scrollable and
* calculate their nesting depth.
*/
private fun visitScrollCaptureCandidates(
fromNode: SemanticsNode,
depth: Int = 0,
onCandidate: (ScrollCaptureCandidate) -> Unit
) {
fromNode.visitDescendants { node ->
// Invisible/disabled nodes can't be candidates, nor can any of their descendants.
if (!node.isVisible || Disabled in node.unmergedConfig) {
return@visitDescendants false
}
val nodeCoordinates =
checkPreconditionNotNull(node.findCoordinatorToGetBounds()) {
"Expected semantics node to have a coordinator."
}
.coordinates
// Zero-sized nodes can't be candidates, and by definition would clip all their children so
// they and their descendants can't be candidates either.
val viewportBoundsInWindow = nodeCoordinates.boundsInWindow().roundToIntRect()
if (viewportBoundsInWindow.isEmpty) {
return@visitDescendants false
}
// If the node is visible, we need to check if it's scrollable.
// TODO(b/329295945) Support explicit opt-in/-out.
// Don't care about horizontal scroll containers.
if (!node.canScrollVertically) {
// Not a scrollable, so can't be a candidate, but its descendants might be.
return@visitDescendants true
}
// We found a node that looks scrollable! Report it, then visit its children with an
// incremented depth counter.
val candidateDepth = depth + 1
onCandidate(
ScrollCaptureCandidate(
node = node,
depth = candidateDepth,
viewportBoundsInWindow = viewportBoundsInWindow,
coordinates = nodeCoordinates,
)
)
visitScrollCaptureCandidates(
fromNode = node,
depth = candidateDepth,
onCandidate = onCandidate
)
// We've just visited descendants ourselves, don't need this visit call to do it.
return@visitDescendants false
}
}
internal val SemanticsNode.scrollCaptureScrollByAction
get() = unmergedConfig.getOrNull(ScrollByOffset)
private val SemanticsNode.canScrollVertically: Boolean
get() {
val scrollByOffset = scrollCaptureScrollByAction
val verticalScrollAxisRange = unmergedConfig.getOrNull(VerticalScrollAxisRange)
return scrollByOffset != null &&
verticalScrollAxisRange != null &&
verticalScrollAxisRange.maxValue() > 0f
}
/**
* Visits all the descendants of this [SemanticsNode].
*
* @param onNode Function called for each [SemanticsNode]. Iff this function returns true, the
* children of the current node will be visited.
*/
private inline fun SemanticsNode.visitDescendants(onNode: (SemanticsNode) -> Boolean) {
val nodes = mutableVectorOf<SemanticsNode>()
nodes.addAll(getChildrenForSearch())
while (nodes.isNotEmpty()) {
val node = nodes.removeAt(nodes.lastIndex)
val visitChildren = onNode(node)
if (visitChildren) {
nodes.addAll(node.getChildrenForSearch())
}
}
}
private fun SemanticsNode.getChildrenForSearch() =
getChildren(
includeDeactivatedNodes = false,
includeReplacedSemantics = false,
includeFakeNodes = false
)
/**
* Information about a potential [ScrollCaptureTarget] needed to both select the final candidate and
* create its [ComposeScrollCaptureCallback].
*/
private class ScrollCaptureCandidate(
val node: SemanticsNode,
val depth: Int,
val viewportBoundsInWindow: IntRect,
val coordinates: LayoutCoordinates,
) {
override fun toString(): String =
"ScrollCaptureCandidate(node=$node, " +
"depth=$depth, " +
"viewportBoundsInWindow=$viewportBoundsInWindow, " +
"coordinates=$coordinates)"
}