Files
VibeCoding/.agents/skills/display-glasses-with-jetpack-compose-glimmer/references/stackstate-source.md

325 lines
13 KiB
Markdown

When creating a Glimmer Stack component, refer to the following source code in
`StackState.kt` for creating a state for the stack:
```kotlin
/*
* 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.stack
import androidx.annotation.IntRange
import androidx.collection.MutableIntIntMap
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.spring
import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.gestures.ScrollScope
import androidx.compose.foundation.gestures.ScrollableState
import androidx.compose.foundation.interaction.InteractionSource
import androidx.compose.foundation.pager.PagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.annotation.FrequentlyChangingValue
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.FocusState
import androidx.compose.ui.unit.IntSize
/**
* Creates and remembers a [StackState] for a [VerticalStack].
*
* The returned [StackState] is remembered across compositions and can be used to control or observe
* the state of a [VerticalStack]. It's essential to pass this state to the `state` parameter of the
* corresponding [VerticalStack] composable.
*
* Note: Properties of the state will only be correctly populated after the [VerticalStack] it is
* associated with has been composed for the first time.
*
* Warning: A single [StackState] instance must not be shared across multiple [VerticalStack]
* composables.
*
* @param initialTopItem The index of the item to show at the top of the stack initially. Must be
* non-negative. Defaults to 0.
* @see StackState
* @see VerticalStack
*/
@Composable
public fun rememberStackState(@IntRange(from = 0) initialTopItem: Int = 0): StackState =
rememberSaveable(saver = StackState.Saver) { StackState(initialTopItem) }
/**
* The [VerticalStack] state that allows programmatic control and observation of the stack's state.
*
* A [StackState] object can be created and remembered using [rememberStackState].
*
* Note: Properties of the state will only be correctly populated after the [VerticalStack] it is
* associated with has been composed for the first time.
*
* Warning: A single [StackState] instance must not be shared across multiple [VerticalStack]
* composables.
*
* @param initialTopItem The index of the item to show at the top of the stack initially. Must be
* non-negative. Defaults to 0.
* @see rememberStackState
* @see VerticalStack
*/
// TODO(b/413429531): add ScrollIndicatorState.
@Stable
public class StackState(@IntRange(from = 0) initialTopItem: Int = 0) : ScrollableState {
init {
require(initialTopItem >= 0) { "initialTopItem must be non-negative" }
}
internal var itemCount by mutableIntStateOf(0)
internal val pagerState = PagerState(currentPage = initialTopItem, pageCount = { itemCount })
/** The index of the item that's currently at the top of the stack, defaults to 0. */
public val topItem: Int
get() = topItemState.value
/**
* Backing state for [topItem] derived from [PagerState.currentPage] and
* [PagerState.currentPageOffsetFraction].
*
* In Stack, an item is considered the top of the stack item until it completely moves off the
* viewport (when scrolling forward), or until the previous item enters the viewport (when
* scrolling backward).
*/
internal val topItemState = derivedStateOf {
if (pagerState.currentPageOffsetFraction >= 0) pagerState.currentPage
else pagerState.currentPage - 1
}
/**
* The offset of the top item as a fraction of the stack item container size. The value
* indicates how much the item is offset from the snapped position. This value ranges between
* 0.0 (snapped position) and 1.0 (lower bound of the top item is at the top of the viewport).
*/
public val topItemOffsetFraction: Float
@FrequentlyChangingValue
get() {
// In Pager, [PagerState.currentPage] changes to the next page when the current page
// scrolls more than half way off the viewport, which is also when
// [PagerState.currentPageOffsetFraction] reaches 0.5. Similarly, when scrolling back,
// the [PagerState.currentPage] switches to the previous page when
// [PagerState.currentPageOffsetFraction] reaches -0.5. In other words, the current
// page's offset fraction ranges between -0.5 and 0.5. In Stack, an item is considered
// the top of the stack item until it completely moves off the viewport when scrolling
// forward, or until the previous item enters the viewport when scrolling backward. In
// other words, the top item's offset fraction ranges between 0 (at the snapped
// position) to 1.0 (at the top of the viewport).
val currentPageOffsetFraction = pagerState.currentPageOffsetFraction
return if (currentPageOffsetFraction >= 0) currentPageOffsetFraction
else currentPageOffsetFraction + 1f
}
/**
* [InteractionSource] that's used to dispatch drag events when this stack is being dragged. To
* know whether a fling (or animated scroll) is in progress, use [isScrollInProgress].
*/
public val interactionSource: InteractionSource
get() = pagerState.interactionSource
/**
* Contains useful information about the currently displayed layout of this stack. The
* information is available after the first measure pass.
*/
// TODO(b/446933128): when making layoutInfo public, consider making it a State.
internal val layoutInfoInternal = StackLayoutInfoImpl(pagerState, topItemState)
private var hasFocus: Boolean = false
private var focusedItem: Int = initialTopItem
/**
* Scroll (jump immediately) to a given [item] index.
*
* @param item The index of the destination item
*/
public suspend fun scrollToItem(item: Int) {
if (itemCount == 0) return
pagerState.scrollToPage(item.coerceIn(0, itemCount - 1))
}
/**
* Scroll animate to a given [item]'s closest snap position. If the [item] is too far away from
* [topItem], not all the items in the range will be composed. Instead, the stack will jump to a
* nearer item, then compose and animate the rest of the items until the destination [item].
*
* @param item The index of the destination item
* @param animationSpec An [AnimationSpec] to move between items
*/
public suspend fun animateScrollToItem(
item: Int,
animationSpec: AnimationSpec<Float> = spring(),
) {
if (itemCount == 0) return
pagerState.animateScrollToPage(
item.coerceIn(0, itemCount - 1),
pageOffsetFraction = 0f,
animationSpec,
)
}
override suspend fun scroll(
scrollPriority: MutatePriority,
block: suspend ScrollScope.() -> Unit,
) {
if (itemCount == 0) return
pagerState.scroll(scrollPriority, block)
}
override fun dispatchRawDelta(delta: Float): Float {
if (itemCount == 0) return 0f
return pagerState.dispatchRawDelta(delta)
}
override val isScrollInProgress: Boolean
get() = pagerState.isScrollInProgress
@get:Suppress("GetterSetterNames")
override val canScrollForward: Boolean
get() = pagerState.currentPage < pagerState.pageCount - 1
@get:Suppress("GetterSetterNames")
override val canScrollBackward: Boolean
get() = pagerState.currentPage > 0
@get:Suppress("GetterSetterNames")
override val lastScrolledForward: Boolean
get() = pagerState.lastScrolledForward
@get:Suppress("GetterSetterNames")
override val lastScrolledBackward: Boolean
get() = pagerState.lastScrolledBackward
/** Callback for top-level (pager-level) focus state changes. */
internal fun onTopLevelFocusChanged(focusState: FocusState) {
hasFocus = focusState.hasFocus
}
/** Callback for item-level focus state changes for the item at [index]. */
internal fun onItemFocusChanged(index: Int, focusState: FocusState) {
if (focusState.isFocused) focusedItem = index
}
/**
* Moves focus to [index] either to the current top item or the next item depending on whether
* the top item has moved past [FocusMoveThreshold] and if the item is not already in focus.
*
* If the stack doesn't already have focus, the auto focus logic doesn't apply.
*/
internal fun notifyAutoFocus(index: Int, focusRequester: FocusRequester) {
if (!hasFocus) {
// Do not move focus if the stack doesn't already have focus.
return
}
val topItemValue = topItem
val intendedFocusedItem =
if (topItemOffsetFraction < FocusMoveThreshold) topItemValue
else (topItemValue + 1).coerceAtMost(itemCount - 1)
if (intendedFocusedItem != index) {
// The intended focused item is not the item at the requested index.
return
}
if (intendedFocusedItem == focusedItem) {
// No need to move focus if the intended focused item is already in focus.
return
}
focusRequester.requestFocus()
}
public companion object {
/** The default [Saver] implementation for [StackState]. */
public val Saver: Saver<StackState, *> =
Saver(save = { it.topItem }, restore = { StackState(it) })
}
}
/**
* Contains useful information about the currently displayed layout of a [VerticalStack]. This
* information is available after the first measure pass.
*
* Use [StackState.layoutInfoInternal] to retrieve this.
*/
@Stable
internal sealed interface StackLayoutInfo {
// TODO(b/446933128): decide what properties should be exposed as public States.
}
/** The default implementation of [StackLayoutInfo]. */
internal class StackLayoutInfoImpl
internal constructor(private val pagerState: PagerState, private val topItemState: State<Int>) :
StackLayoutInfo {
/** The overall size of this stack's viewport. */
internal val viewportSize: IntSize
get() = pagerState.layoutInfo.viewportSize
/** The measured height of the top of the stack item. */
internal val measuredTopItemHeight: Int
get() = measuredHeights.getOrDefault(topItemState.value, defaultValue = 0)
/** The measured height of the item following the top of the stack item. */
internal val measuredNextItemHeight: Int
get() = measuredHeights.getOrDefault(topItemState.value + 1, defaultValue = 0)
/** The measured height of the item following the next item in the stack. */
internal val measuredNextNextItemHeight: Int
get() = measuredHeights.getOrDefault(topItemState.value + 2, defaultValue = 0)
/** The backing storage for measured item heights keyed by item index. */
// TODO(b/446933128): remove this once PageInfo exposes page sizes.
private val measuredHeights: MutableIntIntMap = MutableIntIntMap()
/**
* Updates the measured height of the item at the specified index and trims heights for items
* outside of the close range to the top item.
*/
internal fun updateMeasuredHeight(index: Int, height: Int) {
measuredHeights.put(index, height)
// Clean up measured heights for items that are not in the close range to the top item.
// TODO(b/446933128): find a way to access currentPage inside of withoutReadObservation.
val currentPage = pagerState.currentPage
Snapshot.withoutReadObservation {
val itemCount = pagerState.pageCount
val itemRange = currentPage - 2..(currentPage + 3).coerceAtMost(itemCount - 1)
measuredHeights.removeIf { index, _ -> index !in itemRange }
}
}
}
/**
* The threshold of [StackState.topItemOffsetFraction] past which focus should automatically move to
* the next item.
*/
private const val FocusMoveThreshold = 0.6f
```
<br />