0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

今風なSelectorを作る

Posted at

目標

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

Screen_recording_20241220_135909online-video-cutter.com-ezgif.com-video-to-gif-converter.gif

ひな形

ざっくりと作成しましたが、選択状態に応じて背景の色を変えているだけなので、値が変更されてもアニメーションしません。

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

選択状態を分離

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

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でアニメーションを行う方法はいくつかありますが、今回は 価値ベースのアニメーション (value based animationの誤訳?)を使ってoffsetの値を変更させます。

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

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

完成品

設定可能な値をジェネリック化し、画面の縦横切り替え等、実際に使用する際問題になりそうか箇所を修正したソースが以下になります。

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
                        }
                    )
                }
            }
        }
    }
}

使い方は以下のようになります。

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を指定することで、独自の型の値を変更させていくことも可能です。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?