Google Maps & Location Jetpack Compose

Alberto Rodríguez Díaz
6 min readJul 10, 2023

--

Today, I would like to show you how to use Google Maps and user location in Jetpack Compose.

We will use flows, FusedLocationProvider to obtain the user’s location, Hilt for dependency injection and Accompanist library for permission management.

Recently, I have been working with this concept of Unidirectional Data Flow, and I must say it’s an incredible way to work. It makes it easier to understand how our app is working and ensures that our UI always reflects the latest state.

In this example, the view will send events to the ViewModel so that it is responsible for updating the UI state.

The project is divided into the following main classes:

  • LocationService: it’s responsible for provide user’s location.
  • LocationModule: it informs Hilt how to provide LocationService instance.
  • GetLocationUseCase: intermediate layer responsible for calling location service. This layer is optional.
  • MainActivityVM: ViewModel responsible for receiving data from GetLocationUseCase and updating ViewState with its current value.
  • ViewState: represents current state of the view.
  • PermissionEvent: responsible for sending events to ViewModel to update the ViewState.

Project Setup

Before we start coding, it’s important to remember that we need to obtain the Google API key to use Google Maps.

Add these libraries to use permission control, Hilt, Google Maps and Google Services in build.gradle (app):

plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'kotlin-kapt'
id 'com.google.dagger.hilt.android'
id 'com.google.android.libraries.mapsplatform.secrets-gradle-plugin'
}

. . .

dependencies {

. . .

//Lifecycle
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1'
implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.6.1'

//Google Services & Maps
implementation 'com.google.android.gms:play-services-location:21.0.1'
implementation 'com.google.maps.android:maps-compose:2.9.0'
implementation 'com.google.android.gms:play-services-maps:18.1.0'

//Accompanist (Permission)
implementation 'com.google.accompanist:accompanist-permissions:0.31.3-beta'

//Hilt
implementation "com.google.dagger:hilt-android:2.44"
kapt "com.google.dagger:hilt-compiler:2.44"
}

Additionaly, we need some plugins in build.gradle (project):

plugins {
id 'com.android.application' version '8.0.2' apply false
id 'com.android.library' version '8.0.2' apply false
id 'org.jetbrains.kotlin.android' version '1.7.20' apply false
id 'com.google.dagger.hilt.android' version '2.44' apply false
id 'com.google.android.libraries.mapsplatform.secrets-gradle-plugin' version '2.0.1' apply false
}

We are ready to start coding!

The first steps is to declare permissions in the manifest file and create ILocationService & LocationService (remember to add Metadata tag with Google Maps API Key that we generated earlier in the Manifest file):

<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
interface ILocationService {
fun requestLocationUpdates(): Flow<LatLng?>
fun requestCurrentLocation(): Flow<LatLng?>
}

class LocationService @Inject constructor(
private val context: Context,
private val locationClient: FusedLocationProviderClient
): ILocationService {
@SuppressLint("MissingPermission")
@RequiresApi(Build.VERSION_CODES.S)
override fun requestLocationUpdates(): Flow<LatLng?> = callbackFlow {

if (!context.hasLocationPermission()) {
trySend(null)
return@callbackFlow
}

val request = LocationRequest.Builder(10000L)
.setIntervalMillis(10000L)
.setPriority(Priority.PRIORITY_HIGH_ACCURACY)
.build()

val locationCallback = object : LocationCallback() {
override fun onLocationResult(locationResult: LocationResult) {
locationResult.locations.lastOrNull()?.let {
trySend(LatLng(it.latitude, it.longitude))
}
}
}

locationClient.requestLocationUpdates(
request,
locationCallback,
Looper.getMainLooper()
)

awaitClose {
locationClient.removeLocationUpdates(locationCallback)
}
}

override fun requestCurrentLocation(): Flow<LatLng?> {
TODO("Not yet implemented")
}

}

FusedLocationProviderClient is a location API provided by Google Play Services that allow us to request the most recent location or live location of user’s device. It manages location technology and allow us to specify requirements such as high precission or low energy consumption. It’s a simple and convenient API to work with.

RequestLocationUpdates function returns a flow that emit a LatLng object with user’s location every 10 seconds. If we don’t have the necessary permission, it returns null.

RequestCurrentLocation can be used to obtain last known position on user’s device.

hasLocationPermission is an extension function to check location permissions:

fun Context.hasLocationPermission(): Boolean {
return ContextCompat.checkSelfPermission(
this,
Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED && ContextCompat.checkSelfPermission(
this,
Manifest.permission.ACCESS_COARSE_LOCATION
) == PackageManager.PERMISSION_GRANTED
}

The next step will be to prepare the module to inject our LocationService class:

@Module
@InstallIn(SingletonComponent::class)
object LocationModule {

@Singleton
@Provides
fun provideLocationClient(
@ApplicationContext context: Context
): ILocationService = LocationService(
context,
LocationServices.getFusedLocationProviderClient(context)
)
}

We can create a UseCase class to call LocationService. Additionally, if we need to perform any transformations, this is the place to do so.

class GetLocationUseCase @Inject constructor(
private val locationService: ILocationService
) {
@RequiresApi(Build.VERSION_CODES.S)
operator fun invoke(): Flow<LatLng?> = locationService.requestLocationUpdates()

}

Now, everything is ready to create our ViewState, Event and ViewModel to work with received location:

@RequiresApi(Build.VERSION_CODES.S)
@HiltViewModel
class MainActivityVM @Inject constructor(
private val getLocationUseCase: GetLocationUseCase
) : ViewModel() {

private val _viewState: MutableStateFlow<ViewState> = MutableStateFlow(ViewState.Loading)
val viewState = _viewState.asStateFlow()

/* This function is responsible for updating the ViewState based
on the event coming from the view */
fun handle(event: PermissionEvent) {
when (event) {
PermissionEvent.Granted -> {
viewModelScope.launch {
getLocationUseCase.invoke().collect { location ->
_viewState.value = ViewState.Success(location)
}
}
}

PermissionEvent.Revoked -> {
_viewState.value = ViewState.RevokedPermissions
}
}
}
}

// To simplify, I've added ViewState and PermissionEvent in the same file.
// Ideally, it's better to have them in separate files

sealed interface ViewState {
object Loading : ViewState
data class Success(val location: LatLng?) : ViewState
object RevokedPermissions : ViewState
}

sealed interface PermissionEvent {
object Granted : PermissionEvent
object Revoked : PermissionEvent
}

To conclude, we will create the view in MainActivity, where we will handle location permissions and determine what should be displayed based on whether we have them or not.

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@RequiresApi(Build.VERSION_CODES.S)
@SuppressLint("MissingPermission")
@OptIn(ExperimentalPermissionsApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

val locationViewModel: MainActivityVM by viewModels()

setContent {

val permissionState = rememberMultiplePermissionsState(
permissions = listOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
)

val viewState by locationViewModel.viewState.collectAsStateWithLifecycle()

LocationExampleTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {

LaunchedEffect(!hasLocationPermission()) {
permissionState.launchMultiplePermissionRequest()
}

when {
permissionState.allPermissionsGranted -> {
LaunchedEffect(Unit) {
locationViewModel.handle(PermissionEvent.Granted)
}
}

permissionState.shouldShowRationale -> {
RationaleAlert(onDismiss = { }) {
permissionState.launchMultiplePermissionRequest()
}
}

!permissionState.allPermissionsGranted && !permissionState.shouldShowRationale -> {
LaunchedEffect(Unit) {
locationViewModel.handle(PermissionEvent.Revoked)
}
}
}

with(viewState) {
when (this) {
ViewState.Loading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}

ViewState.RevokedPermissions -> {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("We need permissions to use this app")
Button(
onClick = {
startActivity(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS))
},
enabled = !hasLocationPermission()
) {
if (hasLocationPermission()) CircularProgressIndicator(
modifier = Modifier.size(14.dp),
color = Color.White
)
else Text("Settings")
}
}
}

is ViewState.Success -> {
val currentLoc =
LatLng(
location?.latitude ?: 0.0,
location?.longitude ?: 0.0
)
val cameraState = rememberCameraPositionState()

LaunchedEffect(key1 = currentLoc) {
cameraState.centerOnLocation(currentLoc)
}

MainScreen(
currentPosition = LatLng(
currentLoc.latitude,
currentLoc.longitude
),
cameraState = cameraState
)
}
}
}
}
}
}
}
}

@Composable
fun MainScreen(currentPosition: LatLng, cameraState: CameraPositionState) {
val marker = LatLng(currentPosition.latitude, currentPosition.longitude)
GoogleMap(
modifier = Modifier.fillMaxSize(),
cameraPositionState = cameraState,
properties = MapProperties(
isMyLocationEnabled = true,
mapType = MapType.HYBRID,
isTrafficEnabled = true
)
) {
Marker(
state = MarkerState(position = marker),
title = "MyPosition",
snippet = "This is a description of this Marker",
draggable = true
)
}
}

@Composable
fun RationaleAlert(onDismiss: () -> Unit, onConfirm: () -> Unit) {

Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties()
) {
Surface(
modifier = Modifier
.wrapContentWidth()
.wrapContentHeight(),
shape = MaterialTheme.shapes.large,
tonalElevation = AlertDialogDefaults.TonalElevation
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "We need location permissions to use this app",
)
Spacer(modifier = Modifier.height(24.dp))
TextButton(
onClick = {
onConfirm()
onDismiss()
},
modifier = Modifier.align(Alignment.End)
) {
Text("OK")
}
}
}
}
}

private suspend fun CameraPositionState.centerOnLocation(
location: LatLng
) = animate(
update = CameraUpdateFactory.newLatLngZoom(
location,
15f
),
durationMs = 1500
)

A brief summary of what we have done in the MainActivity:

  • If user has not accepted approximate and precise location permissions, we will prompt them to grant those permissions. Remember that if user has rejected the permissions twice, he will need to modify them directly from the device settings.
  • In the when block we check if permissions are granted or not. If both permissions are granted, we notify to ViewModel and it updates ViewState. If shoulShowRationale is true, an AlertDialog will appear to inform user of the reasons why we need the location permissions. When user clicks on OK button, permissions request will appear again.
  • As a final point, when block has three options:
    Loading show user a Circular Progress Indicator, RevokedPermissions show a screen which inform user that permissions are needed and a button to go to location settings and Success show our Google Map with location updates received from LocationService.

And that’s is! Here we have a simple way to implement a Google Maps with live user locations. While there are many ways to implement this functionallity, I believe this is a good starting point. I hope this project helps you understand how to use FusedLocationClient, Flows and Unidirectional Data Flow.

If you are interested in trying out this project, you can donwload it from my Github repository. Feel free to explore, experiment and provide feedback.

--

--