📍

GoogleMapsComposeのMarkerComposableを使ってみる

2024/09/24に公開

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が更新されることを心待ちにしましょう!
https://github.com/googlemaps/android-maps-compose/issues/398

4. まとめ

当時のSDKバージョンではできなかったな、と思っていたことも
すでに実装されていたり、issueで議論されていたりすることも多いので、
久しぶりに導入しているライブラリを見返してみるのも、いいんじゃないかなと思います!

5. 最後に

スペースマーケットでは一緒に働く仲間を募集しています!
カジュアルに話を聞きたいだけという方でも大歓迎ですので、ちょっとでも興味があれば以下からご応募お待ちしております!

▼Webエンジニア
https://herp.careers/v1/spmhr/9zYSnsOQ0UMA

▼バックエンドエンジニア(EM候補)
https://herp.careers/v1/spmhr/f8x6AkIueBSb

スペースマーケット Engineer Blog

Discussion