When creating a Glimmer Stack component, refer to the following source code in
Stack.kt:
/* * 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.compose.animation.core.VisibilityThreshold import androidx.compose.animation.core.spring import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.pager.PagerDefaults import androidx.compose.foundation.pager.PagerSnapDistance import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.VerticalPager import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.referentialEqualityPolicy import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.onPlaced import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastFirstOrNull import androidx.compose.ui.util.fastForEach import androidx.compose.ui.util.fastMap import androidx.compose.ui.util.lerp import androidx.compose.ui.zIndex import androidx.xr.glimmer.internal.SingleItemScrollConstraintConnection import kotlin.math.roundToInt /** * [VerticalStack] is a lazy, vertically scrollable layout that arranges its items in a visually * overlapping, three-dimensional sequence, which resembles a deck of cards. The primary item is * prominently displayed in the foreground. Subsequent items are positioned behind the primary item * along the z-axis with a small portion of the next item revealed to indicate depth and upcoming * content. * * As the user scrolls vertically, the active foreground item transitions out of view, allowing the * item immediately beneath it to slide into the prominent foreground position. Items always * snap-animate into the foreground position after the user's gesture ends. * * Note: When displaying text within a [VerticalStack], it is strongly recommended to set * [androidx.compose.ui.text.TextStyle.textMotion] to * [androidx.compose.ui.text.style.TextMotion.Animated]. This ensures smooth rendering during layout * animations or scaling transitions, preventing pixel-snapping artifacts. * * @sample androidx.xr.glimmer.samples.VerticalStackSample * @param modifier the modifier to apply to this layout. * @param state the state of the stack. * @param content a block that describes the content. Inside this block you can use methods like * [StackScope.item] to add a single item or [StackScope.items] to add a collection of items. */ @Composable public fun VerticalStack( modifier: Modifier = Modifier, state: StackState = rememberStackState(), content: StackScope.() -> Unit, ) { val latestContent = rememberUpdatedState(content) val stackItemHolderState = remember(state) { // Re-run the DSL to parse items only when the content lambda instance changes. derivedStateOf(referentialEqualityPolicy()) { StackItemHolder(state, latestContent.value).also { // Set the item count on the StackState immediately when the derived state // re-evaluates (i.e., when content changes), even before recomposition. state.itemCount = it.itemCount } } .also { // This second assignment is necessary to force the derivedStateOf lambda above // (which is executed lazily) to execute synchronously inside this block. state.itemCount = it.value.itemCount } } val singleItemScrollConstraintConnection = remember(state.pagerState) { SingleItemScrollConstraintConnection(state.pagerState) } VerticalPager( state = state.pagerState, modifier = modifier .then(StackInitialFocusElement(state)) .stackScrim() .nestedScroll(singleItemScrollConstraintConnection), contentPadding = PaddingValues(bottom = RevealAreaSize), key = { page -> stackItemHolderState.value.getKey(page) }, beyondViewportPageCount = MaxNextVisibleItemCount, flingBehavior = PagerDefaults.flingBehavior( state = state.pagerState, pagerSnapDistance = PagerSnapDistance.atMost(1), snapAnimationSpec = SnapAnimationSpec, ), ) { page -> val stackItemHolder = stackItemHolderState.value stackItemHolder.withInterval(page) { localIndex, itemInterval -> val key = itemInterval.getKeyOrDefault(globalIndex = page, localIntervalIndex = localIndex) val itemScope = itemInterval.getOrCreateItemScope(key) itemScope.index = page StackItemLayout(page = page, state = state, itemScope = itemScope) { itemInterval.item(itemScope, localIndex) } } } } @Composable private fun StackItemLayout( page: Int, state: StackState, itemScope: StackItemScopeImpl, modifier: Modifier = Modifier, content: @Composable () -> Unit, ) { val focusRequester = remember { FocusRequester() } Layout( content = content, modifier = modifier .zIndex(-page.toFloat()) .onPlaced { itemScope.coordinates = it } .maskItemsBelow(state, itemScope) .focusRequester(focusRequester) .onFocusChanged { state.onItemFocusChanged(page, it) }, ) { measurables, constraints -> var maxWidth = 0 var maxHeight = 0 val placeables = measurables.fastMap { it.measure(constraints).also { placeable -> maxWidth = maxOf(maxWidth, placeable.width) maxHeight = maxOf(maxHeight, placeable.height) } } if (!isLookingAhead) { state.layoutInfoInternal.updateMeasuredHeight(index = page, height = maxHeight) } layout(maxWidth, maxHeight) { placeables.fastForEach { placeable -> placeable.placeRelativeWithLayer(x = 0, y = 0) { val revealHeight = RevealAreaSize.roundToPx() val topItem = state.topItem when { page.isTopItem(topItem = topItem) -> { translationY = state.topItemTranslationY( revealHeight = revealHeight, topItemHeight = placeable.height, ) state.notifyAutoFocus(page, focusRequester) } page.isNextItem(topItem = topItem) -> { translationY = state.nextItemTranslationY( revealHeight = revealHeight, topItem = topItem, nextItemHeight = placeable.height, ) val scale = state.nextItemScale() scaleX = scale scaleY = scale state.notifyAutoFocus(page, focusRequester) } page.isNextNextItem(topItem = topItem) -> { translationY = state.nextNextItemTranslationY( revealHeight = revealHeight, topItem = topItem, nextNextItemHeight = placeable.height, ) scaleX = NextItemMinScale scaleY = NextItemMinScale } } } } } } } /** * Masks the items following the current item (items that are below the current item on Z-axis) if * the item's decoration fills the viewport width. */ private fun Modifier.maskItemsBelow(state: StackState, itemScope: StackItemScopeImpl): Modifier = this.drawWithContent { val viewportSize = state.layoutInfoInternal.viewportSize val viewportWidth = viewportSize.width.toFloat() if (itemScope.maskWidth >= (viewportWidth - DecorationWidthNoiseThresholdPx)) { // Only apply the mask to the items below (Z-axis) if the mask fills the viewport width. val viewportHeight = viewportSize.height.toFloat() drawRect( Color.Black, blendMode = BlendMode.DstOut, // The coordinate space here is for the item's layout, which changes position in the // stack viewport depending on the scroll position. We need to deduct the viewport // height to make the starting Y offset negative, so that the mask region extends // upwards to the top of the stack viewport. topLeft = Offset(x = 0f, y = itemScope.maskBottomY - viewportHeight), size = Size(width = viewportWidth, height = viewportHeight), ) } drawContent() } /** * The translation Y of the top item in pixels, which is always equal to the snapped position * offset. */ private fun StackState.topItemTranslationY(revealHeight: Int, topItemHeight: Int) = calculateTopPositionOffset(topItemHeight, revealHeight) /** The current translation Y of the next item in pixels. */ private fun StackState.nextItemTranslationY( revealHeight: Int, topItem: Int, nextItemHeight: Int, ): Float { val nextPageOffset = pagerState.pageOffset(topItem + 1) val progress = topItemOffsetFraction val topItemHeight = layoutInfoInternal.measuredTopItemHeight val topPageTopPositionOffset = calculateTopPositionOffset(topItemHeight, revealHeight) val nextPageTopPositionOffset = calculateTopPositionOffset(nextItemHeight, revealHeight) val topItemBottom = topPageTopPositionOffset + topItemHeight // startOffset is the initial offset of the next item when it's revealed below the top item. val startOffset = calculateInitialBehindPosition( itemAboveBottom = topItemBottom + revealHeight, itemBehindHeight = nextItemHeight, ) val currentOffset = lerp(start = startOffset, stop = nextPageTopPositionOffset, fraction = progress) return currentOffset - nextPageOffset } /** The current scale of the next item. */ private fun StackState.nextItemScale(): Float { val progress = topItemOffsetFraction return lerp(start = NextItemMinScale, stop = 1f, fraction = progress) } /** The current translation Y of the next-next item in pixels. */ private fun StackState.nextNextItemTranslationY( revealHeight: Int, topItem: Int, nextNextItemHeight: Int, ): Float { val nextNextPageOffset = pagerState.pageOffset(topItem + 2) val nextItemHeight = layoutInfoInternal.measuredNextItemHeight // Calculate where the next item *would* be if it were currently the top item. val nextItemTopPositionOffset = calculateTopPositionOffset(nextItemHeight, revealHeight) val nextItemTopPositionBottom = nextItemTopPositionOffset + nextItemHeight // Calculate the static target position the next-next item. // We add revealHeight to nextItemBottom to ensure the item waits at the exact position // where it will need to be when it transitions to being the next item. val offset = calculateInitialBehindPosition( itemAboveBottom = nextItemTopPositionBottom + revealHeight, itemBehindHeight = nextNextItemHeight, ) return offset - nextNextPageOffset } /** * Calculates the offset from the top of the viewport for an item of a given height for the item's * top snapped position. Items are aligned to the bottom of the stack layout. */ private fun StackState.calculateTopPositionOffset(itemHeight: Int, revealHeight: Int): Float { val viewportHeight = layoutInfoInternal.viewportSize.height return (viewportHeight - itemHeight - revealHeight).coerceAtLeast(0).toFloat() } /** Calculates the initial offset for an item when it is positioned behind the item above it. */ private fun calculateInitialBehindPosition(itemAboveBottom: Float, itemBehindHeight: Int): Float = itemAboveBottom - itemBehindHeight * NextItemPositioningScale /** * The main axis offset of the item in pixels from the top of the viewport. * * If the page is in the visible list (inside the viewport), we return its exact offset. If it's not * in the list but is composed (due to beyondViewportPageCount), we calculate where it *should* be * based on the current page and offset fraction. * * Note: this method assumes that the Pager's layoutInfo is already available. */ private fun PagerState.pageOffset(page: Int): Int { val layoutInfo = layoutInfo // First check if the page is already visible. val visiblePage = layoutInfo.visiblePagesInfo.fastFirstOrNull { it.index == page } if (visiblePage != null) return visiblePage.offset // Calculate the distance in "pages" from the current snap position. // (page - currentPage) gives the index distance. // (- currentPageOffsetFraction) accounts for the sub-page scroll. val offsetFromCurrentPage = page - currentPage - currentPageOffsetFraction val stride = layoutInfo.pageSize + layoutInfo.pageSpacing return (offsetFromCurrentPage * stride).roundToInt() } /** The size of the area where the items beneath the top of the stack item are revealed. */ internal val RevealAreaSize = 18.dp /** The maximum number of items that can be visible at the same time in addition to the top item. */ private const val MaxNextVisibleItemCount = 2 /** * The scale of the next item when it is fully behind the top item, and the default scale of the * item behind it (the next-next item). */ private const val NextItemMinScale = 0.94f /** The scale factor that's between 1.0 and [NextItemMinScale], which is used in positioning. */ private const val NextItemPositioningScale = 0.97f // (1f + NextItemMinScale) / 2f /** The animation spec used for snapping stack items. */ private val SnapAnimationSpec = spring( dampingRatio = 0.56f, stiffness = 118f, visibilityThreshold = Int.VisibilityThreshold.toFloat(), )