Jetpack compose NavHost prevent recomposition screens - android

as you see this is how i implemented NavHost with MaterialBottomNavigation, i have many items on both Messages and Feeds screens, when i navigate between them both screens, they automatically recomposed but i don't wanna because of much data there it flickring and fps drops to under 10 when navigating, i tried to initialize data viewModels before NavHost but still same result, is there any way to compose screens once and update them just when viewModels data updated?
#Composable
private fun MainScreenNavigationConfigurations(
navController: NavHostController,
messagesViewModel: MessagesViewModel = viewModel(),
feedsViewModel: FeedsViewModel = viewModel(),
) {
val messages: List<Message> by messagesViewModel.messages.observeAsState(listOf())
val feeds: List<Feed> by feedsViewModel.messages.observeAsState(listOf())
NavHost(
navController = navController,
startDestination = "Messages"
) {
composable("Messages") {
Messages(navController, messages)
}
composable("Feeds") { Feeds(navController, feeds) }
}
}

I had a similar problem. In my case I needed to instantiate a boolean state "hasAlreadyNavigated".
The problem was:
-> Screen 1 should navigate to Screen 2;
-> Screen 1 has a conditional statement for navigating directly to screen 2 or show a content screen with an action button that navigates to Screen 2;
-> After it navigates to Screen 2, Screen 1 recomposes and it reaches the if statement again, causing a "navigation loop".
val hasAlreadyNavigated = remember { mutableStateOf(false) }
if (!hasAlreadyNavigated.value) {
if (!screen1ViewModel.canNavigate()) {
Screen1Content{
hasAlreadyNavigated.value = true
screen1ViewModel.allowNavigation()
navigateToScreen2()
}
} else {
hasAlreadyNavigated.value = true
navigateToScreen2()
}
}
With this solution, i could prevent recomposition and the "re-navigation".
I don't know if we need to be aware and build composables thinking of this recomposition after navigation or it should be library's responsibility.

Please use this code above your code. It will remember state of your current screen.
val navController = rememberNavController()
for more info check this out:
https://developer.android.com/jetpack/compose/navigation

Passing the navcontroller as a parameter causes recomposition. Use it as a lambda instead.
composable("Messages") {
Messages( onClick = {navController.navigate(route = "Click1")},
onClick2 = {navController.navigate(route = "Click2")},
messages)
}

Related

Jetpack compose - single Scaffold shared by multiple screens

This is a question about general navigation design in Jetpack compose which I find a bit confusing.
As I understand it, having multiple screens with each own Scaffold causes flickers when navigating (I definitely noticed this issue). Now in the app, I have a network observer that is tied to Scaffold (e.g. to show Snackbar when there is no internet connection) so that's another reason I'm going for a single Scaffold design.
I have a MainViewModel that holds the Scaffold state (e.g. top bar, bottom bar, fab, title) that each screen underneath can turn on and off.
#Composable
fun AppScaffold(
networkMgr: NetworkManager,
mainViewModel: MainViewModel,
navAction: NavigationAction = NavigationAction(mainViewModel.navHostController),
content: #Composable (PaddingValues) -> Unit
) {
LaunchedEffect(Unit) {
mainViewModel.navHostController.currentBackStackEntryFlow.collect { backStackEntry ->
Timber.d("Current screen " + backStackEntry.destination.route)
val route = requireNotNull(backStackEntry.destination.route)
var show = true
// if top level screen, do not show
topLevelScreens().forEach {
if (it.route.contains(route)) {
show = false
return#forEach
}
}
mainViewModel.showBackButton = show
mainViewModel.showFindButton = route == DrawerScreens.Home.route
}
}
Scaffold(
scaffoldState = mainViewModel.scaffoldState,
floatingActionButton = {
if (mainViewModel.showFloatingButton) {
FloatingActionButton(onClick = { }) {
Icon(Icons.Filled.Add, contentDescription = "Add")
}
}
},
floatingActionButtonPosition = FabPosition.End,
topBar = {
if (mainViewModel.showBackButton) {
BackTopBar(mainViewModel, navAction)
} else {
AppTopBar(mainViewModel, navAction)
}
},
bottomBar = {
if (mainViewModel.showBottomBar) {
// TODO
}
},
MainActivity looks like this
setContent {
AppCompatTheme {
var mainViewModel: MainViewModel = viewModel()
mainViewModel.coroutineScope = rememberCoroutineScope()
mainViewModel.navHostController = rememberNavController()
mainViewModel.scaffoldState = rememberScaffoldState()
AppScaffold(networkMgr, mainViewModel) {
NavigationGraph(mainViewModel)
}
}
}
Question 1) How do I make this design scalable? As one screen's FAB may have different actions from another screen's FAB. The bottom bar may be different between screens. The main problem is I need good a way for screens to talk to the parent Scaffold.
Question 2) Where is the best place to put the code under "LaunchedEffect" block whether it's ok here?
I found this StackOverflow answer that covers your question pretty well.
The key answers to your questions according to this answer are:
You define a data class that holds variables for each element that might change between the different screens that will be displayed inside the scaffold. This most probably will be at least the title:
data class ScaffoldViewState(
#StringRes val topAppBarTitle: Int? = null
)
Then, you store this data class using remember, so that a recomposition will be triggered whenever one value within the data class changes:
var scaffoldViewState by remember {
mutableStateOf(ScaffoldViewState())
}
Finally, you can assign the field within the data class to the title slot of the Scaffold.
Changing the variables of the data class should happen from the NavHost, as seen in the linked post.

Bottom Nav Android Jetpack Compose issue

The below is an overview of the BottomNav implementation.The app shows the bottom Nav bar properly but when an item is selected, it calls the NavHost multiple times. I see a similar issue for Jetpack compose samples https://github.com/android/compose-samples/tree/main/Jetsnack. Is there any workaround to avoid multiple Navhost calls?
#Composable
fun MainScreen() {
val navController = rememberNavController()
Scaffold(
bottomBar = { BottomMenu(navController = navController) }
) {
BottomNavGraphBar(navController = navController)
}
}
// handling the click event
BottomNavigationItem(
onClick = {
navController.navigate(screen.route) {
popUpTo(navController.graph.findStartDestination().id)
launchSingleTop = true
}
}
)
//NavHost implementation
#Composable
fun BottomNavGraphBar(navController: NavHostController) {
NavHost(
navController = navController,
startDestination = Screen.Home.route
) {
composable(route = Screen.Home.route) {
Log.d("BottomNavGraph","BottomNavGraph->HomeScreen")
HomeScreen()
}
composable(route = Screen.Settings.route) {
Log.d("BottomNavGraph","BottomNavGraph->AppSettingsScreen")
AppSettingsScreen()
}
composable(route = Screen.Profile.route) {
Log.d("BottomNavGraph","BottomNavGraph->ProfileScreen")
ProfileScreen()
}
}
}
<!---LogCat-->
// When app is launched
BottomNavGraph->HomeScreen
BottomNavGraph->HomeScreen
// clicked on the profile.
BottomNavGraph->HomeScreen
BottomNavGraph->ProfileScreen
BottomNavGraph->HomeScreen
BottomNavGraph->ProfileScreen
Compose can (and will, depending on multiple things) call composable functions to "re-compose" them. Although it is smart and can cache the output of composable functions for their previous inputs, so that it does not have to recompute their results (e.g. their emitted UI).
In your example, the composable(..) { ... } might get recomposed, but if you use composables inside it (like a few Texts) it will use its cache from its last rendering.
You don't need to worry about your functions being called, but you do have to take care of your computations. This is why you'd want to use remember to calculate something and store it in the cache, so it is not re-calculated again.

Skip landing page in NavHost when user opens the app for second time using Jetpack Compose with Flow

I have an app with HorizontalPager at startDestination screen and after I go to the last page, the app shows the home page.
When I open the app for second time it should show Home page immediately and never again show that startDestination screen with HorizontalPager.
I used dataStore and it works, but the problem is that every time I open the app it flashes for a second that HorizontalPager landing page and then it switches to the home page.
I used flow to get true/false state of the app start, so it will know it will know app was already opened for first time.
class MainActivity : ComponentActivity() {
#ExperimentalAnimationApi
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
WebSafeTheme {
// A surface container using the 'background' color from the theme
Surface(color = MaterialTheme.colors.background) {
val navController = rememberNavController()
Navigation(navController)
}
}
}
}
}
#ExperimentalAnimationApi
#ExperimentalMaterialApi
#ExperimentalFoundationApi
#ExperimentalPagerApi
#Composable
fun Navigation(
navController: NavHostController
) {
val context = LocalContext.current
val preferencesManager = PreferencesManager(context)
val preferencesFlow = preferencesManager.preferencesFlow
val scope = rememberCoroutineScope()
val result = remember { mutableStateOf(Constants.SKIP_LANDING_PAGE) }
scope.launch {
result.value = preferencesFlow.first().skipLandingPage
}
NavHost(
navController = navController,
//it goes it this line 2 times, first time when the app opens and second time after the flow is finished
startDestination = if (result.value) Screen.HomeScreen.route else Screen.LandingScreen.route,
modifier = Modifier.fillMaxSize()
) {
composable(
route = Screen.LandingScreen.route
) {
Landing(navController)
}
composable(
route = Screen.SkipChooseCountryScreen.route
) {
ChooseCountry()
}
composable(
route = Screen.HomeScreen.route
) {
Home(navController)
}
}
}
It goes to NavHost for the first time after app openes and it always returns FALSE as it is default value, after that flow returns TRUE(so it knows app was opened at least once before) and then it openes the correct screen.
I have no idea how to make NavHost to wait that flow to finishes. I tried to put NavHost into the scope but it didn't allow me.
I also need to read data from data store, so this is how I do it:
Crossfade(
targetState = state.value.loadState
) { loadState ->
when (loadState) {
LoadState.NOT_LOADED -> Box(modifier = Modifier.fillMaxSize())
LoadState.SHOW_PIN -> PinScreen(
loadState = loadState,
state = state,
modifier = Modifier.fillMaxSize(),
)
LoadState.SHOW_CONTENT -> MainContent(
state = state,
)
}
}
Initially my state is NOT_LOADED so I just display an empty box that fills the screen. You could alternatively display a spinner, or your app's logo. Once the data has been loaded, I show the PIN screen if the user has PIN enabled, or the main screen otherwise.
Also, note that you should not have your viewpager screen as your root destination, your home page should be your root destination, and you should conditionally navigate to the viewpager if the user has not seen your onboarding flow yet. Check this for details.

How to save paging state of LazyColumn during navigation in Jetpack Compose

I'm using androidx.paging:paging-compose (v1.0.0-alpha-14), together with Jetpack Compose (v1.0.3), I have a custom PagingSource which is responsible for pulling items from backend.
I also use compose navigation component.
The problem is I don't know how to save a state of Pager flow between navigating to different screen via NavHostController and going back (scroll state and cached items).
I was trying to save state via rememberSaveable but it cannot be done as it is not something which can be putted to Bundle.
Is there a quick/easy step to do it?
My sample code:
#Composable
fun SampleScreen(
composeNavController: NavHostController? = null,
myPagingSource: PagingSource<Int, MyItem>,
) {
val pager = remember { // rememberSaveable doesn't seems to work here
Pager(
config = PagingConfig(
pageSize = 25,
),
initialKey = 0,
pagingSourceFactory = myPagingSource
)
}
val lazyPagingItems = pager.flow.collectAsLazyPagingItems()
LazyColumn() {
itemsIndexed(items = lazyPagingItems) { index, item ->
MyRowItem(item) {
composeNavController?.navigate(...)
}
}
}
}
I found a solution!
#Composable
fun Sample(data: Flow<PagingData<Something>>):
val listState: LazyListState = rememberLazyListState()
val items: LazyPagingItems<Something> = data.collectAsLazyPagingItems()
when {
items.itemCount == 0 -> LoadingScreen()
else -> {
LazyColumn(state = listState, ...) {
...
}
}
}
...
I just found out what the issue is when using Paging.
The reason the list scroll position is not remembered with Paging when navigating boils down to what happens below the hood.
It looks like this:
Composable with LazyColumn is created.
We asynchronously request our list data from the pager. Current pager list item count = 0.
The UI draws a lazyColumn with 0 items.
The pager responds with data, e.g. 10 items, and the UI is recomposed to show them.
User scrolls e.g. all the way down and clicks the bottom item, which navigates them elsewhere.
User navigates back using e.g. the back button.
Uh oh. Due to navigation, our composable with LazyColumn is recomposed. We start again with asynchronously requesting pager data. Note: pager item count = 0 again!
rememberLazyListState is evaluated, and it tells the UI that the user scrolled down all the way, so it now should go back to the same offset, e.g. to the fifth item.
This is the point where the UI screams in wild confusion, as the pager has 0 items, so the lazyColumn has 0 items.
The UI cannot handle the scroll offset to the fifth item. The scroll position is set to just show from item 0, as there are only 0 items.
What happens next:
The pager responds that there are e.g. 10 items again, causing another recomposition.
After recomposition, we see our list again, with scroll position starting on item 0.
To confirm this is the case with your code, add a simple log statement just above the LazyColumn call:
Log.w("TEST", "List state recompose. " +
"first_visible=${listState.firstVisibleItemIndex}, " +
"offset=${listState.firstVisibleItemScrollOffset}, " +
"amount items=${items.itemCount}")
You should see, upon navigating back, a log line stating the exact same first_visible and offset, but with amount items=0.
The line directly after that will show that first_visible and offset are reset to 0.
My solution works, because it skips using the listState until the pager has loaded the data.
Once loaded, the correct values still reside in the listState, and the scroll position is correctly restored.
Source: https://issuetracker.google.com/issues/177245496
Save the list state in your viewmodel and reload it when you navigate back to the screen containing the list. You can use LazyListState in your viewmodel to save the state and pass that into your composable as a parameter. Something like this:
class MyViewModel: ViewModel() {
var listState = LazyListState()
}
#Composable
fun MessageListHandler() {
MessageList(
messages: viewmodel.messages,
listState = viewmode.listState
)
}
#Composable
fun MessageList(
messages: List<Message>,
listState: LazyListState) {
LazyColumn(state = listState) {
}
}
If you don't like the limitations that Navigation Compose puts on you, you can try using Jetmagic. It allows you to pass any object between screens and even manages your viewmodels in a way that makes them easier to access from any composable:
https://github.com/JohannBlake/Jetmagic
The issue is that when you navigate forward and back your composable will recompose and collectAsLazyPagingItems() will be called again, triggering a new network request.
If you want to avoid this issue, you should call pager.flow.cacheIn(viewModelScope) on your ViewModel with activity scope (the ViewModel instance is kept across fragments) before calling collectAsLazyPagingItems().
LazyPagingItems is not intended as a persistent data store; it is just a simple wrapper for the UI layer. Pager data should be cached in the ViewModel.
please try using '.cachedIn(viewModelScope) '
simple example:
#Composable
fun Simple() {
val simpleViewModel:SimpleViewModel = viewModel()
val list = simpleViewModel.simpleList.collectAsLazyPagingItems()
when (list.loadState.refresh) {
is LoadState.Error -> {
//..
}
is LoadState.Loading -> {
BoxProgress()
}
is LoadState.NotLoading -> {
when (list.itemCount) {
0 -> {
//..
}
else -> {
LazyColumn(){
items(list) { b ->
//..
}
}
}
}
}
}
//..
}
class SimpleViewModel : ViewModel() {
val simpleList = Pager(
PagingConfig(PAGE_SIZE),
pagingSourceFactory = { SimpleSource() }).flow.cachedIn(viewModelScope)
}

Android Compose setupWithNavController

I am looking for a Compose variant of setupWithNavController(Toolbar, NavController) to automatically update the up button in an AppBar whenever the Navigation destination changes.
So far I haven't found anything useful.
Is it considered a bad design in Compose? Or is there some simple way how to achieve the same thing that I am not seeing?
I've hacked up a solution, but I am not satisfied.
androidx.navigation:navigation-compose:1.0.0-alpha08 provides an extension function to observe the current back stack entry.
#Composable
fun NavController.currentBackStackEntryAsState(): State<NavBackStackEntry?>
I've created a similar extension to observe the previous back stack entry
/**
* Gets the previous navigation back stack entry as a [MutableState]. When the given navController
* changes the back stack due to a [NavController.navigate] or [NavController.popBackStack] this
* will trigger a recompose and return the second top entry on the back stack.
*
* #return a mutable state of the previous back stack entry
*/
#Composable
fun NavController.previousBackStackEntryAsState(): State<NavBackStackEntry?> {
val previousNavBackStackEntry = remember { mutableStateOf(previousBackStackEntry) }
// setup the onDestinationChangedListener responsible for detecting when the
// previous back stack entry changes
DisposableEffect(this) {
val callback = NavController.OnDestinationChangedListener { controller, _, _ ->
previousNavBackStackEntry.value = controller.previousBackStackEntry
}
addOnDestinationChangedListener(callback)
// remove the navController on dispose (i.e. when the composable is destroyed)
onDispose {
removeOnDestinationChangedListener(callback)
}
}
return previousNavBackStackEntry
}
and a composable for the back button
#Composable
fun NavigationIcon(navController: NavController): #Composable (() -> Unit)? {
val previousBackStackEntry: NavBackStackEntry? by navController.previousBackStackEntryAsState()
return previousBackStackEntry?.let {
{
IconButton(onClick = {
navController.popBackStack()
}) {
Icon(Icons.Default.ArrowBack, contentDescription = "Up button")
}
}
}
}
I had to return a #Composable (() -> Unit)? (instead of no return value common to composable functions) because the TopAppBar uses the nullability to check whether to offset the title by 16dp (without icon) or by 72dp (with icon).
Finally, the content looks something like this
#Composable
fun MainContent() {
val navController: NavHostController = rememberNavController()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Weather") },
navigationIcon = NavigationIcon(navController)
)
},
) {
NavHost(
navController,
startDestination = "list"
) {
composable("list") {
...
}
composable("detail") {
...
}
}
}
}
List:
Detail:
It might be cleaner to create a custom NavigationTopAppBar composable and hoist the NavController out of NavigationIcon but the idea stays the same. I didn't bother tinkering further.
I've also attempted to automatically update the title according to the current NavGraph destination. Unfortunately, there is not a reliable way to set a label to destinations without extracting quite a big chunk of internal implementation out of androidx.navigation:navigation-compose library.

Categories

Resources