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 上讨论 →

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

保护原创,请勿转载!

相关推荐
CYRUS_STUDIO8 小时前
用 Frida 控制 Android 线程:kill 命令、挂起与恢复全解析
android·linux·逆向
CYRUS_STUDIO8 小时前
Frida 实战:Android JNI 数组 (jobjectArray) 操作全流程解析
android·逆向
用户0912 小时前
Gradle Cache Entries 深度探索
android·java·kotlin
循环不息优化不止12 小时前
安卓 View 绘制机制深度解析
android
叽哥12 小时前
Kotlin学习第 9 课:Kotlin 实战应用:从案例到项目
android·java·kotlin
雨白1 天前
Java 线程通信基础:interrupt、wait 和 notifyAll 详解
android·java
Lei活在当下1 天前
【业务场景架构实战】4. 支付状态分层流转的设计和实现
架构·android jetpack·响应式设计
诺诺Okami1 天前
Android Framework-Launcher-UI和组件
android
潘潘潘1 天前
Android线程间通信机制Handler介绍
android
潘潘潘1 天前
Android动态链接库So的加载
android