ScrollableWithPivot.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.tv.foundation

import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.Orientation.Horizontal
import androidx.compose.foundation.gestures.Orientation.Vertical
import androidx.compose.foundation.gestures.ScrollableState
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.onFocusedBoundsChanged
import androidx.compose.foundation.relocation.BringIntoViewResponder
import androidx.compose.foundation.relocation.bringIntoViewResponder
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollDispatcher
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.OnPlacedModifier
import androidx.compose.ui.layout.OnRemeasuredModifier
import androidx.compose.ui.modifier.ModifierLocalProvider
import androidx.compose.ui.modifier.modifierLocalOf
import androidx.compose.ui.platform.debugInspectorInfo
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.toSize
import androidx.compose.ui.util.fastForEach
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch

/* Copied from
 compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/
 Scrollable.kt and modified */

/**
 * Configure touch scrolling and flinging for the UI element in a single [Orientation].
 *
 * Users should update their state themselves using default [ScrollableState] and its
 * `consumeScrollDelta` callback or by implementing [ScrollableState] interface manually and reflect
 * their own state in UI when using this component.
 *
 * @param state [ScrollableState] state of the scrollable. Defines how scroll events will be
 * interpreted by the user land logic and contains useful information about on-going events.
 * @param orientation orientation of the scrolling
 * @param pivotOffsets offsets of child element within the parent and starting edge of the child
 * from the pivot defined by the parentOffset.
 * @param enabled whether or not scrolling in enabled
 * @param reverseDirection reverse the direction of the scroll, so top to bottom scroll will
 * behave like bottom to top and left to right will behave like right to left.
 * drag events when this scrollable is being dragged.
 */

@OptIn(ExperimentalFoundationApi::class)
@ExperimentalTvFoundationApi
fun Modifier.scrollableWithPivot(
    state: ScrollableState,
    orientation: Orientation,
    pivotOffsets: PivotOffsets,
    enabled: Boolean = true,
    reverseDirection: Boolean = false
): Modifier = composed(
    inspectorInfo = debugInspectorInfo {
        name = "scrollableWithPivot"
        properties["orientation"] = orientation
        properties["state"] = state
        properties["enabled"] = enabled
        properties["reverseDirection"] = reverseDirection
        properties["pivotOffsets"] = pivotOffsets
    },
    factory = {
        val coroutineScope = rememberCoroutineScope()
        val keepFocusedChildInViewModifier =
            remember(coroutineScope, orientation, state, reverseDirection) {
                ContentInViewModifier(
                    coroutineScope, orientation, state, reverseDirection, pivotOffsets)
            }

        Modifier
            .focusGroup()
            .then(keepFocusedChildInViewModifier.modifier)
            .pointerScrollable(
                orientation,
                reverseDirection,
                state,
                enabled
            )
            .then(if (enabled) ModifierLocalScrollableContainerProvider else Modifier)
    }
)

internal interface ScrollConfig {
    fun Density.calculateMouseWheelScroll(event: PointerEvent, bounds: IntSize): Offset
}

@Composable
internal fun platformScrollConfig(): ScrollConfig = AndroidConfig

private object AndroidConfig : ScrollConfig {
    override fun Density.calculateMouseWheelScroll(event: PointerEvent, bounds: IntSize): Offset {
        // 64 dp value is taken from ViewConfiguration.java, replace with better solution
        return event.changes.fastFold(Offset.Zero) { acc, c -> acc + c.scrollDelta } * -64.dp.toPx()
    }
}

@Suppress("ComposableModifierFactory")
@Composable
private fun Modifier.pointerScrollable(
    orientation: Orientation,
    reverseDirection: Boolean,
    controller: ScrollableState,
    enabled: Boolean
): Modifier {
    val nestedScrollDispatcher = remember { mutableStateOf(NestedScrollDispatcher()) }
    val scrollLogic = rememberUpdatedState(
        ScrollingLogic(
            orientation,
            reverseDirection,
            controller
        )
    )
    val nestedScrollConnection = remember(enabled) {
        scrollableNestedScrollConnection(scrollLogic, enabled)
    }

    return this.nestedScroll(nestedScrollConnection, nestedScrollDispatcher.value)
}

private class ScrollingLogic(
    val orientation: Orientation,
    val reverseDirection: Boolean,
    val scrollableState: ScrollableState,
) {
    private fun Float.toOffset(): Offset = when {
        this == 0f -> Offset.Zero
        orientation == Horizontal -> Offset(this, 0f)
        else -> Offset(0f, this)
    }

    private fun Offset.toFloat(): Float =
        if (orientation == Horizontal) this.x else this.y
    private fun Float.reverseIfNeeded(): Float = if (reverseDirection) this * -1 else this

    fun performRawScroll(scroll: Offset): Offset {
        return if (scrollableState.isScrollInProgress) {
            Offset.Zero
        } else {
            scrollableState.dispatchRawDelta(scroll.toFloat().reverseIfNeeded())
                .reverseIfNeeded().toOffset()
        }
    }
}

private fun scrollableNestedScrollConnection(
    scrollLogic: State<ScrollingLogic>,
    enabled: Boolean
): NestedScrollConnection = object : NestedScrollConnection {
    override fun onPostScroll(
        consumed: Offset,
        available: Offset,
        source: NestedScrollSource
    ): Offset = if (enabled) {
        scrollLogic.value.performRawScroll(available)
    } else {
        Offset.Zero
    }
}

/**
 * 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)
private class ContentInViewModifier(
    private val scope: CoroutineScope,
    private val orientation: Orientation,
    private val scrollableState: ScrollableState,
    private val reverseDirection: Boolean,
    private val pivotOffsets: PivotOffsets
) : BringIntoViewResponder, OnRemeasuredModifier, OnPlacedModifier {
    private var focusedChild: LayoutCoordinates? = null
    private var coordinates: LayoutCoordinates? = null
    private var oldSize: IntSize? = 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, pivotOffsets)
    }

    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(localRect, calculateRectForParent(localRect))
    }

    private fun onSizeChanged(coordinates: LayoutCoordinates, oldSize: IntSize) {
        val containerShrunk = if (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 focusedBounds = focusedChild
            ?.let { coordinates.localBoundingBoxOf(it, clipBounds = false) }
            ?: return
        val myOldBounds = Rect(Offset.Zero, oldSize.toSize())
        val adjustedBounds = computeDestination(focusedBounds, coordinates.size, pivotOffsets)
        val wasVisible = myOldBounds.overlaps(focusedBounds)
        val isFocusedChildClipped = adjustedBounds != focusedBounds

        if (wasVisible && isFocusedChildClipped) {
            scope.launch {
                performBringIntoView(focusedBounds, adjustedBounds)
            }
        }
    }

    /**
     * Compute the destination given the source rectangle and current bounds.
     *
     * @param source The bounding box of the item that sent the request to be brought into view.
     * @param pivotOffsets offsets of child element within the parent and starting edge of the child
     * from the pivot defined by the parentOffset.
     * @return the destination rectangle.
     */
    private fun computeDestination(
        source: Rect,
        intSize: IntSize,
        pivotOffsets: PivotOffsets
    ): Rect {
        val size = intSize.toSize()
        return when (orientation) {
            Vertical ->
                source.translate(
                    0f,
                    relocationDistance(source.top, source.bottom, size.height, pivotOffsets))
            Horizontal ->
                source.translate(
                    relocationDistance(source.left, source.right, size.width, pivotOffsets),
                    0f)
        }
    }

    /**
     * Using the source and destination bounds, perform an animated scroll.
     */
    private suspend fun performBringIntoView(source: Rect, destination: Rect) {
        val offset = when (orientation) {
            Vertical -> source.top - destination.top
            Horizontal -> source.left - destination.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(
        leadingEdgeOfItemRequestingFocus: Float,
        trailingEdgeOfItemRequestingFocus: Float,
        parentSize: Float,
        pivotOffsets: PivotOffsets
    ): Float {
        val totalWidthOfItemRequestingFocus =
            trailingEdgeOfItemRequestingFocus - leadingEdgeOfItemRequestingFocus
        val pivotOfItemRequestingFocus =
            pivotOffsets.childFraction * totalWidthOfItemRequestingFocus
        val intendedLocationOfItemRequestingFocus = parentSize * pivotOffsets.parentFraction

        return leadingEdgeOfItemRequestingFocus - intendedLocationOfItemRequestingFocus +
            pivotOfItemRequestingFocus
    }
}

// TODO: b/203141462 - make this public and move it to ui
/**
 * Whether this modifier is inside a scrollable container, provided by
 * [Modifier.scrollableWithPivot]. Defaults to false.
 */
internal val ModifierLocalScrollableContainer = modifierLocalOf { false }

private object ModifierLocalScrollableContainerProvider : ModifierLocalProvider<Boolean> {
    override val key = ModifierLocalScrollableContainer
    override val value = true
}

/**
 * Accumulates value starting with [initial] value and applying [operation] from left to right
 * to current accumulator value and each element.
 *
 * Returns the specified [initial] value if the collection is empty.
 *
 * **Do not use for collections that come from public APIs**, since they may not support random
 * access in an efficient way, and this method may actually be a lot slower. Only use for
 * collections that are created by code we control and are known to support random access.
 *
 * @param [operation] function that takes current accumulator value and an element, and calculates the next accumulator value.
 */
@Suppress("BanInlineOptIn") // Treat Kotlin Contracts as non-experimental.
@OptIn(ExperimentalContracts::class)
internal inline fun <T, R> List<T>.fastFold(initial: R, operation: (acc: R, T) -> R): R {
    contract { callsInPlace(operation) }
    var accumulator = initial
    fastForEach { e ->
        accumulator = operation(accumulator, e)
    }
    return accumulator
}