Thêm bản đồ vào ứng dụng Android (Kotlin với Compose)

1. Trước khi bắt đầu

Lớp học lập trình này hướng dẫn bạn cách tích hợp Maps SDK cho Android với ứng dụng của bạn và sử dụng các tính năng cốt lõi của SDK này bằng cách tạo một ứng dụng hiển thị bản đồ các ngọn núi ở Colorado, Hoa Kỳ, bằng nhiều loại điểm đánh dấu. Ngoài ra, bạn sẽ học cách vẽ các hình dạng khác trên bản đồ.

Sau đây là hình minh hoạ sản phẩm của bạn sau khi hoàn tất lớp học lập trình này:

Điều kiện tiên quyết

Bạn sẽ thực hiện

  • Bật và sử dụng thư viện Maps Compose cho Maps SDK dành cho Android để thêm GoogleMap vào một ứng dụng Android
  • Thêm và tuỳ chỉnh điểm đánh dấu
  • Vẽ đa giác trên bản đồ
  • Điều khiển điểm nhìn của camera theo phương thức lập trình

Bạn cần có

2. Bắt đầu thiết lập

Đối với bước bật sau đây, bạn cần bật Maps SDK cho Android.

Thiết lập Nền tảng Google Maps

Nếu bạn chưa có tài khoản Google Cloud Platform và dự án có bật tính năng thanh toán, vui lòng xem hướng dẫn Bắt đầu sử dụng Google Maps Platform để tạo tài khoản thanh toán và dự án.

  1. Trong Cloud Console, hãy nhấp vào trình đơn thả xuống dự án rồi chọn dự án mà bạn muốn sử dụng cho lớp học lập trình này.

  1. Bật các API và SDK của Google Maps Platform cần thiết cho lớp học lập trình này trong Google Cloud Marketplace. Để làm như vậy, hãy làm theo các bước trong video này hoặc tài liệu này.
  2. Tạo khoá API trong trang Thông tin xác thực của Cloud Console. Bạn có thể làm theo các bước trong video này hoặc tài liệu này. Tất cả các yêu cầu gửi đến Nền tảng Google Maps đều cần có khoá API.

3. Bắt đầu nhanh

Để giúp bạn bắt đầu nhanh nhất có thể, sau đây là một số mã khởi đầu giúp bạn theo dõi lớp học lập trình này. Bạn có thể chuyển ngay đến giải pháp, nhưng nếu muốn làm theo tất cả các bước để tự xây dựng, hãy tiếp tục đọc.

  1. Sao chép kho lưu trữ nếu bạn đã cài đặt git.
git clone https://github.com/googlemaps-samples/codelab-maps-platform-101-compose.git

Ngoài ra, bạn có thể nhấp vào nút sau đây để tải mã nguồn xuống.

  1. Sau khi nhận được mã, hãy mở dự án trong thư mục starter trong Android Studio.

4. Thêm khoá API vào dự án

Phần này mô tả cách lưu trữ khoá API để ứng dụng của bạn có thể tham chiếu một cách an toàn. Bạn không nên kiểm tra khoá API trong hệ thống kiểm soát phiên bản, vì vậy, bạn nên lưu trữ khoá này trong tệp secrets.properties. Tệp này sẽ được đặt trong bản sao cục bộ của thư mục gốc của dự án. Để biết thêm thông tin về tệp secrets.properties, hãy xem phần Tệp thuộc tính Gradle.

Để đơn giản hoá tác vụ này, bạn nên sử dụng Trình bổ trợ Secrets Gradle cho Android.

Cách cài đặt Trình bổ trợ Secrets Gradle cho Android trong dự án Google Maps:

  1. Trong Android Studio, hãy mở tệp build.gradle.kts cấp cao nhất rồi thêm mã sau vào phần tử dependencies trong buildscript.
    buildscript {
        dependencies {
            classpath("com.google.android.libraries.mapsplatform.secrets-gradle-plugin:secrets-gradle-plugin:2.0.1")
        }
    }
    
  2. Mở tệp build.gradle.kts ở cấp mô-đun rồi thêm đoạn mã sau vào phần tử plugins.
    plugins {
        // ...
        id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin")
    }
    
  3. Trong tệp build.gradle.kts ở cấp mô-đun, hãy đảm bảo rằng targetSdkcompileSdk được đặt thành ít nhất là 34.
  4. Lưu tệp và đồng bộ hoá dự án với Gradle.
  5. Mở tệp secrets.properties trong thư mục cấp cao nhất, sau đó thêm đoạn mã sau. Thay thế YOUR_API_KEY bằng khoá API của bạn. Lưu trữ khoá của bạn trong tệp này vì secrets.properties không được đưa vào hệ thống quản lý phiên bản.
    MAPS_API_KEY=YOUR_API_KEY
    
  6. Lưu tệp.
  7. Tạo tệp local.defaults.properties trong thư mục cấp cao nhất (cùng thư mục với tệp secrets.properties), rồi thêm mã sau.
        MAPS_API_KEY=DEFAULT_API_KEY
    
    Mục đích của tệp này là cung cấp một vị trí sao lưu cho khoá API nếu không tìm thấy tệp secrets.properties để các bản dựng không bị lỗi. Điều này sẽ xảy ra khi bạn sao chép ứng dụng từ một hệ thống kiểm soát phiên bản và bạn chưa tạo tệp secrets.properties cục bộ để cung cấp khoá API.
  8. Lưu tệp.
  9. Trong tệp AndroidManifest.xml, hãy chuyển đến com.google.android.geo.API_KEY rồi cập nhật thuộc tính android:value. Nếu thẻ <meta-data> không tồn tại, hãy tạo thẻ này dưới dạng thẻ con của thẻ <application>.
        <meta-data
            android:name="com.google.android.geo.API_KEY"
            android:value="${MAPS_API_KEY}" />
    
  10. Trong Android Studio, hãy mở tệp build.gradle.kts ở cấp mô-đun rồi chỉnh sửa thuộc tính secrets. Nếu thuộc tính secrets không tồn tại, hãy thêm thuộc tính này.Chỉnh sửa các thuộc tính của trình bổ trợ để đặt propertiesFileName thành secrets.properties, đặt defaultPropertiesFileName thành local.defaults.properties và đặt mọi thuộc tính khác.
    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. Thêm Google Maps

Trong phần này, bạn sẽ thêm một Google Map để Google Map tải khi bạn chạy ứng dụng.

Thêm phần phụ thuộc Maps Compose

Giờ đây, khoá API của bạn có thể được truy cập trong ứng dụng. Bước tiếp theo là thêm phần phụ thuộc Maps SDK for Android vào tệp build.gradle.kts của ứng dụng. Để tạo bằng Jetpack Compose, hãy dùng thư viện Maps Compose. Thư viện này cung cấp các phần tử của Maps SDK cho Android dưới dạng các hàm có khả năng kết hợp và kiểu dữ liệu.

build.gradle.kts

Trong tệp build.gradle.kts ở cấp ứng dụng, hãy thay thế các phần phụ thuộc SDK Bản đồ dành cho Android không phải Compose:

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")
}

với các thành phần kết hợp tương ứng:

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")
}

Thêm thành phần kết hợp Google Maps

Trong MountainMap.kt, hãy thêm thành phần kết hợp GoogleMap bên trong thành phần kết hợp Box được lồng trong thành phần kết hợp MapMountain.

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 }
        )

        // ...
    }
}

Bây giờ, hãy tạo bản dựng và chạy ứng dụng. Hãy xem! Bạn sẽ thấy một bản đồ được căn giữa trên Đảo Null khét tiếng, còn được gọi là vĩ độ 0 và kinh độ 0. Sau này, bạn sẽ tìm hiểu cách đặt bản đồ vào vị trí và mức thu phóng mà bạn muốn, nhưng trước mắt, hãy ăn mừng chiến thắng đầu tiên của bạn!

6. Định kiểu bản đồ dựa trên đám mây

Bạn có thể tuỳ chỉnh kiểu bản đồ bằng tính năng Định kiểu bản đồ dựa trên đám mây.

Tạo mã bản đồ

Nếu bạn chưa tạo mã bản đồ có kiểu bản đồ được liên kết, hãy xem hướng dẫn về Mã bản đồ để hoàn tất các bước sau:

  1. Tạo mã bản đồ.
  2. Liên kết mã bản đồ với kiểu bản đồ.

Thêm mã bản đồ vào ứng dụng

Để sử dụng mã bản đồ mà bạn đã tạo, khi khởi tạo thành phần kết hợp GoogleMap, hãy sử dụng mã bản đồ khi tạo đối tượng GoogleMapOptions được chỉ định cho tham số googleMapOptionsFactory trong hàm khởi tạo.

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

Sau khi hoàn tất, hãy chạy ứng dụng để xem bản đồ theo kiểu mà bạn đã chọn!

7. Tải dữ liệu điểm đánh dấu

Nhiệm vụ chính của ứng dụng là tải một bộ sưu tập núi từ bộ nhớ cục bộ và hiển thị những ngọn núi đó trong GoogleMap. Trong bước này, bạn sẽ tìm hiểu về cơ sở hạ tầng được cung cấp để tải dữ liệu về núi và trình bày dữ liệu đó cho giao diện người dùng.

Núi

Lớp dữ liệu Mountain lưu giữ tất cả dữ liệu về từng ngọn núi.

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

Xin lưu ý rằng sau này, các ngọn núi sẽ được phân vùng dựa trên độ cao. Những ngọn núi cao ít nhất 14.000 feet được gọi là núi cao 14.000 feet. Mã khởi đầu bao gồm một hàm mở rộng để thực hiện việc kiểm tra này cho bạn.

/**
 * 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

Lớp MountainsScreenViewState chứa tất cả dữ liệu cần thiết để hiển thị khung hiển thị. Trạng thái này có thể là Loading hoặc MountainList, tuỳ thuộc vào việc danh sách núi đã tải xong hay chưa.

/**
 * 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()
}

Các lớp được cung cấp: MountainsRepositoryMountainsViewModel

Trong dự án khởi đầu, lớp MountainsRepository đã được cung cấp cho bạn. Lớp này đọc danh sách các địa điểm trên núi được lưu trữ trong tệp GPS Exchange Format hoặc GPX, top_peaks.gpx. Khi gọi mountainsRepository.loadMountains(), hệ thống sẽ trả về 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

MountainsViewModel là một lớp ViewModel tải các bộ sưu tập núi và hiển thị các bộ sưu tập đó cũng như các phần khác của trạng thái giao diện người dùng thông qua mountainsScreenViewState. mountainsScreenViewState là một StateFlow nóng mà giao diện người dùng có thể quan sát dưới dạng trạng thái có thể thay đổi bằng hàm mở rộng collectAsState.

Theo các nguyên tắc cấu trúc âm thanh, MountainsViewModel giữ tất cả trạng thái của ứng dụng. Giao diện người dùng gửi các hoạt động tương tác của người dùng đến mô hình hiển thị bằng phương thức 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) }
  }
}

Nếu muốn tìm hiểu về cách triển khai các lớp này, bạn có thể truy cập vào các lớp đó trên GitHub hoặc mở các lớp MountainsRepositoryMountainsViewModel trong Android Studio.

Sử dụng ViewModel

Mô hình hiển thị được dùng trong MainActivity để lấy viewState. Bạn sẽ dùng viewState để kết xuất các điểm đánh dấu sau này trong lớp học lập trình này. Xin lưu ý rằng mã này đã có trong dự án khởi đầu và chỉ xuất hiện ở đây để tham khảo.

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

8. Đặt camera

GoogleMap mặc định sẽ đặt tâm ở vĩ độ 0, kinh độ 0. Các điểm đánh dấu mà bạn sẽ kết xuất nằm ở Tiểu bang Colorado, Hoa Kỳ. viewState do mô hình hiển thị cung cấp sẽ trình bày một LatLngBounds chứa tất cả các điểm đánh dấu.

Trong MountainMap.kt, hãy tạo một CameraPositionState được khởi tạo ở giữa hộp giới hạn. Đặt tham số cameraPositionState của GoogleMap thành biến cameraPositionState mà bạn vừa tạo.

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

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

Bây giờ, hãy chạy mã và xem bản đồ tập trung vào Colorado.

Phóng to đến phạm vi của điểm đánh dấu

Để thực sự tập trung bản đồ vào các điểm đánh dấu, hãy thêm hàm zoomAll vào cuối tệp MountainMap.kt. Xin lưu ý rằng hàm này cần có CoroutineScope vì việc chuyển động camera đến một vị trí mới là một thao tác không đồng bộ và mất thời gian để hoàn tất.

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

Tiếp theo, hãy thêm mã để gọi hàm zoomAll bất cứ khi nào ranh giới xung quanh bộ sưu tập điểm đánh dấu thay đổi hoặc khi người dùng nhấp vào nút thu phóng phạm vi trong TopApp bar. Xin lưu ý rằng nút thu phóng phạm vi đã được kết nối để gửi các sự kiện đến mô hình hiển thị. Bạn chỉ cần thu thập những sự kiện đó từ mô hình hiển thị và gọi hàm zoomAll để phản hồi.

Nút Phạm vi

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)
                }
            }
        }
    }
}

Giờ đây, khi bạn chạy ứng dụng, bản đồ sẽ bắt đầu tập trung vào khu vực mà các điểm đánh dấu sẽ xuất hiện. Bạn có thể định vị lại và thay đổi mức thu phóng. Khi nhấp vào nút thu phóng theo phạm vi, bản đồ sẽ tập trung lại vào khu vực điểm đánh dấu. Đó là một bước tiến! Nhưng bản đồ thực sự cần có nội dung để xem. Và đó là những gì bạn sẽ làm trong bước tiếp theo!

9. Điểm đánh dấu cơ bản

Trong bước này, bạn sẽ thêm Marker vào bản đồ để biểu thị những địa điểm yêu thích mà bạn muốn làm nổi bật trên bản đồ. Bạn sẽ sử dụng danh sách các ngọn núi đã được cung cấp trong dự án khởi đầu và thêm những địa điểm này làm điểm đánh dấu trên bản đồ.

Bắt đầu bằng cách thêm một khối nội dung vào GoogleMap. Sẽ có nhiều loại điểm đánh dấu, vì vậy, hãy thêm câu lệnh when để phân nhánh cho từng loại và bạn sẽ triển khai từng loại theo thứ tự trong các bước tiếp theo.

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

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

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

Thêm điểm đánh dấu

Chú giải BasicMarkersMapContent bằng @GoogleMapComposable. Xin lưu ý rằng bạn chỉ được phép sử dụng các hàm @GoogleMapComposable trong khối nội dung GoogleMap. Đối tượng mountains có một danh sách các đối tượng Mountain. Bạn sẽ thêm một điểm đánh dấu cho từng ngọn núi trong danh sách đó, sử dụng vị trí, tên và độ cao từ đối tượng Mountain. Vị trí này được dùng để đặt tham số trạng thái của Marker, từ đó kiểm soát vị trí của điểm đánh dấu.

// ...
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
        )
    }
}

Hãy chạy ứng dụng, bạn sẽ thấy các điểm đánh dấu mà bạn vừa thêm!

Tuỳ chỉnh điểm đánh dấu

Có một số lựa chọn tuỳ chỉnh cho các điểm đánh dấu mà bạn vừa thêm để giúp các điểm này nổi bật và truyền tải thông tin hữu ích cho người dùng. Trong nhiệm vụ này, bạn sẽ khám phá một số lựa chọn trong số đó bằng cách tuỳ chỉnh hình ảnh của từng điểm đánh dấu.

Dự án khởi đầu có một hàm trợ giúp vectorToBitmap để tạo BitmapDescriptor từ @DrawableResource.

Đoạn mã khởi đầu bao gồm một biểu tượng núi baseline_filter_hdr_24.xml mà bạn sẽ dùng để tuỳ chỉnh các điểm đánh dấu.

Hàm vectorToBitmap chuyển đổi một vectơ vẽ được thành BitmapDescriptor để dùng với thư viện Maps. Màu biểu tượng được đặt bằng cách sử dụng một thực thể 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 {
    // ...
}

Sử dụng hàm vectorToBitmap để tạo 2 BitmapDescriptor tuỳ chỉnh; một cho núi cao trên 4.267 mét và một cho núi thông thường. Sau đó, hãy dùng tham số icon của thành phần kết hợp Marker để đặt biểu tượng. Ngoài ra, hãy đặt tham số anchor để thay đổi vị trí của điểm neo so với biểu tượng. Sử dụng tâm sẽ phù hợp hơn với những biểu tượng hình tròn này.

@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,
        )
    }
}

Chạy ứng dụng và chiêm ngưỡng các điểm đánh dấu tuỳ chỉnh. Bật nút chuyển Show all để xem toàn bộ dãy núi. Các ngọn núi sẽ có các điểm đánh dấu khác nhau tuỳ thuộc vào việc ngọn núi đó có phải là một ngọn núi cao trên 14.000 feet (4.267 mét) hay không.

10. Thẻ đánh dấu nâng cao

AdvancedMarkers bổ sung các tính năng khác cho Markers cơ bản. Trong bước này, bạn sẽ đặt hành vi va chạm và định cấu hình kiểu ghim.

Thêm @GoogleMapComposable vào hàm AdvancedMarkersMapContent. Lặp lại trên mountains bằng cách thêm một AdvancedMarker cho mỗi mountains.

@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
            }
        )
    }
}

Hãy lưu ý tham số collisionBehavior. Bằng cách đặt thông số này thành REQUIRED_AND_HIDES_OPTIONAL, điểm đánh dấu của bạn sẽ thay thế mọi điểm đánh dấu có mức độ ưu tiên thấp hơn. Bạn có thể thấy điều này bằng cách phóng to một điểm đánh dấu cơ bản so với một điểm đánh dấu nâng cao. Điểm đánh dấu cơ bản có thể sẽ có cả điểm đánh dấu của bạn và điểm đánh dấu được đặt ở cùng một vị trí trên bản đồ cơ sở. Điểm đánh dấu nâng cao sẽ khiến điểm đánh dấu có mức độ ưu tiên thấp hơn bị ẩn.

Chạy ứng dụng để xem các điểm đánh dấu Nâng cao. Nhớ chọn thẻ Advanced markers trong hàng điều hướng dưới cùng.

Tuỳ chỉnh AdvancedMarkers

Các biểu tượng sử dụng bảng phối màu chính và phụ để phân biệt giữa các đỉnh núi cao trên 4.267 mét và các đỉnh núi khác. Dùng hàm vectorToBitmap để tạo 2 BitmapDescriptor; một cho các đỉnh núi cao trên 14.000 feet và một cho các đỉnh núi khác. Sử dụng các biểu tượng đó để tạo một pinConfig tuỳ chỉnh cho từng loại. Cuối cùng, hãy áp dụng ghim cho AdvancedMarker tương ứng dựa trên hàm is14er().

@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. Điểm đánh dấu được phân cụm

Ở bước này, bạn sẽ dùng thành phần kết hợp Clustering để thêm tính năng nhóm các mục dựa trên mức thu phóng.

Thành phần kết hợp Clustering yêu cầu phải có một tập hợp các ClusterItem. MountainClusterItem triển khai giao diện ClusterItem. Thêm lớp này vào tệp 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
}

Giờ đây, hãy thêm mã để tạo MountainClusterItem từ danh sách các ngọn núi. Xin lưu ý rằng mã này sử dụng UnitsConverter để chuyển đổi thành các đơn vị hiển thị phù hợp với người dùng dựa trên ngôn ngữ của họ. Điều này được thiết lập trong MainActivity bằng cách sử dụng 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,
    )
}

Với mã đó, các điểm đánh dấu được nhóm lại dựa trên mức thu phóng. Đẹp và gọn gàng!

Tuỳ chỉnh cụm

Giống như các loại điểm đánh dấu khác, bạn có thể tuỳ chỉnh điểm đánh dấu theo cụm. Tham số clusterItemContent của thành phần kết hợp Clustering sẽ đặt một khối thành phần kết hợp tuỳ chỉnh để kết xuất một mục không được phân cụm. Triển khai hàm @Composable để tạo điểm đánh dấu. Hàm SingleMountain kết xuất một Icon có khả năng kết hợp Material 3 với bảng phối màu nền tuỳ chỉnh.

Trong ClusteringMarkersMapContent.kt, hãy tạo một lớp dữ liệu xác định bảng phối màu cho một điểm đánh dấu:

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

Ngoài ra, trong ClusteringMarkersMapContent.kt, hãy tạo một hàm có khả năng kết hợp để hiển thị biểu tượng cho một bảng phối màu nhất định:

@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)
    )
}

Bây giờ, hãy tạo một bảng phối màu cho các đỉnh núi cao trên 4.000 mét và một bảng phối màu khác cho các đỉnh núi khác. Trong khối clusterItemContent, hãy chọn bảng phối màu dựa trên việc ngọn núi đã cho có phải là ngọn núi cao trên 14.000 feet hay không.

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)
      },
  )
}

Bây giờ, hãy chạy ứng dụng để xem các phiên bản tuỳ chỉnh của từng mục.

12. Vẽ trên bản đồ

Mặc dù bạn đã khám phá một cách vẽ trên bản đồ (bằng cách thêm điểm đánh dấu), nhưng Maps SDK cho Android hỗ trợ nhiều cách khác để bạn có thể vẽ nhằm hiển thị thông tin hữu ích trên bản đồ.

Ví dụ: nếu muốn biểu thị các tuyến đường và khu vực trên bản đồ, bạn có thể dùng PolylinePolygon để hiển thị các đối tượng này trên bản đồ. Hoặc nếu muốn cố định hình ảnh vào bề mặt đất, bạn có thể dùng GroundOverlay.

Trong nhiệm vụ này, bạn sẽ tìm hiểu cách vẽ các hình dạng, cụ thể là đường viền xung quanh tiểu bang Colorado. Ranh giới của Colorado được xác định là giữa vĩ độ 37°B và 41°B, kinh độ 102°03'T và 109°03'T. Điều này giúp việc vẽ đường viền trở nên khá đơn giản.

Đoạn mã khởi đầu bao gồm một lớp DMS để chuyển đổi từ ký hiệu độ-phút-giây sang độ thập phân.

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

Với lớp DMS, bạn có thể vẽ đường biên giới của Colorado bằng cách xác định vị trí của 4 góc LatLng và hiển thị các vị trí đó dưới dạng Polygon. Thêm mã sau vào 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),
    )
}

Bây giờ, hãy gọi ColoradoPolyon() bên trong khối nội dung GoogleMap.

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

Giờ đây, ứng dụng sẽ phác thảo Tiểu bang Colorado trong khi vẫn giữ nguyên màu nền.

13. Thêm lớp KML và thanh tỷ lệ

Trong phần cuối cùng này, bạn sẽ phác thảo sơ bộ các dãy núi và thêm một thanh tỷ lệ vào bản đồ.

Vẽ đường viền cho dãy núi

Trước đây, bạn đã vẽ một đường viền xung quanh Colorado. Tại đây, bạn sẽ thêm các hình dạng phức tạp hơn vào bản đồ. Mã khởi động bao gồm một tệp Ngôn ngữ đánh dấu Keyhole (KML) phác thảo sơ bộ các dãy núi quan trọng. Thư viện tiện ích SDK Maps dành cho Android có một chức năng để thêm lớp KML vào bản đồ. Trong MountainMap.kt, hãy thêm lệnh gọi MapEffect vào khối nội dung GoogleMap sau khối when. Hàm MapEffect được gọi bằng một đối tượng GoogleMap. Nền tảng này có thể đóng vai trò là cầu nối hữu ích giữa các API và thư viện không kết hợp được, yêu cầu đối tượng GoogleMap.

  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()
      }
    }

Thêm tỷ lệ bản đồ

Trong nhiệm vụ cuối cùng, bạn sẽ thêm một tỷ lệ vào bản đồ. ScaleBar triển khai một thành phần kết hợp tỷ lệ có thể được thêm vào bản đồ. Xin lưu ý rằng ScaleBar không phải là

@GoogleMapComposable và do đó không thể thêm vào nội dung GoogleMap. Thay vào đó, bạn sẽ thêm đối tượng này vào Box chứa bản đồ.

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

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

Chạy ứng dụng để xem lớp học lập trình đã triển khai đầy đủ.

14. Lấy đoạn mã giải pháp

Để tải mã này xuống khi lớp học lập trình đã kết thúc, bạn có thể sử dụng các lệnh sau:

  1. Sao chép kho lưu trữ nếu bạn đã cài đặt git.
$ git clone https://github.com/googlemaps-samples/codelab-maps-platform-101-compose.git

Ngoài ra, bạn có thể nhấp vào nút sau đây để tải mã nguồn xuống.

  1. Sau khi nhận được mã, hãy mở dự án trong thư mục solution trong Android Studio.

15. Xin chúc mừng

Xin chúc mừng! Bạn đã tìm hiểu nhiều nội dung và hy vọng rằng bạn đã hiểu rõ hơn về các tính năng cốt lõi có trong Maps SDK dành cho Android.

Tìm hiểu thêm

  • Maps SDK for Android – Tạo bản đồ, vị trí và trải nghiệm không gian địa lý linh hoạt, mang tính tương tác và được tuỳ chỉnh cho các ứng dụng Android của bạn.
  • Thư viện Maps Compose – một tập hợp các hàm có khả năng kết hợp và kiểu dữ liệu nguồn mở mà bạn có thể dùng với Jetpack Compose để tạo ứng dụng.
  • android-maps-compose – mã mẫu trên GitHub minh hoạ tất cả các tính năng được đề cập trong lớp học lập trình này và nhiều tính năng khác.
  • Các lớp học lập trình khác về Kotlin để tạo ứng dụng Android bằng Google Maps Platform