在 Android 應用程式中加入地圖 (Kotlin with Compose)

1. 事前準備

本程式碼研究室將說明如何整合 Maps SDK for Android 與應用程式,並使用其核心功能,方法是建構一個應用程式,使用各種標記顯示美國科羅拉多州的山脈地圖。此外,您也會學到如何在 Google 地圖上繪製其他形狀。

完成本程式碼研究室後,您建立的資訊方塊會如下所示:

必要條件

學習內容

  • 啟用並使用 Maps SDK for Android 適用的 Maps Compose 程式庫,將 GoogleMap 新增至 Android 應用程式
  • 新增及自訂標記
  • 在地圖上繪製多邊形
  • 以程式輔助的方式控制攝影機的視角

軟硬體需求

2. 做好準備

在啟用步驟中,您需要啟用 Maps SDK for Android

設定 Google 地圖平台

如果您尚未建立 Google Cloud Platform 帳戶,以及啟用計費功能的專案,請參閱「開始使用 Google 地圖平台」指南,建立帳單帳戶和專案。

  1. Cloud 控制台中,按一下專案下拉式選單,然後選取要用於本程式碼研究室的專案。

  1. Google Cloud Marketplace 中,啟用本程式碼研究室所需的 Google 地圖平台 API 和 SDK。如要瞭解如何操作,請觀看這部影片或參閱這份說明文件
  2. 在 Cloud Console 的「憑證」頁面中產生 API 金鑰。你可以按照這部影片這份文件中的步驟操作。所有 Google 地圖平台要求都需要 API 金鑰。

3. 快速入門

為協助您盡快上手,我們提供一些範例程式碼,方便您跟著本程式碼研究室的說明操作。您可以直接跳到解決方案,但如果想按照所有步驟自行建構,請繼續閱讀。

  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 屬性檔案」。

建議您使用 Secrets Gradle Plugin for Android 來簡化這項工作。

如要在 Google 地圖專案中安裝 Secrets Gradle Plugin for Android,請按照下列步驟操作:

  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 金鑰的備份位置,以確保建置程序不會失敗。如果您從版本管控系統複製應用程式,且尚未在本機建立 secrets.properties 檔案來提供 API 金鑰,就會發生這種情況。
  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 屬性不存在,請新增該屬性。編輯外掛程式的屬性,將 propertiesFileName 設為 secrets.properties、將 defaultPropertiesFileName 設為 local.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 金鑰,下一步是在應用程式的 build.gradle.kts 檔案中新增 Maps SDK for Android 依附元件。如要使用 Jetpack Compose 建構應用程式,請使用 Maps Compose 程式庫,該程式庫會以可組合函式和資料型別的形式提供 Maps SDK for Android 的元素。

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 中,將 GoogleMap 可組合函式新增至 MapMountain 可組合函式內巢狀結構的 Box 可組合函式。

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 島為中心的地圖,也就是經緯度皆為零度的位置。稍後,您將瞭解如何將地圖定位在所需位置和縮放等級,但現在先慶祝您的第一場勝利!

6. 雲端式地圖樣式設定

您可以使用雲端式地圖樣式設定自訂地圖樣式。

建立地圖 ID

如果尚未建立地圖 ID 並與地圖樣式建立關聯,請參閱「地圖 ID」指南,完成下列步驟:

  1. 建立地圖 ID。
  2. 將地圖 ID 與地圖樣式建立關聯。

在應用程式中加入地圖 ID

如要使用您建立的地圖 ID,請在例項化 GoogleMap 可組合函式時,建立 GoogleMapOptions 物件,並將其指派給建構函式中的 googleMapOptionsFactory 參數。

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 類別會保留顯示檢視畫面所需的所有資料。視山脈清單是否已載入完畢,狀態可能為 LoadingMountainList

/**
 * 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 類別,可載入山脈集合,並透過 mountainsScreenViewState 公開該集合和 UI 狀態的其他部分。mountainsScreenViewState 是「熱」StateFlow,UI 可以使用 collectAsState 擴充功能函式,將其視為可變動的狀態。

遵循健全的架構原則,MountainsViewModel 會保留應用程式的所有狀態。UI 會使用 onEvent 方法,將使用者互動傳送至 ViewModel。

@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 中開啟 MountainsRepositoryMountainsViewModel 類別。

使用 ViewModel

檢視畫面模型會在 MainActivity 中用於取得 viewState。您會在程式碼研究室的後續章節使用 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. 基本標記

在這個步驟中,您要將標記新增至地圖,代表要在地圖上醒目顯示的搜尋點。您將使用入門專案中提供的山脈清單,並在地圖上將這些地點新增為標記。

首先,請在 GoogleMap 中新增內容區塊。標記類型有多種,因此請新增 when 陳述式,分支到每種型別,並在後續步驟中逐一實作。

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

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

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

新增標記

使用 @GoogleMapComposableBasicMarkersMapContent 加上註解。請注意,您只能在 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
        )
    }
}

請執行應用程式,您會看到剛才新增的標記!

自訂標記

您可以透過多種自訂選項,讓剛新增的標記更加顯眼,並向使用者傳達實用資訊。在這項工作中,您將自訂每個標記的圖片,藉此探索部分屬性。

入門專案包含輔助函式 vectorToBitmap,可從 @DrawableResource 建立 BitmapDescriptor

範例程式碼包含山脈圖示 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 函式建立兩個自訂 BitmapDescriptor,一個用於海拔超過 14,000 英尺的山峰,另一個用於一般山峰。然後使用 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. 進階標記

AdvancedMarkers 在基本 Markers 中新增額外功能。在這個步驟中,您將設定碰撞行為並設定圖釘樣式。

@GoogleMapComposable 新增至 AdvancedMarkersMapContent 函式。在 mountains 上疊代,為每個 AdvancedMarker 新增 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 函式建立兩個 BitmapDescriptor,一個用於十四座山峰,另一個用於其他山脈。使用這些圖示為每種型別建立自訂 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 集合。MountainClusterItem 會實作 ClusterItem 介面。將這個類別新增至 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 轉換為適合使用者所在地區的顯示單位。這項設定是在 MainActivity 中使用 CompositionLocal 設定

@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 函式會以自訂背景色彩配置,算繪可組合的 Material 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 區塊中,根據指定山峰是否為海拔超過 14, 000 英尺的山峰,選取相應的色彩配置。

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°03' 和西經 109°03' 之間。因此繪製輪廓相當簡單。

範例程式碼包含 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 類別,您可以定義四個角落的 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 標記語言 (KML) 檔案,大致標示出重要的山脈範圍。Maps SDK for Android 公用程式庫提供可將 KML 圖層新增至地圖的函式。在 MountainMap.kt 中,於 when 區塊後方的 GoogleMap 內容區塊中新增 MapEffect 呼叫。系統會使用 GoogleMap 物件呼叫 MapEffect 函式。對於需要 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
    )
    // ...
}

執行應用程式,查看完整實作的程式碼研究室。

14. 取得解決方案程式碼

完成程式碼研究室後,如要下載當中用到的程式碼,您可以使用這些指令:

  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 - GitHub 上的程式碼範例,展示本程式碼研究室涵蓋的所有功能,以及更多內容。
  • 更多 Kotlin 程式碼研究室,協助您使用 Google 地圖平台建構 Android 應用程式