GoogleMapsComposeのMarkerComposableを使ってみる
0. はじめに
スペースマーケットのAndroidニキことseoです。
JetpackComposeが浸透して時間が経ちます。
Compose創世記からXMLからの置き換えを進めている方々は、
当時のComposeの技術ではこれは実現できなさそう・めんどくさそうだな、
と後回しにした箇所があったりしないでしょうか?
今回は、「あのときあきらめた、あれ、あるよ」という話です。
地図を扱うアプリの場合、GoogleMapsComposeは必ず採用しているかと思います。
ちょうど1年前の23年8月ごろにv2.14.0よりMarkerComposableが追加されました!
弊社スペースマーケットアプリでも置き換えをしましたので、
どれくらい便利になったのかお話ししたいと思います。
1. 以前のMarkerの作成方法(Drawableを使用)
v2.14.0以前は、地図上にマーカーを表示する際にMarkerOptionsを使って、Drawableリソースを設定する必要がありました。
次のようにGoogleMapにアクセスしてマーカーを追加していました。
val googleMap = remember { /* GoogleMapインスタンスの取得 */ }
val markerOptions = MarkerOptions()
.position(LatLng(35.681236, 139.767125))
.title("Tokyo Station")
.icon(BitmapDescriptorFactory.fromResource(R.drawable.marker_icon))
googleMap.addMarker(markerOptions)
この方法では、GoogleMapオブジェクトに直接アクセスし、リソースからビットマップアイコンを作成してマーカーに設定していました。
スペースマーケットアプリでは、いくらから借りれるかわかりやすいように、「¥〇〇~」という金額を記載したMarkerを地図上にプロットしています。
Drawable上にお気に入りを表すハートアイコンと金額の絶妙な間合いを保ちながら、選択されたMarkerは背景を黒にするためにこのような実装をしていました...
val latitude = it.room.space?.latitude ?: return@map
val longitude = it.room.space?.longitude ?: return@map
val infoWindowBinding = LayoutSearchRoomInfoWindowBinding.inflate(LayoutInflater.from(context))
val infoWindowLayout = infoWindowBinding.backgroundLayout
val textView = infoWindowBinding.textView
val favoriteIcon = infoWindowBinding.favoriteIcon
var markerZ = 1
when (it.isSelectedOnMap) {
true -> {
infoWindowLayout.setBackgroundResource(R.drawable.pin_active)
textView.setTextColor(context.getColor(R.color.white))
markerZ++
favoriteIcon.apply {
setImageDrawable(resources.getDrawable(R.drawable.ic_heart_white))
}
}
false -> {
infoWindowLayout.setBackgroundResource(R.drawable.pin_default)
textView.setTextColor(context.getColor(R.color.black))
favoriteIcon.apply {
setImageDrawable(resources.getDrawable(R.drawable.ic_heart))
}
}
}
textView.apply {
text = it.room.minPriceTextForMap()
typeface = Typeface.DEFAULT_BOLD
measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED)
}
infoWindowLayout.measure(
View.MeasureSpec.UNSPECIFIED,
View.MeasureSpec.UNSPECIFIED
)
val heightPadding = ViewHelper.convertDpToPx(
context,
context.resources.getInteger(R.integer.search_result_marker_height_padding)
)
val widthPaddingBig = ViewHelper.convertDpToPx(
context,
context.resources.getInteger(R.integer.search_result_marker_width_padding_big)
)
val widthPadding = ViewHelper.convertDpToPx(
context,
context.resources.getInteger(R.integer.search_result_marker_width_padding)
)
val textViewAdjustHeight = ViewHelper.convertDpToPx(
context,
context.resources.getInteger(R.integer.search_result_marker_text_view_height_adjust)
)
when (it.isFavorite) {
true -> {
infoWindowLayout.layout(
0,
0,
textView.measuredWidth + favoriteIcon.measuredWidth + widthPaddingBig,
textView.measuredHeight + heightPadding
)
textView.layout(
infoWindowLayout.left + (infoWindowLayout.width - textView.measuredWidth) / 4,
infoWindowLayout.top + (infoWindowLayout.height - textView.measuredHeight) / 2 + textViewAdjustHeight,
infoWindowLayout.right,
infoWindowLayout.bottom
)
favoriteIcon.layout(
(infoWindowLayout.left + (infoWindowLayout.width + textView.measuredWidth) / 2.2).toInt(),
infoWindowLayout.top,
infoWindowLayout.right - favoriteIcon.measuredWidth,
infoWindowLayout.bottom
)
favoriteIcon.isVisible = true
}
false -> {
infoWindowLayout.layout(
0,
0,
textView.measuredWidth + widthPadding,
textView.measuredHeight + heightPadding
)
textView.layout(
infoWindowLayout.left + (infoWindowLayout.width - textView.measuredWidth) / 2,
infoWindowLayout.top + (infoWindowLayout.height - textView.measuredHeight) / 2 + textViewAdjustHeight,
infoWindowLayout.right,
infoWindowLayout.bottom
)
favoriteIcon.isGone = true
}
}
何をしているのかはここではあえて言わないです...
このMarkerに会場タイプを表すアイコンをさらに追加したいとのことで、
「これはいよいよ限界だな」と思っていたところ、MarkerComposableがリリースされていました!
2. MarkerComposableで設定できるプロパティ
MarkerComposableを使用することで、マーカーの表示や動作をComposeの中でカスタマイズできます。
以下は、MarkerComposableで設定できる主要なプロパティの一部です。
state
MarkerStateを使ってマーカーの位置や状態を管理します。
例えば、動的にマーカーの位置を変更したり、選択状態を保持するために使用されます。
val markerState = rememberMarkerState(position = LatLng(35.681236, 139.767125))
MarkerComposable(
state = markerState,
onClick = {
// マーカーがクリックされたときの処理
markerState.position = LatLng(35.6895, 139.6917) // 新しい位置に移動
false
}
)
onClick
マーカーがクリックされたときに実行される処理を定義します。戻り値にtrueを返すと、クリックイベントが消費され、falseを返すと他のリスナーにイベントが伝播します。
MarkerComposable(
state = rememberMarkerState(position = LatLng(35.681236, 139.767125)),
onClick = {
// マーカーがクリックされたときの処理
Toast.makeText(context, "Marker clicked", Toast.LENGTH_SHORT).show()
true
}
)
zIndex
マーカーの重なり順序を指定するために使用されます。zIndexの値が大きいほど、上に表示されます。
MarkerComposable(
state = rememberMarkerState(position = LatLng(35.681236, 139.767125)),
zIndex = 1f // zIndexを設定
)
カスタムUI(contentスロット)
contentスロットにComposable関数を指定することができるようになり、
これによって、Maker部分をComposeで作成できるようになりました。
MarkerComposable(
state = rememberMarkerState(position = LatLng(35.681236, 139.767125))
) {
Card(
shape = RoundedCornerShape(10.dp),
backgroundColor = Color.White,
elevation = 4.dp
) {
Column(
modifier = Modifier.padding(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Custom Marker")
Image(
painter = painterResource(R.drawable.ic_custom_icon),
contentDescription = null,
modifier = Modifier.size(24.dp)
)
}
}
}
3. DrawableからComposeに置き換え
では、実際に先ほどのDrawableに色々手を加えていたコードを置き換えたらこうなります。
MarkerComposable(
keys = arrayOf(it),
state = rememberMarkerState(
position = it.room.space?.latLng() ?: return@forEach,
),
onClick = { _ ->
it.room.id?.let { id -> onSelected(id) }
false
},
zIndex = it.zIndex,
) {
val containerColor = if (it.isSelectedOnMap) Color.Black else Color.White
val textColor = if (it.isSelectedOnMap) Color.White else Color.Black
Card(
shape = RoundedCornerShape(60.dp),
colors = CardColors(
containerColor = containerColor,
contentColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
disabledContentColor = Color.Transparent,
),
border = BorderStroke(
width = 0.5.dp,
color = if (it.isSelectedOnMap) colorResource(id = R.color.black) else colorResource(id = R.color.gray20)
)
) {
Row(
modifier = Modifier
.background(containerColor)
.padding(
vertical = 5.dp,
horizontal = 6.dp,
),
verticalAlignment = Alignment.CenterVertically,
) {
Image(
painter = painterResource(
if (it.isSelectedOnMap) it.room.space.onIcon() else it.room.space.offIcon()
),
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(dimensionResource(id = R.dimen.spacing_xxsmall)))
Row {
Text(
text = stringResource(id = R.string.yen_mark_hankaku),
fontSize = dimensionResource(id = R.dimen.text_xsmall).value.sp,
color = textColor,
modifier = Modifier.alignByBaseline()
)
Text(
text = it.room.minPriceWithoutYen() ?: return@Card,
fontSize = dimensionResource(id = R.dimen.text).value.sp,
fontWeight = FontWeight.W700,
color = textColor,
modifier = Modifier.alignByBaseline()
)
}
if (it.isFavorite) {
Spacer(modifier = Modifier.width(dimensionResource(id = R.dimen.spacing_6)))
Image(
painter = if (it.isSelectedOnMap) painterResource(id = R.drawable.ic_heart_white) else painterResource(id = R.drawable.ic_heart),
contentDescription = null,
modifier = Modifier.size(13.dp)
)
}
}
}
}
ComposeでViewを構成できるため、paddingの調整などもやりやすく、
先ほどよりもそれぞれのComposeが何をしているかがわかりやすくなったと思います。
1つ注意点として、MapとMarkerを3次元に表現したいため、
Markerにelevationを付けたかったので、CardのComposeでMarkerを作成しました。
しかし、GoogleMapsComposeの不具合なのか、previewではshadowが付いているが、
実機ではshadowが表示されていませんでした...
こちらのissueが更新されることを心待ちにしましょう!
4. まとめ
当時のSDKバージョンではできなかったな、と思っていたことも
すでに実装されていたり、issueで議論されていたりすることも多いので、
久しぶりに導入しているライブラリを見返してみるのも、いいんじゃないかなと思います!
5. 最後に
スペースマーケットでは一緒に働く仲間を募集しています!
カジュアルに話を聞きたいだけという方でも大歓迎ですので、ちょっとでも興味があれば以下からご応募お待ちしております!
▼Webエンジニア
▼バックエンドエンジニア(EM候補)
スペースを簡単に貸し借りできるサービス「スペースマーケット」のエンジニアによる公式ブログです。 弊社採用技術スタックはこちら -> whatweuse.dev/company/spacemarket
Discussion