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