Android 앱에 지도 추가(Kotlin)

1. 시작하기 전에

이 Codelab에서는 Android용 Maps SDK를 앱과 통합하고 미국 캘리포니아주 샌프란시스코에 있는 자전거 매장을 표시하는 앱을 빌드하여 핵심 기능을 사용하는 방법을 알려줍니다.

f05e1ca27ff42bf6.png

기본 요건

  • Kotlin 및 Android 개발에 대한 기본 지식

수행할 작업

  • Android용 Maps SDK를 사용하여 Android 앱에 Google 지도를 추가합니다.
  • 마커를 추가, 맞춤설정 및 클러스터링합니다.
  • 지도에 다중선과 다각형을 그립니다.
  • 프로그래밍 방식으로 카메라의 시점을 조정합니다.

필요한 항목

2. 설정

다음 사용 설정 단계를 진행하려면 Android용 Maps SDK를 사용 설정해야 합니다.

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의 Credentials 페이지에서 API 키를 생성합니다. 이 동영상 또는 이 문서의 단계를 따릅니다. Google Maps Platform에 대한 모든 요청에는 API 키가 필요합니다.

3. 빠른 시작

빠르게 시작할 수 있도록 이 Codelab을 따라하는 데 도움이 되는 시작 코드가 있습니다. 해법으로 바로 넘어갈 수 있지만 모든 단계를 따라하면서 직접 빌드하려면 계속 읽으시기 바랍니다.

  1. git을 설치한 경우 저장소를 클론합니다.
git clone https://github.com/googlecodelabs/maps-platform-101-android.git

또는 다음 버튼을 클릭하여 소스 코드를 다운로드할 수도 있습니다.

  1. 코드를 받으면 Android 스튜디오의 starter 디렉터리 내에 있는 프로젝트를 엽니다.

4. Google 지도 추가하기

이 섹션에서는 앱을 실행할 때 로드되도록 Google 지도를 추가합니다.

d1d068b5d4ae38b9.png

API 키 추가하기

이전 단계에서 만든 API 키를 앱에 제공해야 Android용 Maps SDK에서 키를 앱과 연결할 수 있습니다.

  1. API 키를 제공하려면 프로젝트의 루트 디렉터리(gradle.propertiessettings.gradle과 동일한 수준)에서 local.properties라는 파일을 엽니다.
  2. 이 파일에서 내가 만든 API 키를 값으로 갖는 새 키 GOOGLE_MAPS_API_KEY를 정의합니다.

local.properties

GOOGLE_MAPS_API_KEY=YOUR_KEY_HERE

local.properties는 Git 저장소의 .gitignore 파일에 나열되어 있습니다. 이는 API 키가 민감한 정보로 간주되고 가급적 소스 제어에 체크인하면 안 되기 때문입니다.

  1. 다음으로, 앱 전체에서 사용할 수 있도록 API를 노출하려면 app/ 디렉터리에 있는 앱의 build.gradle 파일에 Secrets Gradle Plugin for Android 플러그인을 포함하고 plugins 블록 내에 다음 줄을 추가합니다.

앱 수준 build.gradle

plugins {
    // ...
    id 'com.google.android.libraries.mapsplatform.secrets-gradle-plugin'
}

또한 다음 클래스 경로를 포함하도록 프로젝트 수준의 build.gradle 파일을 수정해야 합니다.

프로젝트 수준 build.gradle

buildscript {
    dependencies {
        // ...
        classpath "com.google.android.libraries.mapsplatform.secrets-gradle-plugin:secrets-gradle-plugin:1.3.0"
    }
}

이 플러그인은 local.properties 파일 내에서 정의한 키를 Android 매니페스트 파일의 빌드 변수로 제공하고 빌드 시 이를 Gradle에서 생성된 BuildConfig 클래스의 변수로 사용할 수 있게 합니다. 이 플러그인을 사용하면 local.properties에서 속성을 읽는 데 필요한 상용구 코드가 제거되어 앱 전체에서 액세스할 수 있습니다.

Google 지도 종속 항목 추가하기

  1. 앱 내에서 API 키에 액세스할 수 있게 되었으므로, 다음 단계는 Android용 Maps SDK 종속 항목을 앱의 build.gradle 파일에 추가하는 것입니다.

이 Codelab과 함께 제공되는 시작 프로젝트에서는 이 종속 항목이 이미 추가되어 있습니다.

build.gradle

dependencies {
   // Dependency to include Maps SDK for Android
   implementation 'com.google.android.gms:play-services-maps:17.0.0'
}
  1. 그런 다음 AndroidManifest.xml에 새 meta-data 태그를 추가하여 이전 단계에서 만든 API 키를 전달합니다. 이렇게 하려면 Android 스튜디오에서 이 파일을 열고 app/src/main에 있는 AndroidManifest.xml 파일의 application 객체 내에 다음 meta-data 태그를 추가합니다.

AndroidManifest.xml

<meta-data
   android:name="com.google.android.geo.API_KEY"
   android:value="${GOOGLE_MAPS_API_KEY}" />
  1. 다음으로, app/src/main/res/layout/ 디렉터리에 activity_main.xml이라는 새 레이아웃 파일을 만들고 다음과 같이 정의합니다.

activity_main.xml

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:tools="http://schemas.android.com/tools"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   tools:context=".MainActivity">

   <fragment
       class="com.google.android.gms.maps.SupportMapFragment"
       android:id="@+id/map_fragment"
       android:layout_width="match_parent"
       android:layout_height="match_parent" />

</FrameLayout>

이 레이아웃에는 SupportMapFragment가 포함된 단일 FrameLayout이 있습니다. 이 프래그먼트에는 이후 단계에서 사용하는 기본 GoogleMaps 객체가 포함됩니다.

  1. 마지막으로, app/src/main/java/com/google/codelabs/buildyourfirstmap에 있는 MainActivity 클래스를 업데이트합니다. 다음 코드를 추가하여 onCreate 메서드를 재정의하면 방금 만든 새 레이아웃으로 콘텐츠를 설정할 수 있습니다.

MainActivity

override fun onCreate(savedInstanceState: Bundle?) {
   super.onCreate(savedInstanceState)
   setContentView(R.layout.activity_main)
}
  1. 앱을 실행하면 기기 화면에 지도가 로드됩니다.

5. 클라우드 기반 지도 스타일 지정 (선택사항)

클라우드 기반 지도 스타일 지정을 사용하여 지도 스타일을 맞춤설정할 수 있습니다.

지도 ID 만들기

연결된 지도 스타일이 있는 지도 ID를 아직 만들지 않은 경우 지도 ID 가이드를 참고하여 다음 단계를 완료하세요.

  1. 지도 ID 만들기
  2. 지도 ID를 지도 스타일에 연결하기

앱에 지도 ID 추가하기

만든 지도 ID를 사용하려면 activity_main.xml 파일을 수정하고 SupportMapFragmentmap:mapId 속성에 지도 ID를 전달합니다.

activity_main.xml

<fragment xmlns:map="http://schemas.android.com/apk/res-auto"
    class="com.google.android.gms.maps.SupportMapFragment"
    <!-- ... -->
    map:mapId="YOUR_MAP_ID" />

그런 다음 앱을 실행하여 선택한 스타일로 지도를 확인합니다.

6. 마커 추가하기

이 작업을 통해 지도에서 강조하려는 관심 장소를 나타내는 마커를 지도에 추가합니다. 먼저 시작 프로젝트에 제공된 장소 목록을 검색한 다음, 해당 장소를 지도에 추가합니다. 이 예에서는 자전거 매장입니다.

bc5576877369b554.png

GoogleMap 참조 가져오기

먼저 메서드를 사용할 수 있도록 GoogleMap 객체에 대한 참조를 가져와야 합니다. 이렇게 하려면 setContentView() 호출 바로 다음에 있는 MainActivity.onCreate() 메서드에 다음 코드를 추가하세요.

MainActivity.onCreate()

val mapFragment = supportFragmentManager.findFragmentById(
    R.id.map_fragment
) as? SupportMapFragment
mapFragment?.getMapAsync { googleMap ->
    addMarkers(googleMap)
}

이 구현은 먼저 SupportFragmentManager 객체의 findFragmentById() 메서드를 사용하여 이전 단계에서 추가한 SupportMapFragment를 찾습니다. 참조를 가져오면 getMapAsync() 호출 후에 람다를 전달합니다. 이 람다는 GoogleMap 객체가 전달되는 위치입니다. 이 람다 내부에서는 곧 정의될 addMarkers() 메서드 호출이 이루어집니다.

제공된 클래스: PlacesReader

시작 프로젝트에는 PlacesReader 클래스가 제공되어 있습니다. 이 클래스는 places.json이라는 JSON 파일에 저장된 49개 장소의 목록을 읽고 List<Place>로 반환합니다. 장소는 미국 캘리포니아주 샌프란시스코에 있는 자전거 매장 목록을 나타냅니다.

이 클래스의 구현에 관해 궁금한 점이 있다면 GitHub에서 액세스하거나 Android 스튜디오에서 PlacesReader 클래스를 열면 됩니다.

PlacesReader

package com.google.codelabs.buildyourfirstmap.place

import android.content.Context
import com.google.codelabs.buildyourfirstmap.R
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import java.io.InputStream
import java.io.InputStreamReader

/**
* Reads a list of place JSON objects from the file places.json
*/
class PlacesReader(private val context: Context) {

   // GSON object responsible for converting from JSON to a Place object
   private val gson = Gson()

   // InputStream representing places.json
   private val inputStream: InputStream
       get() = context.resources.openRawResource(R.raw.places)

   /**
    * Reads the list of place JSON objects in the file places.json
    * and returns a list of Place objects
    */
   fun read(): List<Place> {
       val itemType = object : TypeToken<List<PlaceResponse>>() {}.type
       val reader = InputStreamReader(inputStream)
       return gson.fromJson<List<PlaceResponse>>(reader, itemType).map {
           it.toPlace()
       }
   }

장소 로드하기

자전거 매장 목록을 로드하려면 MainActivityplaces라는 속성을 추가하고 다음과 같이 정의합니다.

MainActivity.places

private val places: List<Place> by lazy {
   PlacesReader(this).read()
}

이 코드는 List<Place>를 반환하는 PlacesReaderread() 메서드를 호출합니다. Place에는 name이라는 속성, 장소의 이름, latLng(장소가 위치한 곳의 좌표)가 있습니다.

장소

data class Place(
   val name: String,
   val latLng: LatLng,
   val address: LatLng,
   val rating: Float
)

지도에 마커 추가하기

이제 장소 목록이 메모리에 로드되었으므로 다음 단계는 지도에 이러한 장소를 나타내는 것입니다.

  1. MainActivityaddMarkers()라는 메서드를 만들고 다음과 같이 정의합니다.

MainActivity.addMarkers()

/**
* Adds marker representations of the places list on the provided GoogleMap object
*/
private fun addMarkers(googleMap: GoogleMap) {
   places.forEach { place ->
       val marker = googleMap.addMarker(
           MarkerOptions()
               .title(place.name)
               .position(place.latLng)
       )
   }
}

이 메서드는 places 목록을 반복해서 처리한 후 제공된 GoogleMap 객체에서 addMarker() 메서드를 호출합니다. 마커는 MarkerOptions 객체를 인스턴스화하여 생성되므로 마커 자체를 맞춤설정할 수 있습니다. 이 경우, 마커의 제목과 위치가 제공되며, 이는 각각 자전거 매장 이름과 해당 좌표를 나타냅니다.

  1. 앱을 실행하고 샌프란시스코로 이동하여 방금 추가한 마커를 확인하세요.

7. 마커 맞춤설정하기

방금 추가한 마커에 대한 여러 맞춤설정 옵션을 통해 마커를 돋보이게 만들고 사용자에게 유용한 정보를 전달할 수 있습니다. 이 작업에서는 각 마커의 이미지와 마커를 탭할 때 표시되는 정보 창을 맞춤설정하여 해당 옵션 중 일부를 살펴봅니다.

a26f82802fe838e9.png

정보 창 추가하기

기본적으로 마커를 탭하면 정보 창에 제목과 스니펫이 표시됩니다(설정된 경우). 장소의 주소 및 평점과 같은 추가 정보를 표시하도록 맞춤설정할 수 있습니다.

marker_info_contents.xml 만들기

먼저 marker_info_contents.xml이라는 새 레이아웃 파일을 만듭니다.

  1. 이렇게 하려면 Android 스튜디오의 프로젝트 뷰에서 app/src/main/res/layout 폴더를 마우스 오른쪽 버튼으로 클릭하고 새로 만들기 > 레이아웃 리소스 파일을 선택합니다.

8cac51fcbef9171b.png

  1. 대화상자의 파일 이름 필드에 marker_info_contents를 입력하고 Root element 필드에 LinearLayout을 입력한 다음 확인을 클릭합니다.

8783af12baf07a80.png

이 레이아웃 파일은 나중에 정보 창에 있는 내용을 나타내도록 확장됩니다.

  1. 세로 LinearLayout 뷰 그룹 내에 세 개의 TextViews를 추가하는 다음 코드 스니펫의 내용을 복사하여 파일의 기본 코드에 덮어씁니다.

marker_info_contents.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:tools="http://schemas.android.com/tools"
   android:orientation="vertical"
   android:layout_width="wrap_content"
   android:layout_height="wrap_content"
   android:gravity="center_horizontal"
   android:padding="8dp">

   <TextView
       android:id="@+id/text_view_title"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:textColor="@android:color/black"
       android:textSize="18sp"
       android:textStyle="bold"
       tools:text="Title"/>

   <TextView
       android:id="@+id/text_view_address"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:textColor="@android:color/black"
       android:textSize="16sp"
       tools:text="123 Main Street"/>

   <TextView
       android:id="@+id/text_view_rating"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:textColor="@android:color/black"
       android:textSize="16sp"
       tools:text="Rating: 3"/>

</LinearLayout>

InfoWindowAdapter 구현 만들기

맞춤 정보 창의 레이아웃 파일을 만든 후 다음 단계는 GoogleMap.InfoWindowAdapter 인터페이스를 구현하는 것입니다. 이 인터페이스에는 getInfoWindow()getInfoContents()라는 두 가지 메서드가 포함됩니다. 두 메서드 모두 선택 사항인 View 객체를 반환하는데, 여기에서 전자는 창 자체를 맞춤설정하는 데 사용되고 후자는 내용을 맞춤설정하는 데 사용됩니다. 이 경우에는 getInfoWindow()에서 null을 반환하는 동안 두 가지를 모두 구현하고 getInfoContents()의 반환을 맞춤설정합니다. 이는 기본 창을 사용해야 함을 나타냅니다.

  1. Android 스튜디오에서 프로젝트 뷰의 app/src/main/java/com/google/codelabs/buildyourfirstmap 폴더를 마우스 오른쪽 버튼으로 클릭한 다음 새로 만들기 > Kotlin 파일/클래스를 선택하여 MainActivity와 동일한 패키지에 MarkerInfoWindowAdapter라는 새 Kotlin 파일을 만듭니다.

3975ba36eba9f8e1.png

  1. 대화상자에서 MarkerInfoWindowAdapter를 입력하고 파일이 강조 표시된 상태를 유지합니다.

992235af53d3897f.png

  1. 파일을 만들었으면 다음 코드 스니펫의 내용을 새 파일에 복사합니다.

MarkerInfoWindowAdapter

import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.widget.TextView
import com.google.android.gms.maps.GoogleMap
import com.google.android.gms.maps.model.Marker
import com.google.codelabs.buildyourfirstmap.place.Place

class MarkerInfoWindowAdapter(
    private val context: Context
) : GoogleMap.InfoWindowAdapter {
   override fun getInfoContents(marker: Marker?): View? {
       // 1. Get tag
       val place = marker?.tag as? Place ?: return null

       // 2. Inflate view and set title, address, and rating
       val view = LayoutInflater.from(context).inflate(
           R.layout.marker_info_contents, null
       )
       view.findViewById<TextView>(
           R.id.text_view_title
       ).text = place.name
       view.findViewById<TextView>(
           R.id.text_view_address
       ).text = place.address
       view.findViewById<TextView>(
           R.id.text_view_rating
       ).text = "Rating: %.2f".format(place.rating)

       return view
   }

   override fun getInfoWindow(marker: Marker?): View? {
       // Return null to indicate that the
       // default window (white bubble) should be used
       return null
   }
}

getInfoContents() 메서드의 내용에서, 메서드에 제공된 마커는 Place 유형으로 변환되며, 변환이 불가능한 경우 메서드는 null을 반환합니다(Marker에 태그 속성을 아직 설정하지 않았지만 다음 단계에서 이를 설정합니다).

다음으로, marker_info_contents.xml 레이아웃이 확장되고 TextViews 포함에 관한 텍스트가 Place 태그로 설정됩니다.

MainActivity 업데이트하기

지금까지 만든 모든 구성요소를 연결하려면 MainActivity 클래스에 두 줄을 추가해야 합니다.

먼저 getMapAsync 메서드 호출 내에 맞춤 InfoWindowAdapter, MarkerInfoWindowAdapter를 전달하려면 GoogleMap 객체의 setInfoWindowAdapter() 메서드를 호출하고 MarkerInfoWindowAdapter의 새 인스턴스를 만듭니다.

  1. getMapAsync() 람다 내에 있는 addMarkers() 메서드 호출 뒤에 다음 코드를 추가하여 이 작업을 수행합니다.

MainActivity.onCreate()

// Set custom info window adapter
googleMap.setInfoWindowAdapter(MarkerInfoWindowAdapter(this))

마지막으로, 지도에 추가된 모든 마커에서 각 장소를 태그 속성으로 설정해야 합니다.

  1. 이렇게 하려면 addMarkers() 함수의 places.forEach{} 호출을 다음과 같이 수정합니다.

MainActivity.addMarkers()

places.forEach { place ->
   val marker = googleMap.addMarker(
       MarkerOptions()
           .title(place.name)
           .position(place.latLng)
           .icon(bicycleIcon)
   )

   // Set place as the tag on the marker object so it can be referenced within
   // MarkerInfoWindowAdapter
   marker.tag = place
}

맞춤 마커 이미지 추가하기

마커 이미지 맞춤설정은 지도에서 마커가 나타내는 장소의 유형을 재미있게 전달하는 방식 중 하나입니다. 이 단계에서는 지도에 각 매장을 나타내는 기본 빨간색 마커 대신 자전거를 표시합니다. 시작 프로젝트에는 app/src/res/drawable에 자전거 아이콘 ic_directions_bike_black_24dp.xml이 포함되어 있으며, 이를 사용하게 됩니다.

6eb7358bb61b0a88.png

마커에 맞춤 비트맵 설정하기

벡터 드로어블 자전거 아이콘을 사용하는 경우, 다음 단계는 해당 드로어블을 지도에 있는 각 마커의 아이콘으로 설정하는 것입니다. MarkerOptions에는 이 작업을 처리하는 데 사용하는 BitmapDescriptor에서 취하는 icon 메서드가 있습니다.

먼저 방금 추가한 벡터 드로어블을 BitmapDescriptor로 변환해야 합니다. 시작 프로젝트에 포함된 BitMapHelper라는 파일에는 이 작업을 처리하는 vectorToBitmap()이라는 도우미 함수가 포함되어 있습니다.

BitmapHelper

package com.google.codelabs.buildyourfirstmap

import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.util.Log
import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes
import androidx.core.content.res.ResourcesCompat
import androidx.core.graphics.drawable.DrawableCompat
import com.google.android.gms.maps.model.BitmapDescriptor
import com.google.android.gms.maps.model.BitmapDescriptorFactory

object BitmapHelper {
   /**
    * Demonstrates converting a [Drawable] to a [BitmapDescriptor],
    * for use as a marker icon. Taken from ApiDemos on GitHub:
    * https://github.com/googlemaps/android-samples/blob/main/ApiDemos/kotlin/app/src/main/java/com/example/kotlindemos/MarkerDemoActivity.kt
    */
   fun vectorToBitmap(
      context: Context,
      @DrawableRes id: Int,
      @ColorInt color: Int
   ): BitmapDescriptor {
       val vectorDrawable = ResourcesCompat.getDrawable(context.resources, id, null)
       if (vectorDrawable == null) {
           Log.e("BitmapHelper", "Resource not found")
           return BitmapDescriptorFactory.defaultMarker()
       }
       val bitmap = Bitmap.createBitmap(
           vectorDrawable.intrinsicWidth,
           vectorDrawable.intrinsicHeight,
           Bitmap.Config.ARGB_8888
       )
       val canvas = Canvas(bitmap)
       vectorDrawable.setBounds(0, 0, canvas.width, canvas.height)
       DrawableCompat.setTint(vectorDrawable, color)
       vectorDrawable.draw(canvas)
       return BitmapDescriptorFactory.fromBitmap(bitmap)
   }
}

이 메서드는 Context, 드로어블 리소스 ID, 색상 정수를 사용하며, 메서드의 BitmapDescriptor 표현을 만듭니다.

도우미 메서드를 사용하여 bicycleIcon이라는 새 속성을 선언하고 MainActivity.BicycleIcon 정의를 해당 속성에 부여하기

private val bicycleIcon: BitmapDescriptor by lazy {
   val color = ContextCompat.getColor(this, R.color.colorPrimary)
   BitmapHelper.vectorToBitmap(this, R.drawable.ic_directions_bike_black_24dp, color)
}

이 속성은 앱에서 사전 정의된 색상 colorPrimary를 사용하여 자전거 아이콘에 색조를 적용하거나 BitmapDescriptor로 반환합니다.

  1. 이 속성을 사용하여 아이콘 맞춤설정을 완료하려면 addMarkers() 메서드에서 MarkerOptionsicon 메서드를 호출합니다. 그러면 마커 속성이 다음과 같이 됩니다.

MainActivity.addMarkers()

val marker = googleMap.addMarker(
    MarkerOptions()
        .title(place.name)
        .position(place.latLng)
        .icon(bicycleIcon)
)
  1. 앱을 실행하여 업데이트된 마커를 확인하세요.

8. 마커 클러스터링

지도를 확대/축소하는 정도에 따라 추가한 마커가 겹칠 수 있습니다. 중첩되어 있는 마커는 상호작용하기가 매우 까다로우며 많은 노이즈를 생성하므로 앱의 사용성에 영향을 미칩니다.

689591edc86d73724.png

이와 관련한 사용자 환경을 개선하려면 밀집하여 클러스터링된 큰 데이터 세트가 있을 때마다 마커 클러스터링을 구현하는 것이 좋습니다. 클러스터링을 사용하면 지도를 확대/축소할 때 가까이에 있는 마커가 다음과 같이 함께 클러스터링됩니다.

f05e1ca27ff42bf6.png

이 기능을 구현하려면 Android용 Maps SDK 유틸리티 라이브러리가 필요합니다.

Android용 Maps SDK 유틸리티 라이브러리

Android용 Maps SDK 유틸리티 라이브러리는 Android용 Maps SDK의 기능을 확장하기 위한 방편으로 생성되었습니다. 이는 마커 클러스터링, 히트맵, KML 및 GeoJson 지원, 다중선 인코딩 및 디코딩, 구면 기하학에 대한 몇몇 도우미 함수와 같은 고급 기능을 제공합니다.

build.gradle 업데이트하기

유틸리티 라이브러리는 Android용 Maps SDK와 별도로 패키징되므로 build.gradle 파일에 추가 종속 항목을 추가해야 합니다.

  1. app/build.gradle 파일의 dependencies 섹션을 업데이트합니다.

build.gradle

implementation 'com.google.maps.android:android-maps-utils:1.1.0'
  1. 이 줄을 추가한 후에는 프로젝트 동기화를 수행하여 새 종속 항목을 가져와야 합니다.

b7b030ec82c007fd.png

클러스터링 구현하기

앱에서 클러스터링을 구현하려면 다음 3가지 단계를 따르세요.

  1. ClusterItem 인터페이스를 구현합니다.
  2. DefaultClusterRenderer 클래스의 서브클래스를 만듭니다.
  3. ClusterManager를 만들고 항목을 추가합니다.

ClusterItem 인터페이스 구현하기

지도에서 클러스터형 마커를 나타내는 모든 객체는 ClusterItem 인터페이스를 구현해야 합니다. 이 경우, Place 모델이 ClusterItem을 준수해야 합니다. Place.kt 파일을 열어 다음과 같이 수정합니다.

장소

data class Place(
   val name: String,
   val latLng: LatLng,
   val address: String,
   val rating: Float
) : ClusterItem {
   override fun getPosition(): LatLng =
       latLng

   override fun getTitle(): String =
       name

   override fun getSnippet(): String =
       address
}

ClusterItem은 다음 세 가지 메서드를 정의합니다.

  • getPosition() - 장소의 LatLng를 나타냅니다.
  • getTitle() - 장소의 이름을 나타냅니다.
  • getSnippet() - 장소의 주소를 나타냅니다.

DefaultClusterRenderer 클래스의 서브클래스 만들기

클러스터링을 구현하는 클래스인 ClusterManager는 화면을 이동하거나 지도를 확대/축소할 때 내부적으로 ClusterRenderer 클래스를 사용하여 클러스터 생성을 처리합니다. 기본적으로 ClusterRenderer를 구현하는 기본 렌더기인 DefaultClusterRenderer가 제공됩니다. 간단한 경우에는 이 렌더기로 충분합니다. 그러나 이 경우에는 마커를 맞춤설정해야 하므로 이 클래스를 확장하고 여기에 맞춤설정을 추가해야 합니다.

com.google.codelabs.buildyourfirstmap.place 패키지에서 Kotlin 파일 PlaceRenderer.kt를 만들고 다음과 같이 정의합니다.

PlaceRenderer

package com.google.codelabs.buildyourfirstmap.place

import android.content.Context
import androidx.core.content.ContextCompat
import com.google.android.gms.maps.GoogleMap
import com.google.android.gms.maps.model.BitmapDescriptor
import com.google.android.gms.maps.model.Marker
import com.google.android.gms.maps.model.MarkerOptions
import com.google.codelabs.buildyourfirstmap.BitmapHelper
import com.google.codelabs.buildyourfirstmap.R
import com.google.maps.android.clustering.ClusterManager
import com.google.maps.android.clustering.view.DefaultClusterRenderer

/**
* A custom cluster renderer for Place objects.
*/
class PlaceRenderer(
   private val context: Context,
   map: GoogleMap,
   clusterManager: ClusterManager<Place>
) : DefaultClusterRenderer<Place>(context, map, clusterManager) {

   /**
    * The icon to use for each cluster item
    */
   private val bicycleIcon: BitmapDescriptor by lazy {
       val color = ContextCompat.getColor(context,
           R.color.colorPrimary
       )
       BitmapHelper.vectorToBitmap(
           context,
           R.drawable.ic_directions_bike_black_24dp,
           color
       )
   }

   /**
    * Method called before the cluster item (the marker) is rendered.
    * This is where marker options should be set.
    */
   override fun onBeforeClusterItemRendered(
      item: Place,
      markerOptions: MarkerOptions
   ) {
       markerOptions.title(item.name)
           .position(item.latLng)
           .icon(bicycleIcon)
   }

   /**
    * Method called right after the cluster item (the marker) is rendered.
    * This is where properties for the Marker object should be set.
    */
   override fun onClusterItemRendered(clusterItem: Place, marker: Marker) {
       marker.tag = clusterItem
   }
}

이 클래스는 다음 두 함수를 재정의합니다.

  • onBeforeClusterItemRendered(): 클러스터가 지도에 렌더링되기 전에 호출됩니다. 여기에서 MarkerOptions를 통해 맞춤설정을 제공할 수 있으며, 이 경우에는 마커의 제목, 위치 및 아이콘이 설정됩니다.
  • onClusterItemRenderer(): 지도에서 마커가 렌더링된 직후 호출됩니다. 여기에서 생성된 Marker 객체에 액세스할 수 있으며, 이 경우에는 마커의 태그 속성을 설정합니다.

ClusterManager를 만들고 항목 추가하기

마지막으로 클러스터링을 사용하려면 MainActivity를 수정하여 ClusterManager를 인스턴스화하고 필요한 종속 항목을 제공해야 합니다. ClusterManager는 마커(ClusterItem 객체)를 내부적으로 추가하므로 마커를 지도에 직접 추가하는 대신 이 책임이 ClusterManager에 위임됩니다. 또한 ClusterManager는 내부적으로 setInfoWindowAdapter()도 호출하므로 ClusterMangerMarkerManager.Collection 객체에서 맞춤 정보 창을 설정해야 합니다.

  1. 시작하려면 MainActivity.onCreate()getMapAsync() 호출에서 람다의 내용을 수정합니다. addMarkers()setInfoWindowAdapter() 호출을 주석 처리하고 대신 다음 단계에서 정의하는 addClusteredMarkers()라는 메서드를 호출합니다.

MainActivity.onCreate()

mapFragment?.getMapAsync { googleMap ->
    //addMarkers(googleMap)
    addClusteredMarkers(googleMap)

    // Set custom info window adapter.
    // googleMap.setInfoWindowAdapter(MarkerInfoWindowAdapter(this))
}
  1. 다음으로, MainActivity에서 addClusteredMarkers()를 정의합니다.

MainActivity.addClusteredMarkers()

/**
* Adds markers to the map with clustering support.
*/
private fun addClusteredMarkers(googleMap: GoogleMap) {
   // Create the ClusterManager class and set the custom renderer.
   val clusterManager = ClusterManager<Place>(this, googleMap)
   clusterManager.renderer =
       PlaceRenderer(
           this,
           googleMap,
           clusterManager
       )

   // Set custom info window adapter
   clusterManager.markerCollection.setInfoWindowAdapter(MarkerInfoWindowAdapter(this))

   // Add the places to the ClusterManager.
   clusterManager.addItems(places)
   clusterManager.cluster()

   // Set ClusterManager as the OnCameraIdleListener so that it
   // can re-cluster when zooming in and out.
   googleMap.setOnCameraIdleListener {
       clusterManager.onCameraIdle()
   }
}

이 메서드는 ClusterManager를 인스턴스화하고 여기에 맞춤 렌더기 PlacesRenderer를 전달하고, 모든 장소를 추가하고, cluster() 메서드를 호출합니다. 또한 ClusterManager는 지도 객체에서 setInfoWindowAdapter() 메서드를 사용하므로 ClusterManager.markerCollection 객체에서 맞춤 정보 창을 설정해야 합니다. 마지막으로 사용자가 화면을 이동하고 지도를 확대/축소할 때 클러스터링이 변경되도록 해야 하므로, 카메라가 유휴 상태가 되면 clusterManager.onCameraIdle()이 호출되도록 googleMapOnCameraIdleListener가 제공됩니다.

  1. 앱을 실행하여 새로운 클러스터링된 매장을 확인하세요.

9. 지도에 그리기

마커를 추가하여 지도에 그리는 한 가지 방법을 알아보았는데, Android용 Maps SDK는 지도에 유용한 정보를 표시하기 위해 그릴 수 있는 여러 가지 다른 방법을 지원합니다.

예를 들어 지도에 경로와 영역을 나타내려면 다중선 및 다각형을 사용하여 지도에 표시할 수 있습니다. 또는 지표면에 이미지를 고정하려는 경우 지면 오버레이를 사용할 수 있습니다.

이 작업에서는 마커를 탭할 때마다 그 주변에 도형(원)을 그리는 방법을 알아봅니다.

f98ce13055453052.png

클릭 리스너 추가하기

일반적으로 클릭 리스너를 마커에 추가하는 방법은 setOnMarkerClickListener()를 통해 GoogleMap 객체에 직접 클릭 리스너를 전달하는 것입니다. 하지만 클러스터링을 사용 중이므로 대신 클릭 리스너를 ClusterManager에 제공해야 합니다.

  1. MainActivityaddClusteredMarkers() 메서드에서 cluster()에 대한 호출 바로 뒤에 다음 줄을 추가합니다.

MainActivity.addClusteredMarkers()

// Show polygon
clusterManager.setOnClusterItemClickListener { item ->
   addCircle(googleMap, item)
   return@setOnClusterItemClickListener false
}

이 메서드는 리스너를 추가하고 다음 단계에서 정의하는 addCircle() 메서드를 호출합니다. 마지막으로, 이 메서드에서 false가 반환되어 이 메서드가 이 이벤트를 사용하지 않았음을 나타냅니다.

  1. 다음으로 MainActivitycircle 속성과 addCircle() 메서드를 정의해야 합니다.

MainActivity.addCircle()

private var circle: Circle? = null

/**
* Adds a [Circle] around the provided [item]
*/
private fun addCircle(googleMap: GoogleMap, item: Place) {
   circle?.remove()
   circle = googleMap.addCircle(
       CircleOptions()
           .center(item.latLng)
           .radius(1000.0)
           .fillColor(ContextCompat.getColor(this, R.color.colorPrimaryTranslucent))
           .strokeColor(ContextCompat.getColor(this, R.color.colorPrimary))
   )
}

circle 속성이 설정되어 새 아이콘을 탭할 때마다 이전 원이 삭제되고 새 원이 추가됩니다. 원을 추가하기 위한 API는 마커를 추가하는 것과 매우 유사합니다.

  1. 지금 앱을 실행하여 변경사항을 확인하세요.

10. 카메라 제어

마지막 작업으로, 특정 지역을 중심으로 초점을 맞출 수 있는 몇 가지 카메라 제어 방식을 살펴봅니다.

카메라 및 뷰

앱을 실행하면 카메라가 아프리카 대륙을 표시합니다. 이때 추가한 마커를 찾으려면 번거롭게 샌프란시스코까지 화면을 이동한 후 확대/축소해야 합니다. 이는 세상을 탐색하는 재미있는 방법일 수 있지만 마커를 바로 표시하고자 하는 경우에는 유용하지 않습니다.

이를 위해 카메라의 위치를 프로그래밍을 통해 설정하여 뷰가 원하는 곳의 중앙에 위치하게 할 수 있습니다.

  1. 앱이 실행될 때 카메라 뷰를 조정하여 샌프란시스코로 초기화되도록 getMapAsync() 호출에 다음 코드를 추가해 보세요.

MainActivity.onCreate()

mapFragment?.getMapAsync { googleMap ->
   // Ensure all places are visible in the map.
   googleMap.setOnMapLoadedCallback {
       val bounds = LatLngBounds.builder()
       places.forEach { bounds.include(it.latLng) }
       googleMap.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds.build(), 20))
   }
}

먼저 지도가 로드된 후에만 카메라가 업데이트되도록 setOnMapLoadedCallback()이 호출됩니다. 카메라 업데이트를 호출하기 전에 크기와 같은 지도 속성을 계산해야 하므로 이 단계가 필요합니다.

람다에는 지도의 직사각형 영역을 정의하는 새로운 LatLngBounds 객체가 생성됩니다. 이는 모든 장소가 경계 내에 있도록 하기 위해 모든 장소의 LatLng 값을 포함하는 방식으로 점진적으로 빌드됩니다. 이 객체가 빌드되면 GoogleMapmoveCamera() 메서드가 호출되고 CameraUpdateFactory.newLatLngBounds(bounds.build(), 20)을 통해 여기에 CameraUpdate가 제공됩니다.

  1. 앱을 실행하고 카메라가 이제 샌프란시스코로 초기화되는지 확인합니다.

카메라 변경사항 수신하기

카메라 위치를 수정하는 것 외에도, 사용자가 지도를 탐색할 때 카메라 업데이트를 수신할 수도 있습니다. 이 기능은 카메라가 이동할 때 UI를 수정하려는 경우에 유용할 수 있습니다.

재미를 위해 카메라가 움직일 때마다 마커가 반투명해지도록 코드를 수정합니다.

  1. addClusteredMarkers() 메서드에서 메서드 하단에 다음 줄을 추가합니다.

MainActivity.addClusteredMarkers()

// When the camera starts moving, change the alpha value of the marker to translucent.
googleMap.setOnCameraMoveStartedListener {
   clusterManager.markerCollection.markers.forEach { it.alpha = 0.3f }
   clusterManager.clusterMarkerCollection.markers.forEach { it.alpha = 0.3f }
}

그러면 OnCameraMoveStartedListener가 추가되어 카메라가 움직이기 시작하면 모든 마커(클러스터와 마커 모두)의 알파 값이 0.3f로 수정되어 마커가 반투명으로 표시됩니다.

  1. 마지막으로, 카메라가 멈출 때 반투명 마커를 다시 불투명으로 수정하려면 addClusteredMarkers() 메서드에서 setOnCameraIdleListener의 내용을 다음과 같이 수정합니다.

MainActivity.addClusteredMarkers()

googleMap.setOnCameraIdleListener {
   // When the camera stops moving, change the alpha value back to opaque.
   clusterManager.markerCollection.markers.forEach { it.alpha = 1.0f }
   clusterManager.clusterMarkerCollection.markers.forEach { it.alpha = 1.0f }

   // Call clusterManager.onCameraIdle() when the camera stops moving so that reclustering
   // can be performed when the camera stops moving.
   clusterManager.onCameraIdle()
}
  1. 앱을 실행하여 결과를 확인하세요.

11. 지도 KTX

Google Maps Platform Android SDK를 하나 이상 사용하는 Kotlin 앱의 경우 Kotlin 확장 프로그램이나 KTX 라이브러리를 사용하여 코루틴, 확장 속성/함수 등과 같은 Kotlin 언어 기능을 활용할 수 있습니다. 각 Google Maps SDK에는 아래와 같이 해당하는 KTX 라이브러리가 있습니다.

Google Maps Platform KTX 다이어그램

이 작업에서는 지도 KTX 및 지도 유틸리티 KTX 라이브러리를 앱에 사용하고 이전 작업을 리팩터링하여 앱에서 Kotlin 관련 언어 기능을 사용할 수 있도록 합니다.

  1. 앱 수준 build.gradle 파일에 KTX 종속 항목 포함

앱은 Android용 Maps SDK와 Android용 Maps SDK 유틸리티 라이브러리를 모두 사용하므로 이 라이브러리에 해당하는 KTX 라이브러리를 포함해야 합니다. 또한 이 작업에서 AndroidX Lifecycle KTX 라이브러리에 있는 기능을 사용하므로 이 종속 항목을 앱 수준 build.gradle 파일에도 포함해야 합니다.

build.gradle

dependencies {
    // ...

    // Maps SDK for Android KTX Library
    implementation 'com.google.maps.android:maps-ktx:3.0.0'

    // Maps SDK for Android Utility Library KTX Library
    implementation 'com.google.maps.android:maps-utils-ktx:3.0.0'

    // Lifecycle Runtime KTX Library
    implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1'
}
  1. GoogleMap.addMarker() 및 GoogleMap.addCircle() 확장 함수 사용

지도 KTX 라이브러리는 이전 단계에서 사용된 GoogleMap.addMarker(MarkerOptions)GoogleMap.addCircle(CircleOptions)을 위한 DSL 스타일 API 대안을 제공합니다. 앞서 언급한 API를 사용하려면 마커 또는 원 옵션이 포함된 클래스를 구성해야 합니다. KTX 대체 옵션을 사용하면 제공된 람다에 마커 또는 원 옵션을 설정할 수 있습니다.

이러한 API를 사용하려면 MainActivity.addMarkers(GoogleMap)MainActivity.addCircle(GoogleMap) 메서드를 업데이트합니다.

MainActivity.addMarkers(GoogleMap)

/**
 * Adds markers to the map. These markers won't be clustered.
 */
private fun addMarkers(googleMap: GoogleMap) {
    places.forEach { place ->
        val marker = googleMap.addMarker {
            title(place.name)
            position(place.latLng)
            icon(bicycleIcon)
        }
        // Set place as the tag on the marker object so it can be referenced within
        // MarkerInfoWindowAdapter
        marker.tag = place
    }
}

MainActivity.addCircle(GoogleMap)

/**
 * Adds a [Circle] around the provided [item]
 */
private fun addCircle(googleMap: GoogleMap, item: Place) {
    circle?.remove()
    circle = googleMap.addCircle {
        center(item.latLng)
        radius(1000.0)
        fillColor(ContextCompat.getColor(this@MainActivity, R.color.colorPrimaryTranslucent))
        strokeColor(ContextCompat.getColor(this@MainActivity, R.color.colorPrimary))
    }
}

위의 방식으로 이러한 메서드를 다시 쓰면 훨씬 더 간결해지며 Kotlin의 수신자가 있는 함수 리터럴을 사용하면 가능합니다.

  1. SupportMapFragment.awaitMap() 및 GoogleMap.awaitMapLoad() 확장 정지 함수 사용

지도 KTX 라이브러리는 코루틴 내에서 사용할 정지 함수 확장도 제공합니다. 특히 SupportMapFragment.getMapAsync(OnMapReadyCallback)GoogleMap.setOnMapLoadedCallback(OnMapLoadedCallback)의 정지 함수 대안이 있습니다. 이러한 대체 API를 사용하면 콜백을 전달할 필요가 없으며 그 대신 이러한 메서드의 응답을 직렬 및 동기 방식으로 수신할 수 있습니다.

이러한 메서드는 정지 함수이므로 코루틴 내에서 메서드를 사용해야 합니다. Lifecycle 런타임 KTX 라이브러리는 수명 주기를 인식하는 코루틴 범위를 제공하는 확장 프로그램을 제공하므로 적절한 수명 주기 이벤트에서 코루틴이 실행되고 중지됩니다.

이러한 개념을 조합하여 MainActivity.onCreate(Bundle) 메서드를 업데이트합니다.

MainActivity.onCreate(Bundle)

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    val mapFragment =
        supportFragmentManager.findFragmentById(R.id.map_fragment) as SupportMapFragment
    lifecycleScope.launchWhenCreated {
        // Get map
        val googleMap = mapFragment.awaitMap()

        // Wait for map to finish loading
        googleMap.awaitMapLoad()

        // Ensure all places are visible in the map
        val bounds = LatLngBounds.builder()
        places.forEach { bounds.include(it.latLng) }
        googleMap.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds.build(), 20))

        addClusteredMarkers(googleMap)
    }
}

lifecycleScope.launchWhenCreated 코루틴 범위는 활동이 최소한 생성된 상태일 때 블록을 실행합니다. 또한 GoogleMap 객체를 검색하고 지도 로드가 완료될 때까지 대기하는 호출이 각각 SupportMapFragment.awaitMap()GoogleMap.awaitMapLoad()로 대체되었습니다. 이러한 정지 함수를 사용하여 코드를 리팩터링하면 상응하는 콜백 기반 코드를 순차적으로 작성할 수 있습니다.

  1. 리팩터링된 변경사항으로 앱을 다시 빌드하세요.

12. 축하합니다

수고하셨습니다 많은 내용을 살펴봤습니다. 이제 Android용 Maps SDK에서 제공하는 핵심 기능에 대해 더 잘 이해하셨기를 바랍니다.

자세히 알아보기

  • Android용 Places SDK: 풍부한 장소 데이터 세트를 살펴보고 주변의 비즈니스를 탐색합니다.
  • android-maps-ktx: Android용 Maps SDK 및 Android용 Maps SDK 유틸리티 라이브러리를 Kotlin 친화적인 방식으로 통합할 수 있는 오픈소스 라이브러리입니다.
  • android-place-ktx: Kotlin 친화적인 방식으로 Android용 Places SDK와 통합할 수 있는 오픈소스 라이브러리
  • android-samples: 이 Codelab 등에서 다룬 모든 기능을 보여주는 GitHub의 샘플 코드입니다.
  • Google Maps Platform으로 Android 앱을 빌드하기 위한 추가 Kotlin Codelab