1 /*
<lambda>null2 * Copyright 2024 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17 package androidx.compose.ui.scrollcapture
18
19 import android.graphics.Canvas as AndroidCanvas
20 import android.graphics.Color
21 import android.graphics.Paint
22 import android.graphics.Rect as AndroidRect
23 import android.os.CancellationSignal
24 import android.util.Log
25 import android.view.ScrollCaptureCallback
26 import android.view.ScrollCaptureSession
27 import android.view.View
28 import androidx.annotation.RequiresApi
29 import androidx.compose.runtime.withFrameNanos
30 import androidx.compose.ui.MotionDurationScale
31 import androidx.compose.ui.geometry.Offset
32 import androidx.compose.ui.graphics.toAndroidRect
33 import androidx.compose.ui.graphics.toArgb
34 import androidx.compose.ui.graphics.toComposeIntRect
35 import androidx.compose.ui.internal.checkPreconditionNotNull
36 import androidx.compose.ui.semantics.SemanticsActions.ScrollByOffset
37 import androidx.compose.ui.semantics.SemanticsNode
38 import androidx.compose.ui.semantics.SemanticsProperties.VerticalScrollAxisRange
39 import androidx.compose.ui.unit.IntRect
40 import java.util.function.Consumer
41 import kotlin.math.roundToInt
42 import kotlin.random.Random
43 import kotlinx.coroutines.CoroutineScope
44 import kotlinx.coroutines.Job
45 import kotlinx.coroutines.NonCancellable
46 import kotlinx.coroutines.launch
47 import kotlinx.coroutines.plus
48
49 private const val DEBUG = false
50 private const val TAG = "ScrollCapture"
51
52 /**
53 * Implementation of [ScrollCaptureCallback] that captures Compose scroll containers.
54 *
55 * This callback interacts with the scroll container via semantics, namely [ScrollByOffset], and
56 * supports any container that publishes that action – whether the size of the scroll contents are
57 * known or not (e.g. `LazyColumn`). Pixels are captured by drawing the node directly after each
58 * scroll operation.
59 */
60 @RequiresApi(31)
61 internal class ComposeScrollCaptureCallback(
62 private val node: SemanticsNode,
63 private val viewportBoundsInWindow: IntRect,
64 coroutineScope: CoroutineScope,
65 private val listener: ScrollCaptureSessionListener,
66 private val composeView: View,
67 ) : ScrollCaptureCallback {
68 // Don't animate scrollByOffset calls.
69 private val coroutineScope = coroutineScope + DisableAnimationMotionDurationScale
70
71 private val scrollTracker =
72 RelativeScroller(
73 viewportSize = viewportBoundsInWindow.height,
74 scrollBy = { delta ->
75 val scrollByOffset = checkPreconditionNotNull(node.scrollCaptureScrollByAction)
76 val reverseScrolling = node.unmergedConfig[VerticalScrollAxisRange].reverseScrolling
77
78 val actualDelta = if (reverseScrolling) -delta else delta
79 if (DEBUG)
80 Log.d(
81 TAG,
82 "scrolling by delta $actualDelta " +
83 "(reverseScrolling=$reverseScrolling, requested delta=$delta)"
84 )
85
86 // This action may animate, ensure any calls to this RelativeScroll are done with a
87 // coroutine context that disables animations.
88 val consumed = scrollByOffset(Offset(0f, actualDelta))
89 if (reverseScrolling) -consumed.y else consumed.y
90 }
91 )
92
93 /** Only used when [DEBUG] is true. */
94 private var requestCount = 0
95
96 override fun onScrollCaptureSearch(signal: CancellationSignal, onReady: Consumer<AndroidRect>) {
97 val bounds = viewportBoundsInWindow
98 onReady.accept(bounds.toAndroidRect())
99 }
100
101 override fun onScrollCaptureStart(
102 session: ScrollCaptureSession,
103 signal: CancellationSignal,
104 onReady: Runnable
105 ) {
106 scrollTracker.reset()
107 requestCount = 0
108 listener.onSessionStarted()
109 onReady.run()
110 }
111
112 override fun onScrollCaptureImageRequest(
113 session: ScrollCaptureSession,
114 signal: CancellationSignal,
115 captureArea: AndroidRect,
116 onComplete: Consumer<AndroidRect>
117 ) {
118 coroutineScope.launchWithCancellationSignal(signal) {
119 val result = onScrollCaptureImageRequest(session, captureArea.toComposeIntRect())
120 onComplete.accept(result.toAndroidRect())
121 }
122 }
123
124 private suspend fun onScrollCaptureImageRequest(
125 session: ScrollCaptureSession,
126 captureArea: IntRect,
127 ): IntRect {
128 // Scroll the requested capture area into the viewport so we can draw it.
129 val targetMin = captureArea.top
130 val targetMax = captureArea.bottom
131 if (DEBUG) Log.d(TAG, "capture request for $targetMin..$targetMax")
132 scrollTracker.scrollRangeIntoView(targetMin, targetMax)
133
134 // Wait a frame to allow layout to respond to the scroll.
135 withFrameNanos {}
136
137 // Calculate the viewport-relative coordinates of the capture area, clipped to
138 // the viewport.
139 val viewportClippedMin = scrollTracker.mapOffsetToViewport(targetMin)
140 val viewportClippedMax = scrollTracker.mapOffsetToViewport(targetMax)
141 if (DEBUG) Log.d(TAG, "drawing viewport $viewportClippedMin..$viewportClippedMax")
142 val viewportClippedRect =
143 captureArea.copy(top = viewportClippedMin, bottom = viewportClippedMax)
144
145 if (viewportClippedMin == viewportClippedMax) {
146 // Requested capture area is outside the bounds of scrollable content,
147 // nothing to capture.
148 return IntRect.Zero
149 }
150
151 val canvas = session.surface.lockHardwareCanvas()
152 try {
153 if (DEBUG) {
154 canvas.drawDebugBackground()
155 }
156 canvas.save()
157 canvas.translate(
158 -viewportClippedRect.left.toFloat(),
159 -viewportClippedRect.top.toFloat()
160 )
161
162 // slide the viewPort over to make it window-relative
163 canvas.translate(
164 -viewportBoundsInWindow.left.toFloat(),
165 -viewportBoundsInWindow.top.toFloat()
166 )
167 // draw the content from the root view (DecorView) including the window background
168 composeView.rootView.draw(canvas)
169
170 if (DEBUG) {
171 canvas.restore()
172 canvas.drawDebugOverlay()
173 }
174 } finally {
175 session.surface.unlockCanvasAndPost(canvas)
176 }
177
178 // Translate back to "original" coordinates to report.
179 val resultRect = viewportClippedRect.translate(0, scrollTracker.scrollAmount.roundToInt())
180 if (DEBUG) Log.d(TAG, "captured rectangle $resultRect")
181 return resultRect
182 }
183
184 override fun onScrollCaptureEnd(onReady: Runnable) {
185 coroutineScope.launch(NonCancellable) {
186 scrollTracker.scrollTo(0f)
187 listener.onSessionEnded()
188 onReady.run()
189 }
190 }
191
192 private fun AndroidCanvas.drawDebugBackground() {
193 drawColor(
194 androidx.compose.ui.graphics.Color.hsl(
195 hue = Random.nextFloat() * 360f,
196 saturation = 0.75f,
197 lightness = 0.5f,
198 alpha = 1f
199 )
200 .toArgb()
201 )
202 }
203
204 private fun AndroidCanvas.drawDebugOverlay() {
205 val circleRadius = 20f
206 val circlePaint =
207 Paint().apply {
208 color = Color.RED
209 textSize = 48f
210 }
211 drawCircle(0f, 0f, circleRadius, circlePaint)
212 drawCircle(width.toFloat(), 0f, circleRadius, circlePaint)
213 drawCircle(width.toFloat(), height.toFloat(), circleRadius, circlePaint)
214 drawCircle(0f, height.toFloat(), circleRadius, circlePaint)
215
216 drawText(requestCount.toString(), width / 2f, height / 2f, circlePaint)
217 requestCount++
218 }
219
220 interface ScrollCaptureSessionListener {
221 fun onSessionStarted()
222
223 fun onSessionEnded()
224 }
225 }
226
launchWithCancellationSignalnull227 private fun CoroutineScope.launchWithCancellationSignal(
228 signal: CancellationSignal,
229 block: suspend CoroutineScope.() -> Unit
230 ): Job {
231 val job = launch(block = block)
232 job.invokeOnCompletion { cause ->
233 if (cause != null) {
234 signal.cancel()
235 }
236 }
237 signal.setOnCancelListener { job.cancel() }
238 return job
239 }
240
241 /**
242 * Helper class for scrolling to specific offsets relative to an original scroll position and
243 * mapping those offsets to the current viewport coordinates.
244 */
245 private class RelativeScroller(
246 private val viewportSize: Int,
247 private val scrollBy: suspend (Float) -> Float
248 ) {
249 var scrollAmount = 0f
250 private set
251
resetnull252 fun reset() {
253 scrollAmount = 0f
254 }
255
256 /**
257 * Scrolls so that the range ([min], [max]) is in the viewport. The range must fit inside the
258 * viewport.
259 */
scrollRangeIntoViewnull260 suspend fun scrollRangeIntoView(min: Int, max: Int) {
261 if (DEBUG) Log.d(TAG, "scrollRangeIntoView(min=$min, max=$max)")
262 require(min <= max) { "Expected min=$min ≤ max=$max" }
263 require(max - min <= viewportSize) {
264 "Expected range (${max - min}) to be ≤ viewportSize=$viewportSize"
265 }
266
267 if (min >= scrollAmount && max <= scrollAmount + viewportSize) {
268 // Already visible, no need to scroll.
269 if (DEBUG) Log.d(TAG, "requested range already in view, not scrolling")
270 return
271 }
272
273 // Scroll to the nearest edge.
274 val target = if (min < scrollAmount) min else max - viewportSize
275 if (DEBUG) Log.d(TAG, "scrolling to $target")
276 scrollTo(target.toFloat())
277 }
278
279 /**
280 * Given [offset] relative to the original scroll position, maps it to the current offset in the
281 * viewport. Values are clamped to the viewport.
282 *
283 * This is an identity map for values inside the viewport before any scrolling has been done
284 * after calling `scrollTo(0f)`.
285 */
mapOffsetToViewportnull286 fun mapOffsetToViewport(offset: Int): Int {
287 return (offset - scrollAmount.roundToInt()).coerceIn(0, viewportSize)
288 }
289
290 /** Try to scroll to [offset] pixels past the original scroll position. */
scrollTonull291 suspend fun scrollTo(offset: Float) {
292 scrollBy(offset - scrollAmount)
293 }
294
scrollBynull295 private suspend fun scrollBy(delta: Float) {
296 val consumed = scrollBy.invoke(delta)
297 scrollAmount += consumed
298 if (DEBUG)
299 Log.d(TAG, "scrolled $consumed of requested $delta, after scrollAmount=$scrollAmount")
300 }
301 }
302
303 private object DisableAnimationMotionDurationScale : MotionDurationScale {
304 override val scaleFactor: Float
305 get() = 0f
306 }
307