All Downloads are FREE. Search and download functionalities are using the official Maven repository.

commonMain.androidx.compose.foundation.gestures.ContentInViewModifier.kt Maven / Gradle / Ivy

Go to download

Higher level abstractions of the Compose UI primitives. This library is design system agnostic, providing the high-level building blocks for both application and design-system developers

There is a newer version: 1.8.0-alpha01
Show newest version
/*
 * Copyright 2023 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.foundation.gestures

import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.Orientation.Horizontal
import androidx.compose.foundation.gestures.Orientation.Vertical
import androidx.compose.foundation.onFocusedBoundsChanged
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.BringIntoViewResponder
import androidx.compose.foundation.relocation.bringIntoViewResponder
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.OnPlacedModifier
import androidx.compose.ui.layout.OnRemeasuredModifier
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.toSize
import kotlin.math.abs
import kotlinx.coroutines.*

/**
 * Static field to turn on a bunch of verbose logging to debug animations. Since this is a constant,
 * any log statements guarded by this value should be removed by the compiler when it's false.
 */
private const val DEBUG = false
private const val TAG = "ContentInViewModifier"

/**
 * A [Modifier] to be placed on a scrollable container (i.e. [Modifier.scrollable]) that animates
 * the [ScrollableState] to handle [BringIntoViewRequester] requests and keep the currently-focused
 * child in view when the viewport shrinks.
 *
 * Instances of this class should not be directly added to the modifier chain, instead use the
 * [modifier] property since this class relies on some modifiers that must be specified as modifier
 * factory functions and can't be implemented as interfaces.
 */
// TODO(b/242732126) Make this logic reusable for TV's mario scrolling implementation.
@OptIn(ExperimentalFoundationApi::class)
internal class ContentInViewModifier(
    private val scope: CoroutineScope,
    private val orientation: Orientation,
    private val scrollState: ScrollableState,
    private val reverseDirection: Boolean
) : BringIntoViewResponder,
    OnRemeasuredModifier,
    OnPlacedModifier {

    /**
     * Ongoing requests from [bringChildIntoView], with the invariant that it is always sorted by
     * overlapping order: each item's [Rect] completely overlaps the next item.
     *
     * May contain requests whose bounds are too big to fit in the current viewport. This is for
     * a few reasons:
     *  1. The viewport may shrink after a request was enqueued, causing a request that fit at the
     *     time it was enqueued to no longer fit.
     *  2. The size of the bounds of a request may change after it's added, causing it to grow
     *     larger than the viewport.
     *  3. Having complete information about too-big requests allows us to make the right decision
     *     about what part of the request to bring into view when smaller requests are also present.
     */
    private val bringIntoViewRequests = BringIntoViewRequestPriorityQueue()

    /** The [LayoutCoordinates] of this modifier (i.e. the scrollable container). */
    private var coordinates: LayoutCoordinates? = null
    private var focusedChild: LayoutCoordinates? = null

    /**
     * The previous bounds of the [focusedChild] used by [onRemeasured] to calculate when the
     * focused child is first clipped when scrolling is reversed.
     */
    private var focusedChildBoundsFromPreviousRemeasure: Rect? = null

    /**
     * Set to true when this class is actively animating the scroll to keep the focused child in
     * view.
     */
    private var trackingFocusedChild = false

    /** The size of the scrollable container. */
    private var viewportSize = IntSize.Zero
    private var isAnimationRunning = false
    private val animationState = UpdatableAnimationState()

    val modifier: Modifier = this
        .onFocusedBoundsChanged {
            focusedChild = it
            if (DEBUG) println("[$TAG] new focused child: ${getFocusedChildBounds()}")
        }
        .bringIntoViewResponder(this)

    override fun calculateRectForParent(localRect: Rect): Rect {
        check(viewportSize != IntSize.Zero) {
            "Expected BringIntoViewRequester to not be used before parents are placed."
        }
        // size will only be zero before the initial measurement.
        return computeDestination(localRect, viewportSize)
    }

    override suspend fun bringChildIntoView(localRect: () -> Rect?) {
        // Avoid creating no-op requests and no-op animations if the request does not require
        // scrolling or returns null.
        if (localRect()?.isMaxVisible() != false) return

        suspendCancellableCoroutine { continuation ->
            val request = Request(currentBounds = localRect, continuation = continuation)
            if (DEBUG) println("[$TAG] Registering bringChildIntoView request: $request")
            // Once the request is enqueued, even if it returns false, the queue will take care of
            // handling continuation cancellation so we don't need to do that here.
            if (bringIntoViewRequests.enqueue(request) && !isAnimationRunning) {
                launchAnimation()
            }
        }
    }

    override fun onPlaced(coordinates: LayoutCoordinates) {
        this.coordinates = coordinates
    }

    override fun onRemeasured(size: IntSize) {
        val oldSize = viewportSize
        viewportSize = size

        // Don't care if the viewport grew.
        if (size >= oldSize) return

        if (DEBUG) println("[$TAG] viewport shrunk: $oldSize -> $size")

        getFocusedChildBounds()?.let { focusedChild ->
            if (DEBUG) println("[$TAG] focused child bounds: $focusedChild")
            val previousFocusedChildBounds = focusedChildBoundsFromPreviousRemeasure ?: focusedChild
            if (!isAnimationRunning && !trackingFocusedChild &&
                // Resize caused it to go from being fully visible to at least partially
                // clipped. Need to use the lastFocusedChildBounds to compare with the old size
                // only to handle the case where scrolling direction is reversed: in that case, when
                // the child first goes out-of-bounds, it will be out of bounds regardless of which
                // size we pass in, so the only way to detect the change is to use the previous
                // bounds.
                previousFocusedChildBounds.isMaxVisible(oldSize) && !focusedChild.isMaxVisible(size)
            ) {
                if (DEBUG) println(
                    "[$TAG] focused child was clipped by viewport shrink: $focusedChild"
                )
                trackingFocusedChild = true
                launchAnimation()
            }

            this.focusedChildBoundsFromPreviousRemeasure = focusedChild
        }
    }

    private fun getFocusedChildBounds(): Rect? {
        val coordinates = this.coordinates?.takeIf { it.isAttached } ?: return null
        val focusedChild = this.focusedChild?.takeIf { it.isAttached } ?: return null
        return coordinates.localBoundingBoxOf(focusedChild, clipBounds = false)
    }

    private fun launchAnimation() {
        check(!isAnimationRunning)

        if (DEBUG) println("[$TAG] launchAnimation")

        scope.launch(start = CoroutineStart.UNDISPATCHED) {
            var cancellationException: CancellationException? = null
            val animationJob = coroutineContext.job

            try {
                isAnimationRunning = true
                scrollState.scroll {
                    animationState.value = calculateScrollDelta()
                    if (DEBUG) println(
                        "[$TAG] Starting scroll animation down from ${animationState.value}…"
                    )
                    animationState.animateToZero(
                        // This lambda will be invoked on every frame, during the choreographer
                        // callback.
                        beforeFrame = { delta ->
                            // reverseDirection is actually opposite of what's passed in through the
                            // (vertical|horizontal)Scroll modifiers.
                            val scrollMultiplier = if (reverseDirection) 1f else -1f
                            val adjustedDelta = scrollMultiplier * delta
                            if (DEBUG) println(
                                "[$TAG] Scroll target changed by Δ$delta to " +
                                    "${animationState.value}, scrolling by $adjustedDelta " +
                                    "(reverseDirection=$reverseDirection)"
                            )
                            val consumedScroll = scrollMultiplier * scrollBy(adjustedDelta)
                            if (DEBUG) println("[$TAG] Consumed $consumedScroll of scroll")
                            // consumedScroll check changed in JetBrains Fork
                            // keep it during merge if it is not upstreamed
                            if (abs(consumedScroll) < abs(delta)) {
                                // If the scroll state didn't consume all the scroll on this frame,
                                // it probably won't consume any more later either (we might have
                                // hit the scroll bounds). This is a terminal condition for the
                                // animation: If we don't cancel it, it could loop forever asking
                                // for a scroll that will never be consumed.
                                // Note this will cancel all pending BIV jobs.
                                // TODO(b/239671493) Should this trigger nested scrolling?
                                animationJob.cancel(
                                    "Scroll animation cancelled because scroll was not consumed " +
                                        "(${abs(consumedScroll)} < ${abs(delta)})"
                                )
                            }
                        },
                        // This lambda will be invoked on every frame, but will be dispatched to run
                        // after the choreographer callback, and after any composition and layout
                        // passes for the frame. This means that the scroll performed in the above
                        // lambda will have been applied to the layout nodes.
                        afterFrame = {
                            if (DEBUG) println("[$TAG] afterFrame")

                            // Complete any BIV requests that were satisfied by this scroll
                            // adjustment.
                            bringIntoViewRequests.resumeAndRemoveWhile { bounds ->
                                // If a request is no longer attached, remove it.
                                if (bounds == null) return@resumeAndRemoveWhile true
                                bounds.isMaxVisible().also { visible ->
                                    if (DEBUG && visible) {
                                        println("[$TAG] Completed BIV request with bounds $bounds")
                                    }
                                }
                            }

                            // Stop tracking any KIV requests that were satisfied by this scroll
                            // adjustment.
                            if (trackingFocusedChild &&
                                getFocusedChildBounds()?.isMaxVisible() == true
                            ) {
                                if (DEBUG) println(
                                    "[$TAG] Completed tracking focused child request"
                                )
                                trackingFocusedChild = false
                            }

                            // Compute a new scroll target taking into account any resizes,
                            // replacements, or added/removed requests since the last frame.
                            animationState.value = calculateScrollDelta()
                            if (DEBUG) println(
                                "[$TAG] scroll target after frame: ${animationState.value}"
                            )
                        }
                    )
                }

                // Complete any BIV requests if the animation didn't need to run, or if there were
                // requests that were too large to satisfy. Note that if the animation was
                // cancelled, this won't run, and the requests will be cancelled instead.
                if (DEBUG) println(
                    "[$TAG] animation completed successfully, resuming" +
                        " ${bringIntoViewRequests.size} remaining BIV requests…"
                )
                bringIntoViewRequests.resumeAndRemoveAll()
            } catch (e: CancellationException) {
                cancellationException = e
                throw e
            } finally {
                if (DEBUG) {
                    println(
                        "[$TAG] animation completed with ${bringIntoViewRequests.size} " +
                            "unsatisfied BIV requests"
                    )
                    cancellationException?.printStackTrace()
                }
                isAnimationRunning = false
                // Any BIV requests that were not completed should be considered cancelled.
                bringIntoViewRequests.cancelAndRemoveAll(cancellationException)
                trackingFocusedChild = false
            }
        }
    }

    /**
     * Calculates how far we need to scroll to satisfy all existing BringIntoView requests and the
     * focused child tracking.
     */
    private fun calculateScrollDelta(): Float {
        if (viewportSize == IntSize.Zero) return 0f

        val rectangleToMakeVisible: Rect = findBringIntoViewRequest()
            ?: (if (trackingFocusedChild) getFocusedChildBounds() else null)
            ?: return 0f

        val size = viewportSize.toSize()
        return when (orientation) {
            Vertical -> relocationDistance(
                rectangleToMakeVisible.top,
                rectangleToMakeVisible.bottom,
                size.height
            )

            Horizontal -> relocationDistance(
                rectangleToMakeVisible.left,
                rectangleToMakeVisible.right,
                size.width
            )
        }
    }

    /**
     * Find the largest BIV request that can completely fit inside the viewport.
     */
    private fun findBringIntoViewRequest(): Rect? {
        var rectangleToMakeVisible: Rect? = null
        bringIntoViewRequests.forEachFromSmallest { bounds ->
            // Ignore detached requests for now. They'll be removed later.
            if (bounds == null) return@forEachFromSmallest
            if (bounds.size <= viewportSize.toSize()) {
                rectangleToMakeVisible = bounds
            } else {
                // Found a request that doesn't fit, use the next-smallest one.
                // TODO(klippenstein) if there is a request that's too big to fit in the current
                //  bounds, we should try to fit the largest part of it that contains the
                //  next-smallest request.
                return rectangleToMakeVisible
            }
        }
        return rectangleToMakeVisible
    }

    /**
     * Compute the destination given the source rectangle and current bounds.
     *
     * @param childBounds The bounding box of the item that sent the request to be brought into view.
     * @return the destination rectangle.
     */
    private fun computeDestination(childBounds: Rect, containerSize: IntSize): Rect {
        return childBounds.translate(-relocationOffset(childBounds, containerSize))
    }

    /**
     * Returns true if this [Rect] is as visible as it can be given the [size] of the viewport.
     * This means either it's fully visible or too big to fit in the viewport all at once and
     * already filling the whole viewport.
     */
    private fun Rect.isMaxVisible(size: IntSize = viewportSize): Boolean {
        return relocationOffset(this, size) == Offset.Zero
    }

    private fun relocationOffset(childBounds: Rect, containerSize: IntSize): Offset {
        val size = containerSize.toSize()
        return when (orientation) {
            Vertical -> Offset(
                x = 0f,
                y = relocationDistance(childBounds.top, childBounds.bottom, size.height)
            )

            Horizontal -> Offset(
                x = relocationDistance(childBounds.left, childBounds.right, size.width),
                y = 0f
            )
        }
    }

    /**
     * Calculate the offset needed to bring one of the edges into view. The leadingEdge is the side
     * closest to the origin (For the x-axis this is 'left', for the y-axis this is 'top').
     * The trailing edge is the other side (For the x-axis this is 'right', for the y-axis this is
     * 'bottom').
     */
    private fun relocationDistance(leadingEdge: Float, trailingEdge: Float, containerSize: Float) =
        when {
            // If the item is already visible, no need to scroll.
            leadingEdge >= 0 && trailingEdge <= containerSize -> 0f

            // If the item is visible but larger than the parent, we don't scroll.
            leadingEdge < 0 && trailingEdge > containerSize -> 0f

            // Find the minimum scroll needed to make one of the edges coincide with the parent's
            // edge.
            abs(leadingEdge) < abs(trailingEdge - containerSize) -> leadingEdge
            else -> trailingEdge - containerSize
        }

    private operator fun IntSize.compareTo(other: IntSize): Int = when (orientation) {
        Horizontal -> width.compareTo(other.width)
        Vertical -> height.compareTo(other.height)
    }

    private operator fun Size.compareTo(other: Size): Int = when (orientation) {
        Horizontal -> width.compareTo(other.width)
        Vertical -> height.compareTo(other.height)
    }

    /**
     * A request to bring some [Rect] in the scrollable viewport.
     *
     * @param currentBounds A function that returns the current bounds that the request wants to
     * make visible.
     * @param continuation The [CancellableContinuation] from the suspend function used to make the
     * request.
     */
    internal class Request(
        val currentBounds: () -> Rect?,
        val continuation: CancellableContinuation,
    ) {
        override fun toString(): String {
            // Include the coroutine name in the string, if present, to help debugging.
            val name = continuation.context[CoroutineName]?.name
            return "Request@${hashCode().toString(radix = 16)}" +
                (name?.let { "[$it](" } ?: "(") +
                "currentBounds()=${currentBounds()}, " +
                "continuation=$continuation)"
        }
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy