1 /*
<lambda>null2  * Copyright 2022 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.foundation.gestures
18 
19 import androidx.compose.foundation.ExperimentalFoundationApi
20 import androidx.compose.foundation.MutatePriority
21 import androidx.compose.foundation.gestures.Orientation.Horizontal
22 import androidx.compose.foundation.gestures.Orientation.Vertical
23 import androidx.compose.foundation.internal.checkPrecondition
24 import androidx.compose.foundation.relocation.BringIntoViewRequester
25 import androidx.compose.ui.Modifier
26 import androidx.compose.ui.geometry.Offset
27 import androidx.compose.ui.geometry.Rect
28 import androidx.compose.ui.geometry.Size
29 import androidx.compose.ui.input.nestedscroll.NestedScrollSource
30 import androidx.compose.ui.layout.LayoutCoordinates
31 import androidx.compose.ui.node.CompositionLocalConsumerModifierNode
32 import androidx.compose.ui.node.LayoutAwareModifierNode
33 import androidx.compose.ui.node.currentValueOf
34 import androidx.compose.ui.node.requireLayoutCoordinates
35 import androidx.compose.ui.unit.IntSize
36 import androidx.compose.ui.unit.toSize
37 import kotlin.math.abs
38 import kotlin.math.absoluteValue
39 import kotlinx.coroutines.CancellableContinuation
40 import kotlinx.coroutines.CancellationException
41 import kotlinx.coroutines.CoroutineName
42 import kotlinx.coroutines.CoroutineStart
43 import kotlinx.coroutines.cancel
44 import kotlinx.coroutines.job
45 import kotlinx.coroutines.launch
46 import kotlinx.coroutines.suspendCancellableCoroutine
47 
48 /**
49  * Static field to turn on a bunch of verbose logging to debug animations. Since this is a constant,
50  * any log statements guarded by this value should be removed by the compiler when it's false.
51  */
52 private const val DEBUG = false
53 private const val TAG = "ContentInViewModifier"
54 
55 /** A minimum amount of delta that it is considered a valid scroll. */
56 private const val MinScrollThreshold = 0.5f
57 
58 /**
59  * A [Modifier] to be placed on a scrollable container (i.e. [Modifier.scrollable]) that animates
60  * the [ScrollableState] to handle [BringIntoViewRequester] requests and keep the currently-focused
61  * child in view when the viewport shrinks.
62  */
63 // TODO(b/242732126) Make this logic reusable for TV's mario scrolling implementation.
64 @Suppress("DEPRECATION") // b/376080744
65 @OptIn(ExperimentalFoundationApi::class)
66 internal class ContentInViewNode(
67     private var orientation: Orientation,
68     private val scrollingLogic: ScrollingLogic,
69     private var reverseDirection: Boolean,
70     private var bringIntoViewSpec: BringIntoViewSpec?
71 ) :
72     Modifier.Node(),
73     androidx.compose.foundation.relocation.BringIntoViewResponder,
74     LayoutAwareModifierNode,
75     CompositionLocalConsumerModifierNode {
76 
77     override val shouldAutoInvalidate: Boolean = false
78 
79     /**
80      * Ongoing requests from [bringChildIntoView], with the invariant that it is always sorted by
81      * overlapping order: each item's [Rect] completely overlaps the next item.
82      *
83      * May contain requests whose bounds are too big to fit in the current viewport. This is for a
84      * few reasons:
85      * 1. The viewport may shrink after a request was enqueued, causing a request that fit at the
86      *    time it was enqueued to no longer fit.
87      * 2. The size of the bounds of a request may change after it's added, causing it to grow larger
88      *    than the viewport.
89      * 3. Having complete information about too-big requests allows us to make the right decision
90      *    about what part of the request to bring into view when smaller requests are also present.
91      */
92     private val bringIntoViewRequests = BringIntoViewRequestPriorityQueue()
93 
94     private var focusedChild: LayoutCoordinates? = null
95 
96     /**
97      * Set to true when this class is actively animating the scroll to keep the focused child in
98      * view.
99      */
100     private var trackingFocusedChild = false
101 
102     /**
103      * When the viewport shrinks, we need to wait for the focused bounds to be updated, which
104      * happens when globally positioned callbacks happen - this is _after_ this node has been
105      * remeasured. As a result we cannot bring into view until we know what the new bounds of the
106      * child are, as this can be affected by the measurement logic of this scrollable container /
107      * its parent(s). The old bounds of the child might still appear to be 'visible', even though it
108      * will now be placed in a different place, and become invisible.
109      */
110     private var childWasMaxVisibleBeforeViewportShrunk = false
111 
112     /** The size of the scrollable container. */
113     internal var viewportSize = IntSize.Zero
114         private set
115 
116     private var isAnimationRunning = false
117 
118     override fun calculateRectForParent(localRect: Rect): Rect {
119         checkPrecondition(viewportSize != IntSize.Zero) {
120             "Expected BringIntoViewRequester to not be used before parents are placed."
121         }
122         // size will only be zero before the initial measurement.
123         return computeDestination(localRect, viewportSize)
124     }
125 
126     private fun requireBringIntoViewSpec(): BringIntoViewSpec {
127         return bringIntoViewSpec ?: currentValueOf(LocalBringIntoViewSpec)
128     }
129 
130     override suspend fun bringChildIntoView(localRect: () -> Rect?) {
131         // Avoid creating no-op requests and no-op animations if the request does not require
132         // scrolling or returns null.
133         if (localRect()?.isMaxVisible() != false) return
134 
135         suspendCancellableCoroutine { continuation ->
136             val request = Request(currentBounds = localRect, continuation = continuation)
137             if (DEBUG) println("[$TAG] Registering bringChildIntoView request: $request")
138             // Once the request is enqueued, even if it returns false, the queue will take care of
139             // handling continuation cancellation so we don't need to do that here.
140             if (bringIntoViewRequests.enqueue(request) && !isAnimationRunning) {
141                 launchAnimation()
142             }
143         }
144     }
145 
146     fun onFocusBoundsChanged(newBounds: LayoutCoordinates?) {
147         focusedChild = newBounds
148 
149         if (childWasMaxVisibleBeforeViewportShrunk) {
150             getFocusedChildBounds()?.let { focusedChild ->
151                 if (DEBUG) println("[$TAG] focused child bounds: $focusedChild")
152                 if (!focusedChild.isMaxVisible(viewportSize)) {
153                     if (DEBUG)
154                         println(
155                             "[$TAG] focused child was clipped by viewport shrink: $focusedChild"
156                         )
157                     trackingFocusedChild = true
158                     launchAnimation()
159                 }
160             }
161         }
162         childWasMaxVisibleBeforeViewportShrunk = false
163     }
164 
165     override fun onRemeasured(size: IntSize) {
166         val previousViewportSize = viewportSize
167         viewportSize = size
168 
169         // Don't care if the viewport grew.
170         if (size >= previousViewportSize) return
171 
172         if (DEBUG) println("[$TAG] viewport shrunk: $previousViewportSize -> $size")
173 
174         // Ignore if we are already tracking an existing animation
175         if (isAnimationRunning || trackingFocusedChild) {
176             if (DEBUG) println("[$TAG] ignoring size change because animation is in progress")
177             return
178         }
179 
180         // onRemeasured is called before the onGloballyPositioned callbacks are dispatched, so
181         // the bounds we get here will essentially be the previous bounds of the focused child,
182         // before this remeasurement occurred.
183         val boundsBeforeRemeasurement = getFocusedChildBounds() ?: return
184 
185         // If the focused child was previously fully visible (its 'previous' bounds fit inside the
186         // previous viewport), we need to see if it is still visible after this remeasurement
187         // finishes and the child is placed in its new location. To do that we need to wait for
188         // its onGloballyPositioned callback, which will then end up calling onFocusBoundsChanged
189         if (boundsBeforeRemeasurement.isMaxVisible(previousViewportSize)) {
190             childWasMaxVisibleBeforeViewportShrunk = true
191         }
192     }
193 
194     private fun getFocusedChildBounds(): Rect? {
195         if (!isAttached) return null
196         val coordinates = requireLayoutCoordinates()
197         val focusedChild = this.focusedChild?.takeIf { it.isAttached } ?: return null
198         return coordinates.localBoundingBoxOf(focusedChild, clipBounds = false)
199     }
200 
201     private fun launchAnimation() {
202         val bringIntoViewSpec = requireBringIntoViewSpec()
203         checkPrecondition(!isAnimationRunning) {
204             "launchAnimation called when previous animation was running"
205         }
206 
207         if (DEBUG) println("[$TAG] launchAnimation")
208         val animationState = UpdatableAnimationState(BringIntoViewSpec.DefaultScrollAnimationSpec)
209         coroutineScope.launch(start = CoroutineStart.UNDISPATCHED) {
210             var cancellationException: CancellationException? = null
211             val animationJob = coroutineContext.job
212 
213             try {
214                 isAnimationRunning = true
215                 scrollingLogic.scroll(scrollPriority = MutatePriority.Default) {
216                     animationState.value = calculateScrollDelta(bringIntoViewSpec)
217                     if (DEBUG)
218                         println(
219                             "[$TAG] Starting scroll animation down from ${animationState.value}…"
220                         )
221                     animationState.animateToZero(
222                         // This lambda will be invoked on every frame, during the choreographer
223                         // callback.
224                         beforeFrame = { delta ->
225                             // reverseDirection is actually opposite of what's passed in through the
226                             // (vertical|horizontal)Scroll modifiers.
227                             val scrollMultiplier = if (reverseDirection) 1f else -1f
228                             val adjustedDelta = scrollMultiplier * delta
229                             if (DEBUG)
230                                 println(
231                                     "[$TAG] Scroll target changed by Δ$delta to " +
232                                         "${animationState.value}, scrolling by $adjustedDelta " +
233                                         "(reverseDirection=$reverseDirection)"
234                                 )
235                             val consumedScroll =
236                                 with(scrollingLogic) {
237                                     scrollMultiplier *
238                                         scrollBy(
239                                                 offset = adjustedDelta.toOffset().reverseIfNeeded(),
240                                                 source = NestedScrollSource.UserInput
241                                             )
242                                             .reverseIfNeeded()
243                                             .toFloat()
244                                 }
245                             if (DEBUG) println("[$TAG] Consumed $consumedScroll of scroll")
246                             if (consumedScroll.absoluteValue < delta.absoluteValue) {
247                                 // If the scroll state didn't consume all the scroll on this frame,
248                                 // it probably won't consume any more later either (we might have
249                                 // hit the scroll bounds). This is a terminal condition for the
250                                 // animation: If we don't cancel it, it could loop forever asking
251                                 // for a scroll that will never be consumed.
252                                 // Note this will cancel all pending BIV jobs.
253                                 // TODO(b/239671493) Should this trigger nested scrolling?
254                                 animationJob.cancel(
255                                     "Scroll animation cancelled because scroll was not consumed " +
256                                         "($consumedScroll < $delta)"
257                                 )
258                             }
259                         },
260                         // This lambda will be invoked on every frame, but will be dispatched to run
261                         // after the choreographer callback, and after any composition and layout
262                         // passes for the frame. This means that the scroll performed in the above
263                         // lambda will have been applied to the layout nodes.
264                         afterFrame = {
265                             if (DEBUG) println("[$TAG] afterFrame")
266 
267                             // Complete any BIV requests that were satisfied by this scroll
268                             // adjustment.
269                             bringIntoViewRequests.resumeAndRemoveWhile { bounds ->
270                                 // If a request is no longer attached, remove it.
271                                 if (bounds == null) return@resumeAndRemoveWhile true
272                                 bounds.isMaxVisible().also { visible ->
273                                     if (DEBUG && visible) {
274                                         println("[$TAG] Completed BIV request with bounds $bounds")
275                                     }
276                                 }
277                             }
278 
279                             // Stop tracking any KIV requests that were satisfied by this scroll
280                             // adjustment.
281                             if (
282                                 trackingFocusedChild &&
283                                     getFocusedChildBounds()?.isMaxVisible() == true
284                             ) {
285                                 if (DEBUG)
286                                     println("[$TAG] Completed tracking focused child request")
287                                 trackingFocusedChild = false
288                             }
289 
290                             // Compute a new scroll target taking into account any resizes,
291                             // replacements, or added/removed requests since the last frame.
292                             animationState.value = calculateScrollDelta(bringIntoViewSpec)
293                             if (DEBUG)
294                                 println("[$TAG] scroll target after frame: ${animationState.value}")
295                         }
296                     )
297                 }
298 
299                 // Complete any BIV requests if the animation didn't need to run, or if there were
300                 // requests that were too large to satisfy. Note that if the animation was
301                 // cancelled, this won't run, and the requests will be cancelled instead.
302                 if (DEBUG)
303                     println(
304                         "[$TAG] animation completed successfully, resuming" +
305                             " ${bringIntoViewRequests.size} remaining BIV requests…"
306                     )
307                 bringIntoViewRequests.resumeAndRemoveAll()
308             } catch (e: CancellationException) {
309                 cancellationException = e
310                 throw e
311             } finally {
312                 if (DEBUG) {
313                     println(
314                         "[$TAG] animation completed with ${bringIntoViewRequests.size} " +
315                             "unsatisfied BIV requests"
316                     )
317                     cancellationException?.printStackTrace()
318                 }
319                 isAnimationRunning = false
320                 // Any BIV requests that were not completed should be considered cancelled.
321                 bringIntoViewRequests.cancelAndRemoveAll(cancellationException)
322                 trackingFocusedChild = false
323             }
324         }
325     }
326 
327     /**
328      * Calculates how far we need to scroll to satisfy all existing BringIntoView requests and the
329      * focused child tracking.
330      */
331     private fun calculateScrollDelta(bringIntoViewSpec: BringIntoViewSpec): Float {
332         if (viewportSize == IntSize.Zero) return 0f
333 
334         val rectangleToMakeVisible: Rect =
335             findBringIntoViewRequest()
336                 ?: (if (trackingFocusedChild) getFocusedChildBounds() else null)
337                 ?: return 0f
338 
339         val size = viewportSize.toSize()
340         return when (orientation) {
341             Vertical ->
342                 bringIntoViewSpec.calculateScrollDistance(
343                     rectangleToMakeVisible.top,
344                     rectangleToMakeVisible.bottom - rectangleToMakeVisible.top,
345                     size.height
346                 )
347             Horizontal ->
348                 bringIntoViewSpec.calculateScrollDistance(
349                     rectangleToMakeVisible.left,
350                     rectangleToMakeVisible.right - rectangleToMakeVisible.left,
351                     size.width
352                 )
353         }
354     }
355 
356     /** Find the largest BIV request that can completely fit inside the viewport. */
357     private fun findBringIntoViewRequest(): Rect? {
358         var rectangleToMakeVisible: Rect? = null
359         bringIntoViewRequests.forEachFromSmallest { bounds ->
360             // Ignore detached requests for now. They'll be removed later.
361             if (bounds == null) return@forEachFromSmallest
362             if (bounds.size <= viewportSize.toSize()) {
363                 rectangleToMakeVisible = bounds
364             } else {
365                 // Found a request that doesn't fit, use the next-smallest one.
366                 // TODO(klippenstein) if there is a request that's too big to fit in the current
367                 //  bounds, we should try to fit the largest part of it that contains the
368                 //  next-smallest request.
369                 // if rectangleToMakeVisible is null, return the current bounds even if it is
370                 // oversized.
371                 return rectangleToMakeVisible ?: bounds
372             }
373         }
374         return rectangleToMakeVisible
375     }
376 
377     /**
378      * Compute the destination given the source rectangle and current bounds.
379      *
380      * @param childBounds The bounding box of the item that sent the request to be brought into
381      *   view.
382      * @return the destination rectangle.
383      */
384     private fun computeDestination(childBounds: Rect, containerSize: IntSize): Rect {
385         return childBounds.translate(-relocationOffset(childBounds, containerSize))
386     }
387 
388     /**
389      * Returns true if this [Rect] is as visible as it can be given the [size] of the viewport. This
390      * means either it's fully visible or too big to fit in the viewport all at once and already
391      * filling the whole viewport.
392      */
393     private fun Rect.isMaxVisible(size: IntSize = viewportSize): Boolean {
394         val relocationOffset = relocationOffset(this, size)
395         return abs(relocationOffset.x) <= MinScrollThreshold &&
396             abs(relocationOffset.y) <= MinScrollThreshold
397     }
398 
399     private fun relocationOffset(childBounds: Rect, containerSize: IntSize): Offset {
400         val size = containerSize.toSize()
401         return when (orientation) {
402             Vertical ->
403                 Offset(
404                     x = 0f,
405                     y =
406                         requireBringIntoViewSpec()
407                             .calculateScrollDistance(
408                                 childBounds.top,
409                                 childBounds.bottom - childBounds.top,
410                                 size.height
411                             )
412                 )
413             Horizontal ->
414                 Offset(
415                     x =
416                         requireBringIntoViewSpec()
417                             .calculateScrollDistance(
418                                 childBounds.left,
419                                 childBounds.right - childBounds.left,
420                                 size.width
421                             ),
422                     y = 0f
423                 )
424         }
425     }
426 
427     private operator fun IntSize.compareTo(other: IntSize): Int =
428         when (orientation) {
429             Horizontal -> width.compareTo(other.width)
430             Vertical -> height.compareTo(other.height)
431         }
432 
433     private operator fun Size.compareTo(other: Size): Int =
434         when (orientation) {
435             Horizontal -> width.compareTo(other.width)
436             Vertical -> height.compareTo(other.height)
437         }
438 
439     fun update(
440         orientation: Orientation,
441         reverseDirection: Boolean,
442         bringIntoViewSpec: BringIntoViewSpec?
443     ) {
444         this.orientation = orientation
445         this.reverseDirection = reverseDirection
446         this.bringIntoViewSpec = bringIntoViewSpec
447     }
448 
449     /**
450      * A request to bring some [Rect] in the scrollable viewport.
451      *
452      * @param currentBounds A function that returns the current bounds that the request wants to
453      *   make visible.
454      * @param continuation The [CancellableContinuation] from the suspend function used to make the
455      *   request.
456      */
457     internal class Request(
458         val currentBounds: () -> Rect?,
459         val continuation: CancellableContinuation<Unit>,
460     ) {
461         override fun toString(): String {
462             // Include the coroutine name in the string, if present, to help debugging.
463             val name = continuation.context[CoroutineName]?.name
464             return "Request@${hashCode().toString(radix = 16)}" +
465                 (name?.let { "[$it](" } ?: "(") +
466                 "currentBounds()=${currentBounds()}, " +
467                 "continuation=$continuation)"
468         }
469     }
470 }
471