1. 시작하기 전에
이 Codelab에서는 Android용 Maps SDK를 앱과 통합하고 다양한 유형의 마커를 사용하여 미국 콜로라도의 산 지도를 표시하는 앱을 빌드하여 핵심 기능을 사용하는 방법을 알려줍니다. 또한 지도에 다른 도형을 그리는 방법도 알아봅니다.
이 Codelab을 마치면 다음과 같이 표시됩니다.
기본 요건
- Kotlin, Jetpack Compose, Android 개발에 관한 기본 지식
실습할 내용
- Android용 Maps SDK의 지도 Compose 라이브러리를 사용 설정하고 사용하여 Android 앱에
GoogleMap
추가 - 마커 추가 및 맞춤설정
- 지도에 다각형 그리기
- 프로그래매틱 방식으로 카메라의 시점 조정
필요한 항목
- Android용 Maps SDK
- 결제가 사용 설정된 Google 계정
- Android 스튜디오의 최신 안정화 버전
- Android 5.0 이상 기반의 Google API 플랫폼을 실행하는 Android 기기 또는 Android Emulator (설치 단계는 Android Emulator에서 앱 실행 참고)
- 인터넷 연결
2. 설정
다음 사용 설정 단계에서는 Android용 Maps SDK를 사용 설정해야 합니다.
Google Maps Platform 설정하기
Google Cloud Platform 계정 및 결제가 사용 설정된 프로젝트가 없는 경우 Google Maps Platform 시작하기 가이드를 참고하여 결제 계정 및 프로젝트를 만듭니다.
- Cloud Console에서 프로젝트 드롭다운 메뉴를 클릭하고 이 Codelab에 사용할 프로젝트를 선택합니다.
3. 빠른 시작
빠르게 시작할 수 있도록 이 Codelab을 따라 하는 데 도움이 되는 시작 코드가 있습니다. 솔루션으로 바로 넘어갈 수도 있지만, 모든 단계를 따라 하면서 직접 빌드하려면 계속 읽어주시기 바랍니다.
git
를 설치한 경우 저장소를 클론합니다.
git clone https://github.com/googlemaps-samples/codelab-maps-platform-101-compose.git
또는 다음 버튼을 클릭하여 소스 코드를 다운로드할 수도 있습니다.
- 코드를 받으면 Android 스튜디오의
starter
디렉터리 내에 있는 프로젝트를 엽니다.
4. 프로젝트에 API 키 추가
이 섹션에서는 앱에서 안전하게 참조할 수 있도록 API 키를 저장하는 방법을 설명합니다. API 키는 버전 제어 시스템에 체크인하면 안 되며, 프로젝트의 루트 디렉터리의 로컬 사본에 배치될 secrets.properties
파일에 저장하는 것이 좋습니다. secrets.properties
파일에 관한 자세한 내용은 Gradle 속성 파일을 참고하세요.
이 작업을 간소화하려면 Android용 Secrets Gradle 플러그인을 사용하는 것이 좋습니다.
Google 지도 프로젝트에 Android용 Secrets Gradle 플러그인을 설치하려면 다음 단계를 따르세요.
- Android 스튜디오에서 최상위 수준
build.gradle.kts
파일을 열고 다음 코드를buildscript
아래dependencies
요소에 추가합니다.buildscript { dependencies { classpath("com.google.android.libraries.mapsplatform.secrets-gradle-plugin:secrets-gradle-plugin:2.0.1") } }
- 모듈 수준
build.gradle.kts
파일을 열고plugins
요소에 다음 코드를 추가합니다.plugins { // ... id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin") }
- 모듈 수준
build.gradle.kts
파일에서targetSdk
및compileSdk
를 34 이상으로 설정해야 합니다. - 파일을 저장하고 프로젝트를 Gradle과 동기화합니다.
- 최상위 수준 디렉터리에서
secrets.properties
파일을 연 후 다음 코드를 추가합니다.YOUR_API_KEY
를 직접 생성한 API 키로 변경합니다.secrets.properties
가 버전 제어 시스템에 체크인되는 데서 제외되었으므로 키를 이 파일에 저장합니다.MAPS_API_KEY=YOUR_API_KEY
- 파일을 저장합니다.
- 최상위 수준 디렉터리에서
secrets.properties
파일과 동일한 폴더에local.defaults.properties
파일을 만든 후 다음 코드를 추가합니다. 이 파일의 목적은MAPS_API_KEY=DEFAULT_API_KEY
secrets.properties
파일이 없는 경우 빌드에 실패하지 않도록 API 키의 백업 위치를 제공하는 것입니다. 이는 버전 제어 시스템에서 앱을 복제하고 API 키를 제공하는secrets.properties
파일을 아직 로컬에서 생성하지 않은 경우 발생합니다. - 파일을 저장합니다.
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}" />
- Android 스튜디오에서 모듈 수준
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 지도를 추가합니다.
지도 Compose 종속 항목 추가
앱 내에서 API 키에 액세스할 수 있게 되었으므로, 다음 단계는 Android용 Maps SDK 종속 항목을 앱의 build.gradle.kts
파일에 추가하는 것입니다. Jetpack Compose로 빌드하려면 Android용 Maps SDK의 요소를 컴포저블 함수 및 데이터 유형으로 제공하는 지도 Compose 라이브러리를 사용하세요.
build.gradle.kts
앱 수준 build.gradle.kts
파일에서 Compose가 아닌 Android용 Maps SDK 종속 항목을 다음으로 바꿉니다.
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. 클라우드 기반 지도 스타일 지정
클라우드 기반 지도 스타일 지정을 사용하여 지도 스타일을 맞춤설정할 수 있습니다.
지도 ID 만들기
연결된 지도 스타일이 있는 지도 ID를 아직 만들지 않은 경우 지도 ID 가이드를 참고하여 다음 단계를 완료하세요.
- 지도 ID 만들기
- 지도 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피트 이상인 산을 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()
}
제공된 클래스: MountainsRepository
, MountainsViewModel
시작 프로젝트에는 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
MountainsViewModel
는 산 컬렉션을 로드하고 mountainsScreenViewState
를 통해 컬렉션과 UI 상태의 다른 부분을 노출하는 ViewModel
클래스입니다. 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 스튜디오에서 MountainsRepository
및 MountainsViewModel
클래스를 열면 됩니다.
ViewModel 사용
뷰 모델은 MainActivity
에서 viewState
를 가져오는 데 사용됩니다. 이 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
을 만듭니다. GoogleMap
의 cameraPositionState
매개변수를 방금 만든 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 bar에서 확대/축소 범위 버튼을 클릭할 때마다 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,
)
}
}
}
마커 추가
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
함수를 사용하여 맞춤설정된 BitmapDescriptor
를 두 개 만듭니다. 하나는 4,000m 이상의 산에 사용하고 다른 하나는 일반 산에 사용합니다. 그런 다음 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
함수를 사용하여 BitmapDescriptor
를 두 개 만듭니다. 하나는 14,000피트가 넘는 산용이고 다른 하나는 나머지 산용입니다. 이러한 아이콘을 사용하여 각 유형에 맞는 맞춤 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
를 사용하여 사용자의 언어에 따라 적절한 표시 단위로 변환합니다. 이는 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
함수는 맞춤 배경 색상 구성표를 사용하여 컴포저블 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)
)
}
이제 4,000m 이상의 산에 대한 색 구성표와 기타 산에 대한 색 구성표를 만듭니다. 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. 지도에 그리기
마커를 추가하여 지도에 그리는 한 가지 방법을 알아보았는데, Android용 Maps SDK는 지도에 유용한 정보를 표시하기 위해 그릴 수 있는 여러 가지 다른 방법을 지원합니다.
예를 들어 지도에 경로와 영역을 나타내려면 Polyline
및 Polygon
을 사용하여 지도에 표시할 수 있습니다. 또는 지표면에 이미지를 고정하려는 경우 GroundOverlay
를 사용할 수 있습니다.
이 작업에서는 콜로라도주 주변에 도형(윤곽선)을 그리는 방법을 알아봅니다. 콜로라도의 경계는 위도 37°N~41°N, 경도 102°03'W~109°03'W로 정의됩니다. 이렇게 하면 윤곽선을 매우 간단하게 그릴 수 있습니다.
시작 코드에는 도-분-초 표기법에서 십진법으로 변환하는 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 레이어 및 축척 추가
이 마지막 섹션에서는 다양한 산맥을 대략적으로 설명하고 지도에 눈금자를 추가합니다.
산맥을 간략하게 설명해 줘.
이전에는 콜로라도 주변에 윤곽선을 그렸습니다. 여기에서는 지도에 더 복잡한 도형을 추가합니다. 시작 코드에는 중요한 산맥을 대략적으로 설명하는 KML(Keyhole 마크업 언어) 파일이 포함되어 있습니다. Android용 Maps SDK 유틸리티 라이브러리에는 지도에 KML 레이어를 추가하는 기능이 있습니다. MountainMap.kt
에서 when
블록 뒤에 있는 GoogleMap
콘텐츠 블록에 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의 코드를 다운로드하려면 다음 명령어를 사용하면 됩니다.
git
를 설치한 경우 저장소를 클론합니다.
$ git clone https://github.com/googlemaps-samples/codelab-maps-platform-101-compose.git
또는 다음 버튼을 클릭하여 소스 코드를 다운로드할 수도 있습니다.
- 코드를 받으면 Android 스튜디오의
solution
디렉터리 내에 있는 프로젝트를 엽니다.
15. 축하합니다
축하합니다. 많은 내용을 살펴봤습니다. 이제 Android용 Maps SDK에서 제공하는 핵심 기능에 대해 더 잘 이해하셨기를 바랍니다.
자세히 알아보기
- Android용 Maps SDK - Android 앱을 위한 양방향의 동적 맞춤 지도, 위치, 지리정보 환경을 구축하세요.
- Maps Compose 라이브러리 - Jetpack Compose와 함께 사용하여 앱을 빌드할 수 있는 오픈소스 구성 가능한 함수 및 데이터 유형의 집합입니다.
- android-maps-compose - 이 Codelab 등에서 다룬 모든 기능을 보여주는 GitHub의 샘플 코드입니다.
- Google Maps Platform으로 Android 앱을 빌드하는 추가 Kotlin Codelab