Android アプリに地図を追加する(Compose で Kotlin を使用)

1. 始める前に

この Codelab では、さまざまな種類のマーカーを使用して米国コロラド州の山々の地図を表示するアプリを作成することで、Maps SDK for Android をアプリに統合する方法と、その主要な機能を使う方法について説明します。また、地図上に他の図形を描画する方法も学習します。

Codelab を完了すると、アプリは次のようになります。

前提条件

演習内容

  • Maps SDK for Android の Maps Compose ライブラリを有効にして使用し、Android アプリに GoogleMap を追加する
  • マーカーを追加、カスタマイズする
  • 地図上にポリゴンを描画する
  • カメラの視点をプログラムで制御する

必要なもの

2. セットアップする

以下の有効化の手順では、Maps SDK for Android を有効にする必要があります。

Google Maps Platform を設定する

課金を有効にした Google Cloud Platform アカウントとプロジェクトをまだ作成していない場合は、Google Maps Platform スタートガイドに沿って請求先アカウントとプロジェクトを作成してください。

  1. Cloud Console で、プロジェクトのプルダウン メニューをクリックし、この Codelab に使用するプロジェクトを選択します。

  1. Google Cloud Marketplace で、この Codelab に必要な Google Maps Platform API と SDK を有効にします。詳しい手順については、こちらの動画またはドキュメントをご覧ください。
  2. Cloud Console の [認証情報] ページで API キーを生成します。詳しい手順については、こちらの動画またはドキュメントをご覧ください。Google Maps Platform へのすべてのリクエストで API キーが必要になります。

3. クイック スタート

できるだけ早く演習を開始できるように、この Codelab で使用できるスターター コードが用意されています。すぐに次のステップに進んでも問題ありませんが、ご自身で構築するためのすべての手順を確認したい場合は、最後までお読みください。

  1. git がインストールされている場合は、リポジトリのクローンを作成します。
git clone https://github.com/googlemaps-samples/codelab-maps-platform-101-compose.git

あるいは、以下のボタンをクリックしてソースコードをダウンロードすることもできます。

  1. コードを入手したら、Android Studio の starter ディレクトリにあるプロジェクトを開いてみましょう。

4. API キーをプロジェクトに追加する

このセクションでは、アプリで安全に参照されるように API キーを保存する方法を説明します。API キーは、バージョン管理システムにはチェックインせず、プロジェクトのルート ディレクトリのローカルコピーに配置される secrets.properties ファイルに保存することをおすすめします。secrets.properties ファイルについて詳しくは、Gradle プロパティ ファイルをご覧ください。

このタスクを効率化するには、Android 用 Secrets Gradle プラグインの使用をおすすめします。

Android 用 Secrets Gradle プラグインを Google マップ プロジェクトにインストールする手順は以下のとおりです。

  1. Android Studio で最上位レベルの build.gradle.kts ファイルを開き、buildscript の配下にある dependencies 要素に次のコードを追加します。
    buildscript {
        dependencies {
            classpath("com.google.android.libraries.mapsplatform.secrets-gradle-plugin:secrets-gradle-plugin:2.0.1")
        }
    }
    
  2. モジュール レベルの build.gradle.kts ファイルを開き、次のコードを plugins 要素に追加します。
    plugins {
        // ...
        id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin")
    }
    
  3. モジュール レベルの build.gradle.kts ファイルで、targetSdkcompileSdk を 34 以上に設定します。
  4. ファイルを保存して、プロジェクトを Gradle と同期します
  5. 最上位レベルのディレクトリで secrets.properties ファイルを開き、次のコードを追加します。YOUR_API_KEY は実際の API キーに置き換えてください。secrets.properties はバージョン管理システムにチェックインされないため、このファイルにキーを保存します。
    MAPS_API_KEY=YOUR_API_KEY
    
  6. ファイルを保存します。
  7. 最上位レベルのディレクトリ(secrets.properties ファイルと同じフォルダ)に local.defaults.properties ファイルを作成し、次のコードを追加します。
        MAPS_API_KEY=DEFAULT_API_KEY
    
    このファイルの目的は、secrets.properties ファイルがない場合に API キーのバックアップ場所を提供し、ビルドが失敗しないようにすることです。この状況は、バージョン管理システムからアプリのクローンを作成し、API キーを提供するために secrets.properties ファイルをまだローカルに作成していない場合に発生します。
  8. ファイルを保存します。
  9. AndroidManifest.xml ファイルで com.google.android.geo.API_KEY に移動し、android:value 属性を更新します。<meta-data> タグがない場合は、<application> タグの子として作成します。
        <meta-data
            android:name="com.google.android.geo.API_KEY"
            android:value="${MAPS_API_KEY}" />
    
  10. Android Studio でモジュール レベルの build.gradle.kts ファイルを開き、secrets プロパティを編集します。secrets プロパティがない場合は追加します。プラグインのプロパティを編集して propertiesFileNamesecrets.properties に、defaultPropertiesFileNamelocal.defaults.properties に設定し、他のプロパティも設定します。
    secrets {
        // Optionally specify a different file name containing your secrets.
        // The plugin defaults to "local.properties"
        propertiesFileName = "secrets.properties"
    
        // A properties file containing default secret values. This file can be
        // checked in version control.
        defaultPropertiesFileName = "local.defaults.properties"
    }
    

5. Google マップを追加する

このセクションでは、Google マップを追加して、アプリの起動時に読み込まれるようにします。

Maps Compose の依存関係を追加する

これで、アプリ内で API キーにアクセスできるようになりました。次に、Maps SDK for Android の依存関係をアプリの build.gradle.kts ファイルに追加します。Jetpack Compose でビルドするには、Maps SDK for Android の要素をコンポーズ可能な関数とデータ型として提供する Maps Compose ライブラリを使用します。

build.gradle.kts

アプリレベルの build.gradle.kts ファイルで、Compose 以外の Maps SDK for Android の依存関係を置き換えます。

dependencies {
    // ...

    // Google Maps SDK -- these are here for the data model.  Remove these dependencies and replace
    // with the compose versions.
    implementation("com.google.android.gms:play-services-maps:18.2.0")
    // KTX for the Maps SDK for Android library
    implementation("com.google.maps.android:maps-ktx:5.0.0")
    // KTX for the Maps SDK for Android Utility Library
    implementation("com.google.maps.android:maps-utils-ktx:5.0.0")
}

コンポーザブルの対応する関数は次のとおりです。

dependencies {
    // ...

    // Google Maps Compose library
    val mapsComposeVersion = "4.4.1"
    implementation("com.google.maps.android:maps-compose:$mapsComposeVersion")
    // Google Maps Compose utility library
    implementation("com.google.maps.android:maps-compose-utils:$mapsComposeVersion")
    // Google Maps Compose widgets library
    implementation("com.google.maps.android:maps-compose-widgets:$mapsComposeVersion")
}

Google マップのコンポーザブルを追加する

MountainMap.kt で、MapMountain コンポーザブル内にネストされた Box コンポーザブル内に GoogleMap コンポーザブルを追加します。

import com.google.maps.android.compose.GoogleMap
import com.google.maps.android.compose.GoogleMapComposable
// ...

@Composable
fun MountainMap(
    paddingValues: PaddingValues,
    viewState: MountainsScreenViewState.MountainList,
    eventFlow: Flow<MountainsScreenEvent>,
    selectedMarkerType: MarkerType,
) {
    var isMapLoaded by remember { mutableStateOf(false) }

    Box(
        modifier = Modifier
            .fillMaxSize()
            .padding(paddingValues)
    ) {
        // Add GoogleMap here
        GoogleMap(
            modifier = Modifier.fillMaxSize(),
            onMapLoaded = { isMapLoaded = true }
        )

        // ...
    }
}

アプリをビルドして実行します。悪名高い Null 島(緯度 0 度、経度 0 度とも呼ばれます)を中心とした地図が表示されます。後で、地図を目的の場所とズームレベルに配置する方法を学習しますが、まずは最初の成功を祝いましょう。

6. Cloud ベースのマップのスタイル設定

Cloud ベースのマップのスタイル設定によって地図のスタイルをカスタマイズすることも可能です。

マップ ID を作成する

地図スタイルを関連付けたマップ ID の作成が済んでいない場合は、マップ ID のガイドを参照して、以下を行いましょう。

  1. マップ ID を作成します。
  2. マップ ID を地図スタイルに関連付けます。

マップ ID をアプリに追加する

作成したマップ ID を使用するには、GoogleMap コンポーザブルをインスタンス化するときに、コンストラクタの googleMapOptionsFactory パラメータに割り当てられる GoogleMapOptions オブジェクトを作成する際にマップ ID を使用します。

GoogleMap(
    // ...
    googleMapOptionsFactory = {
        GoogleMapOptions().mapId("MyMapId")
    }
)

以上が完了したら、アプリを実行してみましょう。指定したスタイルで地図が表示されるはずです。

7. マーカーデータを読み込む

このアプリの主なタスクは、ローカル ストレージから山のコレクションを読み込み、それらの山を GoogleMap に表示することです。このステップでは、山のデータを読み込んで UI に表示するために用意されているインフラストラクチャについて説明します。

Mountain データクラスは、各山に関するすべてのデータを保持します。

data class Mountain(
    val id: Int,
    val name: String,
    val location: LatLng,
    val elevation: Meters,
)

山は後で標高に基づいて分割されます。標高が 14,000 フィート以上の山は、フォーティナーと呼ばれます。スターター コードには、このチェックを行う拡張関数が含まれています。

/**
 * Extension function to determine whether a mountain is a "14er", i.e., has an elevation greater
 * than 14,000 feet (~4267 meters).
 */
fun Mountain.is14er() = elevation >= 14_000.feet

MountainsScreenViewState

MountainsScreenViewState クラスには、ビューのレンダリングに必要なすべてのデータが保持されます。山のリストの読み込みが完了したかどうかに応じて、Loading 状態または MountainList 状態になります。

/**
 * Sealed class representing the state of the mountain map view.
 */
sealed class MountainsScreenViewState {
  data object Loading : MountainsScreenViewState()
  data class MountainList(
    // List of the mountains to display
    val mountains: List<Mountain>,

    // Bounding box that contains all of the mountains
    val boundingBox: LatLngBounds,

    // Switch indicating whether all the mountains or just the 14ers
    val showingAllPeaks: Boolean = false,
  ) : MountainsScreenViewState()
}

提供されるクラス: MountainsRepositoryMountainsViewModel

スターター プロジェクトでは、MountainsRepository クラスが提供されています。このクラスは、GPS Exchange Format または GPX ファイル top_peaks.gpx に保存されている山岳地のリストを読み取ります。mountainsRepository.loadMountains() を呼び出すと、StateFlow<List<Mountain>> が返されます。

MountainsRepository

class MountainsRepository(@ApplicationContext val context: Context) {
  private val _mountains = MutableStateFlow(emptyList<Mountain>())
  val mountains: StateFlow<List<Mountain>> = _mountains
  private var loaded = false

  /**
   * Loads the list of mountains from the list of mountains from the raw resource.
   */
  suspend fun loadMountains(): StateFlow<List<Mountain>> {
    if (!loaded) {
      loaded = true
      _mountains.value = withContext(Dispatchers.IO) {
        context.resources.openRawResource(R.raw.top_peaks).use { inputStream ->
          readMountains(inputStream)
        }
      }
    }
    return mountains
  }

  /**
   * Reads the [Waypoint]s from the given [inputStream] and returns a list of [Mountain]s.
   */
  private fun readMountains(inputStream: InputStream) =
    readWaypoints(inputStream).mapIndexed { index, waypoint ->
      waypoint.toMountain(index)
    }.toList()

  // ...
}

MountainsViewModel

MountainsViewModelViewModel クラスで、山のコレクションを読み込み、そのコレクションと UI 状態の他の部分を mountainsScreenViewState 経由で公開します。mountainsScreenViewState は、UI が collectAsState 拡張関数を使用して可変状態として監視できるホット StateFlow です。

健全なアーキテクチャの原則に従い、MountainsViewModel はアプリの状態をすべて保持します。UI は onEvent メソッドを使用して、ユーザー操作をビューモデルに送信します。

@HiltViewModel
class MountainsViewModel
@Inject
constructor(
  mountainsRepository: MountainsRepository
) : ViewModel() {
  private val _eventChannel = Channel<MountainsScreenEvent>()

  // Event channel to send events to the UI
  internal fun getEventChannel() = _eventChannel.receiveAsFlow()

  // Whether or not to show all of the high peaks
  private var showAllMountains = MutableStateFlow(false)

  val mountainsScreenViewState =
    mountainsRepository.mountains.combine(showAllMountains) { allMountains, showAllMountains ->
      if (allMountains.isEmpty()) {
        MountainsScreenViewState.Loading
      } else {
        val filteredMountains =
          if (showAllMountains) allMountains else allMountains.filter { it.is14er() }
        val boundingBox = filteredMountains.map { it.location }.toLatLngBounds()
        MountainsScreenViewState.MountainList(
          mountains = filteredMountains,
          boundingBox = boundingBox,
          showingAllPeaks = showAllMountains,
        )
      }
    }.stateIn(
      scope = viewModelScope,
      started = SharingStarted.WhileSubscribed(5000),
      initialValue = MountainsScreenViewState.Loading
    )

  init {
    // Load the full set of mountains
    viewModelScope.launch {
      mountainsRepository.loadMountains()
    }
  }

  // Handle user events
  fun onEvent(event: MountainsViewModelEvent) {
    when (event) {
      OnZoomAll -> onZoomAll()
      OnToggleAllPeaks -> toggleAllPeaks()
    }
  }

  private fun onZoomAll() {
    sendScreenEvent(MountainsScreenEvent.OnZoomAll)
  }

  private fun toggleAllPeaks() {
    showAllMountains.value = !showAllMountains.value
  }

  // Send events back to the UI via the event channel
  private fun sendScreenEvent(event: MountainsScreenEvent) {
    viewModelScope.launch { _eventChannel.send(event) }
  }
}

これらのクラスの実装に興味がおありの場合は、GitHub からアクセスするか、Android Studio で MountainsRepository クラスと MountainsViewModel クラスを開いてください。

ViewModel を使用する

ビューモデルは MainActivityviewState を取得するために使用されます。この Codelab の後半で、viewState を使用してマーカーをレンダリングします。このコードはスターター プロジェクトにすでに含まれています。ここでは参照用にのみ示しています。

val viewModel: MountainsViewModel by viewModels()
val screenViewState = viewModel.mountainsScreenViewState.collectAsState()
val viewState = screenViewState.value

8. カメラの位置を調整する

GoogleMap のデフォルトは、緯度 0、経度 0 の中心に設定されます。レンダリングするマーカーは、米国のコロラド州にあります。ビューモデルが提供する viewState は、すべてのマーカーを含む LatLngBounds を表します。

MountainMap.kt で、境界ボックスの中心に初期化された CameraPositionState を作成します。GoogleMapcameraPositionState パラメータを、作成した cameraPositionState 変数に設定します。

fun MountainMap(
    // ...
) {
    // ...
    val cameraPositionState = rememberCameraPositionState {
        position = CameraPosition.fromLatLngZoom(viewState.boundingBox.center, 5f)
    }

    GoogleMap(
        // ...
        cameraPositionState = cameraPositionState,
    )
}

コードを実行して、地図がコロラド州の中央に表示されることを確認します。

マーカーの範囲に合わせてズーム

地図をマーカーにフォーカスするには、MountainMap.kt ファイルの末尾に zoomAll 関数を追加します。カメラを新しい位置に移動するアニメーションは、完了までに時間がかかる非同期オペレーションであるため、この関数には CoroutineScope が必要です。

fun zoomAll(
    scope: CoroutineScope,
    cameraPositionState: CameraPositionState,
    boundingBox: LatLngBounds
) {
    scope.launch {
        cameraPositionState.animate(
            update = CameraUpdateFactory.newLatLngBounds(boundingBox, 64),
            durationMs = 1000
        )
    }
}

次に、マーカー コレクションの境界が変更されたとき、またはユーザーが TopApp バーのズーム範囲ボタンをクリックしたときに zoomAll 関数を呼び出すコードを追加します。ズーム範囲ボタンは、すでにビューモデルにイベントを送信するように設定されています。ビューモデルからこれらのイベントを収集し、レスポンスとして zoomAll 関数を呼び出すだけで済みます。

範囲ボタン

fun MountainMap(
    // ...
) {
    // ...
    val scope = rememberCoroutineScope()

    LaunchedEffect(key1 = viewState.boundingBox) {
        zoomAll(scope, cameraPositionState, viewState.boundingBox)
    }

    LaunchedEffect(true) {
        eventFlow.collect { event ->
            when (event) {
                MountainsScreenEvent.OnZoomAll -> {
                    zoomAll(scope, cameraPositionState, viewState.boundingBox)
                }
            }
        }
    }
}

アプリを実行すると、マーカーが配置されるエリアに地図がフォーカスされます。地図の位置を変更したり、ズームレベルを変更したりできます。ズーム範囲ボタンをクリックすると、マーカー エリアを中心に地図が再フォーカスされます。前進です。しかし、地図には見るべきものがあるべきです。次のステップでこの処理を行います。

9. 基本的なマーカー

このステップでは、地図上でハイライト表示したいスポットを示す Marker を地図に追加します。スターター プロジェクトで提供されている山のリストを使用して、これらの場所を地図上のマーカーとして追加します。

まず、GoogleMap にコンテンツ ブロックを追加します。マーカーの種類は複数あるため、when ステートメントを追加して各タイプに分岐します。各タイプは、次の手順で順番に実装します。

GoogleMap(
    // ...
) {
    when (selectedMarkerType) {
        MarkerType.Basic -> {
            BasicMarkersMapContent(
                mountains = viewState.mountains,
            )
        }

        MarkerType.Advanced -> {
            AdvancedMarkersMapContent(
                mountains = viewState.mountains,
            )
        }

        MarkerType.Clustered -> {
            ClusteringMarkersMapContent(
                mountains = viewState.mountains,
            )
        }
    }
}

マーカーの追加

BasicMarkersMapContent@GoogleMapComposable アノテーションを付けます。GoogleMap コンテンツ ブロックで使用できるのは @GoogleMapComposable 関数のみです。mountains オブジェクトには Mountain オブジェクトのリストが含まれます。Mountain オブジェクトの位置、名前、標高を使用して、リスト内の各山に対応するマーカーを追加します。この位置は Marker の状態パラメータの設定に使用され、このパラメータがマーカーの位置を制御します。

// ...
import com.google.android.gms.maps.model.Marker
import com.google.maps.android.compose.GoogleMapComposable
import com.google.maps.android.compose.Marker
import com.google.maps.android.compose.rememberMarkerState

@Composable
@GoogleMapComposable
fun BasicMarkersMapContent(
    mountains: List<Mountain>,
    onMountainClick: (Marker) -> Boolean = { false }
) {
    mountains.forEach { mountain ->
        Marker(
            state = rememberMarkerState(position = mountain.location),
            title = mountain.name,
            snippet = mountain.elevation.toElevationString(),
            tag = mountain,
            onClick = { marker ->
                onMountainClick(marker)
                false
            },
            zIndex = if (mountain.is14er()) 5f else 2f
        )
    }
}

アプリを実行すると、先ほど追加したマーカーが表示されます。

マーカーをカスタマイズする

先ほど追加したマーカーを目立たせ、有益な情報をユーザーに伝えるのに役立つ、カスタマイズのオプションがいくつかあります。このタスクでは、各マーカーの画像をカスタマイズしながら、それらのオプションのいくつかについて説明します。

スターター プロジェクトには、@DrawableResource から BitmapDescriptor を作成するヘルパー関数 vectorToBitmap が含まれています。

スターター コードには、マーカーのカスタマイズに使用する山のアイコン baseline_filter_hdr_24.xml が含まれています。

vectorToBitmap 関数は、地図ライブラリで使用するために、ベクター ドローアブルを BitmapDescriptor に変換します。アイコンの色は BitmapParameters インスタンスを使用して設定されます。

data class BitmapParameters(
    @DrawableRes val id: Int,
    @ColorInt val iconColor: Int,
    @ColorInt val backgroundColor: Int? = null,
    val backgroundAlpha: Int = 168,
    val padding: Int = 16,
)

fun vectorToBitmap(context: Context, parameters: BitmapParameters): BitmapDescriptor {
    // ...
}

vectorToBitmap 関数を使用して、14,000 フィート級の山と通常の山用に 2 つのカスタマイズされた BitmapDescriptor を作成します。次に、Marker コンポーザブルの icon パラメータを使用してアイコンを設定します。また、anchor パラメータを設定して、アイコンに対するアンカーの位置を変更します。円形のアイコンの場合は、中央を使用する方が効果的です。

@Composable
@GoogleMapComposable
fun BasicMarkersMapContent(
    // ...
) {
    // Create mountainIcon and fourteenerIcon
    val mountainIcon = vectorToBitmap(
        LocalContext.current,
        BitmapParameters(
            id = R.drawable.baseline_filter_hdr_24,
            iconColor = MaterialTheme.colorScheme.secondary.toArgb(),
            backgroundColor = MaterialTheme.colorScheme.secondaryContainer.toArgb(),
        )
    )

    val fourteenerIcon = vectorToBitmap(
        LocalContext.current,
        BitmapParameters(
            id = R.drawable.baseline_filter_hdr_24,
            iconColor = MaterialTheme.colorScheme.onPrimary.toArgb(),
            backgroundColor = MaterialTheme.colorScheme.primary.toArgb(),
        )
    )

    mountains.forEach { mountain ->
        val icon = if (mountain.is14er()) fourteenerIcon else mountainIcon
        Marker(
            // ...
            anchor = Offset(0.5f, 0.5f),
            icon = icon,
        )
    }
}

アプリを実行して、カスタマイズされたマーカーを確認します。Show all スイッチを切り替えると、山脈の全景が表示されます。山が 14,000 フィート級の山であるかどうかによって、山のマーカーが異なります。

10. 高度なマーカー

AdvancedMarker は、基本的な Markers にさまざまな機能を追加します。このステップでは、衝突動作を設定し、ピンのスタイルを構成します。

AdvancedMarkersMapContent 関数に @GoogleMapComposable を追加します。mountains をループ処理し、それぞれに AdvancedMarker を追加します。

@Composable
@GoogleMapComposable
fun AdvancedMarkersMapContent(
    mountains: List<Mountain>,
    onMountainClick: (Marker) -> Boolean = { false },
) {
    mountains.forEach { mountain ->
        AdvancedMarker(
            state = rememberMarkerState(position = mountain.location),
            title = mountain.name,
            snippet = mountain.elevation.toElevationString(),
            collisionBehavior = AdvancedMarkerOptions.CollisionBehavior.REQUIRED_AND_HIDES_OPTIONAL,
            onClick = { marker ->
                onMountainClick(marker)
                false
            }
        )
    }
}

collisionBehavior パラメータに注目してください。このパラメータを REQUIRED_AND_HIDES_OPTIONAL に設定すると、マーカーは優先度の低いマーカーを置き換えます。これは、基本マーカーと高度なマーカーを拡大することで確認できます。基本マーカーでは、マーカーとマーカーの両方がベースマップの同じ場所に配置される可能性があります。優先度の低いマーカーは、優先度の高いマーカーによって非表示になります。

アプリを実行して、高度なマーカーを確認します。下部のナビゲーション行で Advanced markers タブを選択してください。

カスタマイズされた AdvancedMarkers

アイコンは、プライマリ カラーとセカンダリ カラーの配色を使用して、標高 14,000 フィート以上の山とその他の山を区別しています。vectorToBitmap 関数を使用して、2 つの BitmapDescriptor を作成します。1 つは 14,000 フィート級の山用、もう 1 つはその他の山用です。これらのアイコンを使用して、各タイプにカスタム pinConfig を作成します。最後に、is14er() 関数に基づいて、対応する AdvancedMarker にピンを適用します。

@Composable
@GoogleMapComposable
fun AdvancedMarkersMapContent(
    mountains: List<Mountain>,
    onMountainClick: (Marker) -> Boolean = { false },
) {
    val mountainIcon = vectorToBitmap(
        LocalContext.current,
        BitmapParameters(
            id = R.drawable.baseline_filter_hdr_24,
            iconColor = MaterialTheme.colorScheme.onSecondary.toArgb(),
        )
    )

    val mountainPin = with(PinConfig.builder()) {
        setGlyph(PinConfig.Glyph(mountainIcon))
        setBackgroundColor(MaterialTheme.colorScheme.secondary.toArgb())
        setBorderColor(MaterialTheme.colorScheme.onSecondary.toArgb())
        build()
    }

    val fourteenerIcon = vectorToBitmap(
        LocalContext.current,
        BitmapParameters(
            id = R.drawable.baseline_filter_hdr_24,
            iconColor = MaterialTheme.colorScheme.onPrimary.toArgb(),
        )
    )

    val fourteenerPin = with(PinConfig.builder()) {
        setGlyph(PinConfig.Glyph(fourteenerIcon))
        setBackgroundColor(MaterialTheme.colorScheme.primary.toArgb())
        setBorderColor(MaterialTheme.colorScheme.onPrimary.toArgb())
        build()
    }

    mountains.forEach { mountain ->
        val pin = if (mountain.is14er()) fourteenerPin else mountainPin
        AdvancedMarker(
            state = rememberMarkerState(position = mountain.location),
            title = mountain.name,
            snippet = mountain.elevation.toElevationString(),
            collisionBehavior = AdvancedMarkerOptions.CollisionBehavior.REQUIRED_AND_HIDES_OPTIONAL,
            pinConfig = pin,
            onClick = { marker ->
                onMountainClick(marker)
                false
            }
        )
    }
}

11. クラスタ化されたマーカー

このステップでは、Clustering コンポーザブルを使用して、ズームベースのアイテム グループ化を追加します。

Clustering コンポーザブルには、ClusterItem のコレクションが必要です。MountainClusterItemClusterItem インターフェースを実装します。このクラスを ClusteringMarkersMapContent.kt ファイルに追加します。

data class MountainClusterItem(
    val mountain: Mountain,
    val snippetString: String
) : ClusterItem {
    override fun getPosition() = mountain.location
    override fun getTitle() = mountain.name
    override fun getSnippet() = snippetString
    override fun getZIndex() = 0f
}

次に、山のリストから MountainClusterItem を作成するコードを追加します。このコードでは、UnitsConverter を使用して、ユーザーのロケールに基づいて適切な表示単位に変換しています。これは、CompositionLocal を使用して MainActivity で設定されます。

@OptIn(MapsComposeExperimentalApi::class)
@Composable
@GoogleMapComposable
fun ClusteringMarkersMapContent(
    mountains: List<Mountain>,
    // ...
) {
    val unitsConverter = LocalUnitsConverter.current
    val resources = LocalContext.current.resources

    val mountainClusterItems by remember(mountains) {
        mutableStateOf(
            mountains.map { mountain ->
                MountainClusterItem(
                    mountain = mountain,
                    snippetString = unitsConverter.toElevationString(resources, mountain.elevation)
                )
            }
        )
    }

    Clustering(
        items = mountainClusterItems,
    )
}

このコードを使用すると、マーカーはズームレベルに基づいてクラスタ化されます。すっきりしましたね。

クラスタをカスタマイズする

他のマーカー タイプと同様に、クラスタ化されたマーカーはカスタマイズ可能です。Clustering コンポーザブルの clusterItemContent パラメータは、クラスタ化されていないアイテムをレンダリングするカスタム コンポーザブル ブロックを設定します。@Composable 関数を実装してマーカーを作成します。SingleMountain 関数は、カスタマイズされた背景色のカラースキームで、コンポーザブルなマテリアル 3 の Icon をレンダリングします。

ClusteringMarkersMapContent.kt で、マーカーの配色を定義するデータクラスを作成します。

data class IconColor(val iconColor: Color, val backgroundColor: Color, val borderColor: Color)

また、ClusteringMarkersMapContent.kt で、指定されたカラーパターン用のアイコンをレンダリングするコンポーズ可能な関数を作成します。

@Composable
private fun SingleMountain(
    colors: IconColor,
) {
    Icon(
        painterResource(id = R.drawable.baseline_filter_hdr_24),
        tint = colors.iconColor,
        contentDescription = "",
        modifier = Modifier
            .size(32.dp)
            .padding(1.dp)
            .drawBehind {
                drawCircle(color = colors.backgroundColor, style = Fill)
                drawCircle(color = colors.borderColor, style = Stroke(width = 3f))
            }
            .padding(4.dp)
    )
}

次に、標高 14,000 フィート以上の山用のカラーパターンと、その他の山用のカラーパターンを作成します。clusterItemContent ブロックで、指定された山が 14er かどうかに基づいて配色を選択します。

fun ClusteringMarkersMapContent(
    mountains: List<Mountain>,
    // ...
) {
  // ...

  val backgroundAlpha = 0.6f

  val fourteenerColors = IconColor(
      iconColor = MaterialTheme.colorScheme.onPrimary,
      backgroundColor = MaterialTheme.colorScheme.primary.copy(alpha = backgroundAlpha),
      borderColor = MaterialTheme.colorScheme.primary
  )

  val otherColors = IconColor(
      iconColor = MaterialTheme.colorScheme.secondary,
      backgroundColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = backgroundAlpha),
      borderColor = MaterialTheme.colorScheme.secondary
  )

  // ...
  Clustering(
      items = mountainClusterItems,
      clusterItemContent = { mountainItem ->
          val colors = if (mountainItem.mountain.is14er()) {
              fourteenerColors
          } else {
              otherColors
          }
          SingleMountain(colors)
      },
  )
}

アプリを実行して、個々のアイテムのカスタマイズされたバージョンを確認します。

12. 地図上に図形を描画する

地図上で描画を行う方法の一つ(マーカーの追加)についてはすでに確認しましたが、Maps SDK for Android ではその他にも、描画によって地図上に有益な情報を表示できるさまざまな方法をサポートしています。

たとえば、地図上にルートやエリアを示す場合は、PolylinePolygon を使用してこれらを地図に表示できます。また、地面に画像を固定したい場合は、GroundOverlay を使用することも可能です。

このタスクでは、図形(特にコロラド州の輪郭)を描画する方法を説明します。コロラド州の境界は、北緯 37 度から 41 度、西経 102 度 3 分から 109 度 3 分の間と定義されています。これにより、アウトラインの描画が非常に簡単になります。

スターター コードには、度分秒表記から度数表記に変換する DMS クラスが含まれています。

enum class Direction(val sign: Int) {
    NORTH(1),
    EAST(1),
    SOUTH(-1),
    WEST(-1)
}

/**
 * Degrees, minutes, seconds utility class
 */
data class DMS(
    val direction: Direction,
    val degrees: Double,
    val minutes: Double = 0.0,
    val seconds: Double = 0.0,
)

fun DMS.toDecimalDegrees(): Double =
    (degrees + (minutes / 60) + (seconds / 3600)) * direction.sign

DMS クラスを使用すると、4 つの角の LatLng の位置を定義し、それらを Polygon としてレンダリングすることで、コロラド州の境界線を描画できます。MountainMap.kt に次のコードを追加します。

@Composable
@GoogleMapComposable
fun ColoradoPolygon() {
    val north = 41.0
    val south = 37.0
    val east = DMS(WEST, 102.0, 3.0).toDecimalDegrees()
    val west = DMS(WEST, 109.0, 3.0).toDecimalDegrees()

    val locations = listOf(
        LatLng(north, east),
        LatLng(south, east),
        LatLng(south, west),
        LatLng(north, west),
    )

    Polygon(
        points = locations,
        strokeColor = MaterialTheme.colorScheme.tertiary,
        strokeWidth = 3F,
        fillColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f),
    )
}

次に、GoogleMap コンテンツ ブロック内で ColoradoPolyon() を呼び出します。

@Composable
fun MountainMap(
    // ...
) {
   Box(
    // ...
    ) {
        GoogleMap(
            // ...
        ) {
            ColoradoPolygon()
        }
    }
}

これで、コロラド州の輪郭が描かれ、微妙な塗りつぶしが施されます。

13. KML レイヤと縮尺バーを追加する

最後のセクションでは、さまざまな山脈の概要を説明し、地図にスケールバーを追加します。

山脈の輪郭を描く

以前は、コロラド州の輪郭を描画しました。ここでは、より複雑なシェイプを地図に追加します。スターター コードには、重要な山脈の概要を示す Keyhole Markup Language(KML)ファイルが含まれています。Maps SDK for Android ユーティリティ ライブラリには、KML レイヤを地図に追加する関数があります。MountainMap.ktGoogleMap コンテンツ ブロックで、when ブロックの後に MapEffect 呼び出しを追加します。MapEffect 関数が GoogleMap オブジェクトで呼び出されます。これは、GoogleMap オブジェクトを必要とするコンポーザブルでない API とライブラリ間の便利なブリッジとして機能します。

  fun MountainMap(
    // ...
) {
    var isMapLoaded by remember { mutableStateOf(false) }
    val context = LocalContext.current

    GoogleMap(
      // ...
    ) {
      // ...

      when (selectedMarkerType) {
        // ...
      }

      // This code belongs inside the GoogleMap content block, but outside of
      // the 'when' statement
      MapEffect(key1 = true) {map ->
          val layer = KmlLayer(map, R.raw.mountain_ranges, context)
          layer.addLayerToMap()
      }
    }

地図の縮尺を追加する

最後のタスクとして、地図に縮尺を追加します。ScaleBar は、地図に追加できるスケール コンポーザブルを実装します。ScaleBar

@GoogleMapComposable であるため、GoogleMap コンテンツに追加できません。代わりに、地図を保持する Box に追加します。

Box(
  // ...
) {
    GoogleMap(
      // ...
    ) {
        // ...
    }

    ScaleBar(
        modifier = Modifier
            .padding(top = 5.dp, end = 15.dp)
            .align(Alignment.TopEnd),
        cameraPositionState = cameraPositionState
    )
    // ...
}

アプリを実行して、完全に実装された Codelab を確認します。

14. 解答コードを取得する

この Codelab の完成したコードをダウンロードするには、以下のコマンドを使用します。

  1. git がインストールされている場合は、リポジトリのクローンを作成します。
$ git clone https://github.com/googlemaps-samples/codelab-maps-platform-101-compose.git

あるいは、以下のボタンをクリックしてソースコードをダウンロードすることもできます。

  1. コードを入手したら、Android Studio の solution ディレクトリにあるプロジェクトを開いてみましょう。

15. 完了

これで、ここまで多くの内容を学習し、Maps SDK for Android で提供されている主要な機能についての理解を深めていただけたと思います。

その他の情報

  • Maps SDK for Android - Android アプリ用に動的でインタラクティブな地図、位置情報、地理空間のエクスペリエンスを作成し、カスタマイズすることができます。
  • Maps Compose ライブラリ - Jetpack Compose で使用できる、アプリをビルドするためのコンポーズ可能な関数とデータ型のセットがオープンソースとして用意されています。
  • android-maps-compose - この Codelab などで説明されているすべての機能を示す GitHub のサンプルコードです。
  • Google Maps Platform で Android アプリをビルドするためのその他の Kotlin Codelab