Google Map
Jump to Current Location
Let's jump to the current location when it's first available and use a custom icon instead of the default marker.
First, our custom icon. We need to get a BitmapDescriptor
. There's no way to do that yet from Compose, so we'll define a drawable vector image resource for this.
You can create these using File->New->Vector Asset, and select an icon or pull in an SVG. This icon is just a blue circle with a white border. This icon uses a custom color, which must be defined in a resource file, rather than through Compose for now.
We then set up state for the icon and load the icon in a coroutine once the map has loaded.
Note
The factory for the BitmapDescriptor
sometimes isn't available until the map has loaded. It's best to wait until the map is successfully loaded before trying to load the icons.
Finally, we set the icon on the Marker
.
Note
The anchor
parameter defines how the location matches up with the icon. Using an Offset(0.5, 0.5)
means the center of the icon is pinned to the location. For the default icons, the bottom center (the pointy tip) marks the location, so we'd use Offset(0.5, 1.0)
.
Mea culpa: I noticed that I accidentally copied the Offset(0.5, 0.5)
when I first created the location marker in a previous step. It was using the default marker, so it should have used Offset(0.5, 1.0)
.
Running the application now shows
Jumping to the current location on startup is done with a LaunchedEffect
keyed off the currentLocation
. The first time this LaunchedEffect
is run, the currentLocation
is null, and it doesn't do anything. When currentLocation
changes, the LaunchedEffect
is restarted, and, seeing a non-null currentLocation
, asks the cameraPositionState
to change via a CameraUpdate
command. We set initialBoundsSet
to true
so we won't move again.
Note
I'm using the name initialBoundsSet
because later we'll be setting a bounding box based on the current location and set car location.
Code Changes
ADDED: /app/src/main/java/com/androidbyexample/googlemap/DrawableHelpers.kt
package com.androidbyexample.googlemap// Licensed under the Apache License, Version 2.0 (the "License");// you may not use this file except in compliance with the License.// You may obtain a copy of the License at//// https://www.apache.org/licenses/LICENSE-2.0//// Unless required by applicable law or agreed to in writing, software// distributed under the License is distributed on an "AS IS" BASIS,// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.// See the License for the specific language governing permissions and// limitations under the License.import android.content.Contextimport android.content.res.Resourcesimport android.graphics.Bitmapimport android.graphics.Canvasimport android.graphics.drawable.BitmapDrawableimport android.graphics.drawable.Drawableimport androidx.annotation.DrawableResimport androidx.core.content.ContextCompatimport com.google.android.gms.maps.model.BitmapDescriptorimport com.google.android.gms.maps.model.BitmapDescriptorFactory// NOTE: I wanted to show an example of a set of extensions nicely-documented// for KDoc generation using the Dokka tool. There's an example of the setup// in the build scripts. Note that Dokka is still pretty young and doesn't// generate the prettiest documentation... But you can use it to generate// markdown and use another tool to generate something prettier.../** * Returns a [Bitmap] for the intrinsic size of any [Drawable]. If the * drawable is a [BitmapDrawable], we just return it. Otherwise, we create a * new [Bitmap] of the intrinsic size of the [Drawable], draw the [Drawable] * to it, and return the [Bitmap]. * * @sample loadBitmap * @receiver Any [Drawable] * @return a [Bitmap] representation of the [Drawable] */fun Drawable.toBitmap(): Bitmap = when { // if it's already a bitmap; just return it this is BitmapDrawable -> bitmap // otherwise, create a bitmap and draw the drawable to it intrinsicWidth == 0 || intrinsicHeight == 0 -> throw IllegalArgumentException( "Drawable cannot be converted to a Bitmap; it must have " + "non-zero intrinsic width and height") else -> Bitmap.createBitmap( intrinsicWidth, intrinsicHeight, Bitmap.Config.ARGB_8888 ).apply { val canvas = Canvas(this) setBounds(0, 0, canvas.width, canvas.height) draw(canvas) } }/** * Loads a [Bitmap] by the resource id of a [Drawable]. * * @sample loadBitmapDescriptor * @receiver [Context] to load the [Drawable] * @param id [Int] the resource id of the [Drawable] * @return a [Bitmap] representation of the [Drawable] */fun Context.loadBitmap(@DrawableRes id: Int): Bitmap = ContextCompat.getDrawable(this, id)?.toBitmap() ?: throw Resources.NotFoundException(resources.getResourceName(id))/** * Loads a [BitmapDescriptor] by the resource id of a [Drawable]. * * @sample com.javadude.maps.samples.SampleActivity.onCreate * @receiver [Context] to load the [Drawable] * @param id [Int] the resource id of the [Drawable] * @return a [Bitmap] representation of the [Drawable] */@Suppress("KDocUnresolvedReference")// they actually are resolved by dokka 'samples' configfun Context.loadBitmapDescriptor(@DrawableRes id: Int): BitmapDescriptor = loadBitmap(id).toBitmapDescriptor()/** * Converts a [Bitmap] to a [BitmapDescriptor] * * @sample loadBitmapDescriptor */fun Bitmap.toBitmapDescriptor(): BitmapDescriptor = BitmapDescriptorFactory.fromBitmap(this)
CHANGED: /app/src/main/java/com/androidbyexample/googlemap/MainActivity.kt
package com.androidbyexample.googlemap import android.Manifest import android.annotation.SuppressLint import android.app.AlertDialog import android.content.Intent import android.content.pm.PackageManager import android.location.Location import android.net.Uri import android.os.Bundle import android.os.Looper import android.provider.Settings import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.EnterTransition import androidx.compose.animation.fadeOut import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffectimport androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScopeimport androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.platform.LocalContextimport androidx.compose.ui.res.stringResource import androidx.core.app.ActivityCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.androidbyexample.googlemap.ui.theme.GoogleMapTheme import com.google.android.gms.common.GoogleApiAvailability import com.google.android.gms.location.FusedLocationProviderClient import com.google.android.gms.location.LocationCallback import com.google.android.gms.location.LocationRequest import com.google.android.gms.location.LocationResult import com.google.android.gms.location.LocationServices import com.google.android.gms.location.Priority import com.google.android.gms.maps.CameraUpdateFactoryimport com.google.android.gms.maps.model.BitmapDescriptorimport com.google.android.gms.maps.model.CameraPosition import com.google.android.gms.maps.model.LatLng import com.google.maps.android.compose.CameraPositionState import com.google.maps.android.compose.GoogleMap import com.google.maps.android.compose.MapProperties import com.google.maps.android.compose.MapType import com.google.maps.android.compose.MarkerInfoWindowContent import com.google.maps.android.compose.MarkerState import com.google.maps.android.compose.rememberCameraPositionState//private val googleHQ = LatLng(37.42423291057923, -122.08811454627153)//private val defaultCameraPosition = CameraPosition.fromLatLngZoom(googleHQ, 11f)import kotlinx.coroutines.Dispatchersimport kotlinx.coroutines.launch class MainActivity : ComponentActivity() { private val viewModel: CarViewModel by viewModels()private lateinit var fusedLocationProviderClient: FusedLocationProviderClient private val locationCallback = object : LocationCallback() { override fun onLocationResult(locationResult: LocationResult) { viewModel.updateLocation(locationResult.lastLocation) } }private val getLocationPermission = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { isGranted -> if (isGranted.values.any { it }) { startLocationAndMap() } else { // if the user denied permissions, tell them they // cannot use the app without them. In general, // you should try to just reduce function and let the // user continue, but location is a key part of this // application. // (Note that a real version of this application // might allow the user to manually click on the map // to set their current location, and we wouldn't // show this dialog, or perhaps only show it once) // NOTE: This is a normal Android-View-based dialog, not a compose one! AlertDialog.Builder(this) .setTitle("Permissions Needed") .setMessage( "We need coarse-location or fine-location permission " + "to locate a car (fine location is highly " + "recommended for accurate car locating). " + "Please allow these permissions via App Info " + "settings") .setCancelable(false) .setNegativeButton("Quit") { _, _ -> finish() } .setPositiveButton("App Info") { _, _ -> startActivity( Intent( Settings.ACTION_APPLICATION_DETAILS_SETTINGS ).apply { data = Uri.parse("package:$packageName") } ) finish() } .show() } }override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState)GoogleApiAvailability.getInstance() .makeGooglePlayServicesAvailable(this) .addOnSuccessListener {} @SuppressLint("MissingPermission") fun startLocationAndMap() {if (ActivityCompat.checkSelfPermission( this, Manifest.permission.ACCESS_FINE_LOCATION ) != PackageManager.PERMISSION_GRANTED && ActivityCompat.checkSelfPermission( this, Manifest.permission.ACCESS_COARSE_LOCATION ) != PackageManager.PERMISSION_GRANTED ) { getLocationPermission.launch( arrayOf( Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION ) ) } else { startLocationAndMap() }}.addOnFailureListener(this) { Toast.makeText( this, "Google Play services required (or upgrade required)", Toast.LENGTH_SHORT ).show() finish() }val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 5000) .setWaitForAccurateLocation(false) .setMinUpdateIntervalMillis(0) .setMaxUpdateDelayMillis(5000) .build() fusedLocationProviderClient = LocationServices.getFusedLocationProviderClient(this) fusedLocationProviderClient.requestLocationUpdates( locationRequest, locationCallback, Looper.getMainLooper() )setContent { GoogleMapTheme { // A surface container using the 'background' color from the theme Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) {// val cameraPositionState = rememberCameraPositionState {// position = defaultCameraPosition// }val cameraPositionState = rememberCameraPositionState()val currentLocation by viewModel.currentLocation.collectAsStateWithLifecycle( initialValue = null )GoogleMapDisplay( currentLocation = currentLocation,} } } } }// place = googleHQ,// placeDescription = "Google HQ",cameraPositionState = cameraPositionState, modifier = Modifier.fillMaxSize(), )@Composable fun GoogleMapDisplay( currentLocation: Location?,// place: LatLng,// placeDescription: String,cameraPositionState: CameraPositionState, modifier: Modifier, ) {var mapLoaded by remember { mutableStateOf(false) }var currentMapType by remember { mutableStateOf(MapType.NORMAL) } var mapProperties by remember { mutableStateOf(MapProperties(mapType = MapType.NORMAL)) }val currentLocationState = remember(currentLocation) { currentLocation?.let { MarkerState( LatLng( it.latitude, it.longitude ) ) } }val context = LocalContext.current var currentLocationIcon by remember { mutableStateOf<BitmapDescriptor?>(null) } val scope = rememberCoroutineScope()var initialBoundsSet by remember { mutableStateOf(false) } LaunchedEffect(key1 = currentLocation) { if (currentLocation != null) { if (!initialBoundsSet) { initialBoundsSet = true val current = LatLng(currentLocation.latitude, currentLocation.longitude) cameraPositionState.animate( CameraUpdateFactory.newLatLngZoom( current, 16f ), 1000 ) } } }Box(modifier = modifier,) {Column( modifier = Modifier.fillMaxSize() ) { MapTypeSelector( currentValue = currentMapType, modifier = Modifier.fillMaxWidth(), ) { mapProperties = mapProperties.copy(mapType = it) currentMapType = it }GoogleMap(cameraPositionState = cameraPositionState,onMapLoaded = { mapLoaded = truescope.launch(Dispatchers.IO) { currentLocationIcon = context.loadBitmapDescriptor( R.drawable.ic_current_location ) }},properties = mapProperties,) {modifier = Modifier .fillMaxWidth() .weight(1f),currentLocationState?.let { MarkerInfoWindowContent( state = it,} } }icon = currentLocationIcon, anchor = Offset(0.5f, 0.5f),title = stringResource( id = R.string.current_location ), ) }if (!mapLoaded) { AnimatedVisibility( visible = true, modifier = Modifier.fillMaxSize(), enter = EnterTransition.None, exit = fadeOut() ) { CircularProgressIndicator( modifier = Modifier .background(MaterialTheme.colorScheme.background) .wrapContentSize() ) } }}
ADDED: /app/src/main/res/drawable/ic_current_location.xml
<?xml version="1.0" encoding="utf-8"?><shape android:shape="oval" xmlns:android="http://schemas.android.com/apk/res/android"> <size android:height="24dp" android:width="24dp" /> <stroke android:color="@android:color/white" android:width="3dp"/> <solid android:color="@color/my_location" /></shape>
CHANGED: /app/src/main/res/values/colors.xml
<?xml version="1.0" encoding="utf-8"?> <resources><color name="my_location">#FF0000FF</color><color name="purple_200">#FFBB86FC</color> <color name="purple_500">#FF6200EE</color> <color name="purple_700">#FF3700B3</color> <color name="teal_200">#FF03DAC5</color> <color name="teal_700">#FF018786</color> <color name="black">#FF000000</color> <color name="white">#FFFFFFFF</color> </resources>