blob: 9544073a9e8cc08a19a05a7d0efd30205ee7b2d0 [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.BlendMode
import android.graphics.Canvas as AndroidCanvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Rect as AndroidRect
import android.os.CancellationSignal
import android.util.Log
import android.view.ScrollCaptureCallback
import android.view.ScrollCaptureSession
import androidx.annotation.RequiresApi
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.MotionDurationScale
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.toAndroidRect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.toComposeIntRect
import androidx.compose.ui.internal.checkPreconditionNotNull
import androidx.compose.ui.semantics.SemanticsActions.ScrollByOffset
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.semantics.SemanticsProperties.VerticalScrollAxisRange
import androidx.compose.ui.unit.IntRect
import java.util.function.Consumer
import kotlin.math.roundToInt
import kotlin.random.Random
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
private const val DEBUG = false
private const val TAG = "ScrollCapture"
/**
* Implementation of [ScrollCaptureCallback] that captures Compose scroll containers.
*
* This callback interacts with the scroll container via semantics, namely [ScrollByOffset], and
* supports any container that publishes that action – whether the size of the scroll contents are
* known or not (e.g. `LazyColumn`). Pixels are captured by drawing the node directly after each
* scroll operation.
*/
@RequiresApi(31)
internal class ComposeScrollCaptureCallback(
private val node: SemanticsNode,
private val viewportBoundsInWindow: IntRect,
coroutineScope: CoroutineScope,
private val listener: ScrollCaptureSessionListener,
) : ScrollCaptureCallback {
// Don't animate scrollByOffset calls.
private val coroutineScope = coroutineScope + DisableAnimationMotionDurationScale
private val scrollTracker =
RelativeScroller(
viewportSize = viewportBoundsInWindow.height,
scrollBy = { delta ->
val scrollByOffset = checkPreconditionNotNull(node.scrollCaptureScrollByAction)
val reverseScrolling = node.unmergedConfig[VerticalScrollAxisRange].reverseScrolling
val actualDelta = if (reverseScrolling) -delta else delta
if (DEBUG)
Log.d(
TAG,
"scrolling by delta $actualDelta " +
"(reverseScrolling=$reverseScrolling, requested delta=$delta)"
)
// This action may animate, ensure any calls to this RelativeScroll are done with a
// coroutine context that disables animations.
val consumed = scrollByOffset(Offset(0f, actualDelta))
if (reverseScrolling) -consumed.y else consumed.y
}
)
/** Only used when [DEBUG] is true. */
private var requestCount = 0
override fun onScrollCaptureSearch(signal: CancellationSignal, onReady: Consumer<AndroidRect>) {
val bounds = viewportBoundsInWindow
onReady.accept(bounds.toAndroidRect())
}
override fun onScrollCaptureStart(
session: ScrollCaptureSession,
signal: CancellationSignal,
onReady: Runnable
) {
scrollTracker.reset()
requestCount = 0
listener.onSessionStarted()
onReady.run()
}
override fun onScrollCaptureImageRequest(
session: ScrollCaptureSession,
signal: CancellationSignal,
captureArea: AndroidRect,
onComplete: Consumer<AndroidRect>
) {
coroutineScope.launchWithCancellationSignal(signal) {
val result = onScrollCaptureImageRequest(session, captureArea.toComposeIntRect())
onComplete.accept(result.toAndroidRect())
}
}
private suspend fun onScrollCaptureImageRequest(
session: ScrollCaptureSession,
captureArea: IntRect,
): IntRect {
// Scroll the requested capture area into the viewport so we can draw it.
val targetMin = captureArea.top
val targetMax = captureArea.bottom
if (DEBUG) Log.d(TAG, "capture request for $targetMin..$targetMax")
scrollTracker.scrollRangeIntoView(targetMin, targetMax)
// Wait a frame to allow layout to respond to the scroll.
withFrameNanos {}
// Calculate the viewport-relative coordinates of the capture area, clipped to
// the viewport.
val viewportClippedMin = scrollTracker.mapOffsetToViewport(targetMin)
val viewportClippedMax = scrollTracker.mapOffsetToViewport(targetMax)
if (DEBUG) Log.d(TAG, "drawing viewport $viewportClippedMin..$viewportClippedMax")
val viewportClippedRect =
captureArea.copy(top = viewportClippedMin, bottom = viewportClippedMax)
if (viewportClippedMin == viewportClippedMax) {
// Requested capture area is outside the bounds of scrollable content,
// nothing to capture.
return IntRect.Zero
}
// Draw a single frame of the content to a buffer that we can stamp out.
val coordinator =
checkNotNull(node.findCoordinatorToGetBounds()) {
"Could not find coordinator for semantics node."
}
val androidCanvas = session.surface.lockHardwareCanvas()
try {
// Clear any pixels left over from a previous request.
androidCanvas.drawColor(Color.TRANSPARENT, BlendMode.CLEAR)
if (DEBUG) {
androidCanvas.drawDebugBackground()
}
val canvas = Canvas(androidCanvas)
canvas.translate(
dx = -viewportClippedRect.left.toFloat(),
dy = -viewportClippedRect.top.toFloat()
)
coordinator.draw(canvas, graphicsLayer = null)
if (DEBUG) {
canvas.translate(
dx = viewportClippedRect.left.toFloat(),
dy = viewportClippedRect.top.toFloat(),
)
androidCanvas.drawDebugOverlay()
}
} finally {
session.surface.unlockCanvasAndPost(androidCanvas)
}
// Translate back to "original" coordinates to report.
val resultRect = viewportClippedRect.translate(0, scrollTracker.scrollAmount.roundToInt())
if (DEBUG) Log.d(TAG, "captured rectangle $resultRect")
return resultRect
}
override fun onScrollCaptureEnd(onReady: Runnable) {
coroutineScope.launch(NonCancellable) {
scrollTracker.scrollTo(0f)
listener.onSessionEnded()
onReady.run()
}
}
private fun AndroidCanvas.drawDebugBackground() {
drawColor(
androidx.compose.ui.graphics.Color.hsl(
hue = Random.nextFloat() * 360f,
saturation = 0.75f,
lightness = 0.5f,
alpha = 1f
)
.toArgb()
)
}
private fun AndroidCanvas.drawDebugOverlay() {
val circleRadius = 20f
val circlePaint =
Paint().apply {
color = Color.RED
textSize = 48f
}
drawCircle(0f, 0f, circleRadius, circlePaint)
drawCircle(width.toFloat(), 0f, circleRadius, circlePaint)
drawCircle(width.toFloat(), height.toFloat(), circleRadius, circlePaint)
drawCircle(0f, height.toFloat(), circleRadius, circlePaint)
drawText(requestCount.toString(), width / 2f, height / 2f, circlePaint)
requestCount++
}
interface ScrollCaptureSessionListener {
fun onSessionStarted()
fun onSessionEnded()
}
}
private fun CoroutineScope.launchWithCancellationSignal(
signal: CancellationSignal,
block: suspend CoroutineScope.() -> Unit
): Job {
val job = launch(block = block)
job.invokeOnCompletion { cause ->
if (cause != null) {
signal.cancel()
}
}
signal.setOnCancelListener { job.cancel() }
return job
}
/**
* Helper class for scrolling to specific offsets relative to an original scroll position and
* mapping those offsets to the current viewport coordinates.
*/
private class RelativeScroller(
private val viewportSize: Int,
private val scrollBy: suspend (Float) -> Float
) {
var scrollAmount = 0f
private set
fun reset() {
scrollAmount = 0f
}
/**
* Scrolls so that the range ([min], [max]) is in the viewport. The range must fit inside the
* viewport.
*/
suspend fun scrollRangeIntoView(min: Int, max: Int) {
if (DEBUG) Log.d(TAG, "scrollRangeIntoView(min=$min, max=$max)")
require(min <= max) { "Expected min=$min ≤ max=$max" }
require(max - min <= viewportSize) {
"Expected range (${max - min}) to be ≤ viewportSize=$viewportSize"
}
if (min >= scrollAmount && max <= scrollAmount + viewportSize) {
// Already visible, no need to scroll.
if (DEBUG) Log.d(TAG, "requested range already in view, not scrolling")
return
}
// Scroll to the nearest edge.
val target = if (min < scrollAmount) min else max - viewportSize
if (DEBUG) Log.d(TAG, "scrolling to $target")
scrollTo(target.toFloat())
}
/**
* Given [offset] relative to the original scroll position, maps it to the current offset in the
* viewport. Values are clamped to the viewport.
*
* This is an identity map for values inside the viewport before any scrolling has been done
* after calling `scrollTo(0f)`.
*/
fun mapOffsetToViewport(offset: Int): Int {
return (offset - scrollAmount.roundToInt()).coerceIn(0, viewportSize)
}
/** Try to scroll to [offset] pixels past the original scroll position. */
suspend fun scrollTo(offset: Float) {
scrollBy(offset - scrollAmount)
}
private suspend fun scrollBy(delta: Float) {
val consumed = scrollBy.invoke(delta)
scrollAmount += consumed
if (DEBUG)
Log.d(TAG, "scrolled $consumed of requested $delta, after scrollAmount=$scrollAmount")
}
}
private object DisableAnimationMotionDurationScale : MotionDurationScale {
override val scaleFactor: Float
get() = 0f
}