JetpackComposeで単一選択ダイアログを作る

# 気象庁の天気予報を取得するアプリを作ろう ＃2
「JetpackComposeで、いろいろライブラリを触ってみたかったので、気象庁の天気予報を表示するアプリを作ってみよう」第2弾です。
[前回](https://qiita.com/jozuko_dev/items/67af0b69fc4b60d0fca4)は、天気予報を取得できる地点情報をAPI経由で取得するところまで作成しました。
今回は、地点名ボタンをタップすると、単一選択ダイアログを表示して、選ぶと画面が更新されるところを作成します。

## 使用するライブラリとか技術とか
本記事ではライブラリ、記述を使用します。
- JetpackCompose
- MVVM

## 単一選択ダイアログを汎用的に作る
まずはダイアログを作成していきます。
完成コードは[こちら](https://github.com/jozuko/WeatherForecast/blob/28aea0f8f7f21a232cafc78ffe372c9a222f485c/app/src/main/java/com/jozu/weatherforecast/presentation/dialog/SingleSelectionDialog.kt)です。

ダイアログの要素は以下の通りです。

##### 画面に表示するもの
- タイトル
- メッセージ
- ラジオボタン付き選択用ラベルの一覧
- OKボタン
- Cancelボタン

##### 画面には表示しないもの
- OKボタンのタップイベントで返却するオブジェクトリスト
- 初期選択index

### 引数の宣言
上記要素を引数にとるカスタムDialogComposeを作成していきます。
ポイントは、画面に表示するラジオボタン横のラベルと、OKボタンをタップしたときに返却するオブジェクトを分けていることと、
返却するオブジェクトの型をジェネリクスにして汎用化しているところです。

```kotlin:SingleSelectionDialog.kt
@Composable
fun <T> SingleSelectionDialog(
    modifier: Modifier = Modifier,
    labels: List<String>,
    selection: List<T>,
    onConfirmRequest: (selected: T) -> Unit,
    onDismissRequest: () -> Unit,
    title: String? = null,
    message: String? = null,
    defaultSelectedIndex: Int = -1,
    confirmButtonLabel: String = stringResource(id = android.R.string.ok),
    dismissButtonLabel: String = stringResource(id = android.R.string.cancel),
){
}
```

| 要素名 | 説明 | 
|:--|:--|
|modifier | modifierです。 |
|labels | ラジオボタンの横に表示するText一覧 |
|selection | onConfirmRequestの返却オブジェクト一覧 |
|onConfirmRequest|OKボタンのタップイベント|
|onDismissRequest|ダイアログが閉じたときと、cancelボタンのタップイベント|
|title|ダイアログタイトル|
|message|タイトル下のメッセージ|
|defaultSelectedIndex|初期選択index|
|confirmButtonLabel|OKボタンのラベル|
|dismissButtonLabel|Cancelボタンのラベル|

### 画面要素の配置
特別変わったことはやっていないので、[ソース](https://github.com/jozuko/WeatherForecast/blob/28aea0f8f7f21a232cafc78ffe372c9a222f485c/app/src/main/java/com/jozu/weatherforecast/presentation/dialog/SingleSelectionDialog.kt)をご覧ください。
```kotlin:SingleSelectionDialog.kt
    var selectedIndex by remember { mutableStateOf(defaultSelectedIndex) }
    Dialog(
        onDismissRequest = onDismissRequest,
    ) {
        Surface(
            modifier = modifier,
            shape = AlertDialogDefaults.shape,
            color = AlertDialogDefaults.containerColor,
            tonalElevation = AlertDialogDefaults.TonalElevation,
        ) {
            Column(
                modifier = Modifier
                    .sizeIn(minWidth = 280.dp, maxWidth = 560.dp)
                    .padding(8.dp),
            ) {
                title?.let {
                    Title(
                        modifier = Modifier
                            .padding(16.dp)
                            .align(Alignment.CenterHorizontally),
                        title = it,
                    )
                }

                message?.let {
                    Message(
                        modifier = Modifier
                            .padding(8.dp)
                            .align(Alignment.Start),
                        message = it,
                    )
                }

                SelectionList(
                    modifier = Modifier.weight(weight = 1f, fill = false),
                    labels = labels,
                    selectedIndex = selectedIndex,
                    onSelect = {
                        selectedIndex = it
                    }
                )

                Buttons(
                    modifier = Modifier.align(Alignment.End),
                    selection = selection,
                    selectedIndex = selectedIndex,
                    dismissButtonLabel = dismissButtonLabel,
                    confirmButtonLabel = confirmButtonLabel,
                    onDismissRequest = onDismissRequest,
                    onConfirmRequest = onConfirmRequest,
                )
            }
        }
    }
```

## 呼び出し側の作成

### 選択中IDのStateを作成する
ViewModelに選択中のエリアIDを保持するStateを宣言します。
天気予報の取得時に、centerとofficeの2つが必要ですので、2つ作ります。
```kotlin:ForecastViewModel.kt
    var centerId: String by mutableStateOf("")
        private set

    var officeId: String by mutableStateOf("")
        private set
```

### 初期表示時のIDを指定する
初期表示では、area.jsonの一番最初の要素を選択してほしいので、その記述を追記します。

最初の要素を取得する箇所は、「it.value.getCenter(0)」「it.value.getOffice(centerId, 0)」のgetCenterとgetOfficeです。
これは、Areaモデル内に記述しています。（興味がある方は[こちらから](https://github.com/jozuko/WeatherForecast/blob/28aea0f8f7f21a232cafc78ffe372c9a222f485c/app/src/main/java/com/jozu/weatherforecast/domain/repository/Area.kt)どうぞ）

```kotlin:ForecastViewModel.kt
    fun refreshArea() {
        viewModelScope.launch {
            areaFuture = Future.Proceeding
            getAreaUseCase.invoke().collectLatest {
                areaFuture = it
                if (it is Future.Success) {
                    centerId = it.value.getCenter(0)?.first ?: ""
                    officeId = it.value.getOffice(centerId, 0)?.first ?: ""
                }
            }
        }
    }
```

### 地方ID選択時に選択中IDを変更する関数を作る
DialogでOKが押されたときに、選択中IDを変更したいので、そのための関数を作ります。

Centerを選択したときは、Officeの選択IDを最初の要素に変更しています。
```kotlin:ForecastViewModel.kt
    fun selectAreaCenter(id: String) {
        if (centerId != id) {
            viewModelScope.launch {
                val areaData: Future<Area> = areaFuture
                if (areaData is Future.Success) {
                    centerId = id
                    officeId = areaData.value.getOffice(centerId, 0)?.first ?: ""
                }
            }
        }
    }

    fun selectAreaOffice(id: String) {
        if (officeId != id) {
            viewModelScope.launch {
                officeId = id
            }
        }
    }
```

### ダイアログの表示状態を管理するStateを作成する
ダイアログの表示状態を監視するStateと、表示状態を変更する関数を作成します。

```kotlin:ForecastViewModel.kt
    var isShowCenterSelectDialog: Boolean by mutableStateOf(false)
        private set

    var isShowOfficeSelectDialog: Boolean by mutableStateOf(false)
        private set

    fun showCenterSelectDialog() {
        viewModelScope.launch {
            isShowCenterSelectDialog = true
        }
    }

    fun hideCenterSelectDialog() {
        viewModelScope.launch {
            isShowCenterSelectDialog = false
        }
    }

    fun showOfficeSelectDialog() {
        viewModelScope.launch {
            isShowOfficeSelectDialog = true
        }
    }

    fun hideOfficeSelectDialog() {
        viewModelScope.launch {
            isShowOfficeSelectDialog = false
        }
    }
```

### 画面を作る
最初に作ったSingleSelectionDialogを使用して、ScreenにDialogのComposableを作成します。

```kotlin:ForecastScreen.kt
@Composable
fun CenterSelectDialog(
    viewModel: ForecastViewModel,
    area: Area,
) {
    if (viewModel.isShowCenterSelectDialog) {
        val centers = area.centers.toList()

        SingleSelectionDialog(
            title = "地方選択",
            labels = centers.map { it.second.name },
            selection = centers,
            defaultSelectedIndex = centers.indexOfFirst { it.first == viewModel.centerId },
            onConfirmRequest = { (centerId, _) ->
                viewModel.selectAreaCenter(centerId)
                viewModel.hideCenterSelectDialog()
            },
            onDismissRequest = {
                viewModel.hideCenterSelectDialog()
            },
        )
    }
}

@Composable
fun OfficeSelectDialog(
    viewModel: ForecastViewModel,
    area: Area,
) {
    if (viewModel.isShowOfficeSelectDialog) {
        val offices = area.getCenterOffices(viewModel.centerId)

        SingleSelectionDialog(
            title = "事務所選択",
            labels = offices.map { it.second.name },
            selection = offices,
            defaultSelectedIndex = offices.indexOfFirst { it.first == viewModel.officeId },
            onConfirmRequest = { (officeId, _) ->
                viewModel.selectAreaOffice(officeId)
                viewModel.hideOfficeSelectDialog()
            },
            onDismissRequest = {
                viewModel.hideOfficeSelectDialog()
            },
        )
    }
}
```

これらのDialogを画面に表示させます。
Future.Successだったとき、DataScreenとCenterSelectDialog,OfficeSelectDialogが表示できるようにします。

```kotlin:ForecastScreen.kt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ForecastScreen(
    viewModel: ForecastViewModel = hiltViewModel(),
) {
    Scaffold(modifier = Modifier.fillMaxSize()) { paddingValues ->
        val areaState = viewModel.areaFuture
        val modifier = Modifier
            .padding(paddingValues = paddingValues)
            .fillMaxSize()

        when (areaState) {
            is Future.Proceeding -> LoadingScreen(modifier)
            is Future.Error -> ErrorScreen(modifier, viewModel)
            is Future.Success -> {
                DataScreen(modifier, viewModel, areaState.value)
                CenterSelectDialog(viewModel, areaState.value)
                OfficeSelectDialog(viewModel, areaState.value)
            }
        }
    }
}
```

後は、ボタンタップ時にDialogを表示状態にする関数をコールすればOKです。
ボタンのラベルには、選択したCenterとOfficeの名前を使用しています。

```kotlin:ForecastScreen.kt
val center: Center? = area.centers[viewModel.centerId]
val office: Office? = area.offices[viewModel.officeId]

Button(
    onClick = {
        viewModel.showCenterSelectDialog()
    },
) {
    Text(center?.name ?: "未選択")
}
Button(
    onClick = {
        viewModel.showOfficeSelectDialog()
    },
) {
    Text(office?.name ?: "未選択")
}
```

## いざ実行
ボタンをタップ -> ダイアログが出る -> 選択してOKをタップ -> ボタン名が変わる
ができました。[全コードはこちら]（https://github.com/jozuko/WeatherForecast/tree/28aea0f8f7f21a232cafc78ffe372c9a222f485c）
![select_dialog_AdobeExpress.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3491965/3652497e-82a2-64fb-137b-917f3e52eb71.gif)