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,
)
}
}
}
}
Related
I'm trying to implement ExposedDropdownMenu - which I want to be displayed underneath TextField - when I set height of dropdown to max. 20 dp then everything is okay. But for any greater value it is always displayed above. Do you know what could be the issue here?
How it looks like:
My code:
#OptIn(ExperimentalMaterial3Api::class)
#Composable
fun TextFieldWithDropdown(
modifier: Modifier = Modifier,
textFieldState: TextFieldState,
textCallback: (String) -> Unit,
list: List<String>,
keyboardOptions: KeyboardOptions,
textStyle: TextStyle
) {
// .align(Alignment.CenterStart)
val dropDownOptions = remember { mutableStateOf(listOf<String>()) }
val textFieldValue = remember { mutableStateOf(TextFieldValue()) }
val dropDownExpanded = remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
modifier = modifier,
expanded = dropDownExpanded.value, onExpandedChange = {
dropDownExpanded.value = !dropDownExpanded.value
}) {
TextField(
modifier = Modifier
.menuAnchor()
.fillMaxWidth()
.onFocusChanged { focusState ->
if (!focusState.isFocused)
dropDownExpanded.value = false
},
value = textFieldState.text.value,
onValueChange = {
textFieldState.text.value = it
textCallback.invoke(it)
dropDownOptions.value =
list.filter { it.startsWith(textFieldState.text.value) && it != textFieldState.text.value }
.take(3)
dropDownExpanded.value = dropDownOptions.value.isNotEmpty()
},
singleLine = true,
maxLines = 1,
textStyle = textStyle,
)
ExposedDropdownMenu(
expanded = dropDownExpanded.value,
onDismissRequest = {
dropDownExpanded.value = false
},
) {
dropDownOptions.value.forEach { text ->
DropdownMenuItem(text = {
Text(text = text)
}, onClick = {
textFieldState.text.value = text
dropDownExpanded.value = false
})
}
}
}
}
I'm currently trying to show a list of EventType (custom class) objects as DropdownMenuItems in a DropdownMenu.
The code I'm trying is:
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
items(plannerViewModel.eventTypeList) { eventType ->
DropdownMenuItem(onClick = { /*TODO*/ }) {
TypeOfEvent(eventType.color, eventType.name, openDialog)
}
}
}
The problem is the items() function is not being recognized and I don't know how else it could be done.
items is for LazyColumn. It doesn't exist on DropdownMenu.
You can just use a for loop in this case
plannerViewModel.eventTypeList.forEach { eventType ->
DropdownMenuItem(...)
}
Below is syntax of Drop Down menu.There is no scope to add items in syntax.
#Composable
fun DropdownMenu(
expanded: Boolean,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
offset: DpOffset = DpOffset(0.dp, 0.dp),
properties: PopupProperties = PopupProperties(focusable = true),
content: #Composable #ExtensionFunctionType ColumnScope.() -> Unit
): Unit
If you want to add multiple items than you can use for loop like below.
#Preview
#Composable
fun DropdownDemo() {
var expanded by remember { mutableStateOf(false) }
val items = listOf("A", "B", "C", "D", "E", "F")
val disabledValue = "B"
var selectedIndex by remember { mutableStateOf(0) }
Box(modifier = Modifier.fillMaxSize().wrapContentSize(Alignment.TopStart)) {
Text(items[selectedIndex],modifier = Modifier.fillMaxWidth().clickable(onClick = { expanded = true }).background(
Color.Gray))
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
modifier = Modifier.fillMaxWidth().background(
Color.Red)
) {
items.forEachIndexed { index, s ->
DropdownMenuItem(onClick = {
selectedIndex = index
expanded = false
}) {
val disabledText = if (s == disabledValue) {
" (Disabled)"
} else {
""
}
Text(text = s + disabledText)
}
}
}
}
}
I want to select a default value for my dropdown menu as soon as the component renders. Don't want it to set as a label instead I want one of the options from dropdown menu to be selected by default and then the user might change it according to his preference
I am doing something like this , it does sets the default value but then I am unable to change it by selecting from the dropdown
val inspectorList = searchViewModel.inspectors.collectAsState().value
var defaultSelectedInspector = ""
var selectedInspector by remember { mutableStateOf(defaultSelectedInspector)}
if (inspectorList?.isNotEmpty() == true) {
defaultSelectedInspector = inspectorList[0]
selectedInspector = defaultSelectedInspector
}
And this is my dropdown menu code
Box(modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
value = selectedInspector,
onValueChange = {},
enabled = false,
modifier = Modifier
.clickable { showInspectorDropdown = !showInspectorDropdown }
.onGloballyPositioned { coordinates ->
textFieldSize = coordinates.size.toSize()
},
colors = TextFieldDefaults.textFieldColors(
disabledTextColor = Color.DarkGray,
backgroundColor = Color.Transparent
),
trailingIcon = {Icon(imageVector = inspectorDropDownIcon, contentDescription = "")}
)
DropdownMenu(
expanded = showInspectorDropdown,
onDismissRequest = { showInspectorDropdown = false },
Modifier.width(with(LocalDensity.current) { textFieldSize.width.toDp() })
) {
inspectorList?.forEach { inspector ->
DropdownMenuItem(
onClick = {
showInspectorDropdown = false
selectedInspector = inspector
}) {
Text(text = inspector)
}
}
}
}
You can try something like:
val options = listOf("Option 1", "Option 2", "Option 3", "Option 4", "Option 5")
var expanded by remember { mutableStateOf(false) }
var selectedOptionText by remember { mutableStateOf(options[1]) }
// We want to react on tap/press on TextField to show menu
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = {
expanded = !expanded
}
) {
TextField(
readOnly = true,
value = selectedOptionText,
onValueChange = { },
label = { Text("Label") },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(
expanded = expanded
)
},
colors = ExposedDropdownMenuDefaults.textFieldColors()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = {
expanded = false
}
) {
options.forEach { selectionOption ->
DropdownMenuItem(
onClick = {
selectedOptionText = selectionOption
expanded = false
}
) {
Text(text = selectionOption)
}
}
}
}
How can a selected option from a single choice menu be passed to a different composable to that it is displayed in a Text object? Would I need to modify the selectedOption value in some way?
#Composable
fun ScreenSettings(navController: NavController) {
Scaffold(
topBar = {...},
content = {
LazyColumn(...) {
item {
ComposableSettingTheme()
}
}
},
containerColor = ...
)
}
#Composable
fun ComposableSettingTheme() {
val singleDialog = remember { mutableStateOf(false)}
Column(modifier = Modifier
.fillMaxWidth()
.clickable(onClick = {
singleDialog.value = true
})) {
Text(text = "Theme")
Text(text = selectedOption) // selected theme name should be appearing here
if (singleDialog.value) {
AlertSingleChoiceView(state = singleDialog)
}
}
}
#Composable
fun CommonDialog(
title: String?,
state: MutableState<Boolean>,
content: #Composable (() -> Unit)? = null
) {
AlertDialog(
onDismissRequest = {
state.value = false
},
title = title?.let {
{
Column( Modifier.fillMaxWidth() ) {
Text(text = title)
}
}
},
text = content,
confirmButton = {
TextButton(onClick = { state.value = false }) { Text("OK") }
},
dismissButton = {
TextButton(onClick = { state.value = false }) { Text("Cancel") }
}
)
}
#Composable
fun AlertSingleChoiceView(state: MutableState<Boolean>) {
CommonDialog(title = "Theme", state = state) { SingleChoiceView(state = state) }
}
#Composable
fun SingleChoiceView(state: MutableState<Boolean>) {
val radioOptions = listOf("Day", "Night", "System default")
val (selectedOption, onOptionSelected) = remember { mutableStateOf(radioOptions[2]) }
Column(
Modifier.fillMaxWidth()
) {
radioOptions.forEach { themeOption ->
Row(
Modifier
.clickable(onClick = { })
.selectable(
selected = (text == selectedOption),
onClick = {onOptionSelected(text)}
)
) {
RadioButton(
selected = (text == selectedOption),
onClick = { onOptionSelected(text) }
)
Text(text = themeOption)
}
}
}
}
Update
According to official documentation, you should use state hoisting pattern.
Thus:
Just take out selectedOption local variable to the "highest point of it's usage" (you use it in SingleChoiceView and ComposableSettingTheme methods) - ScreenSettings method.
Then, add selectedOption: String and onSelectedOptionChange: (String) -> Unit parameters to SingleChoiceView and ComposableSettingTheme (You can get more info in documentation).
Refactor your code using this new parameters:
Pass selectedOption local variable from ScreenSettings
into SingleChoiceView and ComposableSettingTheme.
Write logic of onSelectedOptionChange - change local variable to new passed value
Hope I helped you!
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
)
}
}
}
}
}