Compose Unstyled:Compose UI中失传的设计系统层

本文译自「Compose Unstyled: The missing Design System layer for Compose UI」,原文链接composables.com/blog/introd...,由Alex Styl发布于2025年8月7日。

使用Compose UI 构建应用的最大问题是 Material Compose 的灵活性不足。Material Compose 的可定制性不足以让你在其上构建自己的设计系统,因此你最终只能对其组件进行修改。另一方面,Compose Foundation 又过于"原始"------它有行和列,但没有按钮或底部表单。而且,由于主题设置与 Material 绑定,如果不完全遵循 Material 的设计决策,你甚至无法为你的应用设置主题。

你可以从头开始构建所有内容,但谁有时间这样做呢?考虑到不同的状态、可访问性和边缘情况,像底部表单这样的单个组件可能需要 3-4 周才能完成。这个问题在 Compose Multiplatform 中变得更加严重------Material 在 iOS 上看起来笨拙,在桌面上显得不成比例。

我需要一个灵活的解决方案,可以在任何平台上使用,而不受 Material 的限制。于是我撸起袖子,自己动手构建了一个:

Compose Unstyled 是基于 Compose Foundation 的 API,可轻松构建任何设计系统 。它提供无样式、可访问的组件,并配有灵活的主题> API------所有关于用户体验和可访问性的繁琐工作都已为你处理。

Unstyled 中的组件完全无需渲染,默认情况下不会在屏幕上显示任何内容。你可以将它们视为"组件模式",它们将"底部表单"或"进度条"的概念引入你的应用,而无需你担心用户体验、键盘导航或可访问性实现。只需添加样式即可。

简洁的 API,提供你所需的样式

Compose Unstyled 不提供任何特殊的样式 API。所有操作都通过 Modifier 完成。如果你知道如何设置 Box() 的样式,那么你就知道如何设置 Compose Unstyled 中每个组件的样式。

下面是一个使用 Compose Unstyled 构建模态底部表单的简单示例,其中包含自定义定位点(表单在屏幕上"停留"的位置)以及你选择的样式:

kotlin 复制代码
val Peek = SheetDetent("peek") { containerHeight, sheetHeight ->
    containerHeight * 0.6f
}
BoxWithConstraints(modifier = Modifier.fillMaxSize().background(Brush.linearGradient(listOf(Color(0xFF800080), Color(0xFFDA70D6))))) {
    val modalSheetState = rememberModalBottomSheetState(
        initialDetent = Hidden,
        detents = listOf(Hidden, Peek, FullyExpanded)
    )
    LaunchedEffect(Unit) {
        delay(50)
        modalSheetState.targetDetent = Peek
    }
    Button(onClick = { modalSheetState.targetDetent = Peek }, modifier = Modifier.align(Alignment.Center).padding(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal).asPaddingValues()), shape = RoundedCornerShape(6.dp), contentPadding = PaddingValues(horizontal = 14.dp, vertical = 10.dp), backgroundColor = Color.White) {
        Text("Show Sheet", fontWeight = FontWeight(500))
    }
    val isCompact = maxWidth < 600.dp
    ModalBottomSheet(state = modalSheetState) {
        Scrim(
            scrimColor = Color.Black.copy(0.3f),
            enter = fadeIn(),
            exit = fadeOut()
        )
        Box(Modifier.fillMaxSize().padding(top = 12.dp).let { if (isCompact) it else it.padding(horizontal = 56.dp) }.displayCutoutPadding().statusBarsPadding().padding(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal).asPaddingValues())) {
            Sheet(
                modifier = Modifier
                    .shadow(4.dp, RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp))
                    .widthIn(max = 640.dp)
                    .fillMaxWidth(),
                shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
                backgroundColor = Color.White,
                contentColor = Color.Black
            ) {
                Box(Modifier.fillMaxWidth().height(600.dp), contentAlignment = Alignment.TopCenter) {
                    DragIndication(
                        modifier = Modifier.padding(top = 22.dp)
                            .background(Color.Black.copy(0.4f), RoundedCornerShape(100)).width(32.dp).height(4.dp)
                    )
                }
            }
        }
    }
}

现在,我知道你可能会想。"Alex!这 API 真奇怪。为什么我需要一个 ModalBottomSheet 和一个 Sheet?为什么不直接用 Slots 呢?"

这个设计选择是经过深思熟虑的:

设计理念

Compose Unstyled 不会替你做出任何设计决策,而是让你完全掌控布局。事实上,如果你被迫使用无法根据需求更改的样式,则会被视为错误(请提交 GitHub 问题,以便我进行调查)。

例如,你可能希望将底部表单放置在屏幕的左侧或右侧。当你想要求开发者提供一个在布局上具有严格位置的组件时,基于插槽的 API 非常实用。

在这种情况下,你可以将 ModalBottomSheet 组件视为表单可以移动的区域。Sheet 是用户可以与之交互的实际表单。通过提供这样的组件,它为开发者提供了清晰的 API,并清楚地说明了组件的功能。例如,Scrim() 组件具有 enterexit 过渡参数。 Compose Unstyled 会在恰当的时机为纱幕添加和隐藏动画,以实现最佳用户体验。你只需指定"如何"实现即可。

由于 Compose Unstyled 在外观方面非常开放,因此与原始的 Compose Foundation 组件相比,它没有任何平台限制。Foundation 中的"对话框"具有固定的最大尺寸,这使得它们在诸如全屏对话框等情况下难以工作。

Compose Unstyled 中的所有组件在所有平台上的工作方式完全相同。这是有意为之,因为此类决策应该属于设计系统层的一部分。尽管这会给开发者带来更多负担,但由于没有"陷阱",它大大缩短了开发时间。组件的样式与你描述的完全一致。

这不会使 Compose Unstyled 与底层平台脱节。在使用对话框和模态底部菜单等模态窗口时,设置系统窗口的样式是 Android 样式设置的重要组成部分。在这种情况下,Compose Unstyled 提供了一个 LocalModalWindow 组合本地接口,允许你访问渲染模态窗口的 Window。请注意,此类 API 仅适用于 Android 目标平台,不属于通用目标平台 API。

最后,每个组件的代码都包含在其自己的文件中,即使你不是 Compose 专家,也能轻松理解。你无需担心任何限制。如果你现在需要更改某些内容,并且迫不及待地想要提交错误并在库级别修复它,你只需将组件的单个文件复制粘贴到你的代码库中即可。这为你节省了通常需要你自己完成的 4 周工作时间。

滑块

以下是如何构建一个具有你所选样式的滑块。

它与 Compose 的"InteractionState"集成,以便你可以按照自己想要的方式完善组件。键盘交互功能开箱即用,用户可以通过按下键盘上的"向上"或"向下"键来增加或减少值:

kotlin 复制代码
Box(modifier = Modifier.fillMaxSize().background(Brush.linearGradient(listOf(Color(0xFFED213A), Color(0xFF93291E)))),   contentAlignment = Alignment.Center) {
    val interactionSource = remember { MutableInteractionSource() }
    val isFocused by interactionSource.collectIsFocusedAsState()
    val isPressed by interactionSource.collectIsPressedAsState()
    val state = rememberSliderState(initialValue = 0.7f)
    Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.padding(horizontal = 16.dp).widthIn(max = 480.dp).fillMaxWidth()) {
        Button(onClick = { state.value -= 0.1f }, modifier = Modifier.shadow(4.dp, CircleShape), shape = CircleShape, backgroundColor = Color.White, contentPadding = PaddingValues(8.dp),) {
            Icon(VolumeDown, "Decrease")
        }
        Slider(
            interactionSource = interactionSource,
            state = state,
            modifier = Modifier.weight(1f),
            track = {
                Box(Modifier.fillMaxWidth().height(8.dp).padding(horizontal = 16.dp).clip(RoundedCornerShape(100.dp))) {
                    // the 'not yet completed' part of the track
                    Box(Modifier.fillMaxHeight().fillMaxWidth().background(Color(0xFF93291E)))
                    // the 'completed' part of the track
                    Box(Modifier.fillMaxHeight().fillMaxWidth(state.value).background(Color.White))
                }
            },
            thumb = {
                val thumbSize by animateDpAsState(targetValue = if (isPressed) 22.dp else 18.dp)
                val thumbInteractionSource = remember { MutableInteractionSource() }
                val isHovered by thumbInteractionSource.collectIsHoveredAsState()
                val glowColor by animateColorAsState(
                    if (isFocused || isHovered) Color.White.copy(0.33f) else Color.Transparent
                )
                // keep the size fixed to ensure that the resizing animation is always centered
                Box(
                    modifier = Modifier.size(36.dp).clip(CircleShape).background(glowColor),
                    contentAlignment = Alignment.Center
                ) {
                    Thumb(
                        color = Color.White,
                        modifier = Modifier.size(thumbSize).shadow(4.dp, CircleShape).hoverable(thumbInteractionSource),
                        shape = CircleShape,
                    )
                }
            }
        )
        Button(onClick = { state.value += 0.1f }, modifier = Modifier.shadow(4.dp, CircleShape), shape = CircleShape, backgroundColor = Color.White, contentPadding = PaddingValues(8.dp),) {
            Icon(VolumeUp, "Increase")
        }
    }
}

下拉菜单

下拉菜单的正确实现非常复杂,尤其是在键盘导航和焦点管理方面。

要充分体验此组件,请在桌面上试用:点击"Options"按钮以聚焦演示,然后使用键盘的上下箭头键进行导航:

kotlin 复制代码
class DropdownOption(val text: String, val icon: ImageVector, val enabled: Boolean = true, val dangerous: Boolean = false)
val options = listOf(
    DropdownOption("Select All", Maximize),
    DropdownOption("Copy", Copy),
    DropdownOption("Cut", Scissors, enabled = false),
    DropdownOption("Paste", Clipboard),
    DropdownOption("Delete", Trash2, dangerous = true),
)
var expanded by remember { mutableStateOf(true) }
DropdownMenu(onExpandRequest = { expanded = true }) {
    Button(shape = RoundedCornerShape(6.dp), backgroundColor = Color.White, onClick = { expanded = true }, contentPadding = PaddingValues(horizontal = 14.dp, vertical = 10.dp),) {
        Text("Options", fontWeight = FontWeight(500))
        Spacer(Modifier.width(8.dp))
        Icon(ChevronDown, null)
    }
    DropdownMenuPanel(
        expanded = expanded,
        onDismissRequest = { expanded = false },
        backgroundColor = Color.White,
        shape = RoundedCornerShape(8.dp),
        modifier = Modifier
            .padding(vertical = 4.dp)
            .width(240.dp)
            .shadow(4.dp, RoundedCornerShape(8.dp)),
        enter = scaleIn(
            animationSpec = tween(durationMillis = 120, easing = LinearOutSlowInEasing),
            initialScale = 0.8f,
            transformOrigin = TransformOrigin(0f, 0f)
        ) + fadeIn(tween(durationMillis = 30)),
        exit = scaleOut(animationSpec = tween(durationMillis = 1, delayMillis = 75), targetScale = 1f) + fadeOut(tween(durationMillis = 75))
    ) {
        options.forEachIndexed { index, option ->
            if (index == 1 || index == options.lastIndex) {
                Separator(color = Color(0xFFBDBDBD))
            }
            Button(onClick = { expanded = false }, enabled = option.enabled, modifier = Modifier.padding(4.dp), contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp), contentColor = (if (option.dangerous) Color(0xFFC62828) else LocalContentColor.current).copy(alpha = if (option.enabled) 1f else 0.5f), shape = RoundedCornerShape(8.dp),) {
                Icon(option.icon, null)
                Spacer(Modifier.width(4.dp))
                Text(text = option.text, modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp, horizontal = 4.dp))
            }
        }
    }
}

以及更多未样式化的组件,例如:

每个组件在构建时都充分考虑了可访问性,包括合理的语义和完整的键盘导航支持。

你可以在文档中找到完整的组件列表 ->(链接:composables.com/docs/com.co...

使用你的设计令牌自定义 Compose 主题

Compose Unstyled 包含一个灵活的主题系统,可与任何设计系统令牌兼容。

以下是如何使用你选择的设计令牌创建完全自定义的主题:

以下是创建 Compose Theme 函数的示例:

kotlin 复制代码
// define your theme properties
private val colors = ThemeProperty<Color>("colors")
private val typography = ThemeProperty<TextStyle>("typography")
private val shapes = ThemeProperty<Shape>("shapes")
private val elevation = ThemeProperty<Dp>("elevation")
// define your theme tokens.
// those are the potential values of your theme properties
private val background = ThemeToken<Color>("background")
private val card = ThemeToken<Color>("surface")
private val onCard = ThemeToken<Color>("onCard")
private val outline = ThemeToken<Color>("outline")
private val accent = ThemeToken<Color>("accent")
private val primary = ThemeToken<Color>("primary")
private val onPrimary = ThemeToken<Color>("onPrimary")
private val onSecondary = ThemeToken<Color>("onSecondary")
private val secondary = ThemeToken<Color>("secondary")
private val subtle = ThemeToken<Dp>("subtle")
private val titleMedium = ThemeToken<TextStyle>("titleMedium")
private val bodyMedium = ThemeToken<TextStyle>("bodyMedium")
private val cardShape = ThemeToken<Shape>("cardShape")
private val albumCoverShape = ThemeToken<Shape>("albumCoverShape")
private val buttonShape = ThemeToken<Shape>("buttonShape")
// create your Compose Theme and assign values to each token
private val LightTheme = buildTheme {
    name = "LightTheme"
    properties[colors] = mapOf(
        accent to Color(0xFF3B82F6),
        card to Color.White,
        onCard to Color(0xFF1E293B),
        outline to Color(0xFFE2E8F0),
        primary to Color(0xFF2563EB),
        onPrimary to Color.White,
        secondary to Color(0xFFE2E8F0),
        onSecondary to Color(0xFF64748B),
        background to Color(0xFFF8F9FA),
    )
    properties[typography] = mapOf(
        titleMedium to TextStyle(
            fontSize = 18.sp,
            fontWeight = FontWeight.SemiBold,
            fontFamily = loadInterFont(),
        ),
        bodyMedium to TextStyle(
            fontSize = 14.sp,
            fontWeight = FontWeight.Normal,
            fontFamily = loadInterFont(),
        )
    )
    properties[shapes] = mapOf(
        cardShape to RoundedCornerShape(16.dp),
        albumCoverShape to RoundedCornerShape(12.dp),
        buttonShape to CircleShape
    )
    properties[elevation] = mapOf(
        subtle to 8.dp
    )
}

然后,你可以使用新的主题函数包装你的应用:

kotlin 复制代码
@Composable
fun MusicPlayerApp() {
    LightTheme {
        MusicPlayerCard()
    }
}

然后,它会使用 Theme 对象授予其子组件对主题的访问权限:

kotlin 复制代码
@Composable
fun MusicPlayerCard(modifier: Modifier = Modifier) {
    val sliderState = rememberSliderState(initialValue = 0.3f)
    Box(
        modifier = modifier
            .outline(1.dp, Theme[colors][outline], Theme[shapes][cardShape])
            .shadow(Theme[elevation][subtle], Theme[shapes][cardShape])
            .background(Theme[colors][card], Theme[shapes][cardShape])
            .padding(24.dp)
    ) {
        ProvideContentColor(Theme[colors][onCard]) {
            Column(verticalArrangement = Arrangement.spacedBy(20.dp)) {
                Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
                    Image(
                        painter = painterResource(Res.drawable.just_hoist_it_cover),
                        modifier = Modifier
                            .clip(Theme[shapes][albumCoverShape])
                            .background(Theme[colors][primary])
                            .size(80.dp),
                        contentDescription = "Album Cover",
                        contentScale = ContentScale.Crop
                    )
                    Column(modifier = Modifier.weight(1f)) {
                        Text("Just hoist it!", style = Theme[typography][titleMedium])
                        Spacer(Modifier.height(4.dp))
                        Text(
                            "The Deprecated",
                            style = Theme[typography][bodyMedium],
                            color = Theme[colors][onSecondary]
                        )
                    }
                }
                Slider(
                    state = sliderState,
                    modifier = Modifier.fillMaxWidth(),
                    track = {
                        Box(Modifier.fillMaxWidth().height(4.dp).clip(RoundedCornerShape(2.dp))) {
                            // the empty part of the track
                            Box(Modifier.fillMaxSize().background(Theme[colors][secondary]))
                            // the filled part of the track
                            Box(
                                Modifier.fillMaxWidth(sliderState.value).fillMaxSize().background(Theme[colors][accent])
                            )
                        }
                    },
                    thumb = {
                        Thumb(
                            color = Theme[colors][accent],
                            modifier = Modifier.size(16.dp),
                            shape = Theme[shapes][buttonShape]
                        )
                    }
                )
                Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically) {
                    Button(onClick = { }, contentPadding = PaddingValues(12.dp), shape = Theme[shapes][buttonShape]) {
                        Icon(imageVector = Lucide.SkipBack, contentDescription = "Previous", modifier = Modifier.size(20.dp))
                    }
                    Button(onClick = { }, backgroundColor = Theme[colors][primary], contentColor = Theme[colors][onPrimary], contentPadding = PaddingValues(16.dp), shape = Theme[shapes][buttonShape]) {
                        Icon(imageVector = Lucide.Pause, contentDescription = "Pause", modifier = Modifier.size(24.dp))
                    }
                    Button(onClick = { }, contentPadding = PaddingValues(12.dp), shape = Theme[shapes][buttonShape]) {
                        Icon(imageVector = Lucide.SkipForward, contentDescription = "Next", modifier = Modifier.size(20.dp))
                    }
                }
            }
        }
    }
}

轮廓修饰符

最后但同样重要的是,Compose Unstyled 引入了一些 Compose Foundation 中缺少的样式 Modifier,但这些修饰符对于构建视觉丰富的界面必不可少:

轮廓

与 Compose Foundation 的 border() 修饰符不同,此修饰符不会影响布局。它还会在组件周围而不是内部进行绘制。当你需要一个与阴影完美融合的半透明轮廓时,这个功能非常方便:

kotlin 复制代码
SimpleButton(
  shape = RectangleShape,
  modifier = Modifier.outline(2.dp, Color(0xFF3B82F6), shape = RectangleShape)
)
SimpleButton(
  shape = RoundedCornerShape(8.dp),
  modifier = Modifier.outline(2.dp, Color(0xFF3B82F6), shape = RoundedCornerShape(8.dp))
)
SimpleButton(
  shape = CircleShape,
  modifier = Modifier.outline(2.dp, Color(0xFF3B82F6), shape = CircleShape)
)

焦点环

焦点环在处理键盘导航和焦点时非常重要。它们只有在获得焦点时才会渲染轮廓。

kotlin 复制代码
val interactionSource = remember { MutableInteractionSource() }
SimpleButton(
    modifier = Modifier.focusRing(
        interactionSource = interactionSource,
        width = 2.dp,
        color = Color(0xFF3B82F6),
        shape = RoundedCornerShape(8.dp),
        offset = 2.dp
    ),
    interactionSource = interactionSource
)

然后呢?

即将推出的组件包括:

  • 侧边栏
  • 工具提示
  • 上下文菜单

以及更多。

如果你愿意为该项目提供资金支持,我们还提供正在制作中的 UI Kit。该 UI Kit 是一套完整的设计系统,适用于触控和指针应用。

为了使这些 API 完美无缺,我们投入了大量的工作和专业知识。通过资金支持该项目,你将在未来几年获得更多资源,而我则可以继续从事开源工作,同时支付房租。

想要随时了解最新动态?请务必在 Github 上关注 Unstyled

想要评论这篇文章吗?在 GitHub 上讨论 →

欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!

保护原创,请勿转载!

相关推荐
一条上岸小咸鱼12 分钟前
Kotlin 控制流(二):返回和跳转
android·kotlin
Jasonakeke18 分钟前
【重学 MySQL】九十二、 MySQL8 密码强度评估与配置指南
android·数据库·mysql
Mertrix_ITCH19 分钟前
在 Android Studio 中修改 APK 启动图标(2025826)
android·ide·android studio
荏苒追寻28 分钟前
Android OpenGL基础1——常用概念及方法解释
android
人生游戏牛马NPC1号38 分钟前
学习 Android (十七) 学习 OpenCV (二)
android·opencv·学习
恋猫de小郭1 小时前
谷歌开启 Android 开发者身份验证,明年可能开始禁止“未经验证”应用的侧载,要求所有开发者向谷歌表明身份
android·前端·flutter
用户091 小时前
Gradle声明式构建总结
android
用户092 小时前
Gradle插件开发实践总结
android
Digitally12 小时前
如何将视频从安卓设备传输到Mac?
android·macos