Jetpack Compose × Google Maps SDKで地図上にカスタマイズしたマーカーを配置 & クラスタリング

:::note info
この記事は[Safie Engineers' Blog! Advent Calendar](https://adventar.org/calendars/8881) 10日目の記事です。
:::
# はじめに
こんにちは。Safieでモバイルアプリの開発をしている@rui_qmaです。

モバイル版Safie Viewerでは、位置情報を持つカメラを地図上で選択することができるマップビューアー機能の実装に取り組んでいます。
AndroidチームではJetpack Composeの導入を積極的に進めていることもあり、本機能についてはMaps SDK for Android を Jetpack Compose で実装できる [Maps Compose](https://developers.google.com/maps/documentation/android-sdk/maps-compose) ライブラリを利用して実装しています。

今回はMaps Composeの導入、マーカーの配置、カスタマイズするまでの流れを4つのStepでまとめてみました。
完成形のイメージは以下です。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/187082/ebb810c0-869c-e331-60c2-293e29d936fa.png" width=30%> <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/187082/1ba578fd-c3f1-99de-db68-373993003e85.png" width=30%> <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/187082/38d00525-0f66-7363-d36d-08f805e83e4a.png" width=30%>

新しいライブラリのため資料がまだ少なく手探りで実装した部分も多いので、備忘録も兼ねて新たに導入する方の参考になればと思います。

# Step1: 導入
### 前提
- Android Studioでプロジェクト作成済み
- Google mapのAPIキーの取得済み
参考：[Using API Keys](https://developers.google.com/maps/documentation/android-sdk/get-api-key)

### SDKをセットアップ
Maps Composeを導入します。
アップデート頻度が高いため、最新バージョンは以下を参照してください。（2023/12時点はv4.3.0)
[Releases](https://github.com/googlemaps/android-maps-compose/releases)
```kotlin:project/app/build.gradle.kts
dependencies {
//...
    implementation("com.google.maps.android:maps-compose:4.3.0")
}
```

### APIキーをmeta-dataに入れる
APIキーをセキュアに参照できるよう、Googleは`secrets-gradle-plugin`を導入することを推奨しています。([参考](https://developers.google.com/maps/documentation/android-sdk/config?hl=ja#step_3_add_your_api_key_to_the_project))

ですが、今回は簡略化のためバージョン管理対象外にしたgradle.propertiesからAPIキーを入れる形にします。
```kotlin:gradle.properties
// バージョン管理の対象外にすること
MAPS_API_KEY=YOUR_API_KEY
```
```kotlin:app/build.gradle
android {
    ...
    defaultConfig {
        ...
        // 追記
        manifestPlaceholders["MAPS_API_KEY"] = project.property("MAPS_API_KEY").toString()
```
```kotlin:AndroidManifest.xml
<application
...
        // 追記
        <meta-data
            android:name="com.google.android.geo.API_KEY"
            android:value="${MAPS_API_KEY}" />
```
各ファイル追記後にSync、buildを実行してエラーがなければ準備完了です。

【参考】
- [Set up an Android Studio project](https://developers.google.com/maps/documentation/android-sdk/config)
- [Maps Compose Library - Installation](https://developers.google.com/maps/documentation/android-sdk/maps-compose#installation)

# Step2: マップを表示
新規プロジェクトの場合は、以下のようにMainActivityを書き換えることでマップを表示できます。
```kotlin:MainActivity.kt
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MapContent()
        }
    }
}

@Composable
fun MapContent() {
    val defaultPosition = LatLng(35.689501, 139.691722) // 東京都庁
    val defaultZoom = 8f
    val cameraPositionState = rememberCameraPositionState {
        position = CameraPosition.fromLatLngZoom(defaultPosition, defaultZoom)
    }
    GoogleMap(
        modifier = Modifier.fillMaxSize(),
        cameraPositionState = cameraPositionState,
    )
}
```
このように実装すると、東京都庁を中心とした関東地方が表示されます。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/187082/a8fa9d1c-e09b-b29f-1fea-01c77d8f0525.png" width=30%>

# Step3: マーカーの配置
関東地方の都道府県庁にマーカーを立てることにします。
座標は[都道府県庁所在地 緯度経度データ](https://www.benricho.org/chimei/latlng_data.html)を参考にして入力していきます。

マーカー情報を以下のようにenumで持つことにします。
```kotlin:Prefecture.kt
enum class Prefecture(val latitude: Double, val longitude: Double) {
    Tokyo(35.689501, 139.691722),
    Kanagawa(35.447734, 139.642537),
    Saitama(35.857033, 139.649012),
    Chiba(35.604560, 140.123154),
    Ibaraki(36.341737, 140.446824),
    Tochigi(36.565912, 139.883592),
    Gunma(36.390688, 139.060453),
}
```
このenumをforEachで回してMarkerを配置していきます。
```kotlin:MainActivity.kt
    GoogleMap(
        modifier = Modifier.fillMaxSize(),
        cameraPositionState = cameraPositionState,
    ) {
        Prefecture.values().forEach {
            Marker(
                state = MarkerState(LatLng(it.latitude, it.longitude)),
                title = it.name,
            )
        }
    }
```
すると、それぞれの県にマーカーが配置されます。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/187082/dcd71e36-9437-fd0b-0695-e48baf87f1ab.png" width=30%>

# Step4: マーカーのカスタマイズ
現状はマーカーの見た目がデフォルトで、押下すると都道府県名が出る挙動になっています。

仮に、マーカーに対して以下の要望が出たとします。
- マーカーとして[都道府県旗](https://ja.wikipedia.org/wiki/%E9%83%BD%E9%81%93%E5%BA%9C%E7%9C%8C%E6%97%97)を表示してほしい
- マーカー上部に都道府県名を常時表示してほしい

マーカーの見た目は[Marker](https://googlemaps.github.io/android-maps-compose/maps-compose/com.google.maps.android.compose/-marker.html)のicon指定でカスタマイズ可能ですが、都道府県名(String)の常時表示はこの方法だと難しいですね。

そこで、マーカーのデザインをComposableでカスタマイズできる[MarkerComposable](https://googlemaps.github.io/android-maps-compose/maps-compose/com.google.maps.android.compose/-marker-composable.html)を利用します。

まずは先ほど作成したPrefectureで都道府県旗を持つよう拡張します。
```kotlin:Prefecture.kt
// drawableに都道府県旗のリソースを入れておく
enum class Prefecture(
    val latitude: Double,
    val longitude: Double,
    @DrawableRes val flag: Int
) {
    Tokyo(35.689501, 139.691722, R.drawable.flag_of_tokyo_metropolis),
    Kanagawa(35.447734, 139.642537, R.drawable.flag_of_kanagawa_prefecture),
...
}
```
`Marker`を`MarkerComposable`に変更して、contentに表示したい内容を実装します。
(吹き出し部分のCallOut()は自作しているので、参考コードを記載します）
```kotlin:MainActivity.kt
...
    GoogleMap(
        modifier = Modifier.fillMaxSize(),
        cameraPositionState = cameraPositionState,
    ) {
        Prefecture.values().forEach {
            MarkerComposable(
                state = MarkerState(LatLng(it.latitude, it.longitude)),
            ) {
                Column(
                    horizontalAlignment = Alignment.CenterHorizontally,
                ) {
                    CallOut(it.name)
                    Spacer(modifier = Modifier.height(4.dp))
                    Image(
                        modifier = Modifier.size(45.dp, 30.dp),
                        painter = painterResource(it.flag),
                        contentDescription = null,
                    )
                }
            }
        }
    }
}
```
```kotlin:CallOut.kt
@Composable
fun CallOut(name: String) {
    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy((-6).dp),
    ) {
        Text(
            text = name,
            fontSize = 12.sp,
            modifier = Modifier
                .drawBehind {
                    drawRoundRect(color = Color.White, cornerRadius = CornerRadius(12f))
                }
                .padding(12.dp, 6.dp)
        )
        Box(
            modifier = Modifier
                .size(12.dp)
                .rotate(45f)
                .clip(RectangleShape)
                .background(Color.White)
        )
    }
}
```
buildすると以下のような表示になります。
`MarkerComposable`を使用することで自由度の高いマーカーを用意することが出来ました！
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/187082/ebb810c0-869c-e331-60c2-293e29d936fa.png" width=30%>

# オマケ: クラスタリング
マーカーをカスタマイズすることによって、情報量を高めることが出来ました。
しかし、地区レベルで細かくマーカーを配置した場合や、日本全土が映るレベルまでマップを縮小した場合、複数のマーカーが重なってしまいユーザーが理解しにくい見た目になるケースがあります。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/187082/e81ec2cd-99c3-b1db-6dd3-24ece6a9169f.png" width=30%>
こういった問題を解消するためGoogleMapsでは密集したマーカーを集約する、クラスタリング機能があります。
Maps Composeでは[Clustering](https://googlemaps.github.io/android-maps-compose/maps-compose-utils/com.google.maps.android.compose.clustering/-clustering.html)を提供しているため、このアプリケーションにも適用していきましょう。

### SDKをセットアップ
```kotlin:project/app/build.gradle.kts
dependencies {
//...
    implementation("com.google.maps.android:maps-compose:4.3.0")
    implementation("com.google.maps.android:maps-compose-utils:4.3.0") //追加
}
```

### Clusteringを実装
クラスタリングを実装するため、ClusterItemを継承したクラスを用意します。
```kotlin:PrefectureClusterItem.kt
data class PrefectureClusterItem(
    val prefecture: Prefecture,
) : ClusterItem {
    override fun getPosition(): LatLng = LatLng(prefecture.latitude, prefecture.longitude)

    override fun getTitle(): String = prefecture.name

    override fun getSnippet(): String = ""

    override fun getZIndex(): Float = 0f
}
```
Clustering実装の流れは大まかに以下です。
1. ClusterItemを継承したクラスのリストを作る
1. ClusterManager変数を作り、タップ時の挙動を定義する
1. ClusterRenderer変数を作り、レイアウトを定義する
1. 2.と3.を`clusterManager.renderer = renderer`と紐付ける
1. Clustering()を呼び出し、引数に1.と2.を入れる
```kotlin:MainActivity.kt
    GoogleMap(
        modifier = Modifier.fillMaxSize(),
        cameraPositionState = cameraPositionState,
    ) {
        val items = remember { mutableListOf<PrefectureClusterItem>() }
        Prefecture.values().forEach {
            items.add(PrefectureClusterItem(it))
        }

        val clusterManager = rememberClusterManager<PrefectureClusterItem>() ?: return@GoogleMap
        clusterManager.setOnClusterClickListener {
            // クラスタータップ時に何かする場合はここに実装
            true
        }
        clusterManager.setOnClusterItemClickListener {
            // マーカータップ時に何かする場合はここに実装
            true
        }

        val renderer = rememberClusterRenderer(
            // クラスタリング時の見た目を指定可能。null指定でデフォルトとなる
            clusterContent = null,
            // MarkerComposableのContentをそのまま実装する
            clusterItemContent = { clusterItem ->
                Column(
                    horizontalAlignment = Alignment.CenterHorizontally,
                ) {
                    CallOut(clusterItem.title)
                    Spacer(modifier = Modifier.height(4.dp))
                    Image(
                        modifier = Modifier.size(45.dp, 30.dp),
                        painter = painterResource(clusterItem.prefecture.flag),
                        contentDescription = null,
                    )
                }
            },
            clusterManager = clusterManager,
        )
        if (clusterManager.renderer != renderer) {
            clusterManager.renderer = renderer ?: return@GoogleMap
        }

        Clustering(
            items = items,
            clusterManager = clusterManager,
        )
    }
```
すると、マップのズームに合わせて動的にクラスタリングするようになりました！
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/187082/0b1e81d1-2f36-a576-6af8-96c2756b9d80.png" width=30%> <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/187082/1ba578fd-c3f1-99de-db68-373993003e85.png" width=30%> <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/187082/38d00525-0f66-7363-d36d-08f805e83e4a.png" width=30%>

# まとめ
[Google Maps Platform](https://developers.google.com/maps/documentation/android-sdk)を参照していただければ分かる通り、もちろんxmlによる従来のレイアウトでも同等な機能を実装することが可能です。
[Maps Compose](https://github.com/googlemaps/android-maps-compose)は現在進行形で活発に開発されているライブラリで、まだまだ資料が少なく私自身も手探りで実装している事が多い状況ですが
調べていく中でJetpack Composeでマップ上のUIを直感的に作り上げる仕組みが整いつつあると感じています。

私が感じるJetpackComposeのメリットの1つとして、ロジックもUIもKotlinベースで管理できる点があるのですが
マップ上に配置するUI要素を作り上げる際に、コードが煩雑にならず直感的に実装できたため、特にこのメリットを実感しました！

まだ触れたことのない方もこの機会にJetpack Compose、Maps Composeに触れてみてはいかがでしょうか。

執筆にあたって実装したコードは以下になります。是非参考にしてください:+1:
https://github.com/ruiqma/MapsComposeSample