skill: add Android skills
This commit is contained in:
@@ -0,0 +1,440 @@
|
||||
When creating a Glimmer Stack component, refer to the following source code in
|
||||
`StackItemScope.kt` for setting the stack item scope:
|
||||
|
||||
|
||||
```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.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
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.graphics.Outline
|
||||
import androidx.compose.ui.graphics.PathMeasure
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.drawOutline
|
||||
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.node.CompositionLocalConsumerModifierNode
|
||||
import androidx.compose.ui.node.DelegatingNode
|
||||
import androidx.compose.ui.node.DrawModifierNode
|
||||
import androidx.compose.ui.node.LayoutAwareModifierNode
|
||||
import androidx.compose.ui.node.ModifierNodeElement
|
||||
import androidx.compose.ui.node.currentValueOf
|
||||
import androidx.compose.ui.platform.InspectorInfo
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.toSize
|
||||
import androidx.compose.ui.util.fastForEach
|
||||
import androidx.xr.glimmer.DepthEffectNode
|
||||
import androidx.xr.glimmer.GlimmerTheme.Companion.LocalGlimmerTheme
|
||||
import kotlin.math.abs
|
||||
|
||||
/** Receiver scope used by item content in [VerticalStack]. */
|
||||
@Stable
|
||||
public sealed interface StackItemScope {
|
||||
|
||||
/**
|
||||
* Adds a decoration shape for this item, which is used in graphical effects applied to stack
|
||||
* items, e.g., item masking and depth effects. For each distinct shape inside a stack item,
|
||||
* this modifier should be applied to match that shape's bounds. For simple items with just one
|
||||
* shape (such as a card), only one modifier is needed.
|
||||
*
|
||||
* Applying this modifier is optional but highly recommended. If not applied, the item will
|
||||
* render normally but will not have the expected depth effect or masking behavior. Omitting
|
||||
* this modifier is a valid use case when no visual item decoration effects are desired.
|
||||
*
|
||||
* @sample androidx.xr.glimmer.samples.VerticalStackSample
|
||||
* @sample androidx.xr.glimmer.samples.VerticalStackWithMultipleShapesSample
|
||||
* @param shape The shape of the element this modifier is applied to.
|
||||
*/
|
||||
public fun Modifier.itemDecoration(shape: Shape): Modifier =
|
||||
this then ItemDecorationElement(this@StackItemScope as StackItemScopeImpl, shape)
|
||||
}
|
||||
|
||||
internal class ItemDecorationElement
|
||||
internal constructor(private val stackItemScope: StackItemScopeImpl, private val shape: Shape) :
|
||||
ModifierNodeElement<ItemDecorationNode>() {
|
||||
|
||||
override fun create(): ItemDecorationNode = ItemDecorationNode(stackItemScope, shape)
|
||||
|
||||
override fun update(node: ItemDecorationNode) {
|
||||
node.update(stackItemScope, shape)
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is ItemDecorationElement) return false
|
||||
|
||||
if (stackItemScope != other.stackItemScope) return false
|
||||
if (shape != other.shape) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = stackItemScope.hashCode()
|
||||
result = 31 * result + shape.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
override fun InspectorInfo.inspectableProperties() {
|
||||
name = "stackItemDecoration"
|
||||
properties["shape"] = shape
|
||||
}
|
||||
}
|
||||
|
||||
internal class StackItemScopeImpl(internal val state: StackState) : StackItemScope {
|
||||
|
||||
internal var index = -1
|
||||
internal val decorations = mutableListOf<ItemDecorationNode>()
|
||||
internal var coordinates: LayoutCoordinates? = null
|
||||
|
||||
internal var maskWidth by mutableFloatStateOf(0f)
|
||||
private set
|
||||
|
||||
internal var maskBottomY by mutableFloatStateOf(0f)
|
||||
private set
|
||||
|
||||
internal fun recalculateMask() {
|
||||
// First find the max width to avoid unnecessary calls to calculateMaskBottomYInItem.
|
||||
var maxDecorationWidth = 0f
|
||||
decorations.fastForEach { decoration ->
|
||||
val width = decoration.outline?.bounds?.width ?: 0f
|
||||
if (width > maxDecorationWidth) {
|
||||
maxDecorationWidth = width
|
||||
}
|
||||
}
|
||||
|
||||
if (maxDecorationWidth <= 0f) {
|
||||
maskWidth = 0f
|
||||
maskBottomY = 0f
|
||||
return
|
||||
}
|
||||
|
||||
var selectedDecoration: ItemDecorationNode? = null
|
||||
var selectedDecorationMaskBottomY = 0f
|
||||
|
||||
decorations.fastForEach { decoration ->
|
||||
val outline = decoration.outline ?: return@fastForEach
|
||||
val bounds = outline.bounds
|
||||
|
||||
// Skip decorations that are narrower than the max found.
|
||||
if (abs(bounds.width - maxDecorationWidth) > DecorationWidthNoiseThresholdPx) {
|
||||
return@fastForEach
|
||||
}
|
||||
|
||||
val decorationMaskBottomY = decoration.calculateMaskBottomYInItem()
|
||||
if (selectedDecoration == null) {
|
||||
selectedDecoration = decoration
|
||||
selectedDecorationMaskBottomY = decorationMaskBottomY
|
||||
} else {
|
||||
// Tie-breaking: prefer higher widest point (smaller Y).
|
||||
if (decorationMaskBottomY < selectedDecorationMaskBottomY) {
|
||||
selectedDecoration = decoration
|
||||
selectedDecorationMaskBottomY = decorationMaskBottomY
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
maskWidth = selectedDecoration?.outline?.bounds?.width ?: 0f
|
||||
maskBottomY = selectedDecorationMaskBottomY
|
||||
}
|
||||
}
|
||||
|
||||
internal class ItemDecorationNode(
|
||||
private var stackItemScope: StackItemScopeImpl,
|
||||
private var shape: Shape,
|
||||
) :
|
||||
DelegatingNode(),
|
||||
LayoutAwareModifierNode,
|
||||
CompositionLocalConsumerModifierNode,
|
||||
DrawModifierNode {
|
||||
|
||||
internal var size = Size.Zero
|
||||
internal var outline: Outline? = null
|
||||
internal var offset: Offset = Offset.Zero
|
||||
|
||||
private var depthEffectNode: DepthEffectNode? = null
|
||||
private var coordinates: LayoutCoordinates? = null
|
||||
|
||||
private var lastOutlineForCachedMaskBottomY: Outline? = null
|
||||
private var cachedMaskBottomY: Float = 0f
|
||||
|
||||
private var minX = FloatArray(0)
|
||||
private var maxX = FloatArray(0)
|
||||
private var pathMeasure: PathMeasure? = null
|
||||
|
||||
override fun onAttach() {
|
||||
depthEffectNode = delegate(DepthEffectNode(currentValueOfDepth(), shape))
|
||||
stackItemScope.decorations.add(this)
|
||||
if (size != Size.Zero) {
|
||||
// If this node is reused, update the decoration in case there is no remeasure.
|
||||
updateDecoration()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRemeasured(size: IntSize) {
|
||||
this.size = size.toSize()
|
||||
updateDecoration()
|
||||
}
|
||||
|
||||
override fun onPlaced(coordinates: LayoutCoordinates) {
|
||||
this.coordinates = coordinates
|
||||
offset = calculateDecorationOffset()
|
||||
stackItemScope.recalculateMask()
|
||||
}
|
||||
|
||||
override fun onDetach() {
|
||||
stackItemScope.decorations.remove(this)
|
||||
depthEffectNode?.let { undelegate(it) }
|
||||
}
|
||||
|
||||
override fun ContentDrawScope.draw() {
|
||||
val index = stackItemScope.index
|
||||
if (index == -1) return
|
||||
val state = stackItemScope.state
|
||||
val topItem = state.topItem
|
||||
val offsetFraction = state.topItemOffsetFraction
|
||||
|
||||
val contentAlpha =
|
||||
calculateContentAlpha(index = index, topItem = topItem, offsetFraction = offsetFraction)
|
||||
|
||||
// Apply alpha to the depth separately from the content so that the shadows are not clipped.
|
||||
depthEffectNode?.apply { drawDepthEffect(alpha = contentAlpha) }
|
||||
|
||||
// Draw item content with a scrim based on the current alpha.
|
||||
drawContent()
|
||||
|
||||
val scrimAlpha =
|
||||
calculateScrimAlpha(index = index, topItem = topItem, offsetFraction = offsetFraction)
|
||||
val scrimColor = getScrimColor(index = index, topItem = topItem)
|
||||
val outline = getDecorationOutline()
|
||||
scrimColor?.let {
|
||||
// If there is a scrim color, apply it on top of the item.
|
||||
drawOutline(outline = outline, color = it, alpha = scrimAlpha)
|
||||
}
|
||||
|
||||
if (contentAlpha < 1f) {
|
||||
drawOutline(
|
||||
outline = outline,
|
||||
blendMode = BlendMode.DstOut,
|
||||
color = Color.Black,
|
||||
alpha = 1f - contentAlpha,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun update(stackItemScope: StackItemScopeImpl, shape: Shape) {
|
||||
depthEffectNode?.update(currentValueOfDepth(), shape)
|
||||
if (this.stackItemScope != stackItemScope || this.shape != shape) {
|
||||
this.stackItemScope.decorations.remove(this)
|
||||
stackItemScope.decorations.add(this)
|
||||
this.stackItemScope = stackItemScope
|
||||
this.shape = shape
|
||||
updateDecoration()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun getOrCalculateMaskBottomY(): Float {
|
||||
if (outline !== lastOutlineForCachedMaskBottomY) {
|
||||
cachedMaskBottomY = outline?.calculateMaskBottomY() ?: 0f
|
||||
lastOutlineForCachedMaskBottomY = outline
|
||||
}
|
||||
return cachedMaskBottomY
|
||||
}
|
||||
|
||||
private fun ContentDrawScope.getDecorationOutline(): Outline {
|
||||
outline?.let {
|
||||
return it
|
||||
}
|
||||
return shape.createOutline(size, layoutDirection, this).also { outline = it }
|
||||
}
|
||||
|
||||
private fun updateDecoration() {
|
||||
if (size == Size.Zero || !isAttached) return
|
||||
|
||||
offset = calculateDecorationOffset()
|
||||
outline = createOutline()
|
||||
|
||||
stackItemScope.recalculateMask()
|
||||
}
|
||||
|
||||
private fun createOutline(): Outline {
|
||||
val density = currentValueOf(LocalDensity)
|
||||
val layoutDirection = currentValueOf(LocalLayoutDirection)
|
||||
return shape.createOutline(size, layoutDirection, density)
|
||||
}
|
||||
|
||||
private fun calculateDecorationOffset(): Offset =
|
||||
coordinates?.let { decorationCoordinates ->
|
||||
stackItemScope.coordinates?.localPositionOf(
|
||||
sourceCoordinates = decorationCoordinates,
|
||||
relativeToSource = Offset.Zero,
|
||||
)
|
||||
} ?: Offset.Zero
|
||||
|
||||
private fun calculateContentAlpha(index: Int, topItem: Int, offsetFraction: Float): Float =
|
||||
when {
|
||||
index.isTopItem(topItem = topItem) -> 1f - offsetFraction
|
||||
index.isNextItem(topItem = topItem) -> 1f
|
||||
index.isNextNextItem(topItem = topItem) -> offsetFraction
|
||||
else -> 0f
|
||||
}
|
||||
|
||||
private fun calculateScrimAlpha(index: Int, topItem: Int, offsetFraction: Float): Float =
|
||||
when {
|
||||
index.isNextItem(topItem = topItem) -> (1f - offsetFraction) * MaxItemScrimAlpha
|
||||
index.isNextNextItem(topItem = topItem) -> MaxItemScrimAlpha
|
||||
else -> 0f
|
||||
}
|
||||
|
||||
private fun getScrimColor(index: Int, topItem: Int): Color? =
|
||||
when {
|
||||
index.isNextItem(topItem = topItem) -> SurfaceLow
|
||||
index.isNextNextItem(topItem = topItem) -> SurfaceLow
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun currentValueOfDepth() = currentValueOf(LocalGlimmerTheme).depthEffectLevels.level1
|
||||
|
||||
/**
|
||||
* Returns the Y coordinate within [Outline] where the bottom boundary of the mask should be.
|
||||
*/
|
||||
private fun Outline.calculateMaskBottomY(): Float =
|
||||
when (this) {
|
||||
is Outline.Rectangle -> 0f
|
||||
is Outline.Rounded ->
|
||||
// The mask's bottom is where the top corners of the round rect start curving.
|
||||
roundRect.top +
|
||||
maxOf(roundRect.topLeftCornerRadius.y, roundRect.topRightCornerRadius.y)
|
||||
is Outline.Generic -> calculateYAtWidestPoint(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Y coordinate within [Outline] where the outline is the widest.
|
||||
*
|
||||
* This uses a heuristic approach by sampling points along the path perimeter using
|
||||
* [PathMeasure] and aggregating them into vertical buckets to determine the min/max X for a
|
||||
* given Y. Shapes with sharp horizontal features might have their width slightly under-reported
|
||||
* depending on sampling phase.
|
||||
*/
|
||||
private fun calculateYAtWidestPoint(outline: Outline.Generic): Float {
|
||||
val bounds = outline.bounds
|
||||
val height = bounds.height
|
||||
if (height <= 0f) return 0f
|
||||
|
||||
// 1 bucket every 2 pixels clamped to a reasonable range.
|
||||
val resolution =
|
||||
(height / 2)
|
||||
.toInt()
|
||||
.coerceIn(
|
||||
minimumValue = MinWidestPointSearchResolution,
|
||||
maximumValue = MaxWidestPointSearchResolution,
|
||||
)
|
||||
|
||||
if (minX.size < resolution || maxX.size < resolution) {
|
||||
// Allocate for max resolution to avoid reallocating the arrays on size changes.
|
||||
minX = FloatArray(MaxWidestPointSearchResolution)
|
||||
maxX = FloatArray(MaxWidestPointSearchResolution)
|
||||
}
|
||||
|
||||
minX.fill(Float.POSITIVE_INFINITY, fromIndex = 0, toIndex = resolution)
|
||||
maxX.fill(Float.NEGATIVE_INFINITY, fromIndex = 0, toIndex = resolution)
|
||||
|
||||
val measure = pathMeasure ?: PathMeasure().also { pathMeasure = it }
|
||||
measure.setPath(outline.path, forceClosed = true)
|
||||
val length = measure.length
|
||||
if (length == 0f) return 0f
|
||||
|
||||
// 2 samples per bucket.
|
||||
val totalSamples = resolution * 2
|
||||
val stepSize = length / totalSamples
|
||||
|
||||
// Scale for converting Y to a bucket index.
|
||||
val indexScale = (resolution - 1) / height
|
||||
val boundsTop = bounds.top
|
||||
|
||||
var distance = 0f
|
||||
while (distance <= length) {
|
||||
val position = measure.getPosition(distance)
|
||||
val x = position.x
|
||||
val y = position.y
|
||||
|
||||
val relativeY = y - boundsTop
|
||||
val bucketIndex = (relativeY * indexScale).toInt().coerceIn(0, resolution - 1)
|
||||
|
||||
if (x < minX[bucketIndex]) minX[bucketIndex] = x
|
||||
if (x > maxX[bucketIndex]) maxX[bucketIndex] = x
|
||||
|
||||
distance += stepSize
|
||||
}
|
||||
|
||||
// Default to the vertical center if no points are processed or path is empty.
|
||||
var selectedIndex = resolution / 2
|
||||
var maxFoundWidth = -1f
|
||||
for (i in 0 until resolution) {
|
||||
if (minX[i] == Float.POSITIVE_INFINITY || maxX[i] == Float.NEGATIVE_INFINITY) continue
|
||||
val width = maxX[i] - minX[i]
|
||||
// If widths are effectively equal, we preserve the lower index.
|
||||
if (width > maxFoundWidth + DecorationWidthNoiseThresholdPx) {
|
||||
maxFoundWidth = width
|
||||
selectedIndex = i
|
||||
}
|
||||
}
|
||||
|
||||
return boundsTop + (height * selectedIndex / resolution.toFloat())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the Y coordinate within the stack item where the bottom boundary of the mask should
|
||||
* be.
|
||||
*/
|
||||
private fun ItemDecorationNode.calculateMaskBottomYInItem(): Float =
|
||||
offset.y + getOrCalculateMaskBottomY()
|
||||
|
||||
/** Returns whether the index is of the top of the stack item. */
|
||||
internal fun Int.isTopItem(topItem: Int) = this == topItem
|
||||
|
||||
/** Returns whether the index is of the next item that follows the top of the stack item. */
|
||||
internal fun Int.isNextItem(topItem: Int) = this == topItem + 1
|
||||
|
||||
/** Returns whether the index is of the next-next item after the top of the stack item. */
|
||||
internal fun Int.isNextNextItem(topItem: Int) = this == topItem + 2
|
||||
|
||||
/**
|
||||
* Tolerance in pixels used to account for floating-point and heuristic estimation inaccuracies when
|
||||
* comparing decoration widths.
|
||||
*/
|
||||
internal const val DecorationWidthNoiseThresholdPx = 0.5f
|
||||
|
||||
private val SurfaceLow = Color(0xFF4F4F4F)
|
||||
private const val MaxItemScrimAlpha = 0.5f
|
||||
private const val MinWidestPointSearchResolution = 20
|
||||
private const val MaxWidestPointSearchResolution = 200
|
||||
```
|
||||
|
||||
<br />
|
||||
Reference in New Issue
Block a user