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(), )