When creating a Glimmer List component, refer to the following source code in
ListState.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.ListState.Companion.Saver import kotlin.math.abs /** * Creates a [ListState] 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 [ListState.firstVisibleItemIndex] * @param initialFirstVisibleItemScrollOffset the initial value for * [ListState.firstVisibleItemScrollOffset] */ @Composable public fun rememberListState( initialFirstVisibleItemIndex: Int = 0, initialFirstVisibleItemScrollOffset: Int = 0, ): ListState = rememberSaveable(saver = ListState.Saver) { ListState(initialFirstVisibleItemIndex, initialFirstVisibleItemScrollOffset) } /** * A state object that can be hoisted to control and observe scrolling. * * In most cases, this will be created via [rememberListState]. * * @param firstVisibleItemIndex the initial value for [ListState.firstVisibleItemIndex] * @param firstVisibleItemScrollOffset the initial value for * [ListState.firstVisibleItemScrollOffset] */ public class ListState(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 = GlimmerListScrollPosition(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 = GlimmerListAutoFocusState() /** 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@ListState.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 [ListLayoutInfo.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 [ListLayoutInfo] 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: ListLayoutInfo @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: GlimmerListItemProvider, 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: GlimmerListMeasureResult, 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@ListState.firstVisibleItemIndex * visibleItemsAverageSize + this@ListState.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 { GlimmerListScrollScope(this@ListState, this) .animateScrollToItem(index, scrollOffset, NumberOfItemsToTeleport, density) } } public companion object { /** The default [Saver] implementation for [ListState]. */ public val Saver: Saver<ListState, Any> = listSaver( save = { listOf(it.firstVisibleItemIndex, it.firstVisibleItemScrollOffset) }, restore = { ListState(firstVisibleItemIndex = it[0], firstVisibleItemScrollOffset = it[1]) }, ) } } private val EmptyLazyListMeasureResult = GlimmerListMeasureResult( 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(), )