I am trying to build an app with navigation to multiple different screens (using Bottom Navigation).
One of the screens, the startDestination, is a Google Maps view, looking at Official compose example: Crane to get it to work, and it does.
However, when navigating to another screen, and back, the MapView gets recomposed and is slowly loading back in. We start back at the initial camera position, zoom level, and so on. There probably is a way to remember and re-apply those attributes, but I am more interested in keeping the complete state of the Google Maps view intact. (Looking at the current Google Maps app, for Android, it does exactly what I'm looking for, eventhough they aren't using Jetpack Compose)
Is there a way to achieve this?
I already remember the MapView
#Composable
fun rememberMapViewWithLifecycle(): MapView {
val context = LocalContext.current
val mapView = remember {
MapView(context).apply {
id = R.id.map
}
}
val lifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(lifecycle, mapView) {
// Make MapView follow the current lifecycle
val lifecycleObserver = getMapLifecycleObserver(mapView)
lifecycle.addObserver(lifecycleObserver)
onDispose {
lifecycle.removeObserver(lifecycleObserver)
}
}
return mapView
}
private fun getMapLifecycleObserver(mapView: MapView): LifecycleEventObserver =
LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_CREATE -> mapView.onCreate(Bundle())
Lifecycle.Event.ON_START -> mapView.onStart()
Lifecycle.Event.ON_RESUME -> mapView.onResume()
Lifecycle.Event.ON_PAUSE -> mapView.onPause()
Lifecycle.Event.ON_STOP -> mapView.onStop()
Lifecycle.Event.ON_DESTROY -> mapView.onDestroy()
else -> throw IllegalStateException()
}
}
To give more context, the MapView is in the top of this screen
#ExperimentalMaterialApi
#Composable
fun MapsScreen(
modifier: Modifier = Modifier,
viewModel: EventViewModel = viewModel()
) {
...
val mapView = rememberMapViewWithLifecycle()
MapsScreenView(modifier, uiState, mapView)
}
A completely different approach I tried, is through a BackdropScaffold (in a Scaffold because I want the BottomBar..) where backLayerContent is the MapsScreen() and the frontLayerContent are the other screens, the "front" is configured so it will cover the entire screen when active. It feels really ugly and horrible, but it kinda works..
Scaffold(
topBar = {
SmallTopAppBar(
title = { Text(text = "Test") },
)
},
bottomBar = {
BottomBar(navController) { screen ->
showMaps = screen == Screen.MainMaps
coroutineScope.launch {
if (showMaps) scaffoldState.reveal() else scaffoldState.conceal()
}
}
},
content = { innerPadding ->
Box(modifier = Modifier.padding(innerPadding)) {
BackdropScaffold(
scaffoldState = scaffoldState,
appBar = { },
frontLayerContent = {
EventsScreen()
},
backLayerContent = {
MapsScreen()
},
peekHeight = if (showMaps) 300.dp else 0.dp,
headerHeight = if (showMaps) BackdropScaffoldDefaults.HeaderHeight else 0.dp,
gesturesEnabled = showMaps
)
}
}
)
Did anyone have this same problem and found an actual solution? (We really need Jetpack Compose support for this I guess, intead of the AndroidView approach)
Google just publish a library to handle the MapView state Android-Maps-Compose
val cameraPositionState: CameraPositionState = rememberCameraPositionState()
GoogleMap(cameraPositionState = cameraPositionState)
Button(onClick = { cameraPositionState.move(CameraUpdateFactory.zoomIn()) }) {
Text(text = "Zoom In")
}
Related
I want to implement a simple user flow, where the user sees multiple screens to input data. The flow should share a common navbar where each screen can contribute its menu items to when it is active (e.g. add a "search" or a "next" button). The navbar also has buttons belonging conceptually to the user flow and not to individual screens (like the back button and a close button). Screens should be reusable in other contexts, so screens should not know about the flow they operate in.
Technically the user flow is implemented as a compose function defining the navbar and using compose navigation. Each screen is implemented as a separate compose function.
In fragment/view based Android this scenario was supported out of box with onCreateOptionsMenu and related functions. But how would I do this in compose? I could not find any guidance on that topic.
To illustrate the problem in code:
#Composable
fun PaymentCoordinator(
navController: NavHostController = rememberNavController()
) {
AppTheme {
Scaffold(
bottomBar = {
BottomAppBar(backgroundColor = Color.Red) {
IconButton(onClick = navController::popBackStack) {
Icon(Icons.Filled.ArrowBack, "Back")
}
Spacer(modifier = Modifier.weight(1f))
// 0..n IconButtons provided by the active Screen
// should be inserted here
// How can we do that, because state should never
// go up from child to parent
// this button (or at least its text and onClick action) should
// be defined by the currently visible Screen as well
Button(
onClick = { /* How to call function of screen? */ }
) {
Text("Next"))
}
}
}
) { padding ->
Box(modifier = Modifier.padding(padding)) {
NavHost(
navController = navController,
startDestination = "selectAccount"
) {
// screens that can contribute items to the menu
composable("selectAccount") {
AccountSelectionRoute(
onAccountSelected = {
navController.navigate("nextScreen")
}
)
}
composable("...") {
// ...
}
}
}
}
}
}
I came up with an approach leveraging side effects and lifecycle listener to achieve my goal. Basically whenever a screen becomes active (ON_START) it informs the parent (coordinator) about its menu configuration. The coordinator evaluates the configuration and updates the navbar accordingly.
The approach is based on Googles documentation on side effects (https://developer.android.com/jetpack/compose/side-effects#disposableeffect)
The approach feels complicated and awkward and I think the compose framework is missing some functionality to achieve this here. However, my implementation seems to be working fine in my test use case.
Helper classes
// currently I only need to configure a single button, however the approach
// can be easily extended now (you can put anything inside rightButton)
data class MenuConfiguration(
val rightButton: #Composable () -> Unit
)
#Composable
fun SimpleMenuConfiguration(
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
onRegisterMenuConfiguration: (MenuConfiguration?) -> Unit,
onUnregisterMenuConfiguration: () -> Unit,
rightButton: #Composable () -> Unit
) {
val currentOnRegisterMenuConfiguration by rememberUpdatedState(onRegisterMenuConfiguration)
val currentOnUnregisterMenuConfiguration by rememberUpdatedState(onUnregisterMenuConfiguration)
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_START) {
currentOnRegisterMenuConfiguration(
MenuConfiguration(
rightButton = rightButton
)
)
} else if (event == Lifecycle.Event.ON_STOP) {
currentOnUnregisterMenuConfiguration()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
}
Coordinator level
#Composable
fun PaymentCoordinator(
navController: NavHostController = rememberNavController()
) {
var menuConfiguration by remember { mutableStateOf<MenuConfiguration?>(null) }
AppTheme {
Scaffold(
bottomBar = {
BottomAppBar(backgroundColor = Color.Red) {
IconButton(onClick = navController::popBackStack) {
Icon(Icons.Filled.ArrowBack, "Back")
}
Spacer(modifier = Modifier.weight(1f))
menuConfiguration?.rightButton?.invoke()
}
}
) { padding ->
Box(modifier = Modifier.padding(padding)) {
PaymentNavHost(
navController = navController,
finishedHandler = finishedHandler,
onRegisterMenuConfiguration = { menuConfiguration = it },
onUnregisterMenuConfiguration = { menuConfiguration = null }
)
}
}
}
}
#Composable
fun PaymentNavHost(
navController: NavHostController = rememberNavController(),
onRegisterMenuConfiguration: (MenuConfiguration?) -> Unit,
onUnregisterMenuConfiguration:() -> Unit
) {
NavHost(
navController = navController,
startDestination = "selectAccount"
) {
composable("selectAccount") {
DemoAccountSelectionRoute(
onAccountSelected = {
navController.navigate("amountInput")
},
onRegisterMenuConfiguration = onRegisterMenuConfiguration,
onUnregisterMenuConfiguration = onUnregisterMenuConfiguration
)
}
composable("amountInput") {
AmountInputRoute(
onRegisterMenuConfiguration = onRegisterMenuConfiguration,
onUnregisterMenuConfiguration = onUnregisterMenuConfiguration,
onFinished = {
...
}
)
}
}
}
Screen level
#Composable
internal fun AmountInputRoute(
onRegisterMenuConfiguration: (MenuConfiguration?) -> Unit,
onUnregisterMenuConfiguration:() -> Unit,
onFinished: (Amount?) -> Unit
) {
SimpleMenuConfiguration(
onRegisterMenuConfiguration = onRegisterMenuConfiguration,
onUnregisterMenuConfiguration = onUnregisterMenuConfiguration,
rightButton = {
Button(
onClick = {
...
}
) {
Text(text = stringResource(id = R.string.next))
}
}
)
I am using compose navigation with single activity and no fragments.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MobileComposePlaygroundTheme {
Surface(color = MaterialTheme.colors.background) {
val navController = rememberNavController()
NavHost(navController, startDestination = "main") {
composable("main") { MainScreen(navController) }
composable("helloScreen") { HelloScreen() }
}
}
}
}
}
}
#Composable
private fun MainScreen(navController: NavHostController) {
val count = remember {
Log.d("TAG", "inner remember, that is, initialized")
mutableStateOf(0)
}
LaunchedEffect("fixedKey") {
Log.d("TAG", "inner LaunchedEffect, that is, initialized")
}
Column {
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
count.value++
Log.d("TAG", "count: ${count.value}")
},
modifier = Modifier.padding(8.dp)
) {
Text(text = "Increase Count ${count.value}")
}
Button(
onClick = { navController.navigate("helloScreen") },
modifier = Modifier.padding(8.dp)
) {
Text(text = "Go To HelloScreen")
}
}
}
#Composable
fun HelloScreen() {
Log.d("TAG", "HelloScreen")
Text("Hello Screen")
}
MainScreen -> HelloScreen -> back button -> MainScreen
After pop HelloScreen by back button, MainScreen restart composition from scratch. That is, not recomposition but initial composition. So remember and LaunchedEffect is recalculated.
I got rememberSaveable for maintaining states on this popping upper screen case. However how can I prevent re-execute LaunchedEffect? In addition, docs saying rememberSavable makes value to survive on configuration change but this is not the exact case.
I expected that LowerScreen is just hidden when UpperScreen is pushed, and LowerScreen reveal again when UpperScreen is popped, like old Android's onPause(), onResume(), etc.
In Compose, is this not recommended?
ps.
Lifecycle of Composable is not tied with ViewModel but with Activity
It needs more care about initialization of ViewModel
Why Compose team design like this?
Can you recommend good architecture sample code?
I am implementing a list of elements that I show in a column with vertical scroll. Each element contains a map. The problem is that on these maps I cannot zoom with finger gestures, nor can I scroll vertically on these maps. Horizontally I can move, but the movement is not fluid. It is as if the vertical scroll of the column is affecting the interaction with the map. Here is the code in case anyone can help me:
Main:
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(vertical = 8.dp)
) {
items(state.data.elements) { element ->
ElementMap(
lat = element.lat,
lon = element.lon
)
}
}
ElementMap:
import com.google.android.gms.maps.GoogleMap
import com.google.android.gms.maps.MapView
fun ElementMap(
lat: Double,
lon: Double
) {
val mapView = rememberMapViewWithLifeCycle()
Column(
modifier = Modifier
.background(Color.White)
) {
AndroidView(
{
mapView
}
) {
mapView.getMapAsync {
val map = it
map.uiSettings.isZoomControlsEnabled = false
map.addMarker(marker(lat, lon, 16f, 250f))
map.moveCamera(CameraUpdateFactory.newLatLngZoom(LatLng(lat, lon), 16f))
map.mapType = GoogleMap.MAP_TYPE_HYBRID
}
}
}
}
#Composable
fun rememberMapViewWithLifeCycle(): MapView {
val context = LocalContext.current
val mapView = remember {
MapView(context).apply {
id = R.id.map_frame
}
}
val lifeCycleObserver = rememberMapLifecycleObserver(mapView)
val lifeCycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(lifeCycle) {
lifeCycle.addObserver(lifeCycleObserver)
onDispose {
lifeCycle.removeObserver(lifeCycleObserver)
}
}
return mapView
}
#Composable
fun rememberMapLifecycleObserver(mapView: MapView): LifecycleEventObserver =
remember(mapView) {
LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_CREATE ->
mapView.onCreate(Bundle())
Lifecycle.Event.ON_START ->
mapView.onStart()
Lifecycle.Event.ON_RESUME ->
mapView.onResume()
Lifecycle.Event.ON_PAUSE ->
mapView.onPause()
Lifecycle.Event.ON_STOP ->
mapView.onStop()
Lifecycle.Event.ON_DESTROY ->
mapView.onDestroy()
else -> throw IllegalStateException()
}
}
}
I have tried to show a map in full screen, that is, outside the vertical scroll, and in this way the gestures work correctly, I can zoom as well as scroll in all directions. Therefore, it seems to be a problem of having a map inside a scroll, but I'm not sure how to solve this.
I have managed to prevent the map from losing touch events due to scrolling. I have created a class of my own implementing MapView (in package com.google.android.gms.maps) as stated in this answer.
class CustomMapView(context: Context) : MapView(context) {
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
when (ev.action) {
MotionEvent.ACTION_UP -> parent.requestDisallowInterceptTouchEvent(false)
MotionEvent.ACTION_DOWN -> parent.requestDisallowInterceptTouchEvent(true)
}
return super.dispatchTouchEvent(ev)
}
}
So instead of using MapView I use my CustomMapView class and it works perfectly.
The problem
I'm trying to implement simple map view inside scrollable column. The problem is that I can't scroll map vertically, as scroll event is captured by column and instead of map, whole column is scrolling. Is there any way to disable column scrolling on map element? I thought about using .nestedScroll() modifier, but I can't find a way to make it work as desired.
Code
LocationInput (child)
// Not important in context of question, but I left it so the code is complete
#Composable
private fun rememberMapLifecycleObserver(mapView: MapView): LifecycleEventObserver =
remember(mapView) {
LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_CREATE -> mapView.onCreate(Bundle())
Lifecycle.Event.ON_START -> mapView.onStart()
Lifecycle.Event.ON_RESUME -> mapView.onResume()
Lifecycle.Event.ON_PAUSE -> mapView.onPause()
Lifecycle.Event.ON_STOP -> mapView.onStop()
Lifecycle.Event.ON_DESTROY -> mapView.onDestroy()
else -> throw IllegalStateException()
}
}
}
// Not important in context of question, but I left it so the code is complete
#Composable
private fun rememberMapViewWithLifecycle(): MapView {
val context = LocalContext.current
val mapView = remember {
MapView(context)
}
// Makes MapView follow the lifecycle of this composable
val lifecycleObserver = rememberMapLifecycleObserver(mapView)
val lifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(lifecycle) {
lifecycle.addObserver(lifecycleObserver)
onDispose {
lifecycle.removeObserver(lifecycleObserver)
}
}
return mapView
}
#Composable
fun LocationInput() {
val map = rememberMapViewWithLifecycle()
Column(Modifier.fillMaxSize()) {
var mapInitialized by remember(map) { mutableStateOf(false) }
val googleMap = remember { mutableStateOf<GoogleMap?>(null) }
LaunchedEffect(map, mapInitialized) {
if (!mapInitialized) {
googleMap.value = map.awaitMap()
googleMap.value!!.uiSettings.isZoomGesturesEnabled = true
mapInitialized = true
}
}
AndroidView({ map }, Modifier.clip(RoundedCornerShape(6.dp))) { mapView ->
}
}
}
ScrollableColumn (parent)
#Composable
fun TallView() {
Column(Modifier.verticalScroll(rememberScrollState())) {
Spacer(Modifier.height(15.dp))
Text(text = "Content", style = MaterialTheme.typography.h3)
Text(text = "Content", style = MaterialTheme.typography.h3)
Text(text = "Content", style = MaterialTheme.typography.h3)
Text(text = "Content", style = MaterialTheme.typography.h3)
Text(text = "Content", style = MaterialTheme.typography.h3)
Row(Modifier.height(250.dp)) {
LocationInput()
}
Text(text = "Content", style = MaterialTheme.typography.h3)
Text(text = "Content", style = MaterialTheme.typography.h3)
Text(text = "Content", style = MaterialTheme.typography.h3)
Text(text = "Content", style = MaterialTheme.typography.h3)
Text(text = "Content", style = MaterialTheme.typography.h3)
}
}
Screen Capture
Okay, so after I posted a question I tried to fix a problem again, and I found a working solution. However I'm not sure if it's the best way to achieve desired effect.
I'm manually handling drag event on AndroidView used to present map.
Code
// AndroidView in LocationInput file:
AndroidView({ map },
Modifier
.clip(RoundedCornerShape(6.dp))
.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
change.consumeAllChanges()
googleMap.value!!.moveCamera(
CameraUpdateFactory.scrollBy(
dragAmount.x * -1,
dragAmount.y * -1
)
)
}
})
{ mapView ->
}
I have started trying out Navigation for compose.
I created my 2 Composables and everything is working fine.
But what I'm missing is Animations (or Transitions) between the pages. I didn't find any resources pointing out how to do it in Compose.
I know all animations are based on states in Compose, but the only thing I know is the Navigation Back Stack.
You can use the composable I made to show enter animation (configure preferable effects in "enter" and "exit" parameters)
fun EnterAnimation(content: #Composable () -> Unit) {
AnimatedVisibility(
visible = true,
enter = slideInVertically(
initialOffsetY = { -40 }
) + expandVertically(
expandFrom = Alignment.Top
) + fadeIn(initialAlpha = 0.3f),
exit = slideOutVertically() + shrinkVertically() + fadeOut(),
content = content,
initiallyVisible = false
)
}
You can use it like this:
NavHost(
navController = navController,
startDestination = "dest1"
) {
composable("dest1") {
EnterAnimation {
FirstScreen(navController)
}
}
composable("dest2") {
EnterAnimation {
SecondScreen(navController)
}
}
}
Accompanist version 0.16.1 and above supports animation between destinations. Here is the article for more info.
implementation("com.google.accompanist:accompanist-navigation-animation:0.16.1")
import com.google.accompanist.navigation.animation.AnimatedNavHost
import com.google.accompanist.navigation.animation.composable
import com.google.accompanist.navigation.animation.rememberAnimatedNavController
val navController = rememberAnimatedNavController()
AnimatedNavHost(navController, startDestination = "first") {
composable(
route = "first",
enterTransition = { _, _ -> slideInHorizontally(animationSpec = tween(500)) },
exitTransition = { _, _ -> slideOutHorizontally(animationSpec = tween(500)) }
) {
FirstScreen()
}
composable(
route = "second",
enterTransition = { _, _ -> slideInHorizontally(initialOffsetX = { it / 2 }, animationSpec = tween(500)) },
exitTransition = { _, _ -> slideOutHorizontally(targetOffsetX = { it / 2 }, animationSpec = tween(500)) }
) {
SecondScreen()
}
}
Result:
In alpha-09 this is not supported. :(
Please, star this issue: https://issuetracker.google.com/issues/172112072
Due to yesterday's update (version 2.4.0-alpha05):
Navigation Compose’s NavHost now always uses Crossfades when navigating through destinations.
Lately Google Accompanist has added a library which provides Compose Animation support for Jetpack Navigation Compose.. Do check it out. 👍🏻
https://github.com/google/accompanist/tree/main/navigation-animation
You can use the library I did. It provides easy navigation and is written using AnimatedVisibility so you can use compose transitions to animate any screen state (enter, exit)
https://github.com/vldi01/AndroidComposeRouting