Generated banner for Display Glasses With Jetpack Compose Glimmer
Generated logo for Display Glasses With Jetpack Compose Glimmer

Display Glasses With Jetpack Compose Glimmer

Android (Google)OfficialCreated by Google LLCDistributed by Android (Google)

  • Skill
  • Tool integration
  • Software development
  • Apache-2.0
  • No issues found

From the author

Provides guidelines for developing projected Android XR apps for display glasses using the Jetpack Compose Glimmer UI toolkit. Covers Glimmer design principles, workflows, and interaction models for the glasses form factor. Use this skill to build an Android XR Augmented Experience app with Jetpack Compose Glimmer. Don't use for audio-only AI glasses without a display, non-glasses Android XR headsets (spatial panels or 3D volumes with Jetpack Compose for XR), or phone and tablet Material 3 UIs.
— the description by Google LLC

AutoGineer summary

Written by AutoGineer from the skill’s own files, not by the author

This skill provides design principles, UI patterns, and development workflows for creating projected Android XR applications for display glasses. It focuses on the Jetpack Compose Glimmer toolkit, covering component usage, theming, input handling, and hardware integration for additive display environments.

What it does

  • Defines Glimmer design principles and interaction models for display glasses.
  • Provides guidance on setting up projected activities and managing device connectivity.
  • Outlines UI component usage including cards, buttons, lists, and stacks.
  • Details typography, color, and depth token usage for additive displays.
  • Explains mapping hardware touchpad and gesture inputs to app actions.
  • Offers patterns for voice input indicators and hardware access.

When to use it

  • When developing Android XR apps specifically for display glasses.
  • When implementing UI using the Jetpack Compose Glimmer toolkit.
  • When designing interactions for one-dimensional hardware controls.
  • When configuring apps to run as projected activities on connected eyewear.

Advantages

  • Ensures consistent UI behavior across display glasses.
  • Provides specific guidance for additive display rendering.
  • Standardizes interaction patterns for limited input hardware.
  • Facilitates proper integration with the Android XR projected context.

Usage examples

“How do I set up a basic projected activity for display glasses?”
The skill provides the AndroidManifest configuration and the necessary Activity setup, including the use of ProjectedDisplayController and GlimmerTheme.
“What are the best practices for card layouts in Glimmer?”
It explains how to use ActionCard for single actions and ButtonGroup with non-focusable cards to avoid focus contention.
“How should I handle user input on display glasses?”
The skill details mapping touchpad gestures like taps and swipes, and explains how to use System Back for navigation.
“How do I implement a voice input indicator?”
It provides a code pattern for using the VoiceInputIndicator component driven by SpeechRecognizer RMS changes.

Install

  1. Download the skill as a ZIP:

    https://autogineer.com/agent-skills/android/display-glasses-with-jetpack-compose-glimmer/download
  2. Unzip it into the cross-client skills folder of your project:

    mkdir -p .agents/skills && unzip display-glasses-with-jetpack-compose-glimmer.zip -d .agents/skills

Documentation by Google LLC

Glossary

TermDefinition
Intelligent eyewearHands-free eyewear with speakers, camera, and microphone (audio-only or display glasses).
Display glassesEyewear with a private in-lens display for glanceable visuals that pair with audio.
Jetpack Compose GlimmerCompose UI toolkit providing components, theming, and focus behavior for display glasses.
Projected activityAn Activity running on a phone host whose UI and input are projected to connected eyewear.
Projected deviceAn XR eyewear device connected to an Android host phone that projects app content to it.
GlimmerThemeRoot styling provider for GlimmerColors, GlimmerTypography, and GlimmerShapes.
Additive displayDisplay where black (#000000) renders as 100% transparent by adding light to the scene.
Augmented experiencesLightweight, additive Android XR experiences that help users while they're on the go.
Visual anglePerceived size in XR; minimum readable text is 0.6 degrees (about 18sp at 1 m).

Prerequisites

  • Target compileSdk 37 or higher (compileSdk = 37 or compileSdk { version = release(37) }). Update compileSdk to 37 if lower.

  • When adding dependencies, follow the instructions in Create your first activity for intelligent eyewear:

    • androidx.xr.glimmer:glimmer (minimum 1.0.0-alpha21, or catalog alias libs.androidx.glimmer)

    • androidx.xr.glimmer:glimmer-google-fonts (minimum 1.0.0-alpha21, or catalog alias libs.androidx.glimmer.google.fonts)

    • androidx.xr.projected:projected (minimum 1.0.0-alpha13, or catalog alias libs.androidx.xr.projected or libs.androidx.projected)

    • Check gradle/libs.versions.toml first; if catalog entries exist, use them in app/build.gradle.kts and verify their versions meet the minimums. Never guess 1.0.0-alpha01 for glimmer-google-fonts.

Core constraints and limitations

  • Don't : Use MaterialTheme or Material 3 components (androidx.compose.material3.*) inside Projected Activities or Glimmer UI.

  • Do : Use GlimmerTheme and Glimmer components (androidx.xr.glimmer.*).

  • Do : Always pass createGoogleSansFlexTypography() from androidx.xr.glimmer.googlefonts as typography in GlimmerTheme.

  • Specific hardware device models or unique capabilities aren't detailed.

  • Elevation : Material 3 shadow or elevation modifiers aren't supported. Use ShadowScope or Depth tokens from Jetpack Compose Glimmer.

  • Typography limits: Minimum readable text size is 18sp (0.6°). Avoid "Thin" or "Hairline" weights, which shimmer on additive lenses.

1. Set up dependencies

  • Set up projected activity : When creating a projected activity, follow Create your first activity for intelligent eyewear and projectedcontext-source.md. Set android:requiredDisplayCategory to android.hardware.display.category.XR_PROJECTED in AndroidManifest.xml:

    <activity
        android:name="com.example.xr.projected.ProjectedMainActivity"
        android:exported="true"
        android:requiredDisplayCategory="android.hardware.display.category.XR_PROJECTED">
        <intent-filter>
            <action android:name="android.intent.action.MAIN" />
            <category android:name="android.intent.category.XR_PROJECTED_LAUNCHER"/>
        </intent-filter>
    </activity>

    <br />

  • Mobile app integration : In the host phone app, add a button with Text("Launch on Glasses"). Observe ProjectedContext.isProjectedDeviceConnected(context, coroutineContext) using collectAsStateWithLifecycle(initialValue = false) to set the button's enabled state so it's disabled when glasses are disconnected. When clicked, launch GlassesMainActivity on the glasses display using ProjectedContext.createProjectedActivityOptions(context).toBundle() as shown in the following snippet:

    val options = ProjectedContext.createProjectedActivityOptions(context)
    val intent = Intent(context, ProjectedMainActivity::class.java)
    context.startActivity(intent, options.toBundle())

    <br />

  • UI library and font : When declaring the Glimmer, Google Fonts, and Projected artifacts in app/build.gradle.kts, follow Declaring Jetpack Compose Glimmer Dependencies, and follow glimmersansflextypography-source.md when configuring Google Sans Flex using createGoogleSansFlexTypography.

  • Theming, initial focus, and black background : On additive displays, pure black (Color.Black or GlimmerTheme.colors.background) renders as 100% transparent. In onCreate (annotated with @OptIn(ExperimentalComposeUiApi::class)), set ComposeUiFlags.isInitialFocusOnFocusableAvailable = true (androidx.compose.ui.ComposeUiFlags), wrap content in GlimmerTheme(typography = createGoogleSansFlexTypography()), and apply Modifier.background(Color.Black) (or GlimmerTheme.colors.background) to the root container:

    @OptIn(ExperimentalProjectedApi::class)
    class ProjectedMainActivity : ComponentActivity() {
    
        private var displayController: ProjectedDisplayController? = null
        private var isVisualUiSupported by mutableStateOf(false)
        private var areVisualsOn by mutableStateOf(true)
    
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
    
            lifecycle.addObserver(object : DefaultLifecycleObserver {
                override fun onDestroy(owner: LifecycleOwner) {
                    displayController?.close()
                    displayController = null
                }
            })
    
            initializeGlassesFeatures()
    
            setContent {
                GlimmerTheme {
                    HomeScreen(
                        areVisualsOn = areVisualsOn,
                        isVisualUiSupported = isVisualUiSupported,
                        onClose = { finish() }
                    )
                }
            }
        }
    
        private fun initializeGlassesFeatures() {
            lifecycleScope.launch {
                // Check device capabilities
                val projectedDeviceController = ProjectedDeviceController.create(this@ProjectedMainActivity)
                isVisualUiSupported = projectedDeviceController.capabilities.contains(CAPABILITY_VISUAL_UI)
    
                val controller = ProjectedDisplayController.create(this@ProjectedMainActivity)
                displayController = controller
                val observer = GlassesLifecycleObserver(
                    context = this@ProjectedMainActivity,
                    controller = controller,
                    onVisualsChanged = { visualsOn -> areVisualsOn = visualsOn }
                )
                lifecycle.addObserver(observer)
            }
        }
    }

    <br />

  • Hardware capabilities, permissions, and access : When checking hardware capabilities, requesting permissions, or accessing glasses hardware, follow Check device capabilities at runtime, Request hardware permissions, and Use a projected context to access hardware.

2. Minimize and translate the UI

  • Build UI with Jetpack Compose Glimmer components and use depth for priority.

  • Design from the bottom up and always bottom-align UI (Alignment.BottomCenter).

  • One thing at a time : Show one primary piece of information at a time (for example, using VerticalStack) and avoid multiple simultaneous cards.

  • Color contrast : When choosing colors, follow material-hct-source.md to maintain at least a 70% tone difference between foreground and background in HCT.

3. Map input controls

  • Map tap and swipe interactions to the glasses touchpad. Inputs are one-dimensional; avoid nesting scrolling controls.

  • Use System Back to dismiss temporary states or detailed views.

  • When implementing focus navigation and hardware input handling, follow Focus in Jetpack Compose Glimmer and Hardware Controls for display glasses. Focus movement is continuous for scrollable containers and discrete for elements like button rows.

4. Build with Jetpack Compose Glimmer

Implementing Glimmer styles

When accessing theme tokens using GlimmerTheme, follow glimmertheme-source.md.

CategoryTokenValue / Role
Colorprimary / secondary#9BBFFF / #4C88E9 (Focal colors)
Colorpositive / negative#7AD48F (Confirmation) / #F57082 (Alert)
Colorbackground#000000 (Pure black, renders 100% transparent)
Colorsurface#262626 (Use SurfaceDefaults.border for borders)
ShapeStandard / SmallRoundedCornerShape(36.dp) / RoundedCornerShape(12.dp)
Typography scale (Google Sans Flex) and depth levels

Use Google Sans Flex with variable axes ROND = 100f, wdth = 100f, opsz = 9f, and wght by role:

StyleSize / Line-HeightWeight
Title Large / Medium / Small30/36.sp, 24/32.sp, 20/28.sp725
Body Large / Medium / Small30/36.sp, 24/32.sp, 20/28.sp520
Caption18.sp / 28.sp650

When simulating depth with DepthEffect and DepthEffectLevels, follow deptheffect-source.md and deptheffectlevels-source.md: use level1 for resting cards and background UI, and level2 for focused or pressed interactive UI.

Implementing Jetpack Compose Glimmer components
Cards, ActionCards, ImageCards, and ButtonGroups

Cards (androidx.xr.glimmer.Card) group related content into a single focal point. Never embed a Card within a ListItem. When implementing cards, follow the instructions in Jetpack Compose Glimmer: Cards, card-source.md, and card-samples-source.md.

  • Clickable versus non-clickable card : Pass onClick = { ... } if the entire Card acts as a trigger; omit onClick if informational. Control focus on non-clickable cards with focusable: Boolean = true (such as Card(focusable = false)).

  • Single primary action (ActionCard) : When a card has a single primary button action anchored to its bottom edge, use androidx.xr.glimmer.ActionCard instead of placing a Button inside Card. Its action button takes focus directly:

    ActionCard(
        action = {
            Button(onClick = {}, trailingIcon = { Icon(FavoriteIcon, "Localized description") }) {
                Text("Send")
            }
        }
    ) {
        Text("This is a card with a title, leading icon, header image, and action")
    }

    <br />

  • Multiple actions (Card and ButtonGroup)---avoiding focus contention : When pairing a card with multiple action buttons, use androidx.xr.glimmer.ButtonGroup (with optional rememberButtonGroupState). Never nest ButtonGroup inside Card { ... }. Place a non-focusable Card(focusable = false) (without onClick) above ButtonGroup as a sibling in a Column so the card and buttons don't compete for focus:

    ButtonGroup(modifier = Modifier.fillMaxWidth()) {
        Button(onClick = {}) { Text("Button 1") }
        Button(onClick = {}) { Text("Button 2") }
        Button(onClick = {}) { Text("Button 3") }
        Button(onClick = {}) { Text("Button 4") }
        Button(onClick = {}) { Text("Button 5") }
    }

    <br />

  • Image cards : Use ImageCard, LeadingImageCard, or TrailingImageCard to display prominent imagery alongside card text.

Buttons and ToggleButtons

When implementing buttons or toggle buttons, follow the instructions in Jetpack Compose Glimmer: Buttons, button-source.md, and button-samples-source.md.

  • Standard and icon buttons : Use Button for text actions (with optional leadingIcon/trailingIcon) and IconButton for icon-only triggers (such as media next-track or previous-track buttons).

  • Toggle buttons (ToggleButton and IconToggleButton) : Use ToggleButton or IconToggleButton for stateful on/off or play/pause controls that morph based on checked:

    @Composable
    fun ToggleButtonWithLeadingIconSample() {
        var checked by remember { mutableStateOf(false) }
    
        ToggleButton(
            checked = checked,
            leadingIcon = {
                Icon(
                    if (checked) FavoriteIcon else OutlinedFavoriteIcon,
                    contentDescription = "Toggle favorite"
                )
            },
            onCheckedChange = { checked = it }
        ) {
            Text(if (checked) "On" else "Off")
        }
    }

    <br />

Title chips

TitleChip (androidx.xr.glimmer.TitleChip) is a non-interactive pill header (three words or fewer, single line). When implementing title chips, follow the instructions in Jetpack Compose Glimmer: Title Chips, titlechip-source.md, and titlechip-samples-source.md.

  • Spatial spacing : When placing a standalone TitleChip above a Card or ActionCard, center the TitleChip horizontally and separate it from the card using TitleChipDefaults.associatedContentSpacing (using Spacer or Arrangement.spacedBy(TitleChipDefaults.associatedContentSpacing)):

    @Composable
    fun TitleChipWithCardSample() {
        Column(horizontalAlignment = Alignment.CenterHorizontally) {
            TitleChip { Text("Title Chip") }
    
            Spacer(Modifier.height(TitleChipDefaults.associatedContentSpacing))
    
            Card(
                title = { Text("Title") },
                subtitle = { Text("Subtitle") },
                leadingIcon = { Icon(FavoriteIcon, "Localized description") },
            ) {
                Text("Card Content")
            }
        }
    }

    <br />

Icons

When adding icons or icon buttons, follow the instructions in Jetpack Compose Glimmer: Icons, icon-source.md, iconbutton-source.md, and iconsizes-source.md.

  • Sizing and interactivity : Size icons with GlimmerTheme.iconSizes (small, medium, large). Use IconButton or IconToggleButton for triggers and Icon for status indicators.

  • Contrast and library : Never use pure black (Color.Black) for icon tints; black renders as transparent. Use Material Symbols (600 weight, Rounded, Unfilled). If androidx.compose.material:material-icons-* isn't in app/build.gradle.kts, build vectors with ImageVector.Builder or painterResource instead of importing androidx.compose.material.icons.*.

Lists

Use GlimmerLazyColumn (androidx.xr.glimmer.list.GlimmerLazyColumn) for homogeneous scrollable items. When building scrollable lists and list items, follow the API definitions in list-source.md, listitem-source.md, and liststate-source.md.

  • Always use GlimmerLazyColumn : Never use foundation LazyColumn. Import GlimmerLazyColumn, rememberGlimmerLazyListState, and items from androidx.xr.glimmer.list (item is a scope member, not imported). For programmatic scrolling to the first item, hoist val listState = rememberGlimmerLazyListState() and call listState.scrollToItem(0) or listState.animateScrollToItem(0).

  • Integrated title, spacing, and rows : Pass headers using title = { TitleChip { Text("...") } } directly on GlimmerLazyColumn (not outside it), set verticalArrangement = Arrangement.spacedBy(20.dp), and render rows with androidx.xr.glimmer.ListItem(onClick = { ... }, supportingLabel = { Text(...) }) { Text(...) } (pass the primary label as the trailing content lambda, not headlineContent, and never nest a Card inside ListItem):

    @Composable
    fun GlimmerLazyColumnWithTitleChipSample() {
        val ingredientItems =
            listOf("Milk", "Flour", "Egg", "Salt", "Apples", "Butter", "Vanilla", "Sugar", "Cinnamon")
        GlimmerLazyColumn(title = { TitleChip { Text("Ingredients") } }) {
            items(ingredientItems) { text -> ListItem { Text(text) } }
        }
    }

    <br />

Stacks

Use VerticalStack (androidx.xr.glimmer.stack.VerticalStack) to display heterogeneous cards one at a time (such as calendar, transit, and weather cards). Never use a TitleChip with VerticalStack. When implementing a VerticalStack, follow the API definitions in stack-source.md, stackstate-source.md, and stackitemscope-source.md.

  • Bottom alignment, height (+66.dp), and itemDecoration : Align VerticalStack to Alignment.BottomCenter, set its height to 66.dp taller than the tallest card (maxCardHeight + 66.dp), and apply Modifier.itemDecoration(CardDefaults.shape) to the Card inside every item { ... } block so background cards are clipped and masked:

    @Composable
    fun VerticalStackSample() {
        VerticalStack {
            item {
                Card { Text("Top Card") }
            }
            item {
                Card { Text("Second Card") }
            }
            item {
                Card { Text("Third Card") }
            }
        }
    }

    <br />

Horizontal pagers and animated text

Use GlimmerHorizontalPager (androidx.xr.glimmer.pager.GlimmerHorizontalPager) with rememberGlimmerPagerState(pageCount = { ... }) for horizontal card walkthroughs (never foundation HorizontalPager).

  • Smooth text motion : Inside pager pages, configure Glimmer Text (androidx.xr.glimmer.Text, never androidx.compose.material3.Text) with textMotion = TextMotion.Animated (using .copy(textMotion = TextMotion.Animated)) for smooth rendering during snap transitions:

    // Hoist the pager state, specifying the total page count with a lambda.
    val pagerState = rememberGlimmerPagerState(pageCount = { 10 })
    
    GlimmerHorizontalPager(
        state = pagerState,
        modifier = Modifier.fillMaxSize(),
    ) { page ->
        // Use Glimmer components like Card and Text for optimized glasses styling.
        Card(modifier = Modifier.fillMaxWidth()) {
            Text(
                text = "Page: $page",
                // Recommended: use TextMotion.Animated for smooth transitions in a pager.
                style = LocalTextStyle.current.copy(textMotion = TextMotion.Animated),
            )
        }
    }

    <br />

Surface

Modifier.surface (androidx.xr.glimmer.surface) builds custom interactive components (such as status badges without Card or Button). When creating custom surfaces, follow the API definitions in surface-source.md.

  • Shared MutableInteractionSource and order : Create a single remember { MutableInteractionSource() } and pass it to both Modifier.surface(interactionSource = interactionSource, ...) and .clickable(interactionSource = interactionSource, ...) (or .focusable), chaining .surface(...) before .clickable(...):

    @Composable
    fun FocusableSurfaceSample() {
        val interactionSource = remember { MutableInteractionSource() }
        Box(
            modifier = Modifier
                .size(100.dp)
                .surface(interactionSource = interactionSource)
                .focusable(interactionSource = interactionSource),
            contentAlignment = Alignment.Center
        ) {
            Text("Focusable")
        }
    }
    
    @Composable
    fun ClickableSurfaceSample() {
        val interactionSource = remember { MutableInteractionSource() }
        Box(
            modifier = Modifier
                .size(100.dp)
                .surface(interactionSource = interactionSource)
                .focusable(interactionSource = interactionSource)
                .clickable(
                    interactionSource = interactionSource,
                    indication = null
                ) { /* Handle click action */ },
            contentAlignment = Alignment.Center
        ) {
            Text("Clickable")
        }
    }

    <br />

Voice input indicator and indirect pointer gestures
  • VoiceInputIndicator and ContainedVoiceInputIndicator : Pass a level lambda returning a Float normalized to 0.0f..1.0f. When driven by SpeechRecognizer (RecognitionListener.onRmsChanged(rmsdB: Float)), normalize and clamp rmsdB with .coerceIn(0f, 1f):

    // Example state variable to hold the normalized level (0.0 to 1.0)
    // that the VoiceInputIndicator component expects.
    val audioLevel = MutableStateFlow(0f)
    
    // Initialize the Android SpeechRecognizer
    val speechRecognizer = SpeechRecognizer.createSpeechRecognizer(context)
    
    // Listener to capture speech events and audio level changes
    val listener = object : RecognitionListener {
        override fun onRmsChanged(rmsdB: Float) {
            // Normalize raw dB level to a 0.0-1.0 range.
            // Android SpeechRecognizer's rmsdB typically ranges from 0 to ~10.
            audioLevel.value = ((rmsdB - 1f) / 9f).coerceIn(0f, 1f)
        }
    
        // ... Implement other required RecognitionListener methods ...
        override fun onReadyForSpeech(params: Bundle?) {}
        override fun onBeginningOfSpeech() {}
        override fun onEndOfSpeech() {}
        override fun onError(error: Int) {}
        override fun onResults(results: Bundle?) {}
        override fun onPartialResults(partialResults: Bundle?) {}
        override fun onEvent(eventType: Int, params: Bundle?) {}
        override fun onBufferReceived(buffer: ByteArray?) {}
    }
    
    // Attach the listener to the recognizer
    speechRecognizer.setRecognitionListener(listener)
    
    // Create an intent to specify the recognition model and behavior
    val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
        putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
    }
    // Begin listening for audio input
    speechRecognizer.startListening(intent)

    <br />

    @Composable
    fun VoiceInputExample(modifier: Modifier) {
        // Collect the flow as Compose State so the UI reacts to changes in real-time.
        val currentLevel by audioLevel.collectAsState()
    
        VoiceInputIndicator(
            // The VoiceInputIndicator component provides a visual "pulse" or indicator
            // that changes based on the 'level' lambda, which returns a Float between 0.0 and 1.0.
            level = { currentLevel },
            modifier = modifier
        )
    }

    <br />

  • Modifier.onIndirectPointerGesture : Handle low-level touchpad tap (onClick), forward swipe (onSwipeForward), and backward swipe (onSwipeBackward) gestures using androidx.xr.glimmer.onIndirectPointerGesture on a container made focusable (using focusTarget, focusable, or surface(focusable = true)):

    @Composable
    fun OnIndirectPointerGestureSample() {
        var lastGesture by remember { mutableStateOf("None") }
    
        Box(
            modifier =
                Modifier.fillMaxSize()
                    .onIndirectPointerGesture(
                        enabled = true,
                        onSwipeForward = { lastGesture = "Forward" },
                        onSwipeBackward = { lastGesture = "Backward" },
                        onClick = { lastGesture = "Click" },
                    )
                    .focusTarget(),
            contentAlignment = Alignment.Center,
        ) {
            Text("Last Gesture: $lastGesture")
        }
    }

    <br />

5. Integrate with system UI

Files

references/stackitemscope-source.md

When creating a Glimmer Stack component, refer to the following source code in StackItemScope.kt for setting the stack item scope:

/*
 * 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 />

Requirements and compatibility

Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta

“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.

Security

No issues found by our static scan· No issues found

A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.

Attribution and licence

Created by
Google LLC
Publisher
Android (Google) (Official)
Distributed by
Android (Google)
Source
android/skills · xr/display-glasses-with-jetpack-compose-glimmer · commit bf68e2d
Licence
Apache License 2.0 (redistribution allowed)
  • LICENSE (added by AutoGineer from LICENSE.txt)
  • ATTRIBUTION.md (written by AutoGineer)
Synced

AutoGineer is not affiliated with or endorsed by Google LLC or Android (Google).

Download ZIP

The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download display-glasses-with-jetpack-compose-glimmer.zip