הוספת מפה לאפליקציית Android (Kotlin עם 'כתיבה')

1. לפני שתתחיל

בשיעור הזה תלמדו איך לשלב את Maps SDK for Android באפליקציה שלכם ולהשתמש בתכונות הליבה שלו. כדי לעשות את זה, תבנו אפליקציה שמציגה מפה של הרים בקולורדו, ארה"ב, באמצעות סוגים שונים של סמנים. בנוסף, נסביר איך לצייר צורות אחרות במפה.

כך זה ייראה בסיום ה-codelab:

דרישות מוקדמות

הפעולות שתבצעו:

  • הפעלה ושימוש בספריית Maps Compose עבור Maps SDK ל-Android כדי להוסיף GoogleMap לאפליקציית Android
  • הוספה והתאמה אישית של סמנים
  • שרטוט פוליגונים במפה
  • שליטה בנקודת המבט של המצלמה באופן פרוגרמטי

מה נדרש

2. להגדרה

בשלב הבא בהפעלה, צריך להפעיל את Maps SDK ל-Android.

הגדרת הפלטפורמה של מפות Google

אם עדיין אין לכם חשבון ב-Google Cloud Platform ופרויקט עם חיוב מופעל, תוכלו לעיין במדריך תחילת העבודה עם הפלטפורמה של מפות Google כדי ליצור חשבון לחיוב ופרויקט.

  1. בCloud Console, לוחצים על התפריט הנפתח של הפרויקט ובוחרים את הפרויקט שבו רוצים להשתמש ב-codelab הזה.

  1. מפעילים ב-Google Cloud Marketplace את ממשקי ה-API וערכות ה-SDK של הפלטפורמה של מפות Google שנדרשים ל-codelab הזה. כדי לעשות זאת, פועלים לפי השלבים בסרטון הזה או בתיעוד הזה.
  2. יוצרים מפתח API בדף Credentials במסוף Cloud. אפשר לפעול לפי השלבים שמפורטים בסרטון הזה או בתיעוד הזה. כל הבקשות אל הפלטפורמה של מפות Google מחייבות מפתח API.

3. התחלה מהירה

כדי לעזור לכם להתחיל כמה שיותר מהר, הנה קוד התחלתי שיעזור לכם לעקוב אחרי ההוראות במעבדת התכנות הזו. אתם יכולים לדלג לפתרון, אבל אם אתם רוצים לבצע את כל השלבים בעצמכם, המשיכו לקרוא.

  1. משכפלים את המאגר אם git מותקן.
git clone https://github.com/googlemaps-samples/codelab-maps-platform-101-compose.git

לחלופין, אפשר ללחוץ על הלחצן הבא כדי להוריד את קוד המקור.

  1. אחרי שמקבלים את הקוד, פותחים את הפרויקט שנמצא בספרייה starter ב-Android Studio.

4. הוספת מפתח ה-API לפרויקט

בקטע הזה מתואר איך לאחסן את מפתח ה-API כך שהאפליקציה תוכל להפנות אליו בצורה מאובטחת. לא מומלץ להוסיף את מפתח ה-API למערכת בקרת הגרסאות, ולכן אנחנו ממליצים לאחסן אותו בקובץ secrets.properties, שיוצב בעותק המקומי של ספריית הבסיס של הפרויקט. מידע נוסף על הקובץ secrets.properties זמין במאמר קובצי מאפיינים של Gradle.

כדי לייעל את המשימה הזו, מומלץ להשתמש בפלאגין של Secrets Gradle ל-Android.

כדי להתקין את הפלאגין Secrets Gradle ל-Android בפרויקט של מפות Google:

  1. ב-Android Studio, פותחים את קובץ build.gradle.kts ברמה העליונה ומוסיפים את הקוד הבא לרכיב dependencies מתחת ל-buildscript.
    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 ברמת המודול, מוודאים שהערכים של targetSdk ו-compileSdk הם לפחות 34.
  4. שומרים את הקובץ ומסנכרנים את הפרויקט עם Gradle.
  5. פותחים את הקובץ secrets.properties בספרייה ברמה העליונה ומוסיפים את הקוד הבא. מחליפים את הערך YOUR_API_KEY במפתח ה-API שלכם. כדאי לאחסן את המפתח בקובץ הזה כי secrets.properties לא נכלל בבדיקה במערכת בקרת גרסאות.
    MAPS_API_KEY=YOUR_API_KEY
    
  6. שומרים את הקובץ.
  7. יוצרים את הקובץ local.defaults.properties בתיקייה ברמה העליונה, באותה תיקייה שבה נמצא הקובץ secrets.properties, ומוסיפים את הקוד הבא.
        MAPS_API_KEY=DEFAULT_API_KEY
    
    המטרה של הקובץ הזה היא לספק מיקום גיבוי למפתח ה-API אם הקובץ secrets.properties לא נמצא, כדי שהגרסאות לא ייכשלו. זה יקרה אם תשכפלו את האפליקציה ממערכת בקרת גרסאות, ועדיין לא יצרתם קובץ 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 נגיש בתוך האפליקציה, השלב הבא הוא להוסיף את התלות ב-Maps SDK for Android לקובץ build.gradle.kts של האפליקציה. כדי ליצור אפליקציות עם Jetpack Compose, צריך להשתמש בספריית Maps Compose שמספקת רכיבים של Maps SDK ל-Android כפונקציות וסוגי נתונים שניתנים להרכבה.

build.gradle.kts

בקובץ build.gradle.kts ברמת האפליקציה, מחליפים את יחסי התלות של Maps SDK for Android שאינם מבוססי 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")
}

עם המקבילות שלהן שניתנות להגדרה:

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 composable בתוך רכיב ה-Box composable שמוטמע בתוך רכיב ה-MapMountain composable.

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

        // ...
    }
}

עכשיו בונים ומריצים את האפליקציה. הנה! אמורה להופיע מפה שבמרכזה אי האפס הידוע לשמצה, שנקרא גם קו רוחב אפס וקו אורך אפס. בהמשך נסביר איך למקם את המפה במיקום וברמת הזום הרצויים, אבל בינתיים כדאי לחגוג את הניצחון הראשון!

6. עיצוב מפות מבוסס-ענן

אתם יכולים להתאים אישית את הסגנון של המפה באמצעות עיצוב מפות מבוסס-ענן.

יצירת מזהה מפה

אם עדיין לא יצרתם מזהה מפה עם סגנון מפה שמשויך אליו, כדאי לעיין במדריך בנושא מזהי מפה כדי לבצע את השלבים הבאים:

  1. יוצרים מזהה מפה.
  2. משייכים מזהה מפה לסגנון מפה.

הוספת מזהה המפה לאפליקציה

כדי להשתמש במזהה המפה שיצרתם, כשמפעילים את ה-composable‏ GoogleMap, משתמשים במזהה המפה כשיוצרים אובייקט GoogleMapOptions שמוקצה לפרמטר googleMapOptionsFactory בבונה.

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

אחרי שמסיימים את הפעולות האלה, מריצים את האפליקציה כדי לראות את המפה בסגנון שבחרתם.

7. טעינת נתוני הסמן

המשימה העיקרית של האפליקציה היא לטעון אוסף של הרים מהאחסון המקומי ולהציג את ההרים האלה ב-GoogleMap. בשלב הזה, נסביר על התשתית שסיפקנו לטעינת נתוני ההרים ולהצגתם בממשק המשתמש.

Mountain

מחלקת הנתונים Mountain מכילה את כל הנתונים על כל הר.

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

שימו לב שההרים יחולקו בהמשך למחיצות על סמך הגובה שלהם. הרים בגובה של לפחות 14,000 רגל נקראים fourteeners. קוד ההתחלה כולל פונקציית הרחבה שמבצעת את הבדיקה הזו בשבילכם.

/**
 * 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 הוא מחלקה של ViewModel שמעלה את האוספים של ההרים וחושפת את האוספים האלה וגם חלקים אחרים של מצב ממשק המשתמש באמצעות mountainsScreenViewState. ‫mountainsScreenViewState הוא Hot StateFlow שהממשק יכול לעקוב אחריו כמצב ניתן לשינוי באמצעות פונקציית התוסף collectAsState.

בהתאם לעקרונות ארכיטקטוניים מוצקים, MountainsViewModel מחזיק בכל מצב האפליקציה. ממשק המשתמש שולח את האינטראקציות של המשתמשים למודל התצוגה באמצעות השיטה 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 או לפתוח את המחלקות MountainsRepository ו-MountainsViewModel ב-Android Studio.

שימוש ב-ViewModel

מודל התצוגה משמש ב-MainActivity כדי לקבל את viewState. בהמשך ה-codelab הזה תשתמשו ב-viewState כדי להציג את הסמנים. הערה: הקוד הזה כבר כלול בפרויקט המתחיל, והוא מוצג כאן רק לצורך עיון.

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

8. מיקום המצלמה

GoogleMap ברירת המחדל היא מרכז בקו רוחב אפס, קו אורך אפס. הסמנים שיוצגו ממוקמים במדינת קולורדו בארה"ב. ה-viewState שמועבר על ידי מודל התצוגה מייצג LatLngBounds שמכיל את כל הסמנים.

ב-MountainMap.kt יוצרים CameraPositionState שמאותחל למרכז של תיבת התוחמת. מגדירים את הפרמטר cameraPositionState של GoogleMap למשתנה cameraPositionState שיצרתם זה עתה.

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

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

עכשיו מריצים את הקוד ורואים שהמפה מתמקדת בקולורדו.

שינוי המרחק מהתצוגה של גבולות הסמן

כדי למקד את המפה בסמנים, מוסיפים את הפונקציה zoomAll לסוף הקובץ MountainMap.kt. שימו לב שצריך להשתמש ב-CoroutineScope בפונקציה הזו, כי אנימציה של המצלמה למיקום חדש היא פעולה אסינכרונית שלוקח זמן להשלים אותה.

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

בשלב הבא, מוסיפים קוד להפעלת הפונקציה 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. שימו לב: אפשר להשתמש רק בפונקציות @GoogleMapComposable בבלוק התוכן GoogleMap. לאובייקט 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, ליצירת BitmapDescriptor מ-@DrawableResource.

הקוד לתחילת הדרך כולל סמל של הר, 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 רגל ואחד בשביל הרים רגילים. לאחר מכן משתמשים בפרמטר icon של Marker composable כדי להגדיר את הסמל. בנוסף, צריך להגדיר את הפרמטר 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 למצב מופעל. ההרים יכללו סמנים שונים בהתאם לגובה שלהם.

10. סמנים מתקדמים

AdvancedMarkers add extra features to basic Markers. בשלב הזה מגדירים את ההתנהגות של ההתנגשות ואת הסגנון של הסיכה.

מוסיפים את @GoogleMapComposable לפונקציה AdvancedMarkersMapContent. מבצעים לולאה על 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

הסמלים משתמשים בסכמות הצבעים הראשיות והמשניות כדי להבחין בין ההרים שגובהם מעל 4,000 מטר לבין הרים אחרים. משתמשים בפונקציה vectorToBitmap כדי ליצור שני BitmapDescriptors: אחד בשביל ההרים שגובהם מעל 4,000 מטר ואחד בשביל שאר ההרים. אפשר להשתמש בסמלים האלה כדי ליצור pinConfig מותאם אישית לכל סוג. לבסוף, מחברים את הסיכה ל-AdvancedMarker המתאים על סמך הפונקציה 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. סמנים מקובצים

בשלב הזה, תשתמשו ב-composable‏ Clustering כדי להוסיף קיבוץ פריטים על סמך זום.

רכיב ה-Composable‏ 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
}

עכשיו מוסיפים את הקוד כדי ליצור MountainClusterItems מרשימת ההרים. הערה: הקוד הזה משתמש ב-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,
    )
}

בעזרת הקוד הזה, הסמנים מקובצים לפי רמת הזום. הכול מסודר!

התאמה אישית של אשכולות

בדומה לסוגים אחרים של סמנים, אפשר להתאים אישית סמנים מקובצים. הפרמטר clusterItemContent של הקומפוזבל Clustering מגדיר בלוק קומפוזבל מותאם אישית לעיבוד פריט לא מקובץ. מטמיעים פונקציה @Composable כדי ליצור את הסמן. הפונקציה SingleMountain מעבדת רכיב Icon של Material 3 שאפשר להרכיב ממנו רכיבים אחרים, עם ערכת צבעים מותאמת אישית לרקע.

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

עכשיו יוצרים ערכת צבעים ל-fourteeners וערכת צבעים נוספת להרים אחרים. בבלוק clusterItemContent, בוחרים את ערכת הצבעים בהתאם לגובה ההר.

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 ל-Android תומך בעוד הרבה דרכים לשרטוט כדי להציג מידע שימושי במפה.

לדוגמה, אם רוצים לייצג מסלולים ואזורים במפה, אפשר להשתמש ב-Polylines וב-Polygons כדי להציג אותם במפה. לחלופין, אם רוצים להצמיד תמונה לפני השטח של הקרקע, אפשר להשתמש ב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),
    )
}

עכשיו מתקשרים אל ColoradoPolyon() בתוך בלוק התוכן GoogleMap.

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

עכשיו האפליקציה משרטטת את קולורדו וממלאת אותה בצבע עדין.

13. הוספת שכבת KML וסרגל קנה מידה

בקטע האחרון הזה תשרטטו בערך את רכסי ההרים השונים ותוסיפו למפה סרגל קנה מידה.

תנו מתאר של רכסי ההרים

בעבר, ציירתם קו מתאר סביב קולורדו. בשלב הזה תוסיפו למפה צורות מורכבות יותר. קוד ההתחלה כולל קובץ Keyhole Markup Language, או KML, שמתאר באופן כללי את רכסי ההרים החשובים. ספריית כלי השירות של Maps SDK ל-Android כוללת פונקציה להוספת שכבת KML למפה. ב-MountainMap.kt מוסיפים קריאה ל-MapEffect בבלוק התוכן GoogleMap אחרי הבלוק when. הפונקציה MapEffect מופעלת עם אובייקט GoogleMap. הוא יכול לשמש כגשר שימושי בין ספריות וממשקי API שאינם ניתנים להרכבה, שנדרש בהם אובייקט 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()
      }
    }

הוספת קנה מידה למפה

המשימה האחרונה היא להוסיף סרגל קנה מידה למפה. הקומפוזיציה ScaleBar מטמיעה קומפוננטה שאפשר להוסיף למפה כדי להציג קנה מידה. הערה: ScaleBar הוא לא

@GoogleMapComposable ולכן אי אפשר להוסיף אותו לתוכן של GoogleMap. במקום זאת, מוסיפים אותו ל-Box שמכיל את המפה.

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

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

מריצים את האפליקציה כדי לראות את ה-codelab המלא.

14. קבלת קוד הפתרון

כדי להוריד את הקוד של ה-codelab המוגמר, אפשר להשתמש בפקודות הבאות:

  1. משכפלים את המאגר אם git מותקן.
$ git clone https://github.com/googlemaps-samples/codelab-maps-platform-101-compose.git

לחלופין, אפשר ללחוץ על הלחצן הבא כדי להוריד את קוד המקור.

  1. אחרי שמקבלים את הקוד, פותחים את הפרויקט שנמצא בספרייה solution ב-Android Studio.

15. מזל טוב

מעולה! הסברנו הרבה נושאים, ואנחנו מקווים שעכשיו יש לכם הבנה טובה יותר של התכונות העיקריות שזמינות ב-Maps SDK ל-Android.

מידע נוסף

  • Maps SDK for Android – יצירת מפות דינמיות, אינטראקטיביות ומותאמות אישית, וחוויות מיקום וגיאו-מרחביות לאפליקציות ל-Android.
  • Maps Compose Library – קבוצה של פונקציות וסוגי נתונים שניתנים להרכבה בקוד פתוח, שאפשר להשתמש בהם עם Jetpack Compose כדי לבנות את האפליקציה.
  • android-maps-compose – קוד לדוגמה ב-GitHub שמדגים את כל התכונות שמוסברות ב-codelab הזה ועוד.
  • עוד סדנאות קוד בנושא Kotlin ליצירת אפליקציות ל-Android באמצעות הפלטפורמה של מפות Google