今風なSelectorを作る


# 目標
複数の項目から1つの項目を選択するUIといえば`RadioButton`ですが、JetpackComposeの`RadioButton`もデザイン的に余りイケてるとは思えません。そこでモダンなUIコンポーネントライブラリなどで見かける、下の動画のようなUIを作成していきます。

![Screen_recording_20241220_135909online-video-cutter.com-ezgif.com-video-to-gif-converter.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/115846/45ad8b85-7b64-a890-2f16-548ced820022.gif)


# ひな形
ざっくりと作成しましたが、選択状態に応じて背景の色を変えているだけなので、値が変更されてもアニメーションしません。
```kotlin:Selector.kt
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
                    }
                )
            }
        }
    }
}
```

![Screen_recording_20241220_135601online-video-cutter.com-ezgif.com-video-to-gif-converter.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/115846/912ec440-98f4-d255-bdeb-7ab29242e902.gif)


# 選択状態を分離
背面に選択状態を表す`Box`を配置します。`offset`で位置を変更できるようにして、項目が選択されたら`offset`を更新し、選択箇所へ移動するようにします。まだアニメーションはしないままです。

```kotlin:Selector.kt
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でアニメーションを行う方法はいくつかありますが、今回は [価値ベースのアニメーション](https://developer.android.com/develop/ui/compose/animation/value-based?hl=ja) (value based animationの誤訳？)を使って`offset`の値を変更させます。

```kotlin:Selector.kt
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)
    }
)
```
![Screen_recording_20241220_135650online-video-cutter.com-ezgif.com-video-to-gif-converter.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/115846/1bcf77cf-9cb0-4664-0fad-1373c9c84c00.gif)

無事アニメーションが行われるようになりました。

# 完成品
設定可能な値をジェネリック化し、画面の縦横切り替え等、実際に使用する際問題になりそうか箇所を修正したソースが以下になります。
```kotlin:Selector.kt
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
                        }
                    )
                }
            }
        }
    }
}
```
使い方は以下のようになります。
```kotlin
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`を指定することで、独自の型の値を変更させていくことも可能です。