compose-expert by vitorpamplona/amethyst
npx skills add https://github.com/vitorpamplona/amethyst --skill compose-expert用于在 Android 和桌面平台间共享可组合项的视觉 UI 模式。
commonMain 中还是保持平台特定委托给其他技能:
android-expert、desktop-expertkotlin-expertgradle-expert默认使用 commons/commonMain,除非平台专家另有指示。
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
commonMain 中共享commonMain 中共享android-expert 或 desktop-expert如果不确定,默认选择共享 - 后期拆分比合并更容易。
@Composable
fun SharedComponent(
// 状态参数(只读)
data: DataClass,
isLoading: Boolean,
// 事件参数(只写)
onAction: () -> Unit,
// 视觉参数
modifier: Modifier = Modifier,
// 可选自定义
colors: ComponentColors = ComponentDefaults.colors()
) {
// 实现
}
模式:状态向下,事件向上
modifier 参数 = 布局控制@Composable
fun AddButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
text: String = "Add",
enabled: Boolean = true
) {
OutlinedButton(
modifier = modifier,
enabled = enabled,
onClick = onClick,
shape = ActionButtonShape,
contentPadding = ActionButtonPadding
) {
Text(text = text, textAlign = TextAlign.Center)
}
}
// 用于一致性的共享常量
val ActionButtonShape = RoundedCornerShape(20.dp)
val ActionButtonPadding = PaddingValues(vertical = 0.dp, horizontal = 16.dp)
为何在所有平台都有效:
@Composable
fun ExpandableCard() {
var isExpanded by remember { mutableStateOf(false) }
Column {
IconButton(onClick = { isExpanded = !isExpanded }) {
Icon(
if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = if (isExpanded) "Collapse" else "Expand"
)
}
if (isExpanded) {
Text("Expanded content...")
}
}
}
视觉模式:切换按钮 → 状态改变 → UI 展开/折叠 用于:简单的 UI 状态(切换、计数器、文本输入)
@Composable
fun ScrollToTopButton(listState: LazyListState) {
// 仅在 showButton 改变时重组,而不是每个滚动像素都重组
val showButton by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}
if (showButton) {
FloatingActionButton(onClick = { /* scroll to top */ }) {
Icon(Icons.Default.ArrowUpward, null)
}
}
}
视觉模式:滚动位置(0, 1, 2...) → 布尔值(显示/隐藏) → 按钮可见性 用于:输入频繁变化,派生结果很少变化 性能:防止在每个滚动事件上重组
@Composable
fun LoadUserProfile(userId: String): State<User?> {
return produceState<User?>(initialValue = null, userId) {
value = repository.fetchUser(userId)
}
}
@Composable
fun ProfileScreen(userId: String) {
val user by LoadUserProfile(userId)
when (user) {
null -> LoadingState("Loading profile...")
else -> ProfileCard(user!!)
}
}
视觉模式:异步操作 → 状态更新 → UI 反映变化 用于:将 Flow、LiveData、回调转换为 Compose 状态 生命周期:当可组合项离开组合时,协程被取消
关于 Kotlin 特定的状态模式(StateFlow、密封类),请参阅 kotlin-expert。
将状态上移以使可组合项可重用:
// ❌ 有状态的 - 难以测试,无法从外部控制
@Composable
fun BadSearchBar() {
var query by remember { mutableStateOf("") }
TextField(value = query, onValueChange = { query = it })
}
// ✅ 无状态的 - 可重用,可测试
@Composable
fun GoodSearchBar(
query: String,
onQueryChange: (String) -> Unit,
modifier: Modifier = Modifier
) {
TextField(
value = query,
onValueChange = onQueryChange,
modifier = modifier
)
}
@Composable
fun SearchScreen() {
var query by remember { mutableStateOf("") }
Column {
GoodSearchBar(query = query, onQueryChange = { query = it })
SearchResults(query = query)
}
}
原则:状态向上,事件向下
query: String(只读参数)onQueryChange: (String) -> Unit(回调参数)在传递给可组合项的数据类上使用 @Immutable:
@Immutable
data class UserProfile(val name: String, val avatar: String)
@Composable
fun ProfileCard(profile: UserProfile) {
// 仅在 profile 实例改变时重组
Row {
RobohashImage(robot = profile.avatar)
Text(profile.name, style = MaterialTheme.typography.titleMedium)
}
}
视觉效果:当父级使用相同数据重组时,防止重组 模式:将参数数据类标记为 @Immutable 注意:关于 @Immutable 的 Kotlin 语言细节,请参阅 kotlin-expert
// ✅ 稳定的 - 除非 colors 实例改变,否则不会触发重组
@Composable
fun ThemedCard(
content: String,
colors: CardColors = CardDefaults.colors(),
modifier: Modifier = Modifier
) {
Card(colors = colors, modifier = modifier) {
Text(content)
}
}
关于 @Stable 注解的详细信息,请参阅 kotlin-expert。
所有共享的可组合项都使用 Material3 以保持一致性:
@Composable
fun ThemedComponent() {
val bg = MaterialTheme.colorScheme.background
val fg = MaterialTheme.colorScheme.onBackground
val primary = MaterialTheme.colorScheme.primary
Column(
modifier = Modifier.background(bg)
) {
Text(
"Title",
style = MaterialTheme.typography.headlineMedium,
color = fg
)
Button(
onClick = { /* ... */ },
colors = ButtonDefaults.buttonColors(containerColor = primary)
) {
Text("Action")
}
}
}
原则:
MaterialTheme.colorScheme.*MaterialTheme.typography.*MaterialTheme.shapes.*@Composable
private fun isLightTheme(): Boolean {
val background = MaterialTheme.colorScheme.background
return (background.red + background.green + background.blue) / 3 > 0.5f
}
@Composable
fun ThemedIcon() {
val isDark = !isLightTheme()
val tint = if (isDark) Color.White else Color.Black
Icon(Icons.Default.Face, null, tint = tint)
}
Amethyst 使用 ImageVector 实现多平台图标。
fun roboBuilder(block: Builder.() -> Unit): ImageVector {
return ImageVector.Builder(
name = "Robohash",
defaultWidth = 300.dp,
defaultHeight = 300.dp,
viewportWidth = 300f,
viewportHeight = 300f
).apply(block).build()
}
fun customIcon(fgColor: SolidColor, builder: Builder) {
builder.addPath(pathData1, fill = fgColor, stroke = Black, strokeLineWidth = 1.5f)
builder.addPath(pathData2, fill = Black, fillAlpha = 0.4f)
builder.addPath(pathData3, fill = Black, fillAlpha = 0.2f)
}
private val pathData1 = PathData {
moveTo(144.5f, 87.5f)
reflectiveCurveToRelative(-51.0f, 3.0f, -53.0f, 55.0f)
lineToRelative(16.0f, 16.0f)
close()
}
@Composable
fun CustomIcon() {
Image(
painter = rememberVectorPainter(
roboBuilder {
customIcon(SolidColor(Color.Blue), this)
}
),
contentDescription = "Custom icon"
)
}
为何使用 ImageVector?
object CustomIcons {
private val cache = mutableMapOf<String, ImageVector>()
fun get(key: String): ImageVector {
return cache.getOrPut(key) {
buildIcon(key)
}
}
}
@Composable
fun CachedIcon(key: String) {
Image(imageVector = CustomIcons.get(key), contentDescription = null)
}
关于详细的图标模式,请参阅 references/icon-assets.md。
@Composable
fun DataScreen(uiState: UiState) {
when (uiState) {
is UiState.Loading -> LoadingState("Loading...")
is UiState.Empty -> EmptyState(
title = "No data",
onRefresh = { /* refresh */ }
)
is UiState.Error -> ErrorState(
message = uiState.message,
onRetry = { /* retry */ }
)
is UiState.Success -> ContentList(uiState.items)
}
}
组件(全部在 commons/commonMain 中):
LoadingState - 进度指示器 + 消息EmptyState - 空消息 + 可选的刷新按钮ErrorState - 错误消息 + 可选的重新尝试按钮@Composable
fun RelayStatusIndicator(connectedCount: Int) {
val statusColor = when {
connectedCount == 0 -> RelayStatusColors.Disconnected
connectedCount < 3 -> RelayStatusColors.Connecting
else -> RelayStatusColors.Connected
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(
imageVector = if (connectedCount > 0) Icons.Default.Check else Icons.Default.Close,
tint = statusColor,
modifier = Modifier.size(16.dp)
)
Text(
"$connectedCount relay${if (connectedCount != 1) "s" else ""}",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
视觉映射:
@Composable
fun PlaceholderScreen(
title: String,
description: String,
modifier: Modifier = Modifier
) {
Column(modifier = modifier) {
Text(title, style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(16.dp))
Text(description, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
// 具体实现
@Composable
fun SearchPlaceholder() = PlaceholderScreen(
title = "Search",
description = "Search for users, notes, and hashtags."
)
模式:通用可组合项 + 带有预设文本的特定包装器
// ❌ 不好 - 每次滚动都重组
@Composable
fun BadButton(scrollState: ScrollState) {
if (scrollState.value > 100) {
Button(onClick = {}) { Text("Top") }
}
}
// ✅ 好 - 仅在可见性改变时重组
@Composable
fun GoodButton(scrollState: ScrollState) {
val show by remember { derivedStateOf { scrollState.value > 100 } }
if (show) {
Button(onClick = {}) { Text("Top") }
}
}
@Composable
fun FeedList(items: List<Item>) {
LazyColumn {
items(items, key = { it.id }) { item ->
FeedItem(item)
}
}
}
关键原则:使用 key 参数实现稳定的项目标识
| 任务 | 模式 | 位置 |
|---|---|---|
| 可重用 UI | 状态提升 | commons/commonMain |
| 简单状态 | remember { mutableStateOf() } | Composable 作用域 |
| 派生状态 | derivedStateOf { } | remember 块 |
| 异步 → 状态 | produceState { } | Composable 函数 |
| 自定义图标 | roboBuilder + PathData | commons/icons |
| 加载/错误 | LoadingState, ErrorState | commons/ui/components |
| 主题颜色 | MaterialTheme.colorScheme | 任何 @Composable |
| 导航 | 委托给平台专家 | amethyst/, desktopApp/ |
commons/src/commonMain/kotlin/.../ui/components/ 开始amethyst/ 或 desktopApp/ 中阅读当前实现commons/commonMain 中创建并提升状态关于导航模式:
android-expertdesktop-expertdesktop-expert每周安装次数
148
代码仓库
GitHub 星标数
1.5K
首次出现
Jan 21, 2026
安全审计
安装于
opencode126
codex123
gemini-cli122
claude-code120
github-copilot118
cursor107
Visual UI patterns for sharing composables across Android and Desktop.
commonMain or keep platform-specificDelegate to other skills:
android-expert, desktop-expertkotlin-expertgradle-expertDefault tocommons/commonMain unless platform experts indicate otherwise.
commonMaincommonMainandroid-expert or desktop-expertIf uncertain, default to sharing - easier to split later than merge.
@Composable
fun SharedComponent(
// State parameters (read-only)
data: DataClass,
isLoading: Boolean,
// Event parameters (write-only)
onAction: () -> Unit,
// Visual parameters
modifier: Modifier = Modifier,
// Optional customization
colors: ComponentColors = ComponentDefaults.colors()
) {
// Implementation
}
Pattern : State down, events up
modifier parameter = layout control@Composable
fun AddButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
text: String = "Add",
enabled: Boolean = true
) {
OutlinedButton(
modifier = modifier,
enabled = enabled,
onClick = onClick,
shape = ActionButtonShape,
contentPadding = ActionButtonPadding
) {
Text(text = text, textAlign = TextAlign.Center)
}
}
// Shared constants for consistency
val ActionButtonShape = RoundedCornerShape(20.dp)
val ActionButtonPadding = PaddingValues(vertical = 0.dp, horizontal = 16.dp)
Why this works on all platforms:
@Composable
fun ExpandableCard() {
var isExpanded by remember { mutableStateOf(false) }
Column {
IconButton(onClick = { isExpanded = !isExpanded }) {
Icon(
if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = if (isExpanded) "Collapse" else "Expand"
)
}
if (isExpanded) {
Text("Expanded content...")
}
}
}
Visual pattern : Toggle button → state changes → UI expands/collapses Use for : Simple UI state (toggles, counters, text input)
@Composable
fun ScrollToTopButton(listState: LazyListState) {
// Only recomposes when showButton changes, not every scroll pixel
val showButton by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}
if (showButton) {
FloatingActionButton(onClick = { /* scroll to top */ }) {
Icon(Icons.Default.ArrowUpward, null)
}
}
}
Visual pattern : Scroll position (0, 1, 2...) → boolean (show/hide) → Button visibility Use for : Input changes frequently, derived result changes rarely Performance : Prevents recomposition on every scroll event
@Composable
fun LoadUserProfile(userId: String): State<User?> {
return produceState<User?>(initialValue = null, userId) {
value = repository.fetchUser(userId)
}
}
@Composable
fun ProfileScreen(userId: String) {
val user by LoadUserProfile(userId)
when (user) {
null -> LoadingState("Loading profile...")
else -> ProfileCard(user!!)
}
}
Visual pattern : Async operation → state updates → UI reflects changes Use for : Convert Flow, LiveData, callbacks into Compose state Lifecycle : Coroutine cancelled when composable leaves composition
For Kotlin-specific state patterns (StateFlow, sealed classes), see kotlin-expert.
Move state up to make composables reusable:
// ❌ Stateful - hard to test, can't control externally
@Composable
fun BadSearchBar() {
var query by remember { mutableStateOf("") }
TextField(value = query, onValueChange = { query = it })
}
// ✅ Stateless - reusable, testable
@Composable
fun GoodSearchBar(
query: String,
onQueryChange: (String) -> Unit,
modifier: Modifier = Modifier
) {
TextField(
value = query,
onValueChange = onQueryChange,
modifier = modifier
)
}
@Composable
fun SearchScreen() {
var query by remember { mutableStateOf("") }
Column {
GoodSearchBar(query = query, onQueryChange = { query = it })
SearchResults(query = query)
}
}
Principle : State up, events down
query: String (read-only parameter)onQueryChange: (String) -> Unit (callback parameter)Use @Immutable on data classes passed to composables:
@Immutable
data class UserProfile(val name: String, val avatar: String)
@Composable
fun ProfileCard(profile: UserProfile) {
// Only recomposes when profile instance changes
Row {
RobohashImage(robot = profile.avatar)
Text(profile.name, style = MaterialTheme.typography.titleMedium)
}
}
Visual effect : Prevents recomposition when parent recomposes with same data Pattern : Mark parameter data classes as @Immutable Note : For Kotlin language details on @Immutable, see kotlin-expert
// ✅ Stable - won't trigger recomposition unless colors instance changes
@Composable
fun ThemedCard(
content: String,
colors: CardColors = CardDefaults.colors(),
modifier: Modifier = Modifier
) {
Card(colors = colors, modifier = modifier) {
Text(content)
}
}
For @Stable annotation details, see kotlin-expert.
All shared composables use Material3 for consistency:
@Composable
fun ThemedComponent() {
val bg = MaterialTheme.colorScheme.background
val fg = MaterialTheme.colorScheme.onBackground
val primary = MaterialTheme.colorScheme.primary
Column(
modifier = Modifier.background(bg)
) {
Text(
"Title",
style = MaterialTheme.typography.headlineMedium,
color = fg
)
Button(
onClick = { /* ... */ },
colors = ButtonDefaults.buttonColors(containerColor = primary)
) {
Text("Action")
}
}
}
Principles:
MaterialTheme.colorScheme.*MaterialTheme.typography.*MaterialTheme.shapes.*@Composable
private fun isLightTheme(): Boolean {
val background = MaterialTheme.colorScheme.background
return (background.red + background.green + background.blue) / 3 > 0.5f
}
@Composable
fun ThemedIcon() {
val isDark = !isLightTheme()
val tint = if (isDark) Color.White else Color.Black
Icon(Icons.Default.Face, null, tint = tint)
}
Amethyst uses ImageVector for multiplatform icons.
fun roboBuilder(block: Builder.() -> Unit): ImageVector {
return ImageVector.Builder(
name = "Robohash",
defaultWidth = 300.dp,
defaultHeight = 300.dp,
viewportWidth = 300f,
viewportHeight = 300f
).apply(block).build()
}
fun customIcon(fgColor: SolidColor, builder: Builder) {
builder.addPath(pathData1, fill = fgColor, stroke = Black, strokeLineWidth = 1.5f)
builder.addPath(pathData2, fill = Black, fillAlpha = 0.4f)
builder.addPath(pathData3, fill = Black, fillAlpha = 0.2f)
}
private val pathData1 = PathData {
moveTo(144.5f, 87.5f)
reflectiveCurveToRelative(-51.0f, 3.0f, -53.0f, 55.0f)
lineToRelative(16.0f, 16.0f)
close()
}
@Composable
fun CustomIcon() {
Image(
painter = rememberVectorPainter(
roboBuilder {
customIcon(SolidColor(Color.Blue), this)
}
),
contentDescription = "Custom icon"
)
}
Why ImageVector?
object CustomIcons {
private val cache = mutableMapOf<String, ImageVector>()
fun get(key: String): ImageVector {
return cache.getOrPut(key) {
buildIcon(key)
}
}
}
@Composable
fun CachedIcon(key: String) {
Image(imageVector = CustomIcons.get(key), contentDescription = null)
}
For detailed icon patterns, see references/icon-assets.md.
@Composable
fun DataScreen(uiState: UiState) {
when (uiState) {
is UiState.Loading -> LoadingState("Loading...")
is UiState.Empty -> EmptyState(
title = "No data",
onRefresh = { /* refresh */ }
)
is UiState.Error -> ErrorState(
message = uiState.message,
onRetry = { /* retry */ }
)
is UiState.Success -> ContentList(uiState.items)
}
}
Components (all in commons/commonMain):
LoadingState - Progress indicator + messageEmptyState - Empty message + optional refresh buttonErrorState - Error message + optional retry button@Composable
fun RelayStatusIndicator(connectedCount: Int) {
val statusColor = when {
connectedCount == 0 -> RelayStatusColors.Disconnected
connectedCount < 3 -> RelayStatusColors.Connecting
else -> RelayStatusColors.Connected
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(
imageVector = if (connectedCount > 0) Icons.Default.Check else Icons.Default.Close,
tint = statusColor,
modifier = Modifier.size(16.dp)
)
Text(
"$connectedCount relay${if (connectedCount != 1) "s" else ""}",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Visual mapping :
@Composable
fun PlaceholderScreen(
title: String,
description: String,
modifier: Modifier = Modifier
) {
Column(modifier = modifier) {
Text(title, style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(16.dp))
Text(description, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
// Specific implementations
@Composable
fun SearchPlaceholder() = PlaceholderScreen(
title = "Search",
description = "Search for users, notes, and hashtags."
)
Pattern : Generic composable + specific wrappers with preset text
// ❌ Bad - recomposes on every scroll
@Composable
fun BadButton(scrollState: ScrollState) {
if (scrollState.value > 100) {
Button(onClick = {}) { Text("Top") }
}
}
// ✅ Good - only recomposes when visibility changes
@Composable
fun GoodButton(scrollState: ScrollState) {
val show by remember { derivedStateOf { scrollState.value > 100 } }
if (show) {
Button(onClick = {}) { Text("Top") }
}
}
@Composable
fun FeedList(items: List<Item>) {
LazyColumn {
items(items, key = { it.id }) { item ->
FeedItem(item)
}
}
}
Key principle : Use key parameter for stable item identity
| Task | Pattern | Location |
|---|---|---|
| Reusable UI | State hoisting | commons/commonMain |
| Simple state | remember { mutableStateOf() } | Composable scope |
| Derived state | derivedStateOf { } | remember block |
| Async → state | produceState { } | Composable function |
| Custom icons | roboBuilder + PathData | commons/icons |
| Loading/Error | LoadingState, ErrorState | commons/ui/components |
| Theme colors | MaterialTheme.colorScheme | Any @Composable |
| Navigation | Delegate to platform expert | amethyst/, desktopApp/ |
commons/src/commonMain/kotlin/.../ui/components/amethyst/ or desktopApp/commons/commonMain with hoisted stateFor navigation patterns:
android-expertdesktop-expertdesktop-expertWeekly Installs
148
Repository
GitHub Stars
1.5K
First Seen
Jan 21, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
opencode126
codex123
gemini-cli122
claude-code120
github-copilot118
cursor107
Kotlin 开发模式与最佳实践 | 构建健壮高效应用程序的惯用指南
1,200 周安装