この記事はSafie Engineers' Blog! Advent Calendar 10日目の記事です。
はじめに
こんにちは。Safieでモバイルアプリの開発をしている@rui_qmaです。
モバイル版Safie Viewerでは、位置情報を持つカメラを地図上で選択することができるマップビューアー機能の実装に取り組んでいます。
AndroidチームではJetpack Composeの導入を積極的に進めていることもあり、本機能についてはMaps SDK for Android を Jetpack Compose で実装できる Maps Compose ライブラリを利用して実装しています。
今回はMaps Composeの導入、マーカーの配置、カスタマイズするまでの流れを4つのStepでまとめてみました。
完成形のイメージは以下です。
新しいライブラリのため資料がまだ少なく手探りで実装した部分も多いので、備忘録も兼ねて新たに導入する方の参考になればと思います。
Step1: 導入
前提
- Android Studioでプロジェクト作成済み
- Google mapのAPIキーの取得済み
参考:Using API Keys
SDKをセットアップ
Maps Composeを導入します。
アップデート頻度が高いため、最新バージョンは以下を参照してください。(2023/12時点はv4.3.0)
Releases
dependencies {
//...
implementation("com.google.maps.android:maps-compose:4.3.0")
}
APIキーをmeta-dataに入れる
APIキーをセキュアに参照できるよう、Googleはsecrets-gradle-plugin
を導入することを推奨しています。(参考)
ですが、今回は簡略化のためバージョン管理対象外にしたgradle.propertiesからAPIキーを入れる形にします。
// バージョン管理の対象外にすること
MAPS_API_KEY=YOUR_API_KEY
android {
...
defaultConfig {
...
// 追記
manifestPlaceholders["MAPS_API_KEY"] = project.property("MAPS_API_KEY").toString()
<application
...
// 追記
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="${MAPS_API_KEY}" />
各ファイル追記後にSync、buildを実行してエラーがなければ準備完了です。
【参考】
Step2: マップを表示
新規プロジェクトの場合は、以下のようにMainActivityを書き換えることでマップを表示できます。
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,
)
}
このように実装すると、東京都庁を中心とした関東地方が表示されます。
Step3: マーカーの配置
関東地方の都道府県庁にマーカーを立てることにします。
座標は都道府県庁所在地 緯度経度データを参考にして入力していきます。
マーカー情報を以下のようにenumで持つことにします。
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を配置していきます。
GoogleMap(
modifier = Modifier.fillMaxSize(),
cameraPositionState = cameraPositionState,
) {
Prefecture.values().forEach {
Marker(
state = MarkerState(LatLng(it.latitude, it.longitude)),
title = it.name,
)
}
}
Step4: マーカーのカスタマイズ
現状はマーカーの見た目がデフォルトで、押下すると都道府県名が出る挙動になっています。
仮に、マーカーに対して以下の要望が出たとします。
- マーカーとして都道府県旗を表示してほしい
- マーカー上部に都道府県名を常時表示してほしい
マーカーの見た目はMarkerのicon指定でカスタマイズ可能ですが、都道府県名(String)の常時表示はこの方法だと難しいですね。
そこで、マーカーのデザインをComposableでカスタマイズできるMarkerComposableを利用します。
まずは先ほど作成したPrefectureで都道府県旗を持つよう拡張します。
// 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()は自作しているので、参考コードを記載します)
...
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,
)
}
}
}
}
}
@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
を使用することで自由度の高いマーカーを用意することが出来ました!
オマケ: クラスタリング
マーカーをカスタマイズすることによって、情報量を高めることが出来ました。
しかし、地区レベルで細かくマーカーを配置した場合や、日本全土が映るレベルまでマップを縮小した場合、複数のマーカーが重なってしまいユーザーが理解しにくい見た目になるケースがあります。
こういった問題を解消するためGoogleMapsでは密集したマーカーを集約する、クラスタリング機能があります。
Maps ComposeではClusteringを提供しているため、このアプリケーションにも適用していきましょう。
SDKをセットアップ
dependencies {
//...
implementation("com.google.maps.android:maps-compose:4.3.0")
implementation("com.google.maps.android:maps-compose-utils:4.3.0") //追加
}
Clusteringを実装
クラスタリングを実装するため、ClusterItemを継承したクラスを用意します。
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実装の流れは大まかに以下です。
- ClusterItemを継承したクラスのリストを作る
- ClusterManager変数を作り、タップ時の挙動を定義する
- ClusterRenderer変数を作り、レイアウトを定義する
- 2.と3.を
clusterManager.renderer = renderer
と紐付ける - Clustering()を呼び出し、引数に1.と2.を入れる
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,
)
}
すると、マップのズームに合わせて動的にクラスタリングするようになりました!
まとめ
Google Maps Platformを参照していただければ分かる通り、もちろんxmlによる従来のレイアウトでも同等な機能を実装することが可能です。
Maps Composeは現在進行形で活発に開発されているライブラリで、まだまだ資料が少なく私自身も手探りで実装している事が多い状況ですが
調べていく中でJetpack Composeでマップ上のUIを直感的に作り上げる仕組みが整いつつあると感じています。
私が感じるJetpackComposeのメリットの1つとして、ロジックもUIもKotlinベースで管理できる点があるのですが
マップ上に配置するUI要素を作り上げる際に、コードが煩雑にならず直感的に実装できたため、特にこのメリットを実感しました!
まだ触れたことのない方もこの機会にJetpack Compose、Maps Composeに触れてみてはいかがでしょうか。
執筆にあたって実装したコードは以下になります。是非参考にしてください
https://github.com/ruiqma/MapsComposeSample