Material 3 Expressive の実装例の紹介シリーズです。
今回は新たに追加されたコンポーネントである Split button を紹介します。
(androidx.compose.material3:material3:1.4.0-alpha15
時点での内容になります。)
Split button とは
ボタンのアクションに関連するオプションを提供するコンポーネントです。
Button にあわせて Extra small、Small、Medium、Large、Extra large の 5 つのサイズと、Elevated、Filled、Tonal、Outlined のスタイルが用意されています。
実装
SplitButtonLayout
SplitButtonLayout
を使用し、Button 系は SplitButtonDefaults
にある SplitButtonDefaults.LeadingButton
と SplitButtonDefaults.TrailingButton
を使います。
TrailingButton
のアイコンの回転や Menu の表示は実装する必要があります。
TrailingButton
のアイコンは自由に設定できる形にはなっていますが、展開と折りたたみのアイコンを常に使用し、選択時に回転するようにガイドラインに記載があります。
https://m3.material.io/components/split-button/guidelines#b535fb7d-62ea-478b-a815-34567f22c5ae
Box {
var checked by remember { mutableStateOf(false) }
SplitButtonLayout(
leadingButton = {
SplitButtonDefaults.LeadingButton(
onClick = { },
) {
Icon(
painter = painterResource(R.drawable.ic_edit),
modifier = Modifier.size(SplitButtonDefaults.LeadingIconSize),
contentDescription = null,
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text("Label")
}
},
trailingButton = {
SplitButtonDefaults.TrailingButton(
checked = checked,
onCheckedChange = { checked = it },
modifier = Modifier.semantics {
stateDescription = if (checked) "Expanded" else "Collapsed"
contentDescription = "Toggle Button"
},
) {
val rotation: Float by
animateFloatAsState(
targetValue = if (checked) 180f else 0f,
label = "Trailing Icon Rotation"
)
Icon(
painter = painterResource(R.drawable.ic_keyboard_arrow_down),
modifier = Modifier
.size(SplitButtonDefaults.TrailingIconSize)
.graphicsLayer {
this.rotationZ = rotation
},
contentDescription = null
)
}
}
)
DropdownMenu(
expanded = checked,
onDismissRequest = { checked = false }
) {
DropdownMenuItem(...)
...
}
}

Elevated、Filled、Tonal、Outlined のスタイル
SplitButtonDefaults
にそれぞれのスタイルが標準で用意されています。
// Elevated
SplitButtonLayout(
leadingButton = {
SplitButtonDefaults.ElevatedLeadingButton(
onClick = { },
) { ... }
},
trailingButton = {
SplitButtonDefaults.ElevatedTrailingButton(
checked = checked,
onCheckedChange = { checked = it },
...
) { ... }
}
)
// Filled
SplitButtonLayout(
leadingButton = {
SplitButtonDefaults.LeadingButton(
onClick = { },
) { ... }
},
trailingButton = {
SplitButtonDefaults.TrailingButton(
checked = checked,
onCheckedChange = { checked = it },
...
) { ... }
}
)
// Tonal
SplitButtonLayout(
leadingButton = {
SplitButtonDefaults.TonalLeadingButton(
onClick = { },
) { ... }
},
trailingButton = {
SplitButtonDefaults.TonalTrailingButton(
checked = checked,
onCheckedChange = { checked = it },
...
) { ... }
}
)
// Outlined
SplitButtonLayout(
leadingButton = {
SplitButtonDefaults.OutlinedLeadingButton(
onClick = { },
) { ... }
},
trailingButton = {
SplitButtonDefaults.OutlinedTrailingButton(
checked = checked,
onCheckedChange = { checked = it },
...
) { ... }
}
)

Extra small、Small、Medium、Large、Extra large のサイズ
デフォルトでない Small 以外の Button の高さが Button 同様に Split button でも標準で用意されており、アイコンのサイズや Padding 等が高さによって自動で適切な値になるように実装する形になります。
// Extra small
val buttonHeight = SplitButtonDefaults.ExtraSmallContainerHeight
SplitButtonLayout(
leadingButton = {
SplitButtonDefaults.LeadingButton(
onClick = { },
modifier = Modifier.heightIn(buttonHeight),
shapes = SplitButtonDefaults.leadingButtonShapesFor(buttonHeight),
contentPadding = SplitButtonDefaults.leadingButtonContentPaddingFor(buttonHeight),
) {
Icon(
painter = painterResource(R.drawable.ic_edit),
modifier = Modifier
.size(SplitButtonDefaults.leadingButtonIconSizeFor(buttonHeight)),
contentDescription = null,
)
Spacer(Modifier.size(ButtonDefaults.iconSpacingFor(buttonHeight)))
Text(
text = "Label",
style = ButtonDefaults.textStyleFor(buttonHeight)
)
}
},
trailingButton = {
SplitButtonDefaults.TrailingButton(
checked = checked,
onCheckedChange = { checked = it },
modifier = Modifier
.heightIn(buttonHeight),
shapes = SplitButtonDefaults.trailingButtonShapesFor(buttonHeight),
contentPadding = SplitButtonDefaults.trailingButtonContentPaddingFor(buttonHeight),
) {
val rotation: Float by
animateFloatAsState(
targetValue = if (checked) 180f else 0f,
label = "Trailing Icon Rotation"
)
Icon(
painter = painterResource(R.drawable.ic_keyboard_arrow_down),
modifier = Modifier
.size(SplitButtonDefaults.trailingButtonIconSizeFor(buttonHeight))
.graphicsLayer {
this.rotationZ = rotation
},
contentDescription = null
)
}
}
)
// Small(Default)
SplitButtonLayout(
leadingButton = {
SplitButtonDefaults.LeadingButton(
onClick = { },
) {
Icon(
painter = painterResource(R.drawable.ic_edit),
modifier = Modifier.size(SplitButtonDefaults.LeadingIconSize),
contentDescription = null,
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text("Label")
}
},
trailingButton = {
SplitButtonDefaults.TrailingButton(
checked = checked,
onCheckedChange = { checked = it },
...
) {
val rotation: Float by
animateFloatAsState(
targetValue = if (checked) 180f else 0f,
label = "Trailing Icon Rotation"
)
Icon(
painter = painterResource(R.drawable.ic_keyboard_arrow_down),
modifier = Modifier
.size(SplitButtonDefaults.TrailingIconSize)
.graphicsLayer {
this.rotationZ = rotation
},
contentDescription = null
)
}
}
)
// Medium
val buttonHeight = SplitButtonDefaults.MediumContainerHeight
SplitButtonLayout(
... // Extra small と同様
)
// Large
val buttonHeight = SplitButtonDefaults.LargeContainerHeight
SplitButtonLayout(
... // Extra small と同様
)
// Extra large
val buttonHeight = SplitButtonDefaults.ExtraLargeContainerHeight
SplitButtonLayout(
... // Extra small と同様
)
