compose-slot-api-pattern
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseCompose: slot API pattern
Compose:插槽API模式
Core principle
核心原则
A reusable Compose component's job is to lay things out, not to enumerate what it lays out. The moment you write , the component has stopped describing a layout and started enumerating call sites — and the next call site will need a parameter the component doesn't have.
title: String, subtitle: String?, leadingIcon: ImageVector?, trailingIcon: ImageVector?, trailingText: String?, showSwitch: Boolean, switchValue: Boolean, onSwitchChange: (Boolean) -> Unit?, badge: String?, …The fix is to delegate content to the caller via lambda parameters. The component contributes structure (where the leading bit, headline, supporting bit, trailing bit go). The caller contributes everything that goes in those slots.
@ComposableMaterial 3's is the canonical example: every visual piece is a slot (, , , , ), not a primitive. That's not over-engineering — it's the design that scales to every list-item shape the design system needs without ever editing again.
ListItemheadlineContentsupportingContentleadingContenttrailingContentoverlineContentListItem可重用Compose组件的职责是进行布局,而非枚举布局内容。当你写出这样的参数列表时,该组件就不再是描述布局,而是在枚举调用场景——而下一个调用场景就会需要组件未提供的参数。
title: String, subtitle: String?, leadingIcon: ImageVector?, trailingIcon: ImageVector?, trailingText: String?, showSwitch: Boolean, switchValue: Boolean, onSwitchChange: (Boolean) -> Unit?, badge: String?, …解决方法是通过 lambda参数将内容委托给调用者。组件负责提供结构(确定前置内容、标题、辅助内容、后置内容的位置),调用者负责填充这些插槽中的所有内容。
@ComposableMaterial 3的是典型示例:每个视觉部分都是一个插槽(、、、、),而非原始类型。这不是过度设计——这种设计可以适配设计系统所需的所有列表项样式,无需再修改本身。
ListItemheadlineContentsupportingContentleadingContenttrailingContentoverlineContentListItemWhen to use this skill
何时使用此技巧
You're designing or reviewing a Compose component intended for reuse (more than one call site, now or planned), its visual content varies by caller, and any of these is true:
- Its signature has ,
title: String,icon: ImageVector, etc. — primitive types describing content.actionText: String? - It has multiple optional-content parameters that vary by call site (,
subtitle: String?,leadingIcon: ImageVector?).trailingText: String? - It has boolean flags whose only purpose is to switch between content shapes (,
showChevron: Boolean,showSwitch: Boolean).mode: Mode.Text | Mode.Switch | … - It accepts a parameter where one caller would want a
Stringwith custom style, a second caller aTextwith aText, a third caller a row of icons.Badge - It already has one slot (often or
trailing) and the rest of the parameters are still primitives.content
当你设计或审查用于复用的Compose组件(当前或计划有多个调用场景),且其视觉内容会因调用者不同而变化,同时满足以下任一条件时:
- 组件签名包含、
title: String、icon: ImageVector等描述内容的原始类型参数。actionText: String? - 组件包含多个随调用场景变化的可选内容参数(如、
subtitle: String?、leadingIcon: ImageVector?)。trailingText: String? - 组件包含仅用于切换内容样式的布尔标志(如、
showChevron: Boolean、showSwitch: Boolean)。mode: Mode.Text | Mode.Switch | … - 组件接受参数,但不同调用者有不同需求:有的需要带自定义样式的
String,有的需要带Text的Badge,有的需要图标行。Text - 组件已有一个插槽(通常是或
trailing),但其余参数仍为原始类型。content
1. Replace primitive content with @Composable
slots
@Composable1. 用@Composable
插槽替换原始内容参数
@ComposableWhere the component asks for caller-controlled content, prefer a slot. Where the slot is structurally required, leave it non-nullable with no default. Where it's optional, make it nullable with a default.
@Composable () -> Unitnullkotlin
// ❌ BAD — primitive parameters; trailing area is the only slot; everything else is locked
@Composable
fun SettingsRow(
title: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
subtitle: String? = null,
leadingIcon: ImageVector? = null,
trailing: (@Composable () -> Unit)? = null,
) { … }This shape seems fine because the call sites today fit ( is always single-line text, is always an ). The problem is the next call site: a row with a next to the title, a leading slot that's a circular avatar (not an ), a subtitle that's a row of chips. Each forces either a new parameter, a new flag, or a workaround.
titleleadingIconImageVectorBadgeImageVectorkotlin
// ✅ GOOD — every visual region is a slot; the row describes structure, not content
@Composable
fun SettingsRow(
headlineContent: @Composable () -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier,
supportingContent: (@Composable () -> Unit)? = null,
leadingContent: (@Composable () -> Unit)? = null,
trailingContent: (@Composable () -> Unit)? = null,
) { … }Call sites stay short because the typical content is a one-liner:
kotlin
SettingsRow(
headlineContent = { Text("Account") },
leadingContent = { Icon(Icons.Default.Person, contentDescription = null) },
trailingContent = { SettingsRowDefaults.Chevron() },
onClick = { … },
)And the awkward cases that would have required new primitive parameters now don't:
kotlin
SettingsRow(
headlineContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Inbox")
Spacer(Modifier.width(8.dp))
Badge { Text("3") }
}
},
onClick = { … },
)对于由调用者控制的内容,优先使用插槽。如果插槽是结构上必需的,保持其非空且无默认值;如果是可选的,设置为可空并以为默认值。
@Composable () -> Unitnullkotlin
// ❌ 不佳——原始类型参数;仅后置区域是插槽;其余内容均被固定
@Composable
fun SettingsRow(
title: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
subtitle: String? = null,
leadingIcon: ImageVector? = null,
trailing: (@Composable () -> Unit)? = null,
) { … }这种形式看起来没问题,因为当前的调用场景都能适配(始终是单行文本,始终是)。但问题出在下一个调用场景:标题旁带的行、前置区域是圆形头像(而非)、副标题是标签行的情况。每种情况都需要添加新参数、新标志或使用变通方法。
titleleadingIconImageVectorBadgeImageVectorkotlin
// ✅ 良好——每个视觉区域都是插槽;组件仅描述结构,不限制内容
@Composable
fun SettingsRow(
headlineContent: @Composable () -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier,
supportingContent: (@Composable () -> Unit)? = null,
leadingContent: (@Composable () -> Unit)? = null,
trailingContent: (@Composable () -> Unit)? = null,
) { … }调用代码会保持简洁,因为典型内容只需一行代码:
kotlin
SettingsRow(
headlineContent = { Text("Account") },
leadingContent = { Icon(Icons.Default.Person, contentDescription = null) },
trailingContent = { SettingsRowDefaults.Chevron() },
onClick = { … },
)而那些原本需要新增原始参数的特殊场景,现在也无需修改组件:
kotlin
SettingsRow(
headlineContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Inbox")
Spacer(Modifier.width(8.dp))
Badge { Text("3") }
}
},
onClick = { … },
)Slot naming
插槽命名规则
- Use for free-form
xxxContentslots (@Composable () -> Unit,headlineContent,supportingContent) — matches Material 3.trailingContent - Use a singular noun (,
title,icon) when the slot is semantically constrained and the component name disambiguates (actions).Scaffold(topBar = { … }, bottomBar = { … }, floatingActionButton = { … }) - Don't use and other
contentslots together — pick one convention per component.xxxContent
- 对于自由形式的插槽,使用
@Composable () -> Unit命名(如xxxContent、headlineContent、supportingContent)——与Material 3保持一致。trailingContent - 当插槽有语义约束,且组件名称可明确区分时,使用单数名词命名(如中的
Scaffold(topBar = { … }, bottomBar = { … }, floatingActionButton = { … })等)。topBar - 不要在同一个组件中同时使用和其他
content插槽——每个组件遵循一种命名规范。xxxContent
2. Scope receivers when the slot emits into a layout
2. 当插槽内容需嵌入布局时使用作用域接收器
If the slot's content will sit inside a // whose layout features (, , alignment) should be available to the caller, declare the slot as a receiver lambda: .
RowColumnBoxModifier.weightBoxScope.matchParentSize@Composable RowScope.() -> Unitkotlin
// ❌ BAD — actions render inside a Row, but callers can't use RowScope.weight()
@Composable
fun MyTopBar(
title: @Composable () -> Unit,
actions: @Composable () -> Unit = {}, // ← caller has no Row scope
)kotlin
// ✅ GOOD — caller gets RowScope; .weight() and alignment-by works inside
@Composable
fun MyTopBar(
title: @Composable () -> Unit,
actions: @Composable RowScope.() -> Unit = {},
)This is what makes work — the caller is implicitly inside a .
TopAppBar(actions = { IconButton(…); IconButton(…) })RowScopeDon't bolt a scope receiver onto every slot reflexively. The receiver should match the actual parent layout the slot emits into. If the slot is rendered inside a , use . If it's inside a , use . If the parent is not a standard layout (or none of its scope APIs are useful in slot content), no receiver.
BoxBoxScopeColumnColumnScope如果插槽内容将位于//中,且调用者需要使用该布局的特性(如、、对齐方式),则将插槽声明为接收器lambda:。
RowColumnBoxModifier.weightBoxScope.matchParentSize@Composable RowScope.() -> Unitkotlin
// ❌ 不佳——操作项在Row中渲染,但调用者无法使用RowScope.weight()
@Composable
fun MyTopBar(
title: @Composable () -> Unit,
actions: @Composable () -> Unit = {}, // ← 调用者无Row作用域
)kotlin
// ✅ 良好——调用者拥有RowScope;可在内部使用.weight()和对齐方式
@Composable
fun MyTopBar(
title: @Composable () -> Unit,
actions: @Composable RowScope.() -> Unit = {},
)这就是能够正常工作的原因——调用者隐式处于中。
TopAppBar(actions = { IconButton(…); IconButton(…) })RowScope不要盲目给所有插槽添加作用域接收器。接收器应与插槽实际嵌入的父布局匹配:如果插槽在中渲染,使用;在中则使用;如果父布局不是标准布局(或其作用域API对插槽内容无用),则无需添加接收器。
BoxBoxScopeColumnColumnScope3. Optional slots — nullable with null
default
null3. 可选插槽——设为可空并以null
为默认值
nullFor slots that may be absent, prefer over :
(@Composable () -> Unit)? = null@Composable () -> Unit = {}kotlin
// ❌ BAD — empty default; "no leading content" is the empty lambda
leadingContent: @Composable () -> Unit = {}
// ✅ GOOD — null means "no slot"; the component can omit space/padding when absent
leadingContent: (@Composable () -> Unit)? = nullWhy: with a nullable slot, the component can branch on and skip the slot's container, spacing, padding entirely. With an empty default, the layout still allocates the slot — sometimes you see a stray padding or spacer around content that turned out to be nothing. The nullable form makes the "absent" case structurally distinct, which is almost always what you want.
leadingContent != nullThe trade-off: callers who pass an explicit empty to silence a slot now have to pass or omit the argument. That's the right answer either way — they shouldn't be passing .
{}null{}对于可能为空的插槽,优先使用而非:
(@Composable () -> Unit)? = null@Composable () -> Unit = {}kotlin
// ❌ 不佳——空默认值;“无前置内容”对应空lambda
leadingContent: @Composable () -> Unit = {}
// ✅ 良好——null表示“无插槽”;组件可在插槽为空时省略其容器、间距和内边距
leadingContent: (@Composable () -> Unit)? = null原因:使用可空插槽时,组件可以通过进行分支判断,完全跳过插槽的容器、间距和内边距。而使用空默认值时,布局仍会为插槽分配空间——有时你会看到空内容周围存在多余的内边距或间隔。可空形式使“不存在”的场景在结构上明确区分,这几乎总是我们想要的效果。
leadingContent != null权衡:原本通过传递显式空来隐藏插槽的调用者,现在需要传递或省略该参数。无论哪种方式都是正确的——他们本就不应该传递。
{}null{}4. Defaults live in XxxDefaults
XxxDefaults4. 默认内容放在XxxDefaults
中
XxxDefaultsWhen you find yourself documenting "the trailing slot should usually be a chevron" or "pass for the default background", co-locate the helpers in a object next to the component:
MaterialTheme.colorScheme.surfaceXxxDefaultskotlin
object SettingsRowDefaults {
@Composable
fun Chevron() = Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
)
@Composable
fun TrailingValue(text: String) = Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}Call sites stay declarative for the common cases and the slot is still fully open for one-offs:
kotlin
SettingsRow(
headlineContent = { Text("Notifications") },
trailingContent = { SettingsRowDefaults.Chevron() },
onClick = { … },
)This matches Material 3's , , etc. — defaults that are themselves composable belong here, not as new component parameters with defaults expanded inline.
ButtonDefaultsTopAppBarDefaultsMaterialTheme.x.y当你需要记录“后置插槽通常应使用 Chevron”或“默认背景传递”时,将这些辅助方法放在组件旁的对象中:
MaterialTheme.colorScheme.surfaceXxxDefaultskotlin
object SettingsRowDefaults {
@Composable
fun Chevron() = Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
)
@Composable
fun TrailingValue(text: String) = Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}常见场景下调用代码保持声明式风格,同时插槽仍完全支持自定义场景:
kotlin
SettingsRow(
headlineContent = { Text("Notifications") },
trailingContent = { SettingsRowDefaults.Chevron() },
onClick = { … },
)这与Material 3的、等保持一致——本身是可组合函数的默认内容应放在此处,而非作为组件参数并内联默认值。
ButtonDefaultsTopAppBarDefaultsMaterialTheme.x.yQuick reference
快速参考
| Symptom | Diagnosis | Fix |
|---|---|---|
| Primitive content params (§1) | Convert to |
Multiple boolean flags ( | Enumerating shapes (§1) | One |
A | Same as flag soup (§1) | Slot it |
| Missing scope receiver (§2) | |
| Empty-lambda default (§3) | |
Component param | Defaults inlined (§4) | Move to |
| Common trailing content repeats at every call site | Missing default helper (§4) | Add |
| 症状 | 诊断 | 修复方案 |
|---|---|---|
可重用组件包含 | 使用了原始内容参数(§1) | 转换为 |
多个布尔标志( | 枚举样式(§1) | 使用一个 |
包含 | 与标志混乱问题相同(§1) | 使用插槽 |
| 缺少作用域接收器(§2) | 修改为 |
可选区域使用 | 使用了空lambda默认值(§3) | 修改为 |
组件参数 | 默认值内联(§4) | 移至 |
| 常见后置内容在每个调用场景重复出现 | 缺少默认辅助方法(§4) | 添加 |
When NOT to apply
何时不适用
- Single-use components. A composable used in exactly one place, with no plan to reuse, doesn't benefit from slot flexibility — and the slot indirection makes the code harder to read for the one reader. Primitive params + inline content is fine. (As soon as a second call site appears, slot it.)
- Design-system primitives where every caller must look identical. A exists because you want every H2 to look the same; making it
Heading2(text: String)invites callers to break the rule. Keep it primitive. (Conversely: ifheadlineContent: @Composable () -> Unitever needs a badge inline, slot it.)Heading2 - Semantic parameters the component intentionally owns. If the component owns typography, iconography, accessibility wording, or product consistency, a primitive parameter may be the constraint you want.
- Constrained-type parameters that genuinely are constrained. A doesn't need its checked indicator to be a slot. Booleans-with-callbacks are not "content."
Switch(checked: Boolean, onCheckedChange: ...) - Performance-critical fast paths (rare in app code; common in framework primitives). A slot is an allocated lambda. In the deepest LazyList item layer, sometimes primitives win. If you're not writing the framework, this doesn't apply.
- 一次性组件:仅在一个地方使用且无复用计划的可组合函数,无法从插槽灵活性中获益——插槽的间接性会让唯一的阅读者更难理解代码。使用原始参数+内联内容即可。(一旦出现第二个调用场景,就改为插槽形式。)
- 要求所有调用者样式完全一致的设计系统原语:存在的目的就是让所有H2样式相同;若改为
Heading2(text: String)会诱使调用者打破规则。保持原始类型即可。(反之:如果headlineContent: @Composable () -> Unit需要在内部添加Badge,则改为插槽形式。)Heading2 - 组件有意控制的语义参数:如果组件负责排版、图标、无障碍措辞或产品一致性,原始参数可能是你需要的约束。
- 真正受约束的类型参数:不需要将选中指示器设为插槽。带回调的布尔值不属于“内容”。
Switch(checked: Boolean, onCheckedChange: ...) - 性能关键的快速路径(应用代码中少见;框架原语中常见):插槽是已分配的lambda。在最深层的LazyList项中,有时原始类型更优。如果你不是在编写框架,此情况不适用。
Red flags during review
代码审查中的危险信号
| Thought | Reality |
|---|---|
| "Title is always a String — making it a slot is over-engineering" | "Always today" is the trap. Material's |
| "Lambdas are heavier than strings" | At the scale of typical Compose UI, this isn't measurable — and the framework's own components ( |
| "I'll add a slot later if someone asks" | The slot turns one parameter into two parameters (the slot itself + maybe an internal flag) and edits every call site. The shape change isn't a "later" change. |
"I'll model the variants with a sealed | Sealed enumeration is bounded; slots are unbounded. A sealed type works until the day someone needs a variant you didn't anticipate — at which point you're back to editing the component. The slot avoids the cycle. |
| "The leading area is always an icon, the trailing area varies — I'll slot only the trailing" | This is the partial-slot trap. The "always-an-icon" assumption breaks the first time a row needs an avatar or a flag emoji or a coloured shape. Slot leading too. |
| "There's only one call site today" | If there's only one call site, you're probably not designing a reusable component yet. See "When NOT to apply" — primitives are fine for a true single-use. The moment you copy-paste it, slot it. |
| 想法 | 实际情况 |
|---|---|
| "标题永远是字符串——做成插槽是过度设计" | "现在永远"是陷阱。Material的 |
| "lambda比字符串更重" | 在典型的Compose UI规模下,这一差异无法被测量——且框架自身的组件( |
| "有人需要时我再添加插槽" | 添加插槽会将一个参数变为两个参数(插槽本身+可能的内部标志),并需要修改所有调用场景。这种结构变更不能留到“以后”。 |
"我用密封的 | 密封枚举是有界的;插槽是无界的。密封类型在你未预料到的变体出现前有效——此时你又要回到修改组件的循环中。插槽可以避免这种循环。 |
| "前置区域永远是图标,后置区域变化——我只给后置区域做插槽" | 这是部分插槽陷阱。“永远是图标”的假设会在第一次需要头像、国旗表情或彩色形状时被打破。前置区域也应做成插槽。 |
| "现在只有一个调用场景" | 如果只有一个调用场景,你可能还没在设计可重用组件。参考“何时不适用”——原始类型适用于真正的一次性组件。一旦你复制粘贴该组件,就改为插槽形式。 |
Related
相关链接
- — the modifier-parameter rule (§1–§3 there) travels with slot APIs. A reusable component takes a
compose-modifier-and-layout-styleparameter and slots its content; the caller owns both placement and what to place.modifier
- ——修饰符参数规则(其中§1–§3)与插槽API配合使用。可重用组件接受
compose-modifier-and-layout-style参数并为内容提供插槽;调用者同时控制布局位置和内容。modifier