Jetpack Compose Navigation Is Triggering Recomposition - kotlin

I have a simple Jetpack Compose application that uses the Navigation Component.
My UI is comprised of the following composables (redacted UI to be concise):
NavHost:
#Composable
fun NavHost(
navController: NavHostController = rememberNavController(),
startDestination: String = "main"
) {
NavHost(
navController = navController,
startDestination = startDestination) {
composable("main") {
SomeList(onNavigateToItemView = {
navController.navigate("listItem")
})
}
composable("listItem") {
ItemView()
}
}
}
SomeList:
#Composable
fun SomeList(onNavigateToItemView: () -> Unit) {
Column {
Row ( Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
Text(text = Constants.APP_TITLE, fontSize = 30.sp, fontWeight = FontWeight.Bold)
}
Box(modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
LazyColumn(
horizontalAlignment = Alignment.CenterHorizontally
) {
items(items) { item->
ItemCard(item, onNavigateToItemView)
}
}
}
}
}
ItemCard:
#Composable
fun ItemCard(item: ItemModel, onNavigateToItemView: () -> Unit) {
Card(
border = BorderStroke(2.dp, Color.Cyan),
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 5.dp)
.clickable {
onNavigateToItemView()
}
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
)
{
....
}
}
}
Now, whenever the user clicks on an ItemCard, he/she transitions to an ItemView.
What I am seeing is that the ItemView is being recomposed several times when navigating to it and also when navigating back from it.
According to the guide linked above,
The NavController's navigate function modifies the NavController's
internal state. To comply with the single source of truth principle as
much as possible, only the composable function or state holder that
hoists the NavController instance and those composable functions that
take the NavController as a parameter should make navigation calls.
Navigation events triggered from other composable functions lower in
the UI hierarchy need to expose those events to the caller
appropriately using functions.
And as you can see above, I am following that practice.
So, am I doing something wrong in my implementation or is this just how navigation and Jetpack Compose work together?
The multiple recomposition calls are affecting my UI unnecessarily.

Short Answer
Yes, navigating using compose navigation will recompose your composable each time you are navigating to it.
Workaround
When you are using navController.navigate() it will automatically recompose the targeted route. However, you can use an option to save the current state of the screen that you leave and restore the state of the screen that you target.
For example :
A->B->A->B
The first time you will load A & B you will recompose because you have no state saved yet. The moment you go from B to A (B->A) the state will be restored so you'll not recompose. Same things occurs the second time you go from A to B (A->B)
Use
Accord to documentation, you can make an extension function like this
fun NavController.popUpTo(destination: String) = navigate(destination) {
popUpTo(graph.findStartDestination().id) {
saveState = true
}
// Restore state when reselecting a previously selected item
restoreState = true
}
And use it like this
SomeList(onNavigateToItemView = {
navController.popUpTo("listItem")
})

Related

How to share different layouts depending on the current page in kotlin jetpack compose?

I am relatively new to kotlin with jetpack compose and I am having a hard time figuring out how to share the same layout of a page among different pages. The thing is that the layout is conditional to whether the user has logged in or not (among other things).
The basic implementation I have looks something like the following code.
Now, when I click in a LoginButton in the LoginPage, it changes a isLoggedIn variable in the ViewModels, which in turns triggers recomposition and the proper layout is displayed is wrapping the actual page contents. Another example is if the "create account" button is clicked, then the SignUpLayout is wrapping the page contents.
Now, another way I can think (and like better than triggering the recomposition in all the application), is for the LoginButton to call the navigate method in the NavHostController. However I am unsure how to implement the "wrapper layouts" for this other way of doing things.
So my questions are:
Is the current way of navigating around layouts fine? Even though it triggers recomposition from the top most composable (aka ClientApp)?
If I wanted for the LoginButton to make navigation by calling the NavHostController.navigate method instead of changing a variable that triggers recomposition, how will the different layouts be handled?
Is there anything else I am missing? Maybe there is another way of accomplishing this that I am not aware of and I am just over engineering things?
enum class Page(
#StringRes val title: Int,
val layout: String,
val route: String
) {
Profile(
title = R.string.profile_page,
layout = "main",
route = "/profile"),
Home(
title = R.string.home_page,
layout = "main",
route = "/home"),
Login(
title = R.string.login_page,
layout = "none",
route = "/login"),
SignUpUserDetails(
title = R.string.user_details_page,
layout = "sign-up",
route = "/sign-up/user-details"),
SignUpAccountDetails(
title = R.string.account_details_page,
layout = "sign-up",
route = "/sign-up/account-details"),
}
#Composable
fun PageNavHost(
navHostController: NavHostController,
modifier: Modifier = Modifier
) {
NavHost(
navController = navHostController,
startDestination = Page.Home.route,
modifier = modifier
) {
composable(route = Page.Profile.route) {
ProfilePage()
}
composable(route = Page.Home.route) {
HomePage()
}
composable(route = Page.Login.route) {
val viewModel = hiltViewModel<LoginViewModel>()
LoginPage(viewModel, navHostController)
}
composable(route = Page.SignUpUser.route) {
SignUpUserPage()
}
}
}
#Composable
fun ClientApp() {
val navHostController: NavHostController = rememberNavController()
val mainViewModel = hiltViewModel<MainViewModel>()
val signUpViewModel = hiltViewModel<SignUpViewModel>()
val loginViewModel = hiltViewModel<LoginViewModel>()
val isLoggedIn by mainViewModel.sessionHolder.isLoggedIn.collectAsState()
if(!isLoggedIn) {
LoginPage(loginViewModel, navHostController)
} else {
val currentPage = navHostController.currentPage()
when (currentPage?.layout) {
"main" -> MainLayout(mainViewModel, navHostController) { content() }
"sign-up" -> SignUpLayout(navHostController, signUpViewModel) { content() }
else -> content()
}
}
}

Weird function behaviour inside composable function

Whenever I tell my NavGraph to start on this composable screen:
#Composable
fun LiveTrainingScreen(viewModel: LiveTrainingViewModel = viewModel(), navController: NavController) {
viewModel.context = LocalContext.current
viewModel.scope = rememberCoroutineScope()
viewModel.navController = navController
val largeVideoPlayerHandler = viewModel.InitLargeVideoDisplay(CameraLink.cockPitRight) //exoplayer saved within this handler and refrences is made within the viewModel
val smallVideoPlayerHandler = viewModel.InitSmallVideoDisplay(CameraLink.navigationAndAltitudeDisplay) //exoplayer saved within this handler and refrences is made within the viewModel
//lots of code
}
and when I want to switch the mediaType/video that is being displayed of the Exoplayer this function will work:
class LiveTrainingViewModel(application: Application) : AndroidViewModel(application) {
fun SwitchLargeVideoDisplay(cameraLinkObject: CameraLinkObject) {
UpdateLargeVideoDisplayCameraLinkObject(cameraLinkObject)
largeVideoDisplay.videoPlayer.setMediaItem(MediaItem.fromUri(cameraLinkObject.url))
largeVideoDisplay.videoPlayer.prepare()
}
}
But whenever I load this Screen from another screen this large screen update function won't work for some reason?
Does anybody know why?

Should I use remember with animateDpAsState?

The Code A is from the official sample code here.
I know that in order to preserve state across recompositions, remember the mutable state using remember.
I think that the code val extraPadding by animateDpAsState(...) should be val extraPadding by remember { animateDpAsState(...) }, is it right?
BTW, val extraPadding by remember { animateDpAsState(...) } will cause the error
Composable calls are not allowed inside the calculation parameter of inline fun remember(calculation: () -> TypeVariable(T)): TypeVariable(T)
Code A
#Composable
private fun Greeting(name: String) {
var expanded by remember { mutableStateOf(false) }
val extraPadding by animateDpAsState( //Should I add remember
if (expanded) 48.dp else 0.dp
)
Surface(
color = MaterialTheme.colors.primary,
modifier = Modifier.padding(vertical = 4.dp, horizontal = 8.dp)
) {
Row(modifier = Modifier.padding(24.dp)) {
Column(modifier = Modifier
.weight(1f)
.padding(bottom = extraPadding)
) {
Text(text = "Hello, ")
Text(text = name)
}
OutlinedButton(
onClick = { expanded = !expanded }
) {
Text(if (expanded) "Show less" else "Show more")
}
}
}
}
No, you shouldn't. This function is marked with #Composable so it should be used directly in the view builder.
animateDpAsState will calculate its value depending on targetValue on each recomposition.
If you check it source code you'll see, that it uses remember inside, that's why it's marked with #Composable, and that's why you shouldn't bother about remembering some values manually.
For anyone that comes across this in the future, instead of attempting to remember the animateDpAsState it would be better to remember the expansion state. Because in this case we're trying to remember the state in an item of a list we use rememberSaveable:
var expanded by rememberSaveable { mutableStateOf(false) }
A very similar case of storing indexes across list items is shown in the official documentation here

Jetbrains Compose Desktop Elements Overlap

I am writing a compose desktop app.
I have a main window:
Window(size = IntSize(600, 600)) {
// snip logic
Column(horizontalAlignment = Alignment.CenterHorizontally) {
ChessBoard(board.value, modifier = Modifier.fillMaxWidth(0.8f))
Row(modifier = Modifier
.fillMaxSize(), horizontalArrangement = Arrangement.SpaceEvenly) {
Button(onClick = previousBoard) {
Text("<")
}
Button(onClick = nextBoard) {
Text(">")
}
}
}
}
Where ChessBoardis defined as
#Composable
fun ChessBoard(board: Board, modifier: Modifier = Modifier) {
Canvas(modifier = modifier) {
// snip logic, I checked that it does not influence the result
}
}
The chessboard takes up the correct amount of space, but the buttons overlap and aren't added at the bottom as expected.
I tried tweaking the modifiers on ChessBoard, but that did not change the fact that the buttons are at the top.
On the Row, remove the Modifier.fillMaxSize()
I think what you need is Modifier.fillMaxWidth()
The Row is taking up the entire space of the screen, and the buttons inside the row, by default stay at the top of the row. To check that this is the case, try adding a Modifier.align(CenterVerticlly) to the row, and the buttons will move to the center of the screen, since the row is still occupying the whole screen. Removing the Modifier.fillMaxSize() should do it, but if you still want to keep it for other Composables, use Modifier.align(Alignment) to fix it

Update State outside the composable function. (Jetpack compose)

I am trying to implement redux with Jetpack compose. The scenario looks like this:
I have a list view where I need to show data, in composable function.
#Composable
fun CreateListView(text: String) {
val listdata = state { store.state }
LazyColumn {
//some listview code here
}
}
above, I want to use the data that I got from the redux store. but the store. The subscription method is standalone, and outside the composable. where, though I am able to update the state through new data, but the changes are not reflecting back to composable listview:
// activity page outside composable
private fun storeSubscription(){
viewModel.storeSubscription = store.subscribe {
when (store.state) {
store.state = // list data from some source
}
}
}
Is it possible to update the composable, like above, from outside the function, and without sending any parameter? Since the redux store is a global one, so it should work I think.
You can use MutableLiveData outside of composable function. Use observeAsState() in composable to recompose when data changes.
private val myLive = MutableLiveData<String>()
fun nonComposableScope(){
myLive.postValue("hello")
}
#Composable
fun MyScreen(textLive:LiveData<String>){
val text: String? by textLive.observeAsState()
// use text here
}
Try something like,
#Composable
fun <T> Store<T>.asState(): State<T> {
val result = remember { mutableStateOf(store.state) }
DisposableEffect {
val unsubscribe = store.subscribe {
result.value = store.state
}
onDispose { unsubscribe() }
}
return result
}
#Composable
fun CreateListView(text: String) {
val listdata by store.asState()
LazyColumn {
//some listview code here
}
}
The exact code might differ as I don't know what redux implementation you are using.
This creates an observable state object that will be updated whenever the lambda passed to subscribe is called. Also, it will automatically unsubscribe when CreateListView is no longer part of the composition.
You have to follow the state hosting pattern
From Android Domcumentaiton
Key Term: State hoisting is a pattern of moving state up the tree to
make a component stateless.
When applied to composables, this often means introducing two
parameters to the composable:
value: T: the current value to display. onValueChange: (T) -> Unit: an
event that requests the value to change where T is the proposed new
value.
So in your case you will save the state in the upper Composable that needs to access it, and pass the value of the state and a lambda function to change it to the other Composable, you can learn more from the Official Documentation.
You could simply use a lambda like so:
(An example from an app I am working on.)
#OptIn(ExperimentalMaterial3Api::class)
#Composable
fun RumbleSearchResult(rumbleSearchResult: RumbleSearchResult, onClick: () -> Unit) {
ListItem(
headlineText = {
rumbleSearchResult.title?.let { title ->
Text(title)
}
},
supportingText = {
rumbleSearchResult.channel.let { creator ->
val text = when {
rumbleSearchResult.views > 0 -> {
"${creator.name}, ${rumbleSearchResult.views} views"
}
else -> {
creator.name ?: ""
}
}
Row {
Text(text)
if (creator.isVerified) {
Icon(
painter = painterResource(R.drawable.ic_baseline_verified_24),
tint = Color.Cyan,
contentDescription = stringResource(id = R.string.mainActivity_verified_content_description)
)
}
}
}
},
leadingContent = {
AsyncImage(
rumbleSearchResult.thumbnailSrc,
contentDescription = null,
modifier = Modifier.size(100.dp, 100.dp)
)
},
modifier = Modifier.clickable {
onClick.invoke()
}
)
Divider()
}
Main composable:
LazyColumn {
items(viewModel.searchResults) {
RumbleSearchResult(rumbleSearchResult = it) {
openDialog = true
}
}
}