Compose Multiplatform UIでプラットフォームごとに表示を切り替えてみた

## はじめに
この記事はKotlinMultiplatformのUIの共有（Compose Multiplatform UI）を用いて作成した画面の中で、プラットフォームごとに画面のデザインを変えてみたものになります。
少しでも参考になれば幸いです。

## UIの共有（Compose Multiplatform UI）とは？
Compose Multiplatform UI（CMP UI）は、JetBrainsが開発した宣言型フレームワークで、Android、iOS、ウェブ、デスクトップなどの複数のプラットフォームでUIを共有することができます。
### 特徴
- Kotlin MultiplatformとJetpack Composeをベースにしている﻿
- 同じコードベースから複数のプラットフォームに対応したアプリケーションを作成できる﻿
- プラットフォーム間でのコードの再利用性が高く、開発効率が向上する﻿
- ロジックからUIまでをKotlinで記述できるため、開発者の学習コストを低減できる﻿
- 異なるプラットフォーム間でも統一されたUIと動作を実現できる﻿

めちゃくちゃ簡単に言うとKotlinのコードだけで複数のプラットフォームの画面で同じ表示ができるというものです。

### UIの共有のやり方
さっそくUIの共有をしようと思うのですが、今回は[Kotlinマルチプラットフォームウィザード](https://kmp.jetbrains.com/?_gl=1*1s9yq65*_gcl_au*Mjg1NTE0MDMuMTczNjIzMzgyNQ..*FPAU*Mjg1NTE0MDMuMTczNjIzMzgyNQ..*_ga*MjAzODAzNjA3OC4xNjg4NTE5ODUz*_ga_9J976DJZ68*MTc0MzM5MTExMy4xOS4wLjE3NDMzOTExMTMuNjAuMC4w)を使用してファイルを作成したいと思います。
KotlinマルチプラットフォームウィザードはCompose Multiplatformの公式ページのファイル作成方法でも記載されているので、こちらを利用しました。

まず、Kotlinマルチプラットフォームウィザードを開くと以下の方に色々項目が出てきます。
![スクリーンショット 2025-03-31 12.26.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3896310/e88e3498-42ef-4707-937e-e727959f2cdd.png)
iosの項目の中のUIの共有が選択されていることを確認してください。
選択することでkotlinだけでiosとAndroidの表示を一緒にできます。
iosだけじゃなくデスクトップとかもやりたいとかであれば都度チェックしてください。
選択ができたらページの下部にあるダウンロードを押下してファイルをダウンロードします。
そしたらAndroid Studioでファイルを開きます。
開いたらファイル構成が以下画像のようになっていると思います。
![スクリーンショット 2025-03-31 12.56.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3896310/bd6d24cb-7160-43e4-bb39-56b69697384c.png)
この中のcommonMainの配下のKotlin配下にkotlinファイルを追加してコードを作成することで先ほど選択したプラットフォームの画面で同一の画面を表示することができます。

#### 画面作成
表示を確認するために簡単なホーム画面を作成します。
ホーム画面の中にタイトルと２つのボタンを追加します。
```
package org.example.project

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Search
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
import org.jetbrains.compose.ui.tooling.preview.Preview

@Composable
fun HomeScreen(
    navController: NavHostController
) {
    var isDialogOpen by remember { mutableStateOf(false) }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .background(Color(0xFF00B0FF)), // 背景色
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        // タイトル部分
        Text(
            text = "社員検索(仮)",
            style = MaterialTheme.typography.h4.copy(
                fontSize = 28.sp,
                color = Color.White,
                fontWeight = androidx.compose.ui.text.font.FontWeight.Bold
            ),
            modifier = Modifier.padding(bottom = 16.dp)
        )

        // アイコンボタン
        IconButton(
            onClick = {navController.navigate("search")},
            modifier = Modifier.size(64.dp)
        ) {
            Icon(
                imageVector = Icons.Filled.Search,
                contentDescription = "Home Icon",
                modifier = Modifier.size(64.dp),
                tint = Color.White
            )
        }

        Spacer(modifier = Modifier.height(16.dp))

        Button(onClick = {
            isDialogOpen = true // ボタンがクリックされたらダイアログを表示
        }) {
            Text("ダイアログを表示する")
        }

        Spacer(modifier = Modifier.height(16.dp))

        // プラットフォームに応じてボタンを表示
        DesktopOnlyButton(onClick = {
            // 追加のボタンがクリックされた時のアクションを定義
            println("デスクトップ専用ボタンがクリックされました！")
        })

        // CustomDialogを呼び出す
        CustomDialog(isDialogOpen = isDialogOpen, onDismiss = {
            isDialogOpen = false // ダイアログを閉じる為に状態を更新
        })


    }
}
```
このコードを画面表示すると以下のような表示になります。
Androidの表示
![スクリーンショット 2025-03-31 12.49.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3896310/9bd88926-dce9-4aba-89f0-fd59ec9003b4.png)
iosの表示
![スクリーンショット 2025-03-31 12.48.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3896310/3842f231-cd44-4d6c-998b-f71a3e8fb7f2.png)
commonMainのKotlinファイルにコードを記載するだけで、Androidとiosで同様の表示ができました。
こんな感じで基本的なUIやロジックはcommonMainに記載していくことで、大幅な工数の削減ができると思います。
## プラットフォームごとに表示を分ける
UIの共有ができたので、次はプラットフォームごとに表示やデザインの出しわけをします。
共有をするのに使うのはcommonMainとiosMain,desktopMainなどのプラットフォームのフォルダです。
今回はダイアログのカラーと文言を変えたいと思います。
ファイルを開いた時からサンプルとして各〇〇Main配下のPlatform.ktの中に似たようなものがあるのでその中に今回は記載しました。
最初に共通のダイアログのメソッドを作ります。
commonMainのPlatform.ktの中に以下を追加します。
```
@Composable
expect fun CustomDialog(isDialogOpen: Boolean, onDismiss: () -> Unit)
```
import等は必要ですが、これだけで大丈夫です。
共有するためにexpectを追加するようにしてください。
これを追加することによってこのメソッドが親だよ！って宣言できます。
呼び出しもとからは上のコードを呼び出すようにしましょう。
次に共通のコード（親）の子コード（各プラットフォーム）を記載します。
これも今回は元々あったサンプルのPlatform.ktに記載します。（androidMain配下のPlatform.android.ktなど）
Platform.android.kt↓
```
@Composable
actual fun CustomDialog(isDialogOpen: Boolean, onDismiss: () -> Unit) {
    if (isDialogOpen) {
        AlertDialog(
            onDismissRequest = onDismiss,
            title = { Text("共通ダイアログのタイトル") },
            text = { Text("これは共通のダイアログの内容です。") },
            confirmButton = {
                Button(onClick = onDismiss) {
                    Text("OK")
                }
            },
            dismissButton = {
                Button(onClick = onDismiss) {
                    Text("キャンセル")
                }
            }
        )
    }
}
```
Platform.ios.kt↓
```
@Composable
actual fun CustomDialog(isDialogOpen: Boolean, onDismiss: () -> Unit) {
    if (isDialogOpen) {
        AlertDialog(
            onDismissRequest = onDismiss,
            title = {
                Text(
                    text = "ios用ダイアログのタイトル",
                    style = MaterialTheme.typography.h6.copy(color = Color.Black)
                )
            },
            text = {
                Text(
                    text = "これはios専用のダイアログの内容です。",
                    modifier = Modifier.padding(8.dp)
                )
            },
            confirmButton = {
                Button(
                    onClick = onDismiss,
                    colors = ButtonDefaults.buttonColors(backgroundColor = Color.Black)
                ) {
                    Text("OK", color = Color.White)
                }
            },
            dismissButton = {
                Button(
                    onClick = onDismiss,
                    colors = ButtonDefaults.buttonColors(backgroundColor = Color.Black)
                ) {
                    Text("キャンセル", color = Color.White)
                }
            },
            modifier = Modifier
                .size(300.dp, 200.dp)
                .border(2.dp, Color.Gray)
                .background(Color.LightGray)
        )
    }
}
```

こんな感じでレイアウトは変えてませんが、色と文言を変更してます。
このコードで大事なことは、
actualを修飾することと、親のメソッドと同じ名前にすることです。
これで同じ名前の親の子供だと宣言できます。
画面表示はこんな感じになります。
android
![スクリーンショット 2025-03-31 12.50.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3896310/d7707509-10d5-4c83-b37b-65f79b5f175a.png)
ios
![スクリーンショット 2025-03-31 12.47.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3896310/e933b366-ba02-43a2-90b7-a1f0a382018b.png)
色と文言が変わってますね！
親と子のメソッドを同じ名前にしてexpect(親)とactual(子)をつけることで出しわけができました！

ついでにデスクトップ専用のボタンを作りました。
commonMainのPlatform.kt
```
@Composable
expect fun DesktopOnlyButton(onClick: () -> Unit)
```
Platform.ios.ktとPlatform.android.kt
```
@Composable
actual fun DesktopOnlyButton(onClick: () -> Unit) {
    // ボタンを表示しない（何もしない）
}
```
Platform.jvm.kt(desktop)
```
@Composable
actual fun DesktopOnlyButton(onClick: () -> Unit) {
    Button(onClick = onClick) {
        Text("デスクトップ専用ボタン") // ボタンのテキスト
    }
}
```
これで以下のようにデスクトップだけダイアログボタンの下にもう一つボタンを表示できます！
![スクリーンショット 2025-03-31 14.09.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3896310/fa06ec0b-00b9-4c14-81bb-4213ff95252b.png)

## まとめ
ざっと記載しましたが、expectとactualを使用することによってCompose Multiplatform UIでのプラットフォームごとの表示の切り替えができました。
記載したコードはまだ出しわけができるようになる方法なので、もっと綺麗なコードにできると思います。
公式ドキュメント等を見る限りiosのSDKとかもいける？らしいのでもし良いと感じたらやってみてください！