目標
複数の項目から1つの項目を選択するUIといえばRadioButton
ですが、JetpackComposeのRadioButton
もデザイン的に余りイケてるとは思えません。そこでモダンなUIコンポーネントライブラリなどで見かける、下の動画のようなUIを作成していきます。
ひな形
ざっくりと作成しましたが、選択状態に応じて背景の色を変えているだけなので、値が変更されてもアニメーションしません。
data class SelectorItem(
val value: Int,
val label: String
)
@Composable
fun Selector(
modifier: Modifier = Modifier,
selectedValue: Int,
selectorItems: List<SelectorItem>,
onSelectionChanged: (value: Int) -> Unit
) {
Row(
modifier = modifier.fillMaxSize()
.border(BorderStroke(2.dp, Color.LightGray))
) {
selectorItems.forEach {
val isSelected = (it.value == selectedValue)
Box(modifier = Modifier
.weight(1f)
.fillMaxHeight()
.background(
color = if (isSelected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.surface
}
)
.selectable(
selected = isSelected,
onClick = {
onSelectionChanged(it.value)
}
)
) {
Text(
modifier = Modifier.align(Alignment.Center),
text = it.label,
color = if (isSelected) {
MaterialTheme.colorScheme.onPrimary
} else {
MaterialTheme.colorScheme.onSurface
}
)
}
}
}
}
選択状態を分離
背面に選択状態を表すBox
を配置します。offset
で位置を変更できるようにして、項目が選択されたらoffset
を更新し、選択箇所へ移動するようにします。まだアニメーションはしないままです。
val density = LocalDensity.current
var itemWidth by remember { mutableStateOf(0.dp) }
var offset by remember { mutableStateOf(0.dp) }
// 選択状態を示す背景
Box(
modifier = Modifier
.width(itemWidth)
.fillMaxHeight()
.offset(x = offset)
.background(color = MaterialTheme.colorScheme.primary)
)
// Composebleのサイズが変更されたら、項目ごとの幅を取得する。
Row(
modifier = Modifier
.fillMaxSize()
.border(BorderStroke(2.dp, Color.LightGray))
.onSizeChanged {
itemWidth = with(density){(it.width.toFloat() / selectorItems.size).toDp()}
}
) {
// 選択状態が変更されたら、offsetを更新する。
.selectable(
selected = isSelected,
onClick = {
offset = itemWidth * index
onSelectionChanged(it.value)
}
)
アニメーションを適用
JetpackComposeでアニメーションを行う方法はいくつかありますが、今回は 価値ベースのアニメーション (value based animationの誤訳?)を使ってoffset
の値を変更させます。
var itemWidth by remember { mutableStateOf(0.dp) }
var selectedIndex by remember { mutableIntStateOf(0) }
val offset by animateDpAsState(
label = "SelectionChange",
targetValue = itemWidth * selectedIndex,
animationSpec = tween(durationMillis = 500, easing = FastOutSlowInEasing)
)
// 選択状態が変更されたら、selectedIndexを更新する。
.selectable(
selected = isSelected,
onClick = {
selectedIndex = index
onSelectionChanged(it.value)
}
)
無事アニメーションが行われるようになりました。
完成品
設定可能な値をジェネリック化し、画面の縦横切り替え等、実際に使用する際問題になりそうか箇所を修正したソースが以下になります。
data class SelectorItem<T>(
val value: T,
val label: String
)
@Composable
fun <T>Selector(
modifier: Modifier = Modifier,
shape: Shape = RectangleShape,
selectedValue: T,
selectorItems: List<SelectorItem<T>>,
onSelectionChanged: (value: T) -> Unit
) {
val density = LocalDensity.current
var itemWidth by remember { mutableStateOf(0.dp) }
var selectedIndex by rememberSaveable { mutableIntStateOf(0) }
val offset by animateDpAsState(
label = "SelectionChange",
targetValue = itemWidth * selectedIndex,
animationSpec = tween(durationMillis = 500, easing = FastOutSlowInEasing)
)
LaunchedEffect(Unit) {
selectorItems.forEachIndexed { index, selectorItem ->
if (selectorItem.value == selectedValue) {
selectedIndex = index
return@forEachIndexed
}
}
}
Box(modifier = modifier
.fillMaxSize()
.clip(shape)) {
Box(
modifier = Modifier
.width(itemWidth)
.fillMaxHeight()
.offset(offset)
.clip(shape)
.background(MaterialTheme.colorScheme.primary)
)
Row(
modifier = Modifier
.fillMaxSize()
.border(BorderStroke(2.dp, Color.LightGray), shape)
.onSizeChanged {
itemWidth = with(density) { (it.width.toFloat() / selectorItems.size).toDp() }
}
) {
selectorItems.forEachIndexed { index, it ->
val isSelected = (it.value == selectedValue)
Box(modifier = Modifier
.weight(1f)
.fillMaxHeight()
.clip(shape)
.selectable(
selected = isSelected,
onClick = {
selectedIndex = index
onSelectionChanged(it.value)
}
)
) {
Text(
modifier = Modifier.align(Alignment.Center),
text = it.label,
color = if (isSelected) {
MaterialTheme.colorScheme.onPrimary
} else {
MaterialTheme.colorScheme.onSurface
}
)
}
}
}
}
}
使い方は以下のようになります。
val selectorItems = listOf(
SelectorItem(1, "AAA"),
SelectorItem(2, "BBB"),
SelectorItem(3, "CCC")
)
var selectedValue by rememberSaveable { mutableIntStateOf(1) }
Selector(
modifier = Modifier
.fillMaxWidth()
.height(64.dp)
.padding(all = 4.dp),
shape = RoundedCornerShape(50),
selectedValue = selectedValue,
selectorItems = selectorItems,
onSelectionChanged = {
selectedValue = it
}
)
終わりに
animate*AsState
を使うと、以前からあるプロパティアニメーションの様に値を変更させていき、結果をState<*>
で取得できるため、Composable
への反映も容易です。今回はanimateDpAsState
を使ってDp
型の値を変更していますが、Float
,Size
,Offset
,Rect
といった一般的な型に対応した関数も用意されていますし、TwoWayConverter
を指定することで、独自の型の値を変更させていくことも可能です。