API source code for GlimmerLazyListState for Glimmer List component

When creating a Glimmer List component, refer to the following source code in GlimmerLazyListState.kt for creating a state for the list:

/*
 * Copyright 2025 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.xr.glimmer.list

import androidx.annotation.IntRange
import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.ScrollIndicatorState
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.ScrollScope
import androidx.compose.foundation.gestures.ScrollableState
import androidx.compose.foundation.interaction.InteractionSource
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.lazy.layout.LazyLayoutPinnedItemList
import androidx.compose.runtime.Composable
import androidx.compose.runtime.annotation.FrequentlyChangingValue
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.neverEqualPolicy
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.layout.AlignmentLine
import androidx.compose.ui.layout.MeasureResult
import androidx.compose.ui.layout.Remeasurement
import androidx.compose.ui.layout.RemeasurementModifier
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.xr.glimmer.list.GlimmerLazyListState.Companion.Saver
import kotlin.math.abs

/**
 * Creates a [GlimmerLazyListState] that is remembered across compositions.
 *
 * Changes to the provided initial values will **not** result in the state being recreated or
 * changed in any way if it has already been created.
 *
 * @param initialFirstVisibleItemIndex the initial value for
 *   [GlimmerLazyListState.firstVisibleItemIndex]
 * @param initialFirstVisibleItemScrollOffset the initial value for
 *   [GlimmerLazyListState.firstVisibleItemScrollOffset]
 */
@Composable
public fun rememberGlimmerLazyListState(
    initialFirstVisibleItemIndex: Int = 0,
    initialFirstVisibleItemScrollOffset: Int = 0,
): GlimmerLazyListState =
    rememberSaveable(saver = GlimmerLazyListState.Saver) {
        GlimmerLazyListState(initialFirstVisibleItemIndex, initialFirstVisibleItemScrollOffset)
    }

/**
 * A state object that can be hoisted to control and observe scrolling.
 *
 * In most cases, this will be created via [rememberGlimmerLazyListState].
 *
 * @param firstVisibleItemIndex the initial value for [GlimmerLazyListState.firstVisibleItemIndex]
 * @param firstVisibleItemScrollOffset the initial value for
 *   [GlimmerLazyListState.firstVisibleItemScrollOffset]
 */
public class GlimmerLazyListState(
    firstVisibleItemIndex: Int = 0,
    firstVisibleItemScrollOffset: Int = 0,
) : ScrollableState {

    private val backingState = ScrollableState { -onScroll(-it) }

    // TODO: b/414961654 - Consider making this abstraction around "anchor item".
    /** The holder class for the current scroll position. */
    private val scrollPosition =
        GlimmerLazyListScrollPosition(firstVisibleItemIndex, firstVisibleItemScrollOffset)

    /** Backing state for [layoutInfo] */
    internal val layoutInfoState = mutableStateOf(EmptyLazyListMeasureResult, neverEqualPolicy())

    private val density: Density
        get() = layoutInfoState.value.density

    /**
     * This field is used to save information about the number of "beyond bounds items" that we want
     * to compose. These items are not within the visible bounds of the lazy layout, but we compose
     * them because they are explicitly requested through the
     * [beyond bounds layout API][androidx.compose.ui.layout.BeyondBoundsLayout].
     */
    internal val beyondBoundsInfo = LazyLayoutBeyondBoundsInfo()

    /** Includes information for requesting focus for children as the list scrolls. */
    internal val autoFocusState = GlimmerLazyListAutoFocusState()

    /** Stores currently pinned items which are always composed. */
    internal val pinnedItems = LazyLayoutPinnedItemList()

    internal val internalInteractionSource: MutableInteractionSource = MutableInteractionSource()

    /**
     * The scroll amount was provided in `onScroll` to be consumed during the current measure pass.
     *
     * Scrolling forward is negative.
     */
    internal var incomingScroll: Float = 0f
        private set

    /**
     * This value retains the scroll amount that we want to carry over between measure passes. It
     * represents the amount of scroll from previous passes that wasn't consumed back then but is
     * awaiting to be consumed later. Together with [incomingScroll], this represents the total
     * scroll available to the list during the current measure pass.
     *
     * This variable exists because the layout uses integer pixels while [onScroll] operates with
     * fractional floats. Consequently, the list might receive tiny fractions of scroll input which,
     * when aggregated, should equate to a few pixels of scrolling. However, because these small
     * fractions are spread across many [onScroll] invocations, the list ignores them, making it
     * feel unresponsive during slow, gentle touches. To avoid this, instead of ignoring these tiny
     * scroll increments, the list accumulates them so they can be added to the next scroll part and
     * actually consumed in a subsequent pass.
     *
     * Note that this value can sometimes be opposite to the scroll direction. This occurs because
     * the [Float] -> [Int] rounding causes us to consume more than was provided. To compensate, we
     * add a negative value to [incomingScroll] the next time.
     *
     * Scrolling forward is negative.
     */
    internal var carryOverScroll: Float = 0f
        private set

    /**
     * This value is updated after the measure pass inside [applyMeasureResult] and defines how much
     * of the [incomingScroll] was actually used.
     */
    private var consumedScroll: Float = 0f

    internal val nearestRange: kotlin.ranges.IntRange by
        LazyLayoutNearestRangeState(0, NearestItemsSlidingWindowSize, NearestItemsExtraItemCount)

    /**
     * The [Remeasurement] object associated with our layout. It allows us to remeasure
     * synchronously during scroll.
     */
    internal var remeasurement: Remeasurement? = null
        private set

    /** The modifier which provides [remeasurement]. */
    internal val remeasurementModifier =
        object : RemeasurementModifier {
            override fun onRemeasurementAvailable(remeasurement: Remeasurement) {
                this@GlimmerLazyListState.remeasurement = remeasurement
            }
        }

    /**
     * Provides a modifier which allows to delay some interactions (e.g. scroll) until layout is
     * ready.
     */
    internal val awaitLayoutModifier = AwaitFirstLayoutModifier()

    /**
     * The index of the first item that is visible within the scrollable viewport area not including
     * items in the content padding region. For the first visible item that includes items in the
     * content padding please use [GlimmerLazyListLayoutInfo.visibleItemsInfo].
     *
     * Note that this property is observable and if you use it in the composable function it will be
     * recomposed on every change causing potential performance issues.
     */
    public val firstVisibleItemIndex: Int
        @FrequentlyChangingValue get() = scrollPosition.index

    /**
     * The scroll offset of the first visible item. Scrolling forward is positive - i.e., the amount
     * that the item is offset backwards.
     *
     * Note that this property is observable and if you use it in the composable function it will be
     * recomposed on every scroll causing potential performance issues.
     */
    public val firstVisibleItemScrollOffset: Int
        @FrequentlyChangingValue get() = scrollPosition.scrollOffset

    /**
     * The object of [GlimmerLazyListLayoutInfo] calculated during the last layout pass. For
     * example, you can use it to calculate what items are currently visible.
     *
     * Note that this property is observable and is updated after every scroll or remeasure. If you
     * use it in the composable function it will be recomposed on every change causing potential
     * performance issues including infinity recomposition loop. Therefore, avoid using it in the
     * composition.
     *
     * If you want to run some side effects like sending an analytics event or updating a state
     * based on this value consider using "snapshotFlow":
     */
    public val layoutInfo: GlimmerLazyListLayoutInfo
        @FrequentlyChangingValue get() = layoutInfoState.value

    /**
     * [InteractionSource] that will be used to dispatch drag events when this list is being
     * dragged. If you want to know whether the fling (or animated scroll) is in progress, use
     * [isScrollInProgress].
     */
    public val interactionSource: InteractionSource
        get() = internalInteractionSource

    /** Snaps to the requested scroll position. */
    internal fun snapToItemIndexInternal(index: Int, scrollOffset: Int) {
        scrollPosition.requestPositionAndForgetLastKnownKey(index, scrollOffset)
        remeasurement?.forceRemeasure()
    }

    /**
     * When the user provided custom keys for the items we can try to detect when there were items
     * added or removed before our current first visible item and keep this item as the first
     * visible one even given that its index has been changed.
     */
    internal fun updateScrollPositionIfTheFirstItemWasMoved(
        itemProvider: GlimmerLazyListItemProvider,
        firstItemIndex: Int,
    ): Int = scrollPosition.updateScrollPositionIfTheFirstItemWasMoved(itemProvider, firstItemIndex)

    /**
     * Called during the measurement pass once the dispatched [incomingScroll] has been handled and
     * the new item positions are known.
     *
     * @param result lazy list measuring results.
     * @param consumedScroll defines how much scroll was consumed during the measure pass.
     * @param scrollToCarryOver tracks the amount of scrolling that the internal logic has reported
     *   as consumed, but wants to save for later measurement. Also, if the list consumes more
     *   scroll than it was given (for example, due to rounding errors), this value can be set to a
     *   negative amount to balance it out in the next pass. This value should never be larger than
     *   [consumedScroll].
     */
    internal fun applyMeasureResult(
        result: GlimmerLazyListMeasureResult,
        consumedScroll: Float,
        scrollToCarryOver: Float,
    ) {
        this.consumedScroll = consumedScroll
        this.carryOverScroll = scrollToCarryOver

        canScrollBackward = result.canScrollBackward
        canScrollForward = result.canScrollForward
        layoutInfoState.value = result

        scrollPosition.updateFromMeasureResult(result)
    }

    internal fun onScroll(distance: Float): Float {
        if (distance < 0 && !canScrollForward || distance > 0 && !canScrollBackward) {
            return 0f
        }

        // Fast path. Skip measure pass.
        if (abs(distance + carryOverScroll) <= 0.5f) {
            // Inside measuring we do `scrollToBeConsumed.roundToInt()` so there will be no scroll
            // if we have less than 0.5 pixels. So just accumulate it for the next pass.
            carryOverScroll += distance
            return distance
        }

        incomingScroll = distance
        // The `forceRemeasure()` invocation triggers the measure pass where [incomingScroll]
        // will be used to update [consumedScroll] and [carryOverScroll] values.
        remeasurement?.forceRemeasure()
        // It's important to reset this value because there are measure passes
        // triggered from outside scrolling. They read this value as well.
        // So, after we used it, we need to reset it to zero.
        incomingScroll = 0f

        return consumedScroll
    }

    override suspend fun scroll(
        scrollPriority: MutatePriority,
        block: suspend ScrollScope.() -> Unit,
    ) {
        awaitLayoutModifier.waitForFirstLayout()
        backingState.scroll(scrollPriority, block)
    }

    override fun dispatchRawDelta(delta: Float): Float = backingState.dispatchRawDelta(delta)

    override val isScrollInProgress: Boolean
        get() = backingState.isScrollInProgress

    @get:Suppress("GetterSetterNames")
    override var canScrollForward: Boolean by mutableStateOf(false)
        private set

    @get:Suppress("GetterSetterNames")
    override var canScrollBackward: Boolean by mutableStateOf(false)
        private set

    override val scrollIndicatorState: ScrollIndicatorState
        get() = _scrollIndicatorState

    private val _scrollIndicatorState =
        object : ScrollIndicatorState {
            override val scrollOffset: Int
                @FrequentlyChangingValue
                get() =
                    with(layoutInfoState.value) {
                        if (this === EmptyLazyListMeasureResult) {
                            Int.MAX_VALUE
                        } else {
                            this@GlimmerLazyListState.firstVisibleItemIndex *
                                visibleItemsAverageSize +
                                this@GlimmerLazyListState.firstVisibleItemScrollOffset
                        }
                    }

            override val contentSize: Int
                @FrequentlyChangingValue
                get() =
                    with(layoutInfoState.value) {
                        if (this === EmptyLazyListMeasureResult) {
                            Int.MAX_VALUE
                        } else {
                            // Approximate size of all content
                            (totalItemsCount * visibleItemsAverageSize) -
                                // Subtract the final trailing spacing that is not shown
                                (if (totalItemsCount > 0) mainAxisItemSpacing else 0) +
                                // Add the inner paddings (which are separate from the item sizes)
                                beforeContentPadding +
                                afterContentPadding
                        }
                    }

            override val viewportSize: Int
                get() =
                    with(layoutInfoState.value) {
                        if (this === EmptyLazyListMeasureResult) {
                            Int.MAX_VALUE
                        } else {
                            mainAxisViewportSize
                        }
                    }
        }

    /**
     * Instantly brings the item at [index] to the top of the viewport, offset by [scrollOffset]
     * pixels.
     *
     * @param index the index to which to scroll. Must be non-negative.
     * @param scrollOffset the offset that the item should end up after the scroll. Note that
     *   positive offset refers to forward scroll, so in a top-to-bottom list, positive offset will
     *   scroll the item further upward (taking it partly offscreen).
     */
    public suspend fun scrollToItem(@IntRange(from = 0) index: Int, scrollOffset: Int = 0) {
        scroll { snapToItemIndexInternal(index, scrollOffset) }
    }

    /**
     * Animate (smooth scroll) to the given item.
     *
     * @param index the index to which to scroll. Must be non-negative.
     * @param scrollOffset the offset that the item should end up after the scroll. Note that
     *   positive offset refers to forward scroll, so in a top-to-bottom list, positive offset will
     *   scroll the item further upward (taking it partly offscreen).
     */
    public suspend fun animateScrollToItem(@IntRange(from = 0) index: Int, scrollOffset: Int = 0) {
        scroll {
            GlimmerLazyListScrollScope(this@GlimmerLazyListState, this)
                .animateScrollToItem(index, scrollOffset, NumberOfItemsToTeleport, density)
        }
    }

    public companion object {
        /** The default [Saver] implementation for [GlimmerLazyListState]. */
        public val Saver: Saver<GlimmerLazyListState, Any> =
            listSaver(
                save = { listOf(it.firstVisibleItemIndex, it.firstVisibleItemScrollOffset) },
                restore = {
                    GlimmerLazyListState(
                        firstVisibleItemIndex = it[0],
                        firstVisibleItemScrollOffset = it[1],
                    )
                },
            )
    }
}

private val EmptyLazyListMeasureResult =
    GlimmerLazyListMeasureResult(
        firstVisibleItem = null,
        firstVisibleItemScrollOffset = 0,
        canScrollForward = false,
        consumedScroll = 0f,
        measureResult =
            object : MeasureResult {
                override val width: Int = 0
                override val height: Int = 0

                @Suppress("PrimitiveInCollection")
                override val alignmentLines: Map<AlignmentLine, Int> = emptyMap()

                override fun placeChildren() {}
            },
        visibleItemsInfo = emptyList(),
        viewportStartOffset = 0,
        viewportEndOffset = 0,
        totalItemsCount = 0,
        reverseLayout = false,
        orientation = Orientation.Vertical,
        afterContentPadding = 0,
        mainAxisItemSpacing = 0,
        remeasureNeeded = false,
        density = Density(1f),
        childConstraints = Constraints(),
    )