mcpField
D
display-glasses-with-jetpack-compose-glimmer

display-glasses-with-jetpack-compose-glimmer

github.com/android开发10.0k1.6k 次安装2 天前更新
代码仓库

关于 display-glasses-with-jetpack-compose-glimmer

为 Android 开发者提供面向显示眼镜的 Jetpack Compose Glimmer 实现指南,涵盖投影 Activity、主题、布局、焦点和输入控制。

主要功能

  1. 01配置投影 Activity 与眼镜连接
  2. 02使用 Glimmer 主题和组件
  3. 03按加法显示特性设计界面
  4. 04处理触控板输入与焦点导航
  5. 05遵循文本尺寸和对比度要求

适用场景

  1. 01构建投影到显示眼镜的 Android 界面
  2. 02为加法显示器设计易读内容
  3. 03实现眼镜触控操作与焦点移动

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/android/skills --skill display-glasses-with-jetpack-compose-glimmer

安装后在 Claude Code 中输入/display-glasses-with-jetpack-compose-glimmer确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

references/
button-samples-source.md
button-source.md
card-samples-source.md
card-source.md
deptheffect-source.md
deptheffectlevels-source.md
glimmersansflextypography-source.md
glimmertheme-source.md
icon-source.md
iconbutton-source.md
iconsizes-source.md
list-source.md
listitem-source.md
liststate-source.md
material-hct-source.md
projectedcontext-source.md
stack-source.md
stackitemscope-source.md
stackstate-source.md
surface-source.md
titlechip-samples-source.md
references/android/design/ui/ai-glasses/guides/interaction/
inputs.md
references/android/develop/xr/jetpack-xr-sdk/
access-hardware-projected-context.md
request-hardware-permissions.md
references/android/develop/xr/jetpack-xr-sdk/jetpack-compose-glimmer/
buttons.md
cards.md
focus.md
icons.md
text.md
title-chips.md

详细介绍

术语表

术语定义
智能眼镜免提眼镜,配有扬声器、摄像头和麦克风(仅音频眼镜或带显示屏的眼镜)。
显示眼镜镜片内置私密显示屏的眼镜,可显示一瞥即可查看的视觉内容,并与音频配合使用。
Jetpack Compose GlimmerCompose UI 工具包,提供适用于显示眼镜的组件、主题和焦点行为。
投射 Activity在手机主机上运行的 Activity,其 UI 和输入会投射到已连接的眼镜上。
投射设备连接到 Android 主机手机并接收应用内容投射的 XR 眼镜设备。
GlimmerTheme为 GlimmerColors、GlimmerTypography 和 GlimmerShapes 提供根样式的提供程序。
加色显示屏通过向场景添加光线来渲染内容的显示屏,其中黑色(#000000)呈现为 100% 透明。
增强体验轻量、加色的 Android XR 体验,可在用户外出时提供帮助。
视觉角度XR 中感知到的尺寸;可读文本的最小视觉角度为 0.6 度(距离 1 m 时约为 18sp)。

先决条件

  • 目标 compileSdk 为 37 或更高(compileSdk = 37 或 compileSdk { version = release(37) })。如果低于 37,请将 compileSdk 更新为 37。
  • 添加依赖项时,请遵循为智能眼镜创建首个 Activity中的说明:
    • androidx.xr.glimmer:glimmer(最低版本为 1.0.0-alpha21,或目录别名 libs.androidx.glimmer)
    • androidx.xr.glimmer:glimmer-google-fonts(最低版本为 1.0.0-alpha21,或目录别名 libs.androidx.glimmer.google.fonts)
    • androidx.xr.projected:projected(最低版本为 1.0.0-alpha13,或目录别名 libs.androidx.xr.projected 或 libs.androidx.projected)
    • 先检查 gradle/libs.versions.toml;如果存在目录条目,请在 app/build.gradle.kts 中使用这些条目,并确认其版本符合最低要求。切勿猜测 glimmer-google-fonts 的版本为 1.0.0-alpha01。

核心约束和限制

  • 不要:在投射 Activity 或 Glimmer UI 中使用 MaterialTheme 或 Material 3 组件(androidx.compose.material3.*)。
  • 应当:使用 GlimmerTheme 和 Glimmer 组件(androidx.xr.glimmer.*)。
  • 应当:始终将 androidx.xr.glimmer.googlefonts 中的 createGoogleSansFlexTypography() 作为 typography 传入 GlimmerTheme。
  • 未详细说明具体硬件设备型号或独特功能。
  • 海拔:不支持 Material 3 阴影或海拔修饰符。请使用 Jetpack Compose Glimmer 中的 ShadowScope 或 Depth 令牌。
  • 排版限制:文本的最小可读字号为 18sp(0.6°)。避免使用“Thin”或“Hairline”字重,因为它们在加色镜片上会闪烁。

1. 设置依赖项

  • 设置投射活动:创建投射活动时,请遵循为智能眼镜创建你的第一个活动和 projectedcontext-source.md。在 AndroidManifest.xml 中将 android:requiredDisplayCategory 设置为 android.hardware.display.category.XR_PROJECTED:

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

  • 移动应用集成:在宿主手机应用中,添加一个按钮,并设置 Text("Launch on Glasses")。使用 collectAsStateWithLifecycle(initialValue = false) 观察 ProjectedContext.isProjectedDeviceConnected(context, coroutineContext), 并据此设置按钮的 enabled 状态,以便眼镜断开连接时禁用按钮。点击按钮后,使用 ProjectedContext.createProjectedActivityOptions(context).toBundle() 在眼镜显示屏上启动 GlassesMainActivity,如下方代码片段所示:

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

  • UI 库和字体:在 app/build.gradle.kts 中声明 Glimmer、Google Fonts 和 Projected 工件时,请遵循声明 Jetpack Compose Glimmer 依赖项。使用 createGoogleSansFlexTypography 配置 Google Sans Flex 时,请遵循 glimmersansflextypography-source.md。

  • 主题、初始焦点和黑色背景:在加色显示屏上,纯黑色(Color.Black 或 GlimmerTheme.colors.background)会渲染为 100% 透明。在 onCreate 中(使用 @OptIn(ExperimentalComposeUiApi::class) 注解),设置 ComposeUiFlags.isInitialFocusOnFocusableAvailable = true (androidx.compose.ui.ComposeUiFlags),使用 GlimmerTheme(typography = createGoogleSansFlexTypography()) 包裹内容,并对根容器应用 Modifier.background(Color.Black)(或 GlimmerTheme.colors.background):

    @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)
            }
        }
    }
    

  • 硬件功能、权限和访问:检查硬件功能、请求权限或访问眼镜硬件时,请遵循 在运行时检查设备功能、请求硬件 权限以及使用投射上下文访问硬件。

2. 精简并转换 UI

  • 使用 Jetpack Compose Glimmer 组件构建 UI,并通过深度体现优先级。
  • 从底部向上设计,并始终将 UI 与底部对齐(Alignment.BottomCenter)。
  • 一次只呈现一项内容:一次只显示一条主要信息(例如使用 VerticalStack),避免同时显示多张卡片。
  • 颜色对比度:选择颜色时,请遵循 material-hct-source.md,确保前景与背景在 HCT 中的色调差至少为 70%。

3. 映射输入控件

  • 将点按和滑动交互映射到眼镜触控板。输入是一维的;避免嵌套滚动控件。
  • 使用系统返回键关闭临时状态或详细视图。
  • 实现焦点导航和硬件输入处理时,请遵循 Jetpack Compose Glimmer 中的焦点以及显示 眼镜的硬件控件。对于可滚动容器,焦点会连续移动;对于按钮行等元素,焦点则离散移动。

4. 使用 Jetpack Compose Glimmer 构建

实现 Glimmer 样式

使用 GlimmerTheme 访问主题令牌时,请遵循 glimmertheme-source.md。

类别令牌值 / 作用
颜色primary / secondary#9BBFFF / #4C88E9(焦点颜色)
颜色positive / negative#7AD48F(确认) / #F57082(警示)
颜色background#000000(纯黑,渲染为 100% 透明)
颜色surface#262626(边框使用 SurfaceDefaults.border)
形状Standard / SmallRoundedCornerShape(36.dp) / RoundedCornerShape(12.dp)
字体排版比例(Google Sans Flex)和深度级别

使用 Google Sans Flex,可变轴设置为 ROND = 100f、wdth = 100f、opsz = 9f,并根据角色设置 wght:

样式大小 / 行高字重
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

使用 DepthEffect 和 DepthEffectLevels 模拟深度时,请遵循 deptheffect-source.md 和 deptheffectlevels-source.md:静置卡片和背景 UI 使用 level1,聚焦或按下的交互式 UI 使用 level2。

实现 Jetpack Compose Glimmer 组件
卡片、ActionCard、ImageCard 和 ButtonGroups

卡片(androidx.xr.glimmer.Card)将相关内容组合成一个焦点。切勿在 ListItem 中嵌入 Card。实现卡片时,请遵循 Jetpack Compose Glimmer:卡片、card-source.md 和 card-samples-source.md 中的说明。

  • 可点击与不可点击卡片:如果整个 Card 都是触发器,请传入 onClick = { ... };如果仅用于展示信息,则省略 onClick。使用 focusable: Boolean = true 控制不可点击卡片的焦点(例如 Card(focusable = false))。

  • 单个主要操作(ActionCard):当卡片在底边附带一个主要按钮操作时,请使用 androidx.xr.glimmer.ActionCard,而不是在 Card 中放置 Button。其 action 按钮可直接获得焦点:

    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")
    }
    

  • 多个操作(Card 和 ButtonGroup)——避免焦点争用: 将卡片与多个操作按钮配对时,请使用 androidx.xr.glimmer.ButtonGroup(可选使用 rememberButtonGroupState)。**切勿将 ButtonGroup 嵌套在 Card { ... } 中。**在 Column 中,将不可聚焦的 Card(focusable = false)(不带 onClick) 放在作为同级元素的 ButtonGroup 上方,这样卡片和按钮 就不会争抢焦点:

    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") }
    }
    

  • 图像卡片:使用 ImageCard、LeadingImageCard 或 TrailingImageCard 在卡片文字旁显示醒目的图像。

按钮和 ToggleButtons

实现按钮或切换按钮时,请遵循 Jetpack Compose Glimmer:按钮、button-source.md 和 button-samples-source.md 中的说明。

  • 标准按钮和图标按钮:文字操作使用 Button(可选添加 leadingIcon / trailingIcon),仅图标触发器使用 IconButton(例如媒体播放器的下一曲或上一曲按钮)。

  • 切换按钮(ToggleButton 和 IconToggleButton):对于会随 checked 状态变化的开/关或播放/暂停控件,请使用 ToggleButton 或 IconToggleButton:

    @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")
        }
    }
    

标题标签

TitleChip(androidx.xr.glimmer.TitleChip)是一种非交互式胶囊形标题(三个词以内,单行显示)。实现标题标签时,请遵循 Jetpack Compose Glimmer:标题标签、titlechip-source.md 和 titlechip-samples-source.md 中的说明。

  • 空间间距:将独立的 TitleChip 放在 Card 或 ActionCard 上方时,请将 TitleChip 水平居中,并使用 TitleChipDefaults.associatedContentSpacing 设置其与卡片之间的间距(通过 Spacer 或 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")
            }
        }
    }
    

图标

添加图标或图标按钮时,请遵循 Jetpack Compose Glimmer:图标、icon-source.md、iconbutton-source.md 和 iconsizes-source.md 中的说明。

  • 尺寸和交互性:使用 GlimmerTheme.iconSizes(small、medium、large)设置图标尺寸。触发操作时使用 IconButton 或 IconToggleButton,状态指示时使用 Icon。
  • 对比度和图标库:绝不要使用纯黑色(Color.Black)作为图标色调;黑色会呈现为透明。请使用 Material Symbols(600 字重、Rounded、未填充)。如果 app/build.gradle.kts 中没有 androidx.compose.material:material-icons-*,请使用 ImageVector.Builder 或 painterResource 构建矢量图,而不要导入 androidx.compose.material.icons.*。
列表

对同类可滚动条目使用 GlimmerLazyColumn(androidx.xr.glimmer.list.GlimmerLazyColumn)。构建可滚动列表和列表项时,请遵循 list-source.md、listitem-source.md 和 liststate-source.md 中的 API 定义。

  • 始终使用 GlimmerLazyColumn:绝不要使用 foundation LazyColumn。从 androidx.xr.glimmer.list 导入 GlimmerLazyColumn、rememberGlimmerLazyListState 和 items(item 是作用域成员,无需导入)。要以编程方式滚动到第一项,请提升 val listState = rememberGlimmerLazyListState(),并调用 listState.scrollToItem(0) 或 listState.animateScrollToItem(0)。

  • 集成标题、间距和行:直接在 GlimmerLazyColumn 上通过 title = { TitleChip { Text("...") } } 传入标题(不要放在其外部),设置 verticalArrangement = Arrangement.spacedBy(20.dp),并使用 androidx.xr.glimmer.ListItem(onClick = { ... }, supportingLabel = { Text(...) }) { Text(...) } 渲染行(将主标签作为末尾的 content lambda 传入,而不是 headlineContent,且绝不要在 ListItem 内嵌套 Card):

    @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) } }
        }
    }
    

堆叠

使用 VerticalStack(androidx.xr.glimmer.stack.VerticalStack)逐个显示不同类型的卡片(例如日历、交通和天气卡片)。绝不要在 VerticalStack 中使用 TitleChip。实现 VerticalStack 时,请遵循 stack-source.md、stackstate-source.md 和 stackitemscope-source.md 中的 API 定义。

  • 底部对齐、尺寸(+66.dp)和 itemDecoration:将 VerticalStack 对齐到 Alignment.BottomCenter,将其高度设置为比最高卡片高 66.dp(maxCardHeight + 66.dp),并在每个 item { ... } 块内的 Card 上应用 Modifier.itemDecoration(CardDefaults.shape),以便裁剪和遮罩背景卡片:

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

水平分页器和动画文本

水平卡片引导流程使用 GlimmerHorizontalPager (androidx.xr.glimmer.pager.GlimmerHorizontalPager)和 rememberGlimmerPagerState(pageCount = { ... })(不要使用 foundation 的 HorizontalPager)。

  • 平滑文本动画:在分页器页面中,将 Glimmer Text (androidx.xr.glimmer.Text,不要使用 androidx.compose.material3.Text)配置为 textMotion = TextMotion.Animated(使用 .copy(textMotion = TextMotion.Animated)),以便在吸附过渡期间实现平滑渲染:

    // 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),
            )
        }
    }
    

Surface

Modifier.surface(androidx.xr.glimmer.surface)用于构建自定义交互组件(例如不使用 Card 或 Button 的状态徽章)。创建自定义 surface 时,请遵循 surface-source.md 中的 API 定义。

  • 共享的 MutableInteractionSource 和顺序:创建单个 remember { MutableInteractionSource() },并将其传递给 Modifier.surface(interactionSource = interactionSource, ...) 和 .clickable(interactionSource = interactionSource, ...)(或 .focusable),按顺序先调用 .surface(...),再调用 .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")
        }
    }
    

语音输入指示器和间接指针手势
  • VoiceInputIndicator 和 ContainedVoiceInputIndicator:传入一个返回 Float 的 level lambda,数值需归一化到 0.0f..1.0f。通过 SpeechRecognizer(RecognitionListener.onRmsChanged(rmsdB: Float))驱动时, 使用 .coerceIn(0f, 1f) 对 rmsdB 进行归一化并限制范围:

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

    @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
        )
    }
    

  • Modifier.onIndirectPointerGesture:在设置为可聚焦的容器上,使用 androidx.xr.glimmer.onIndirectPointerGesture 处理低级触控板轻击 (onClick)、向前滑动(onSwipeForward)和向后滑动(onSwipeBackward)手势 (通过 focusTarget、focusable 或 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")
        }
    }
    

5. 与系统 UI 集成

评论

支持 Markdown