compose-slot-api-pattern

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Compose: 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
title: String, subtitle: String?, leadingIcon: ImageVector?, trailingIcon: ImageVector?, trailingText: String?, showSwitch: Boolean, switchValue: Boolean, onSwitchChange: (Boolean) -> Unit?, badge: String?, …
, 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.
The fix is to delegate content to the caller via
@Composable
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.
Material 3's
ListItem
is the canonical example: every visual piece is a slot (
headlineContent
,
supportingContent
,
leadingContent
,
trailingContent
,
overlineContent
), 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
ListItem
again.
可重用Compose组件的职责是进行布局,而非枚举布局内容。当你写出
title: String, subtitle: String?, leadingIcon: ImageVector?, trailingIcon: ImageVector?, trailingText: String?, showSwitch: Boolean, switchValue: Boolean, onSwitchChange: (Boolean) -> Unit?, badge: String?, …
这样的参数列表时,该组件就不再是描述布局,而是在枚举调用场景——而下一个调用场景就会需要组件未提供的参数。
解决方法是通过
@Composable
lambda参数将内容委托给调用者。组件负责提供结构(确定前置内容、标题、辅助内容、后置内容的位置),调用者负责填充这些插槽中的所有内容。
Material 3的
ListItem
是典型示例:每个视觉部分都是一个插槽(
headlineContent
supportingContent
leadingContent
trailingContent
overlineContent
),而非原始类型。这不是过度设计——这种设计可以适配设计系统所需的所有列表项样式,无需再修改
ListItem
本身。

When 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
    ,
    actionText: String?
    , etc. — primitive types describing content.
  • 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
    String
    parameter where one caller would want a
    Text
    with custom style, a second caller a
    Text
    with a
    Badge
    , a third caller a row of icons.
  • It already has one slot (often
    trailing
    or
    content
    ) and the rest of the parameters are still primitives.
当你设计或审查用于复用的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

1. 用
@Composable
插槽替换原始内容参数

Where the component asks for caller-controlled content, prefer a
@Composable () -> Unit
slot. Where the slot is structurally required, leave it non-nullable with no default. Where it's optional, make it nullable with a
null
default.
kotlin
// ❌ 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 (
title
is always single-line text,
leadingIcon
is always an
ImageVector
). The problem is the next call site: a row with a
Badge
next to the title, a leading slot that's a circular avatar (not an
ImageVector
), a subtitle that's a row of chips. Each forces either a new parameter, a new flag, or a workaround.
kotlin
// ✅ 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 () -> Unit
插槽。如果插槽是结构上必需的,保持其非空且无默认值;如果是可选的,设置为可空并以
null
为默认值。
kotlin
// ❌ 不佳——原始类型参数;仅后置区域是插槽;其余内容均被固定
@Composable
fun SettingsRow(
    title: String,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    subtitle: String? = null,
    leadingIcon: ImageVector? = null,
    trailing: (@Composable () -> Unit)? = null,
) {}
这种形式看起来没问题,因为当前的调用场景都能适配(
title
始终是单行文本,
leadingIcon
始终是
ImageVector
)。但问题出在下一个调用场景:标题旁带
Badge
的行、前置区域是圆形头像(而非
ImageVector
)、副标题是标签行的情况。每种情况都需要添加新参数、新标志或使用变通方法。
kotlin
// ✅ 良好——每个视觉区域都是插槽;组件仅描述结构,不限制内容
@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
    xxxContent
    for free-form
    @Composable () -> Unit
    slots (
    headlineContent
    ,
    supportingContent
    ,
    trailingContent
    ) — matches Material 3.
  • Use a singular noun (
    title
    ,
    icon
    ,
    actions
    ) when the slot is semantically constrained and the component name disambiguates (
    Scaffold(topBar = { … }, bottomBar = { … }, floatingActionButton = { … })
    ).
  • Don't use
    content
    and other
    xxxContent
    slots together — pick one convention per component.
  • 对于自由形式的
    @Composable () -> Unit
    插槽,使用
    xxxContent
    命名(如
    headlineContent
    supportingContent
    trailingContent
    )——与Material 3保持一致。
  • 当插槽有语义约束,且组件名称可明确区分时,使用单数名词命名(如
    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
Row
/
Column
/
Box
whose layout features (
Modifier.weight
,
BoxScope.matchParentSize
, alignment) should be available to the caller, declare the slot as a receiver lambda:
@Composable RowScope.() -> Unit
.
kotlin
// ❌ 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
TopAppBar(actions = { IconButton(…); IconButton(…) })
work — the caller is implicitly inside a
RowScope
.
Don'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
Box
, use
BoxScope
. If it's inside a
Column
, use
ColumnScope
. If the parent is not a standard layout (or none of its scope APIs are useful in slot content), no receiver.
如果插槽内容将位于
Row
/
Column
/
Box
中,且调用者需要使用该布局的特性(如
Modifier.weight
BoxScope.matchParentSize
、对齐方式),则将插槽声明为接收器lambda:
@Composable RowScope.() -> Unit
kotlin
// ❌ 不佳——操作项在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
中。
不要盲目给所有插槽添加作用域接收器。接收器应与插槽实际嵌入的父布局匹配:如果插槽在
Box
中渲染,使用
BoxScope
;在
Column
中则使用
ColumnScope
;如果父布局不是标准布局(或其作用域API对插槽内容无用),则无需添加接收器。

3. Optional slots — nullable with
null
default

3. 可选插槽——设为可空并以
null
为默认值

For slots that may be absent, prefer
(@Composable () -> Unit)? = null
over
@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)? = null
Why: with a nullable slot, the component can branch on
leadingContent != null
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.
The trade-off: callers who pass an explicit empty
{}
to silence a slot now have to pass
null
or omit the argument. That's the right answer either way — they shouldn't be passing
{}
.
对于可能为空的插槽,优先使用
(@Composable () -> Unit)? = null
而非
@Composable () -> Unit = {}
kotlin
// ❌ 不佳——空默认值;“无前置内容”对应空lambda
leadingContent: @Composable () -> Unit = {}

// ✅ 良好——null表示“无插槽”;组件可在插槽为空时省略其容器、间距和内边距
leadingContent: (@Composable () -> Unit)? = null
原因:使用可空插槽时,组件可以通过
leadingContent != null
进行分支判断,完全跳过插槽的容器、间距和内边距。而使用空默认值时,布局仍会为插槽分配空间——有时你会看到空内容周围存在多余的内边距或间隔。可空形式使“不存在”的场景在结构上明确区分,这几乎总是我们想要的效果。
权衡:原本通过传递显式空
{}
来隐藏插槽的调用者,现在需要传递
null
或省略该参数。无论哪种方式都是正确的——他们本就不应该传递
{}

4. Defaults live in
XxxDefaults

4. 默认内容放在
XxxDefaults

When you find yourself documenting "the trailing slot should usually be a chevron" or "pass
MaterialTheme.colorScheme.surface
for the default background", co-locate the helpers in a
XxxDefaults
object next to the component:
kotlin
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
ButtonDefaults
,
TopAppBarDefaults
, etc. — defaults that are themselves composable belong here, not as new component parameters with
MaterialTheme.x.y
defaults expanded inline.
当你需要记录“后置插槽通常应使用 Chevron”或“默认背景传递
MaterialTheme.colorScheme.surface
”时,将这些辅助方法放在组件旁的
XxxDefaults
对象中:
kotlin
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的
ButtonDefaults
TopAppBarDefaults
等保持一致——本身是可组合函数的默认内容应放在此处,而非作为组件参数并内联
MaterialTheme.x.y
默认值。

Quick reference

快速参考

SymptomDiagnosisFix
title: String, subtitle: String?, leadingIcon: ImageVector?
on a reusable component
Primitive content params (§1)Convert to
xxxContent: (@Composable () -> Unit)?
slots
Multiple boolean flags (
showChevron
,
showSwitch
) selecting trailing shapes
Enumerating shapes (§1)One
trailingContent: (@Composable () -> Unit)?
slot
A
mode: Mode.Sealed
parameter listing variants
Same as flag soup (§1)Slot it
actions: @Composable () -> Unit = {}
inside a
Row
body
Missing scope receiver (§2)
actions: @Composable RowScope.() -> Unit = {}
slot: @Composable () -> Unit = {}
for an optional area
Empty-lambda default (§3)
slot: (@Composable () -> Unit)? = null
and branch on it
Component param
defaultColor: Color = MaterialTheme.colorScheme.surface
Defaults inlined (§4)Move to
XxxDefaults.color
and reference it
Common trailing content repeats at every call siteMissing default helper (§4)Add
XxxDefaults.Chevron()
etc.
症状诊断修复方案
可重用组件包含
title: String, subtitle: String?, leadingIcon: ImageVector?
使用了原始内容参数(§1)转换为
xxxContent: (@Composable () -> Unit)?
插槽
多个布尔标志(
showChevron
showSwitch
)用于选择后置样式
枚举样式(§1)使用一个
trailingContent: (@Composable () -> Unit)?
插槽
包含
mode: Mode.Sealed
参数列举变体
与标志混乱问题相同(§1)使用插槽
Row
内部的
actions: @Composable () -> Unit = {}
缺少作用域接收器(§2)修改为
actions: @Composable RowScope.() -> Unit = {}
可选区域使用
slot: @Composable () -> Unit = {}
使用了空lambda默认值(§3)修改为
slot: (@Composable () -> Unit)? = null
并进行分支判断
组件参数
defaultColor: Color = MaterialTheme.colorScheme.surface
默认值内联(§4)移至
XxxDefaults.color
并引用
常见后置内容在每个调用场景重复出现缺少默认辅助方法(§4)添加
XxxDefaults.Chevron()
等方法

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
    Heading2(text: String)
    exists because you want every H2 to look the same; making it
    headlineContent: @Composable () -> Unit
    invites callers to break the rule. Keep it primitive. (Conversely: if
    Heading2
    ever needs a badge inline, slot it.)
  • 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
    Switch(checked: Boolean, onCheckedChange: ...)
    doesn't need its checked indicator to be a slot. Booleans-with-callbacks are not "content."
  • 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.
  • 一次性组件:仅在一个地方使用且无复用计划的可组合函数,无法从插槽灵活性中获益——插槽的间接性会让唯一的阅读者更难理解代码。使用原始参数+内联内容即可。(一旦出现第二个调用场景,就改为插槽形式。)
  • 要求所有调用者样式完全一致的设计系统原语
    Heading2(text: String)
    存在的目的就是让所有H2样式相同;若改为
    headlineContent: @Composable () -> Unit
    会诱使调用者打破规则。保持原始类型即可。(反之:如果
    Heading2
    需要在内部添加Badge,则改为插槽形式。)
  • 组件有意控制的语义参数:如果组件负责排版、图标、无障碍措辞或产品一致性,原始参数可能是你需要的约束。
  • 真正受约束的类型参数
    Switch(checked: Boolean, onCheckedChange: ...)
    不需要将选中指示器设为插槽。带回调的布尔值不属于“内容”。
  • 性能关键的快速路径(应用代码中少见;框架原语中常见):插槽是已分配的lambda。在最深层的LazyList项中,有时原始类型更优。如果你不是在编写框架,此情况不适用。

Red flags during review

代码审查中的危险信号

ThoughtReality
"Title is always a String — making it a slot is over-engineering""Always today" is the trap. Material's
ListItem.headlineContent
exists because tomorrow someone wants a
Text + Badge
. The slot is
8
characters of extra wrapping at every call site (
{ Text(…) }
); the refactor to add a slot later edits every existing call site.
"Lambdas are heavier than strings"At the scale of typical Compose UI, this isn't measurable — and the framework's own components (
Button
,
ListItem
,
TopAppBar
,
Scaffold
) all slot. If your component is in the hottest of hot paths, see "When NOT to apply."
"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
Trailing
type instead"
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的
ListItem.headlineContent
存在的原因就是未来有人会需要
Text + Badge
。插槽仅在每个调用场景多了8个字符的包装(
{ Text(…) }
);而之后再添加插槽则需要修改所有现有调用场景。
"lambda比字符串更重"在典型的Compose UI规模下,这一差异无法被测量——且框架自身的组件(
Button
ListItem
TopAppBar
Scaffold
)都使用插槽。如果你的组件处于最热的路径中,请参考“何时不适用”。
"有人需要时我再添加插槽"添加插槽会将一个参数变为两个参数(插槽本身+可能的内部标志),并需要修改所有调用场景。这种结构变更不能留到“以后”。
"我用密封的
Trailing
类型来建模变体"
密封枚举是有界的;插槽是无界的。密封类型在你未预料到的变体出现前有效——此时你又要回到修改组件的循环中。插槽可以避免这种循环。
"前置区域永远是图标,后置区域变化——我只给后置区域做插槽"这是部分插槽陷阱。“永远是图标”的假设会在第一次需要头像、国旗表情或彩色形状时被打破。前置区域也应做成插槽。
"现在只有一个调用场景"如果只有一个调用场景,你可能还没在设计可重用组件。参考“何时不适用”——原始类型适用于真正的一次性组件。一旦你复制粘贴该组件,就改为插槽形式。

Related

相关链接

  • compose-modifier-and-layout-style
    — the modifier-parameter rule (§1–§3 there) travels with slot APIs. A reusable component takes a
    modifier
    parameter and slots its content; the caller owns both placement and what to place.
  • compose-modifier-and-layout-style
    ——修饰符参数规则(其中§1–§3)与插槽API配合使用。可重用组件接受
    modifier
    参数并为内容提供插槽;调用者同时控制布局位置和内容。