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!
Related
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 was developing an app where I'm using Jetpack Compose as UI developing tool, and I design some kind of custom AlertDialog, which is the following:
CustomAlertDialog.kt
#Composable
fun SimpleAlertDialog(
hero: CharacterModel? = null,
show: Boolean,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
textDescription: String,
textTittle: String,
) {
if(show){
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = onConfirm)
{ Text(text = "OK") }
},
dismissButton = {
TextButton(onClick = onDismiss)
{ Text(text = "Cancel") }
},
title = { Text(text = textTittle) },
text = { Text(text = textDescription) }
)
}
}
But when I try to use in a detailScreen, I get the following context Composable error:
#Composable invocations can only happen from the context of a #Composable function
the region of code where I try to instances the following, where I get the error:
#OptIn(ExperimentalFoundationApi::class)
#Composable
fun MyCharactersListRowView(
viewmodel: MainViewModel,
characterModel: CharacterModel,
popBack: () -> Unit
) {
val (characterSelected, setCharacterSelected) = remember { mutableStateOf<CharacterModel?>(null) } //HOOK FUNCTION
val openDialog = remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = { setCharacterSelected(characterModel) })
.padding(vertical = 8.dp, horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
){
AsyncImage(
model = characterModel.image,
contentDescription = characterModel.name,
contentScale = ContentScale.Fit,
modifier = Modifier
.size(60.dp)
.clip(CircleShape)
.combinedClickable(
onLongClick = {
if (characterSelected != null) {
SimpleAlertDialog(
hero = characterSelected,
show = true,
onConfirm = {
viewmodel
.deleteCharacter(characterSelected!!.id.toLong())
.also {
Log.d(
"info",
"rowAffected: ${viewmodel.rowAffected.value}"
)
if (viewmodel.rowAffected.value.toInt() != 0) {
Toast
.makeText(
LocalContext.current!!,
"ยก ${characterSelected.name} bought sucessfully!",
Toast.LENGTH_LONG
)
.show()
.also {
openDialog.value = false
}
} else {
Toast
.makeText(
LocalContext.current!!,
viewmodel.loadError.value,
Toast.LENGTH_LONG
)
.show()
.also {
openDialog.value = false
}
}
}
},
onDismiss = { openDialog.value = false },
textTittle = "Remove character",
textDescription = "Would you like to remove ${characterSelected.name} from your characters?"
)
} else {
}
},
onClick = {
//TODO {Do something}
}
)
)
...
...
So I know is a quite beginner error, but I've not been able to get into a working solution, due to take thanks in advance is you know how implement it.
You can't call a Dialog from a lambda that doesn't have #Composable annotation. You can check this answer out for differences between Composable and non-Composable functions.
fun Modifier.combinedClickable(
enabled: Boolean = true,
onClickLabel: String? = null,
role: Role? = null,
onLongClickLabel: String? = null,
onLongClick: (() -> Unit)? = null,
onDoubleClick: (() -> Unit)? = null,
onClick: () -> Unit
)
These lambdas are not #Composable
Common way for showing dialog in Jetpack Compose is
var showDialog by remember {mutableStateOf(false)}
if(characterSelected && showDialog) {
SimpleAlertDialog(onDismiss={showDialog = false})
}
and change showDialog to true inside long click
onLongClick = {
showDialog = true
}
Show custom alert dialog in Jetpack Compose
I use the following code to show a edit box dialog with initial value on which a use can input a new description and save it.
I think that the initial value "Hello" will be shown on TextField when I click the "Edit Description" button, but in fact, none is shown on TextField.
What's wrong with my code?
#Composable
fun ScreenDetail(
) {
var editDialog by remember { mutableStateOf(false) }
var description by remember {mutableStateOf("") }
editDialog(
isShow = editDialog,
onDismiss = { editDialog =false },
onConfirm ={ ... },
editFieldContent=description
)
Button(
modifier = Modifier,
onClick = {
description = "Hello"
editDialog = true
}
) {
Text("Edit Description")
}
}
#Composable
fun editDialog(
isShow: Boolean,
onDismiss: () -> Unit,
onConfirm: (String) -> Unit,
saveTitle: String = "Save",
cancelTitle:String = "Cancel",
dialogTitle:String ="Edit",
editFieldTitle:String ="Input description",
editFieldContent:String ="",
) {
var mText by remember { mutableStateOf(editFieldContent) }
if (isShow) {
AlertDialog(
confirmButton = {
TextButton(onClick = { onConfirm(mText) })
{ Text(text = saveTitle) }
},
dismissButton = {
TextButton(onClick = onDismiss)
{ Text(text = cancelTitle) }
},
onDismissRequest = onDismiss,
title = { Text(text = dialogTitle) },
text = {
Column() {
Text(text = editFieldTitle)
TextField(
value = mText,
onValueChange = { mText = it }
)
}
}
)
}
}
var mText by remember { mutableStateOf(editFieldContent) }
it doesn't get updated when editFieldContent changes because remember{} stores value on composition or when keys change.
Then you change mText via delegation or using mText.value = newValue if you don't use by keyword.
If you set a key, block inside remember will be recalculated when editFieldContent parameter of editDialog changes.
var mText by remember(editFieldContent) { mutableStateOf(editFieldContent) }
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
)
}
}
}
}
}
I have a main screen where I have retrieved a list string in a viewmodel. I also have a button that opens a Dialog. In this dialog I have a text field for the user to write the word that he want to filter (potato name field), and buttons to filter and cancel.
How can I apply the filter in the viewmodel when the user clicks on the button to accept that on the main screen the list is already filtered?
MainScreen:
#Composable
fun PotatosScreen(
viewModel: PotatosViewModel,
state: Success<PotatosData>
) {
val expandedItem = viewModel.expandedCardList.collectAsState()
Box(
modifier = Modifier.fillMaxSize()
) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(vertical = 8.dp)
) {
items(state.data.potatos) { potato ->
potatoCard(
potato = potato,
onCardArrowClick = { viewModel.itemArrowClick(potato.id) },
expanded = expandedItem.value.contains(potato.id)
)
}
}
var showCustomDialogWithResult by remember { mutableStateOf(true) }
// Text button for open Dialog
Text(
text = "Filter",
modifier = Modifier
.clickable {
if (showCustomDialogWithResult) {
DialogFilter(
onDismiss = {
showCustomDialogWithResult = !showCustomDialogWithResult
},
onNegativeClick = {
showCustomDialogWithResult = !showCustomDialogWithResult
},
onPositiveClick = {
showCustomDialogWithResult = !showCustomDialogWithResult
}
)
}
}
)
}
}
Dialog:
#Composable
fun DialogFilter(
onDismiss: () -> Unit,
onNegativeClick: () -> Unit,
onPositiveClick: () -> Unit
) {
var text by remember { mutableStateOf("") }
Dialog(onDismissRequest = onDismiss) {
Card(
shape = RoundedCornerShape(10.dp),
modifier = Modifier.padding(10.dp, 5.dp, 10.dp, 10.dp),
elevation = 8.dp
) {
Column(
Modifier.background(Color.White)
) {
Text(
text = stringResource("Filter")
)
TextField(
value = text
)
TextButton(onClick = onNegativeClick) {
Text(
text = "Cancel
)
}
// How apply filter in viewModel here?
TextButton(onClick = onPositiveClick) {
Text(
text = "Filter"
)
}
}
}
}
}
ViewModel:
#HiltViewModel
class PotatosViewModel #Inject constructor(
private val getPotatosDataUseCase: GetPotatosData
) : ViewModel() {
private val _state = mutableStateOf<Response<PotatosData>>(Loading)
val state: State<Response<PotatosData>> = _state
private val _expandedItemList = MutableStateFlow(listOf<Int>())
val expandedCardList: StateFlow<List<Int>> get() = _expandedItemList
private val _isRefreshing = MutableStateFlow(false)
val isRefreshing: StateFlow<Boolean>
get() = _isRefreshing.asStateFlow()
init {
getPotatos()
}
fun refresh() {
viewModelScope.launch {
_isRefreshing.emit(true)
getPotatos()
_isRefreshing.emit(false)
}
}
private fun getPotatos() {
viewModelScope.launch {
getPotatosDataUseCase().collect { response ->
_state.value = response
}
}
}
fun containsItem(potatoId: Int): Boolean {
return _expandedItemList.value.toMutableList().contains(potatoId)
}
fun itemArrowClick(potatoId: Int) {
_expandedItemList.value = _expandedItemList.value.toMutableList().also { list ->
if (list.contains(potatoId)) {
list.remove(potatoId)
} else {
list.add(potatoId)
}
}
}
}
State:
data class PotatosState(
val potatoes: List<Potato>,
)
Potato:
data class Potato(
val id: Int,
val name: String)
You can change the onPositiveClick callback to accept a String and pass it to the ViewModel in order to apply your filter, something like this:
fun DialogFilter(
onDismiss: () -> Unit,
onNegativeClick: () -> Unit,
onPositiveClick: (String) -> Unit
)
And then the callback would call your ViewModel with the text
onPositiveClick = { filter ->
showCustomDialogWithResult = !showCustomDialogWithResult
viewModel.applyFilter(filter)
}
Edit 1
TextButton(onClick = {
onPositiveClick(text)
}) {
Text(
text = "Filter"
)
}