Jetpack Compose: Bottom bar navigation not responding after deep-linking - deep-linking

I have setup a bottom bar in my new Jetpack Compose app with 2 destinations. I have tried to follow the samples from Google.
So for example it looks something like this:
#Composable
fun MyBottomBar(navController: NavHostController) {
val items = listOf(
BottomNavigationScreen.ScreenA,
BottomNavigationScreen.ScreenB
)
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
BottomNavigation {
items.forEach { screen ->
BottomNavigationItem(
onClick = {
navController.navigate(screen.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
icon = { Icon(imageVector = screen.icon, contentDescription = null) },
label = { Text(stringResource(screen.label)) }
)
}
}
}
This all works fine and I'm able to navigate between the two destinations. However, I also have a deep-link to ScreenB. Once this has been invoked, pressing the ScreenA button seems to do nothing (If I add logging I can see that currentDestination is being repeatedly set to ScreenB) but pressing back returns to the startDestination of ScreenA.
My workaround at the moment is to remove the restoreState = true line from the sample code.
My suspicion is that something about the deep-link is being persisted and although it tries to go to ScreenA the navigation component says that it's got a deep-link pointing to ScreenB so it just goes there. I've tried resetting the activity intent so that it has no flags and no data in the intent, I've even tried changing the intent action type but all to no avail.
I am using Compose 1.0.0-rc02 and Compose Navigation 2.4.0-alpha04.
Am I doing something wrong or is this a bug?

I know you got this code from the official documentation, but I don't think it works well for bottom navigation. It keeps the elements in the navigation stack, so pressing the back button from ScreenB will take you back to ScreenA, which does not seem to me to be the right behavior in this case.
That's why it's better to remove all elements from the stack, so that only one of the tabs is always left. And with saveState you won't lose state anyway. This can be done as follows:
fun NavHostController.navigateBottomNavigationScreen(screen: BottomNavigationScreen) = navigate(screen.route) {
val navigationRoutes = BottomNavigationScreen.values()
.map(BottomNavigationScreen::route)
val firstBottomBarDestination = backQueue
.firstOrNull { navigationRoutes.contains(it.destination.route) }
?.destination
if (firstBottomBarDestination != null) {
popUpTo(firstBottomBarDestination.id) {
inclusive = true
saveState = true
}
}
launchSingleTop = true
restoreState = true
}
And use it like this:
BottomNavigationItem(
onClick = {
navController.navigateBottomNavigationScreen(screen)
},
selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
icon = { Icon(imageVector = screen.icon, contentDescription = null) },
label = { Text(screen.label) }
)
For the same reasons, I wouldn't use deep link navigation in this case. Instead, you process them manually. You can use a view model to not re-process deep link if you leave bottom navigation view and come back:
class DeepLinkProcessingViewModel : ViewModel() {
private var deepLinkProcessed = false
fun processDeepLinkIfAvailable(context: Context): String? {
if (!deepLinkProcessed) {
val activity = context.findActivity()
val intentData = activity?.intent?.data?.toString()
deepLinkProcessed = true
return intentData
}
return null
}
}
And using this view model you can calculate start destination like this:
val context = LocalContext.current
val deepLinkProcessingViewModel = viewModel<DeepLinkProcessingViewModel>()
val startDestination = rememberSaveable(context) {
val deepLink = deepLinkProcessingViewModel.processDeepLinkIfAvailable(context)
if (deepLink == "example://playground") {
// deep link handled
BottomNavigationScreen.ScreenB.route
} else {
// default start destination
BottomNavigationScreen.ScreenA.route
}
}
NavHost(navController = navController, startDestination = startDestination) {
...
}
Or, if you have several navigation elements in front of the bottom navigation and you don't want to lose them with a deep link, you can do it as follows:
val navController = rememberNavController()
val context = LocalContext.current
val deepLinkProcessingViewModel = viewModel<DeepLinkProcessingViewModel>()
LaunchedEffect(Unit) {
val deepLink = deepLinkProcessingViewModel.processDeepLinkIfAvailable(context) ?: return#LaunchedEffect
if (deepLink == "example://playground") {
navController.navigateBottomNavigationScreen(BottomNavigationScreen.ScreenB)
}
}
NavHost(
navController = navController,
startDestination = BottomNavigationScreen.ScreenA.route
) {
findActivity:
fun Context.findActivity(): Activity? = when (this) {
is Activity -> this
is ContextWrapper -> baseContext.findActivity()
else -> null
}

Looks like it's finally fixed in the 2.4.0-beta02 release; so it was a bug after all.
I was able to add the saveState and restoreState commands back into my BottomBar (as per the documentation) and following a deep-link I was now still able to click the initial destination.

Related

How to clear the whole backstack for all nested NavGraphs?

I have an application with a NavigationBar that includes three destinations. It is configured so that two of the destinations point to a seperate nested NavGraph, namely IssuesGraph and InstructionsGraph. While navigating within the nested graphs and then switching between the two destinations, the respective stack for each nested graph is saved as I wish.
NavigationBar {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
bottomNavigationItems.forEachIndexed { index, item ->
NavigationBarItem(
icon = { Icon(item.imageResource, stringResource(id = item.titleResource)) },
label = { Text(stringResource(id = item.titleResource)) },
selected = currentDestination?.hierarchy?.any { it.route == item.route } == true,
onClick = {
navController.navigate(item.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
// Restore state when reselecting a previously selected item
restoreState = true
}
}
)
}
}
However, I now need to reset the complete navigation graph, so I want the App to display the first destination within the IssuesGraph, and if I navigate to the InstructionsGraph, it should show its first destination, too.
My NavHost looks like this:
NavHost(
modifier = modifier,
navController = navController,
startDestination = startDestination,
) {
issueGraph(navController, issuesVM)
instructionsGraph(navController, instructionsVM)
composable(Screen.SettingsScreen.route) {
SettingsScreen(
onSave = {
// Here, I want to trigger a reset of
// the backstacks of issuesGraph and instructionsGraph
// and navigate to the first screen of the issuesGraph
navController.navigate(Screen.IssuesGraph.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = false
}
}
}
)
}
}
The seperate NavGraphs are built using the NavGraphBuilder extension function:
fun NavGraphBuilder.issueGraph(navController: NavController, issuesVM: IssuesVM) {
navigation(startDestination = Screen.IssuesHome.route, Screen.IssuesGraph.route) {
composable(Screen.IssuesHome.route) {
//...
}
}
}
The problem is, that calls like this
navController.navigate(Screen.IssuesGraph.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = false
}
}
only clear the back stack for the nested NavGraph the the goal route Screen.IssuesGraph.route resides in. So in my case, the IssuesGraph is reset, but the InstructionsGraph isn't. When I navigate there, it still shows its previous backstack.
I already stumbled across the setGraph documentation, but couldn't understand how I could use it in my case.
Thanks for any efforts!

Nested Navigation inside Compose BottomSheet

for my App, I want to use a ModalBottomSheetLayout which is able to have a nested Navigation inside the BottomSheet.
For e.g. the User clicks a Button on the Homescreen and the BottomSheet opens. Inside the BottomSheet is a list of Elements. If one Element is clicked, then the Details Screen should open inside the same BottomSheet. I have tried to find a solution using appcompanist navigation and the BottomSheetNavigator, but unfortunally it doesnt work as expected.
If I run the following Code and click on one ELement, than the BottomSheet with the Element gets closed and a new one for the Detail-Screen will be opened. But I do not want to have this effect of a new BottomSheet, I would like to have a nested navigation inside one BottomSheet.
val modalBottomSheetState = rememberModalBottomSheetState(
initialValue = ModalBottomSheetValue.Hidden,
skipHalfExpanded = true
)
val bottomSheetNavigator = remember(modalBottomSheetState) {
BottomSheetNavigator(sheetState = modalBottomSheetState)
}
val navHostController = rememberNavController()
ModalBottomSheetLayout(
bottomSheetNavigator = bottomSheetNavigator,
content = {
Scaffold(
bottomBar = {
...
},
content = { innerPadding ->
NavHost(
navController = navHostController,
startDestination = vmMain.getStartDestination(),
modifier = Modifier.padding(innerPadding)
) {
navigation(
startDestination = OnboardingDirections.appPreview().destination,
route = OnboardingDirections.root().destination
) {
composable(OnboardingDirections.appPreview().destination) {
IntroScreen()
}
...
}
bottomSheet(route = FeatureA.root().destination) {
ScreenA()
}
bottomSheet(route = FeatureA.x().destination) {
ScreenB()
}
}
}
)
}
What I would need, is something like this:
bottomSheet(route = FeatureA.root().destination) {
navigation(route = "x") {
ScreenA()
}
navigation(route = "y") {
ScreenB()
}
}

compose can not test Dialog

I have dialog in compose:
#Composable
fun testgtt() {
val saveDialogState = remember { mutableStateOf(false) }
Button(onClick = { saveDialogState.value = true }, modifier = Modifier.testTag(PLACE_TAG)) {
Text(text = "helllow")
}
Dialog(onDismissRequest = { saveDialogState.value = false }) {
Text(text = "helllow",modifier = Modifier.testTag(BUTTON_TAG))
}
}
and want to test it:
#Test
fun das(){
composeTestRule.setContent {
TerTheme {
testgtt()
}
}
composeTestRule.onRoot(useUnmergedTree = true).printToLog("currentLabelExists")
composeTestRule.onNodeWithTag(PLACE_TAG).performClick()
composeTestRule.onNodeWithTag(BUTTON_TAG).assertIsDisplayed()
}
but I get this error:
java.lang.AssertionError: Failed: assertExists.
Reason: Expected exactly '1' node but found '2' nodes that satisfy: (isRoot)
Nodes found:
1) Node #1 at (l=0.0, t=0.0, r=206.0, b=126.0)px
Has 1 child
2) Node #78 at (l=0.0, t=0.0, r=116.0, b=49.0)px
Has 1 child
Inspite of the fact that I see the Dialog itself.
The reason for this error is the line: composeTestRule.onRoot(useUnmergedTree = true).printToLog("currentLabelExists")
onRoot expects a single node, but i suspect both the containing view and the dialog each return their own root (Speculation)
A possible workaround is to instead print both root trees using something like
composeTestRule.onAllNodes(isRoot()).printToLog("currentLabelExists")
use navigation component:
#Composable
fun de(){
val navController = rememberNavController()
Scaffold { innerPadding ->
NavHost(navController, "home", Modifier.padding(innerPadding)) {
composable("home") {
// This content fills the area provided to the NavHost
val saveDialogState = remember { mutableStateOf(true) }
Button(onClick = {
navController.navigate("detail_dialog")
}, modifier = Modifier.testTag(PLACE_TAG)) {
Text(text = "helllow")
}
}
dialog("detail_dialog") {
// This content will be automatically added to a Dialog() composable
// and appear above the HomeScreen or other composable destinations
Dialog(onDismissRequest = {navController.navigate("home")}) {
Card(
shape = RoundedCornerShape(10.dp),
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
// .padding(horizontal = 16.dp)
.padding(vertical = 8.dp),
elevation = 8.dp
){
Text(text = "helllow", modifier = Modifier.testTag(BUTTON_TAG))
}
}
}
}
}
}
As #DanielO said you can use the isRoot() selector, see below. That however prints out the same message as before.
A possible workaround is to instead print both root trees using something like
composeTestRule.onAllNodes(isRoot()).printToLog("currentLabelExists")
You have to distinctivly select which root you are looking for. By using the selectors:
.get( index )
.onFirst()
.onLast()
When added it should look like this:
composeTestRule.onAllNodes(isRoot()).get(1).printToLog("T:")
composeTestRule.onAllNodes(isRoot()).onFirst().printToLog("T:")
composeTestRule.onAllNodes(isRoot()).onLast().printToLog("T:")

Randomly Rearrange items in a LazyRow - Jetpack Compose

I have a LazyRow. Everything works fine. I just want the items to be randomly rearranged every time this LazyRow is drawn on the screen. Here is my code:
LazyRow(
reverseLayout = true,
contentPadding = PaddingValues(top = twelveDp, end = eightDp)
) {
itemsIndexed(
items = featureUsersLazyPagingItems,
key = { _, featuredPerson ->
featuredPerson.uid
}
) { _, featuredUser ->
featuredUser?.let {
//Daw the age suggested People
DrawSuggestedPerson(featuredUser.toPersonUser(),) {
homeViewModel.deleteFeaturedUserFromLocalDb(featuredUser.uid)
}
}
}
featureUsersLazyPagingItems.apply {
when {
loadState.refresh is LoadState.Loading -> {
item {
ShowLazyColumnIsLoadingProgressBar()
}
}
loadState.append is LoadState.Loading -> {
item {
ShowLazyColumnIsLoadingProgressBar()
}
}
loadState.refresh is LoadState.Error -> {
val e = featureUsersLazyPagingItems.loadState.refresh as LoadState.Error
item {
LazyColumnErrorView(
message = e.error.localizedMessage!!,
onClickRetry = { retry() }
)
}
}
loadState.append is LoadState.Error -> {
val e = featureUsersLazyPagingItems.loadState.append as
LoadState.Error
item {
LazyColumnErrorView(
message = e.error.localizedMessage!!,
onClickRetry = { retry() }
)
}
}
}
}
So the LazyRow displays the same set of 30 or so items but only 3- 4 items are visible on screen, for a bit of variety, I would like the items to be re-arranged so that the user can see different items on the screen. Is there a way to achieve this?
You can shuffle your list inside remember, by doing this you're sure that during one view appearance your order will be the same, and it'll be shuffled on the next view appearance. I'm passing featureUsersLazyPagingItems as a key, so if featureUsersLazyPagingItems changes shuffledItems will be recalculated.
val shuffledItems = remember(featureUsersLazyPagingItems) {
featureUsersLazyPagingItems.shuffled()
}
The only problem of remember is that it'll be reset on screen rotation. Not sure if you need that, and if you wanna save state after rotation, you need to use rememberSaveable. As it can only store simple types, which your class isn't, you can store indices instead, like this:
val shuffledItemIndices = rememberSaveable(featureUsersLazyPagingItems) {
featureUsersLazyPagingItems.indices.shuffled()
}
val shuffledItems = remember(featureUsersLazyPagingItems, shuffledItemIndices) {
featureUsersLazyPagingItems.indices
.map(featureUsersLazyPagingItems::get)
}
I suggest you checkout out documentation for details of how state works in compose.

How to perform a haptic feedback in Jetpack Compose

Using jetpack compose, for a clickevent how to perform haptic feedback. I am new to jetpack compose.
This is what i tried -
val hapticFeedback = LocalHapticFeedback
#Composable
fun Tab() {
Row() {
Icon(imageVector = icon, contentDescription = text)
if (selected) {
// i tried both the following ways, none are working.
hapticFeedback.current.performHapticFeedback(
HapticFeedbackType(10)
)
hapticFeedback.current.performHapticFeedback(HapticFeedbackType.TextHandleMove)
....
Spacer(Modifier.width(12.dp))
Text(text.uppercase(Locale.getDefault()))
}
}
}
I am able to see the text when it is getting selected, but not getting a subtle vibrating feedback.
In version rc-01 of Compose you can use only two types of Haptic Feedback: HapticFeedbackType.LongPress or HapticFeedbackType.TextHandleMove.
val haptic = LocalHapticFeedback.current
val context = LocalContext.current
Row(
Modifier.clickable {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
}
)
Currently (Compose UI 1.1.0-beta03) only LongPress and TextHandleMove are supported via
val haptic = LocalHapticFeedback.current
Button(onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
}) { ... }
as #nglauber answer said.
I guess it is because of the multi-platform support for Compose Desktop.
There's another way, however, if you are on Android and you do not need Compose Desktop compatibility, it's fairly easy to use it:
val view = LocalView.current
Button(onClick = {
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
}) { ... }
The HapticFeedbackConstants class has a lot of constants.
On my phone (and other devices I've tested on) neither LongPress nor TextHandleMove makes the phone vibrate.
We worked around this before we moved to Compose like this:
import android.content.Context
import android.view.HapticFeedbackConstants
import android.view.View
import android.view.accessibility.AccessibilityManager
fun View.vibrate() = reallyPerformHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
fun View.vibrateStrong() = reallyPerformHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
private fun View.reallyPerformHapticFeedback(feedbackConstant: Int) {
if (context.isTouchExplorationEnabled()) {
// Don't mess with a blind person's vibrations
return
}
// Either this needs to be set to true, or android:hapticFeedbackEnabled="true" needs to be set in XML
isHapticFeedbackEnabled = true
// Most of the constants are off by default: for example, clicking on a button doesn't cause the phone to vibrate anymore
// if we still want to access this vibration, we'll have to ignore the global settings on that.
performHapticFeedback(feedbackConstant, HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING)
}
private fun Context.isTouchExplorationEnabled(): Boolean {
// can be null during unit tests
val accessibilityManager = getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager?
return accessibilityManager?.isTouchExplorationEnabled ?: false
}
For now we still have to use this code and access it from Compose like in Daniele Segato's answer:
#Composable
fun VibratingButton() {
val view = LocalView.current
Button(onClick = {
view.vibrate()
}) { ... }
}
For me, this worked well when the user presses down the button. Using Compose 1.2.0-beta01
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val hapticFeedback = LocalHapticFeedback.current
LaunchedEffect(key1 = isPressed) {
if (isPressed) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
}
Add the feedback logic inside the Modifier.clickable {...} applied to the Row

Resources