How can I arrange the two inner BottomNav Items so that they are not so close to the "+" FAB?
I tried surrounding the forEach which displays the Items with a Row and use the Arrangement modifier like so:
Row(horizontalArrangement = Arrangement.SpaceBetween) { //Not working :(
items.forEach { item ->
BottomNavigationItem(
icon = { Icon(painterResource(id = item.icon), contentDescription = item.title) },
label = { Text(text = item.title) },
selectedContentColor = Color.White,
unselectedContentColor = Color.White.copy(0.4f),
alwaysShowLabel = true,
selected = currentRoute == item.route,
onClick = {
navController.navigate(item.route) {
navController.graph.startDestinationRoute?.let { route ->
popUpTo(route) {
saveState = true
}
}
launchSingleTop = true
restoreState = true
}
}
)
}
}
Unfortunately thats not working
Arrangement.SpaceBetween works as expected - it adds a spacer between items:
Place children such that they are spaced evenly across the main axis, without free space before the first child or after the last child. Visually: 1##2##3
You need to let your Row know about FAB location. You can add a spacer with Modifier.weight in the middle of your row, for example like this:
items.forEachIndexed { i, item ->
if (i == items.count() / 2) {
Spacer(Modifier.weight(1f))
}
BottomNavigationItem(
// ...
You can use BottomAppBar & give it cutoutShape with a dummy item in the middle. It would give you your desired results.
Output:
Code Sample:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
AppTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
BottomBarWithFabDem()
}
}
}
}
}
val items = listOf(
Screen.PickUp,
Screen.Explore,
Screen.Camera,
Screen.Favorites,
Screen.Profile
)
sealed class Screen(val route: String?, val title: String?, val icon: ImageVector?) {
object PickUp : Screen("pickup", "PickUp", Icons.Default.ShoppingCart)
object Explore : Screen("explore", "Explore", Icons.Default.Info)
object Camera : Screen("camera", null, null)
object Favorites : Screen("favorites", "Fav", Icons.Default.Favorite)
object Profile : Screen("profile", "Profile", Icons.Default.Person)
}
#Composable
fun BottomBarWithFabDem() {
val navController = rememberNavController()
Scaffold(
bottomBar = {
BottomNav(navController)
},
floatingActionButtonPosition = FabPosition.Center,
isFloatingActionButtonDocked = true,
floatingActionButton = {
FloatingActionButton(
shape = CircleShape,
onClick = {
Screen.Camera.route?.let {
navController.navigate(it) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
Screen.Camera.route?.let { navController.navigate(it) }
},
contentColor = Color.White
) {
Icon(imageVector = Icons.Filled.Add, contentDescription = "Add icon")
}
}
) {
MainScreenNavigation(navController)
}
}
#Composable
fun MainScreenNavigation(navController: NavHostController) {
NavHost(navController, startDestination = Screen.Profile.route!!) {
composable(Screen.Profile.route) {}
composable(Screen.Explore.route!!) {}
composable(Screen.Favorites.route!!) {}
composable(Screen.PickUp.route!!) {}
composable(Screen.Camera.route!!) {}
}
}
#Composable
fun BottomNav(navController: NavController) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination
BottomAppBar(cutoutShape = CircleShape, modifier = Modifier.height(64.dp)) {
Row {
items.forEachIndexed { index, it ->
if (index != 2) {
// Main item
BottomNavigationItem(
icon = {
it.icon?.let {
Icon(
imageVector = it,
contentDescription = "",
modifier = Modifier.size(35.dp),
tint = Color.White
)
}
},
label = {
it.title?.let {
Text(
text = it,
color = Color.White
)
}
},
selected = currentRoute?.hierarchy?.any { it.route == it.route } == true,
selectedContentColor = Color(R.color.purple_700),
unselectedContentColor = Color.White.copy(alpha = 0.4f),
onClick = {}
)
} else {
// placeholder for center fab
BottomNavigationItem(
icon = {},
label = { },
selected = false,
onClick = { },
enabled = false
)
}
}
}
}
}
Related
I've tried to implement the SwipeToDismiss behavior in my app and it works perfectly when I try to delete the last or the only item in the LazyColumn. However, if I try to delete an item that is not the last one the next one will swipe to start but not entirely off the screen.
https://streamable.com/4v2i0d
#OptIn(ExperimentalMaterial3Api::class)
#Composable
fun TasksListLayout(
modifier: Modifier = Modifier,
onNavigate: (UiEvent.Navigate) -> Unit,
viewModel: TaskViewModel = hiltViewModel()
) {
val tasks = viewModel.tasks.collectAsState(initial = emptyList())
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = true) {
viewModel.uiEvent.collect { event ->
when (event) {
is UiEvent.ShowSnackbar -> {
val result = scope.launch {
snackbarHostState.showSnackbar(
message = event.message, actionLabel = event.action, duration = SnackbarDuration.Long
)
}
if (result.equals(SnackbarResult.ActionPerformed)) {
viewModel.onEvent(TaskListEvent.OnUndoDeleteTask)
}
}
is UiEvent.Navigate -> onNavigate(event)
else -> Unit
}
}
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
floatingActionButton = {
FloatingActionButton(
onClick = { viewModel.onEvent(TaskListEvent.OnAddTask) },
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.background
) {
Icon(imageVector = Icons.Default.Add, contentDescription = "Add a task")
}
},
topBar = {
TopAppBar(
title = { Text("Planner") }, colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.background
)
)
},
) { padding ->
LazyColumn(
modifier = modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 12.dp),
verticalArrangement = spacedBy(12.dp),
contentPadding = PaddingValues(vertical = 12.dp)
) {
items(tasks.value) { task ->
val currentTask by rememberUpdatedState(newValue = task)
val dismissState = rememberDismissState(confirmValueChange = {
if(it == DismissValue.DismissedToStart) {
viewModel.onEvent(TaskListEvent.OnDeleteTask(currentTask))
}
true
})
SwipeToDismiss(state = dismissState, directions = setOf(DismissDirection.EndToStart),
background = { },
dismissContent = {
TaskCard(
task = task, onEvent = viewModel::onEvent, modifier = modifier
)
})
}
}
}
}
In order to fix the issue I needed to add a key to the LazyColumn.
Changed from this:
items(tasks.value) {}
To this:
items(items = tasks.value, key = { task -> task.hashCode()}) {}
In order to practice Jetpack Compose I wanted to create a MultiComboBox component for later use. It's basically standard ComboBox that allows to pick multiple options. Something like below:
I did prepare a piece of code that IMO should work fine and generally it does, but there's one case when it doesn't and I cannot figure it out what's wrong.
Here's my code:
data class ComboOption(
override val text: String,
val id: Int,
) : SelectableOption
interface SelectableOption {
val text: String
}
#Composable
fun MultiComboBox(
labelText: String,
options: List<ComboOption>,
onOptionsChosen: (Set<ComboOption>) -> Unit,
modifier: Modifier = Modifier,
selectedIds: Set<Int> = emptySet(),
) {
var expanded by remember { mutableStateOf(false) }
// when no options available, I want ComboBox to be disabled
val isEnabled by rememberUpdatedState { options.isNotEmpty() }
var currentlySelected by remember(options, selectedIds) {
mutableStateOf(options.filter { it.id in selectedIds }.toSet())
}
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = {
if (isEnabled()) {
expanded = !expanded
if (!expanded) {
onOptionsChosen(currentlySelected)
}
}
},
modifier = modifier,
) {
val selectedSummary = when (selectedIds.size) {
0 -> ""
1 -> options.first { it.id == selectedIds.first() }.text
else -> "Wybrano ${selectedIds.size}"
}
TextField(
enabled = isEnabled(),
modifier = Modifier.menuAnchor(),
readOnly = true,
value = selectedSummary,
onValueChange = {},
label = { Text(text = labelText) },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
},
colors = ExposedDropdownMenuDefaults.textFieldColors(),
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = {
expanded = false
onOptionsChosen(currentlySelected)
},
) {
for (option in options) {
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = option in currentlySelected,
onCheckedChange = { newCheckedState ->
if (newCheckedState) {
currentlySelected += option
} else {
currentlySelected -= option
}
},
)
Text(text = option.text)
}
},
onClick = {
val isChecked = option in currentlySelected
if (isChecked) {
currentlySelected -= option
} else {
currentlySelected += option
}
},
contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding,
)
}
}
}
}
When I pick options and then dismiss the combo by clicking somewhere outside of it - it works fine. The problem is with onExpandedChange. currentlySelected inside of that lambda is always the same as first value of selectedIds. So for example, when no options are preselected it always calls onOptionsChosen with empty set, hence regardless of what I select - it always sets empty value. Any ideas why it happens an how can it be fixed?
You can use:
#Composable
fun MultiComboBox(
labelText: String,
options: List<ComboOption>,
onOptionsChosen: (List<ComboOption>) -> Unit,
modifier: Modifier = Modifier,
selectedIds: List<Int> = emptyList(),
) {
var expanded by remember { mutableStateOf(false) }
// when no options available, I want ComboBox to be disabled
val isEnabled by rememberUpdatedState { options.isNotEmpty() }
var selectedOptionsList = remember { mutableStateListOf<Int>()}
//Initial setup of selected ids
selectedIds.forEach{
selectedOptionsList.add(it)
}
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = {
if (isEnabled()) {
expanded = !expanded
if (!expanded) {
onOptionsChosen(options.filter { it.id in selectedOptionsList }.toList())
}
}
},
modifier = modifier,
) {
val selectedSummary = when (selectedOptionsList.size) {
0 -> ""
1 -> options.first { it.id == selectedOptionsList.first() }.text
else -> "Wybrano ${selectedOptionsList.size}"
}
TextField(
enabled = isEnabled(),
modifier = Modifier.menuAnchor(),
readOnly = true,
value = selectedSummary,
onValueChange = {},
label = { Text(text = labelText) },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
},
colors = ExposedDropdownMenuDefaults.textFieldColors(),
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = {
expanded = false
onOptionsChosen(options.filter { it.id in selectedOptionsList }.toList())
},
) {
for (option in options) {
//use derivedStateOf to evaluate if it is checked
var checked = remember {
derivedStateOf{option.id in selectedOptionsList}
}.value
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = checked,
onCheckedChange = { newCheckedState ->
if (newCheckedState) {
selectedOptionsList.add(option.id)
} else {
selectedOptionsList.remove(option.id)
}
},
)
Text(text = option.text)
}
},
onClick = {
if (!checked) {
selectedOptionsList.add(option.id)
} else {
selectedOptionsList.remove(option.id)
}
},
contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding,
)
}
}
}
}
I just started using Jetpack Compose and wanted to try out the BottomNavigation. A basic implementation with three items was no problem. Now, one of the three screens should navigate to a detail screen when clicking on a list item. The problem that occurs, is that on the detail screen the bottom navigation item is not selected anymore.
Here's my implementation:
#AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
WhoHasItTheme {
val navController = rememberNavController()
Scaffold(
bottomBar = {
BottomNavigationBar(
items = listOf(
BottomNavItem(
name = "Home",
route = Screen.Home.route,
icon = Icons.Default.Home
),
BottomNavItem(
name = "Search",
route = Screen.GameListScreen.route,
icon = Icons.Default.Search
),
BottomNavItem(
name = "Profile",
route = Screen.Profile.route,
icon = Icons.Default.Person
)
),
navController = navController,
onItemClick = {
navController.navigate(it.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
)
}
) {
Box(modifier = Modifier.padding(it)) {
Navigation(navController = navController)
}
}
}
}
}
}
#Composable
fun Navigation(navController: NavHostController) {
NavHost(
navController = navController,
startDestination = Screen.Home.route
) {
composable(Screen.Home.route) {
HomeScreen()
}
composable(Screen.GameListScreen.route) {
GameListScreen(navController)
}
composable(
route = "${Screen.GameDetailScreen.route}/{gameId}",
arguments = listOf(navArgument("gameId") { type = NavType.IntType })
) {
GameDetailScreen()
}
composable(Screen.Profile.route) {
ProfileScreen()
}
}
}
#OptIn(ExperimentalMaterialApi::class)
#Composable
fun BottomNavigationBar(
items: List<BottomNavItem>,
navController: NavController,
modifier: Modifier = Modifier,
onItemClick: (BottomNavItem) -> Unit
) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
BottomNavigation(
modifier
) {
items.forEach { item ->
BottomNavigationItem(
selected = currentDestination?.hierarchy?.any { it.route == item.route } == true,
onClick = { onItemClick(item) },
selectedContentColor = MaterialTheme.colors.onSurface,
label = { Text(item.name) },
alwaysShowLabel = false,
icon = {
Column(horizontalAlignment = CenterHorizontally) {
if (item.badgeCount > 0) {
BadgeBox(
badgeContent = {
Text(item.badgeCount.toString())
}
) {
Icon(imageVector = item.icon, contentDescription = item.name)
}
} else {
Icon(imageVector = item.icon, contentDescription = item.name)
}
}
}
)
}
}
}
In my understanding currentDestination?.hierarchy should also include the root screen (GameListScreen). What am I understanding wrong here and how can I make it so screens below the root screen of each bottom navigation item still have their tab item as 'selected'?
I want to use outlined and filled icons based on selected state in NavigationBar just like google maps app, using jetpack compose. In case of xml we use selector so what do we use for compose ?
Here is my code ->
MainActivity.kt
#ExperimentalMaterial3Api
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Handle the splash screen transition.
installSplashScreen()
setContent {
MyApp()
}
}
}
#ExperimentalMaterial3Api
#Composable
fun MyApp() {
MyTheme {
val items = listOf(
Screen.HomeScreen,
Screen.MusicScreen,
Screen.ProfileScreen
)
val navController = rememberNavController()
Scaffold(
bottomBar = {
NavigationBar {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
items.forEach { screen ->
NavigationBarItem(
icon = {
Icon(
screen.icon_outlined,
contentDescription = screen.label.toString()
)
},
label = { Text(stringResource(screen.label)) },
selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
onClick = {
navController.navigate(screen.route) {
// Pop up to the start destination of the graph to
// avoid building up a large stack of destinations
// on the back stack as users select items
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
// Avoid multiple copies of the same destination when
// reselecting the same item
launchSingleTop = true
// Restore state when reselecting a previously selected item
restoreState = true
}
}
)
}
}
}
) { innerPadding ->
NavHost(
navController,
startDestination = Screen.HomeScreen.route,
Modifier.padding(innerPadding)
) {
composable(route = Screen.HomeScreen.route) {
HomeScreen()
}
composable(route = Screen.MusicScreen.route) {
MusicScreen()
}
composable(route = Screen.ProfileScreen.route) {
ProfileScreen()
}
}
}
}
}
#ExperimentalMaterial3Api
#Preview(
showBackground = true, name = "Light mode",
uiMode = Configuration.UI_MODE_NIGHT_NO or Configuration.UI_MODE_TYPE_NORMAL
)
#Preview(
showBackground = true, name = "Night mode",
uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL
)
#Composable
fun DefaultPreview() {
MyApp()
}
Screen.kt
sealed class Screen(
val route: String,
#StringRes val label: Int,
val icon_outlined: ImageVector,
val icon_filled: ImageVector
) {
object HomeScreen : Screen(
route = "home_screen",
label = R.string.home,
icon_outlined = Icons.Outlined.Home,
icon_filled = Icons.Filled.Home
)
object MusicScreen : Screen(
route = "music_screen",
label = R.string.music,
icon_outlined = Icons.Outlined.LibraryMusic,
icon_filled = Icons.Filled.LibraryMusic,
)
object ProfileScreen : Screen(
route = "profile_screen",
label = R.string.profile,
icon_outlined = Icons.Outlined.AccountCircle,
icon_filled = Icons.Filled.AccountCircle,
)
}
HomeScreen.kt
#Composable
fun HomeScreen() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Surface(color = MaterialTheme.colorScheme.background) {
Text(
text = "Home",
color = Color.Red,
fontSize = MaterialTheme.typography.displayLarge.fontSize,
fontWeight = FontWeight.Bold
)
}
}
}
#Preview(
showBackground = true, name = "Light mode",
uiMode = Configuration.UI_MODE_NIGHT_NO or Configuration.UI_MODE_TYPE_NORMAL
)
#Preview(
showBackground = true, name = "Night mode",
uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL
)
#Composable
fun HomeScreenPreview() {
HomeScreen()
}
Do I still need to use selector xml or there is alternative way in jetpack compose?
Yeah, you just make a simple if statement based on selected state, like this:
items.forEach { screen ->
val selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true
NavigationBarItem(
icon = {
Icon(
if (selected) screen.icon_filled else screen.icon_outlined,
contentDescription = screen.label.toString()
)
},
selected = selected,
)
}
I was trying to implement jetpack compose bottomnavbar. But I encountered this problem. Whenever label don't get enough space it's overlapping the icon. Am I missing something? Is there any solution like truncating or shrinking text automatically?
compose_version = '1.0.0-beta09'
My Code
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val navController = rememberNavController()
val items = listOf(
Screen.Profile,
Screen.FriendsList,
Screen.Notification,
Screen.Setting
)
Scaffold(
bottomBar = {
BottomNavigation(
backgroundColor = MaterialTheme.colors.surface,
elevation = 8.dp
) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
items.forEachIndexed { index, screen ->
BottomNavigationItem(
icon = {
when (index) {
0 -> Icon(Icons.Filled.Person, "Profile")
1 -> Icon(
painterResource(id = R.drawable.ic_friends),
"Friends"
)
2 -> Icon(Icons.Filled.Notifications, "Notification")
else -> Icon(Icons.Filled.Settings, "Settings")
}
},
label = { Text(stringResource(screen.resourceId)) },
selected = currentRoute == screen.route,
selectedContentColor = MaterialTheme.colors.primary,
unselectedContentColor = Color.Black,
onClick = {
navController.navigate(screen.route) {
navController.graph.startDestinationRoute?.let {
popUpTo(it) {
saveState = true
}
}
launchSingleTop = true
restoreState = true
}
}
)
}
}
}
) {
NavHost(navController, startDestination = Screen.Profile.route) {
composable(Screen.Profile.route) { Profile(navController) }
composable(Screen.FriendsList.route) { FriendsList(navController) }
composable(Screen.Notification.route) { FriendsList(navController) }
composable(Screen.Setting.route) { FriendsList(navController) }
}
}
}
}
You can add the property maxLines = 1 to the Text used in the label:
BottomNavigationItem(
/* your code */
label = { Text("Notification",
maxLines = 1,
overflow = TextOverflow.Ellipsis) /* optional */
}
)