ContentInViewModifier.kt

/*
 * Copyright 2022 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.onFocusedBoundsChanged
import androidx.compose.foundation.relocation.BringIntoViewResponder
import androidx.compose.foundation.relocation.bringIntoViewResponder
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
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.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch

/**
 * Handles any logic related to bringing or keeping content in view, including
 * [BringIntoViewResponder] and ensuring the focused child stays in view when the scrollable area
 * is shrunk.
 */
@OptIn(ExperimentalFoundationApi::class)
internal class ContentInViewModifier(
    private val scope: CoroutineScope,
    private val orientation: Orientation,
    private val scrollableState: ScrollableState,
    private val reverseDirection: Boolean
) : BringIntoViewResponder, OnRemeasuredModifier, OnPlacedModifier {
    private var focusedChild: LayoutCoordinates? = null
    private var coordinates: LayoutCoordinates? = null
    private var oldSize: IntSize? = null

    // These properties are used to detect the case where the viewport size is animated shrinking
    // while the scroll animation used to keep the focused child in view is still running.
    private var focusedChildBeingAnimated: LayoutCoordinates? = null
    private var focusTargetBounds: Rect? by mutableStateOf(null)
    private var focusAnimationJob: Job? = null

    val modifier: Modifier = this
        .onFocusedBoundsChanged { focusedChild = it }
        .bringIntoViewResponder(this)

    override fun onRemeasured(size: IntSize) {
        val coordinates = coordinates
        val oldSize = oldSize
        // We only care when this node becomes smaller than it previously was, so don't care about
        // the initial measurement.
        if (oldSize != null && oldSize != size && coordinates?.isAttached == true) {
            onSizeChanged(coordinates, oldSize)
        }
        this.oldSize = size
    }

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

    override fun calculateRectForParent(localRect: Rect): Rect {
        val oldSize = checkNotNull(oldSize) {
            "Expected BringIntoViewRequester to not be used before parents are placed."
        }
        // oldSize will only be null before the initial measurement.
        return computeDestination(localRect, oldSize)
    }

    override suspend fun bringChildIntoView(localRect: () -> Rect?) {
        // TODO(b/241591211) Read the request's bounds lazily in case they change.
        @Suppress("NAME_SHADOWING")
        val localRect = localRect() ?: return
        performBringIntoView(
            source = localRect,
            destination = calculateRectForParent(localRect)
        )
    }

    /**
     * Handles when the size of the scroll viewport changes by making sure any focused child is kept
     * appropriately visible when the viewport shrinks and would otherwise hide it.
     *
     * One common instance of this is when a text field in a scrollable near the bottom is focused
     * while the soft keyboard is hidden, causing the keyboard to show, and cover the field.
     * See b/192043120 and related bugs.
     *
     * To future debuggers of this method, it might be helpful to add a draw modifier to the chain
     * above to draw the focus target bounds:
     * ```
     * .drawWithContent {
     *   drawContent()
     *   focusTargetBounds?.let {
     *     drawRect(
     *       Color.Red,
     *       topLeft = it.topLeft,
     *       size = it.size,
     *       style = Stroke(1.dp.toPx())
     *     )
     *   }
     * }
     * ```
     */
    private fun onSizeChanged(coordinates: LayoutCoordinates, oldSize: IntSize) {
        val containerShrunk = if (orientation == Orientation.Horizontal) {
            coordinates.size.width < oldSize.width
        } else {
            coordinates.size.height < oldSize.height
        }
        // If the container is growing, then if the focused child is only partially visible it will
        // soon be _more_ visible, so don't scroll.
        if (!containerShrunk) return

        val focusedChild = focusedChild?.takeIf { it.isAttached } ?: return
        val focusedBounds = coordinates.localBoundingBoxOf(focusedChild, clipBounds = false)

        // In order to check if we need to scroll to bring the focused child into view, it's not
        // enough to consider where the child actually is right now. If the viewport was recently
        // shrunk, we may have already started a scroll animation to bring it into view. In that
        // case, we need to compare with the target of the animation, not the current position. If
        // we don't do that, then in some cases when the viewport size is being animated (e.g. when
        // the keyboard insets are being animated on API 30+) we might stop trying to keep the
        // focused child in view before the viewport animation is finished, and the scroll animation
        // will stop short and leave the focused child out of the viewport. See b/230756508.
        val eventualFocusedBounds = if (focusedChild === focusedChildBeingAnimated) {
            // A previous call to this method started an animation that is still running, so compare
            // with the target of that animation.
            checkNotNull(focusTargetBounds)
        } else {
            focusedBounds
        }

        val myOldBounds = Rect(Offset.Zero, oldSize.toSize())
        if (!myOldBounds.overlaps(eventualFocusedBounds)) {
            // The focused child was not visible before the resize, so we don't need to keep
            // it visible.
            return
        }

        val targetBounds = computeDestination(eventualFocusedBounds, coordinates.size)
        if (targetBounds == eventualFocusedBounds) {
            // The focused child is already fully visible (not clipped or hidden) after the resize,
            // or will be after it finishes animating, so we don't need to do anything.
            return
        }

        // If execution has gotten to this point, it means the focused child was at least partially
        // visible before the resize, and it is either partially clipped or completely hidden after
        // the resize, so we need to adjust scroll to keep it in view.
        focusedChildBeingAnimated = focusedChild
        focusTargetBounds = targetBounds
        scope.launch(NonCancellable) {
            val job = launch {
                // Animate the scroll offset to keep the focused child in view. This is a suspending
                // call that will suspend until the animation is finished, and only return if it
                // completes. If any other scroll operations are performed after the animation starts,
                // e.g. the viewport shrinks again or the user manually scrolls, this animation will
                // be cancelled and this function will throw a CancellationException.
                performBringIntoView(source = focusedBounds, destination = targetBounds)
            }
            focusAnimationJob = job

            // If the scroll was interrupted by another viewport shrink that happens while the
            // animation is running, we don't want to clear these fields since the later call to
            // this onSizeChanged method will have updated the fields with its own values.
            // If the animation completed, or was cancelled for any other reason, we need to clear
            // them so the next viewport shrink doesn't think there's already a scroll animation in
            // progress.
            // Doing this wrong has a few implications:
            // 1. If the fields are nulled out when another onSizeChange call happens, it will not
            //    use the current animation target and viewport animations will lose track of the
            //    focusable.
            // 2. If the fields are not nulled out in other cases, the next viewport animation will
            //    not keep the focusable in view if the focus hasn't changed.
            try {
                job.join()
            } finally {
                if (focusAnimationJob === job) {
                    focusedChildBeingAnimated = null
                    focusTargetBounds = null
                    focusAnimationJob = null
                }
            }
        }
    }

    /**
     * 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 {
        val size = containerSize.toSize()
        return when (orientation) {
            Orientation.Vertical ->
                childBounds.translate(
                    translateX = 0f,
                    translateY = -relocationDistance(
                        childBounds.top,
                        childBounds.bottom,
                        size.height
                    )
                )
            Orientation.Horizontal ->
                childBounds.translate(
                    translateX = -relocationDistance(
                        childBounds.left,
                        childBounds.right,
                        size.width
                    ),
                    translateY = 0f
                )
        }
    }

    /**
     * Using the source and destination bounds, perform an animated scroll.
     */
    private suspend fun performBringIntoView(source: Rect, destination: Rect) {
        val offset = when (orientation) {
            Orientation.Vertical -> destination.top - source.top
            Orientation.Horizontal -> destination.left - source.left
        }
        val scrollDelta = if (reverseDirection) -offset else offset

        // Note that this results in weird behavior if called before the previous
        // performBringIntoView finishes due to b/220119990.
        scrollableState.animateScrollBy(scrollDelta)
    }

    /**
     * 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, parentSize: Float) =
        when {
            // If the item is already visible, no need to scroll.
            leadingEdge >= 0 && trailingEdge <= parentSize -> 0f

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

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