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