skill: add Android skills
This commit is contained in:
@@ -0,0 +1,325 @@
|
||||
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 />
|
||||
Reference in New Issue
Block a user