How to create a JSpinner-like widget in Jetpack Compose - android-jetpack-compose

I would like to create a widget in Jetpack Compose (Desktop) with similar functionality as the JSpinner in Swing, i.e. an editable text field and two buttons that increase/decrease the value in the text field. Also, I would like
the value to be validated and to be saved when the spinner loses its focus
the buttons not to be skipped in the navigation, so that the user can navigate directly between multiple spinner text fields
After a lot of trial and error I have figured out the following working version, but I wonder if there is a simpler or more elegant way to do this:
#Composable
fun TextFieldSpinner(
label: #Composable (() -> Unit)?,
lastText: String,
validateText: (String) -> Boolean,
commitText: (String) -> Unit,
onIncrement: () -> Unit,
onDecrement: () -> Unit
) {
val (isEditing, setEditing) = remember { mutableStateOf(false) }
// intermediateTextFieldValue is only used locally to store the temporary state of the TextField while editing
val (intermediateTextFieldValue, setIntermediateTextFieldValue) = remember { mutableStateOf(TextFieldValue(lastText))}
var isError by remember { mutableStateOf(!validateText(lastText)) }
val resetText = { setIntermediateTextFieldValue(TextFieldValue(lastText)) }
if (!isEditing && !isError && lastText != intermediateTextFieldValue.text) {
resetText()
}
val onCommit = {
if (validateText(intermediateTextFieldValue.text)) {
isError = false
commitText(intermediateTextFieldValue.text)
} else {
isError = true
}
}
val onLeaveTextField = {
setEditing(false)
onCommit()
isError = false
}
val onNewFocusState = { newFocusState: Boolean ->
setEditing(newFocusState)
if (!newFocusState)
onCommit()
}
}
TextFieldSpinnerUI(
label = label,
value = intermediateTextFieldValue,
onValueChange = { newTextFieldValue:TextFieldValue ->
setEditing(true)
setIntermediateTextFieldValue(newTextFieldValue)
// eager committing without showing error state
if (validateText(newTextFieldValue.text)) {
isError = false
commitText(newTextFieldValue.text)
}
},
modifier = modifier,
isError = isError,
onIncrement = {
onLeaveTextField()
onIncrement()
},
onDecrement = {
onLeaveTextField()
onDecrement()
},
onFocusChanged = { state ->
onNewFocusState(state.isFocused)
}
)
}
#OptIn(ExperimentalComposeUiApi::class)
#Composable
private fun TextFieldSpinnerUI(
label: #Composable (() -> Unit)?,
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit,
onIncrement: () -> Unit,
onDecrement: () -> Unit,
isError: Boolean,
onFocusChanged: (FocusState) -> Unit
) {
Row() {
DecreaseButton(onDecrement)
TextField(
label = label,
value = value,
singleLine = true,
isError = isError,
onValueChange = onValueChange,
modifier = Modifier.onFocusChanged(onFocusChanged)
)
IncreaseButton(onIncrement)
}
}
#Composable
private fun DecreaseButton(
onClick: () -> Unit
) {
IconButton(
onClick = onClick,
modifier = Modifier.focusProperties {this.canFocus = false }
) {
Icon(
imageVector = Icons.Rounded.Remove
)
}
}
#Composable
private fun IncreaseButton(
onClick: () -> Unit
) {
IconButton(
onClick = onClick,
modifier = Modifier.focusProperties { this.canFocus = false }
) {
Icon(
imageVector = Icons.Rounded.Add
)
}
}
In particular, it seems to be hard to have the text field at the same time
to be editable
to be validated, saved and then recomposed with the new value
to have another widget such as the buttons change its value

Related

Dynamic Item width in Column (Jetpack Compose)

I have some LazyColumn (or Column) in Composable fun:
#Composable
fun MyColumn(items: List<Item>) {
val width = 0.dp // max for all amounts
LazyColumn {
items(items) {
Row(modifier = Modifier.fillMaxWidth()) {
Text(text = it.title, modifier = Modifier.weight(1f))
Text(text = it.amount, modifier = Modifier.background(Color.Red).width(width))
}
}
}
}
class Item(val title: String, val amount: String)
How can I measure the width param? It must be equals the longest value for all amounts in items.
Finally I got some solution (I don't know is it correct or no, but it works):
I use SubcomposeLayout to measure width of whole separate column with amounts. Code of the measuring function:
#Composable
fun WithCalculateWidth(modifier: Modifier = Modifier,
contentForCalculate: #Composable () -> Unit,
dependentContent: #Composable (Dp) -> Unit) {
SubcomposeLayout(modifier = modifier) { constraints ->
val measuredWidth = subcompose("viewToMeasure", contentForCalculate)[0].measure(Constraints()).width.toDp()
val contentPlaceable = subcompose("content") { dependentContent(measuredWidth) }[0].measure(constraints)
layout(contentPlaceable.width, contentPlaceable.height) {
contentPlaceable.place(0, 0)
}
}
}
I use calculated width in my List:
#Composable
fun ListWithMeasuredAmounts(items: List<Item>) {
val allAmounts: #Composable () -> Unit = {
Column {
items.forEach {
AmountItem(amount = it.amount)
}
}
}
WithCalculateWidth(contentForCalculate = { allAmounts() }) { amountWidth ->
LazyColumn {
items(items) {
Row(modifier = Modifier.fillMaxWidth()) {
Text(text = it.title, modifier = Modifier.weight(1f))
AmountItem(amount = it.amount, modifier = Modifier.width(amountWidth))
}
}
}
}
}
#Composable
fun AmountItem(amount: String, modifier: Modifier = Modifier) {
Text(text = amount, modifier = modifier.background(Color.Red))
}

How can I disable a full view in Jetpack Compose

I want to disable a whole view from any interaction (e.g. button presses) when a Boolean in my view model is true. How can I do this in Jetpack Compose without having to disable each of the elements within the view?
See example below as to what I'm trying to do.
#Composable
fun MyView(alertViewModel: AlertViewModel = viewModel()) {
var text by remember { mutableStateOf(TextFieldValue("")) }
Column(
/*
Disable all elements in the column so I don't need to disable each element individually for example:
modifier = Modifier
.disabled(
if (alertViewModel.showAlert == true) {
true
} else {
false
}
*/
) {
Text(text = "My View")
TextField(
value = text,
onValueChange = { newText ->
text = newText
}
)
Button(onClick = { /*TODO*/ }) {
}
}
}
Proceed like this :
#Composable
fun MyView(alertViewModel: AlertViewModel = viewModel()) {
var text by remember { mutableStateOf(TextFieldValue("")) }
if (alertViewModel.showAlert == true) {
Text(text = "Nothing to show")
} else {
Column(modifier = Modifier) {
Text(text = "My View")
TextField(
value = text,
onValueChange = { newText ->
text = newText
}
)
Button(onClick = { /*TODO*/ }) {
}
}
}
}
Example:
#OptIn(ExperimentalMaterial3Api::class)
#Composable
fun MyView(alertViewModel: AlertViewModel = viewModel()) {
var text by remember { mutableStateOf(TextFieldValue("")) }
Box() {
Column() {
Text(text = "My View")
TextField(
value = text,
onValueChange = { newText ->
text = newText
}
)
Button(onClick = { /*TODO*/ }) {
}
}
Box(
modifier = Modifier
.then(
if(alertViewModel.showAlert == true){
Modifier.fillMaxSize().disabled(true)
}
)
)
}
}
Seems as though it isn't possible to disable a whole view in Jetpack Compose. All interact-able elements such as Button and TextField and have to be set to enabled = false individually.
Buttons: Jetpack Compose: How to disable FloatingAction Button?
TextFields: Jetpack Compose: Disable Interaction with TextField

Click a close button on a bottomsheet to hide it in compose

I have the following composable bottomsheet.
I want to be able to close the bottomsheet either by dragging, clicking the background, and clicking the close button.
#Composable
fun CDSModelBottomSheet(toolBar: #Composable () -> Unit, content: #Composable () -> Unit) {
val modelBottomSheetState = rememberModalBottomSheetState(
initialValue = ModalBottomSheetValue.Expanded
)
ModalBottomSheetLayout(
sheetState = modelBottomSheetState,
sheetShape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp),
sheetContent = {
Column {
toolBar()
content()
}
}
) {}
}
#Composable
#Preview
fun PreviewCDSBottomSheet() {
CDSModelBottomSheet(
toolBar = { Toolbar(
title = "Select Account",
trailingIcon = {
IconButton(
modifier = Modifier.size(24.dp),
onClick = {
/* close bottom sheet */
}
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = stringResource(R.string.close_bottom_sheet),
tint = Color.Black,
)
}
})},
content = {
LoginMode()
}
)
}
In the trailingIcon I have an onClick event. But not sure how to trigger the bottomsheet to close. Unless I have to pass in the rememberModelBottomSheetState which I don't want to do.
This is the preview
Create a lambda to hide ModalBottomSheet as
val coroutineScope = rememberCoroutineScope()
val hideModalBottomSheet: () -> Unit = { coroutineScope.launch { sheetState.hide()} }
And pass this lambda as parameter to your content by updating toolbar as
toolbar: #Composable (() -> Unit) -> Unit
Full function
#OptIn(ExperimentalMaterialApi::class)
#Composable
fun CDSModelBottomSheet(
toolBar: #Composable (() -> Unit) -> Unit,
content: #Composable () -> Unit
) {
val modelBottomSheetState = rememberModalBottomSheetState(
initialValue = ModalBottomSheetValue.Expanded
)
val coroutineScope = rememberCoroutineScope()
val hideModalBottomSheet: () -> Unit =
{ coroutineScope.launch { modelBottomSheetState.hide() } }
ModalBottomSheetLayout(
sheetState = modelBottomSheetState,
sheetShape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp),
sheetContent = {
Column {
toolBar(hideModalBottomSheet)
content()
}
}
) {}
}
And use it as
CDSModelBottomSheet(
toolBar = { hide: () -> Unit ->
Toolbar(
title = "Select Account",
trailingIcon = {
IconButton(
modifier = Modifier.size(24.dp),
onClick = hide
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Close",
tint = Color.Black,
)
}
}
)
},
content = {
LoginMode()
}
)

Navigating non-visible FocusRequesters in LazyColumn

I have a scrollable container (LazyColumn) and I want to navigate to the next FocusRequester in the list. But if the FocusRequester exists outside the rendered elements the FocusRequester can not be reached by FocusManger.
Code to reproduce problem:
#ExperimentalComposeUiApi
#Composable
fun exampleDesktop() { // Also works on phone but you need an external keyboard
val focusManager = LocalFocusManager.current
fun handleKeyPreview(evt: KeyEvent) : Boolean {
return if (evt.type == KeyEventType.KeyDown && evt.key == Key.Tab) {
if (evt.isShiftPressed) {
focusManager.moveFocus(FocusDirection.Up)
} else {
focusManager.moveFocus(FocusDirection.Down)
}
true
} else false
}
LazyColumn {
repeat(100) {
item {
FocusableItem(it, ::handleKeyPreview)
}
}
}
}
#ExperimentalComposeUiApi
#Composable
fun examplePhone() {
val focusManager = LocalFocusManager.current
Column {
Row {
Button(onClick = { focusManager.moveFocus(FocusDirection.Up)}) { Text("Up")}
Button(onClick = { focusManager.moveFocus(FocusDirection.Down)}) { Text("Down")}
}
LazyColumn {
repeat(100) {
item {
FocusableItem(it, handler = {false})
}
}
}
}
}
#Composable
fun FocusableItem(i: Int, handler: (KeyEvent) -> Boolean) {
val requester = remember { FocusRequester() }
TextField(
modifier = Modifier
.focusRequester(requester)
.onPreviewKeyEvent(onPreviewKeyEvent = handler),
value = "$i", onValueChange = {})
}
Is there a way to know of the existence of other focus requesters and put them into the view?

Remember list item states while navigation in Jetpack Compose

If we create state for list item like val state = remember(it) { mutableStateOf(ItemState()) } then we loose expanded state while scrolling.
If we lift up states generating higher before LazyColumn then expand state is saving properly
val states = items.map { remember(it) { mutableStateOf(ItemState()) } }
LazyColumn(modifier = Modifier.fillMaxSize()) {....
But when we expand an item, click the button, go to details screen and then go back to items we loose expanded state.
What is the best way to save items state?
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
RememberStateTheme {
val navController = rememberNavController()
NavHost(navController, startDestination = "items") {
composable("items") {
Greeting(
onItemClick = { navController.navigate("details/$it") }
)
}
composable(
"details/{index}",
arguments = listOf(navArgument("index") { type = NavType.IntType })
) { backStackEntry ->
DetailsScreen(backStackEntry.arguments?.getInt("index") ?: -1)
}
}
}
}
}
}
#Composable
fun Greeting(
onItemClick: (Int) -> Unit
) {
val items = remember { (0..100).toList() }
Surface(color = MaterialTheme.colors.background) {
val states = items.map { remember(it) { mutableStateOf(ItemState()) } }
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(items) { item ->
// If we create state here like val state = remember(it) { mutableStateOf(ItemState()) }
// then we loose expanded state while scrolling
// If we lift up states generating higher before LazyColumn then expand state
// is saving properly
//
// But when we expand an item, click the button and then go back to items we loose
// expanded state
val state = states[item]
key(item) {
Item(index = item,
state = state.value,
onClick = { onItemClick(item) },
modifier = Modifier
.fillMaxSize()
.clickable {
state.value.changeState()
}
)
}
Divider()
}
}
}
}
#Composable
fun Item(
index: Int,
state: ItemState,
modifier: Modifier = Modifier,
onClick: () -> Unit
) {
Box(modifier = modifier) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.align(Alignment.Center)
) {
Text(
text = index.toString(),
modifier = Modifier.padding(16.dp)
)
if (state.expanded) {
Button(
onClick = onClick,
modifier = Modifier.padding(8.dp)
) {
Text(text = "Click me")
}
}
}
}
}
class ItemState {
val expanded: Boolean
get() = _expanded.value
private val _expanded = mutableStateOf(false)
fun changeState() {
_expanded.value = !_expanded.value
}
}
#Composable
fun DetailsScreen(
index: Int,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.fillMaxSize()
.background(Color.Gray.copy(alpha = 0.3f))
) {
Text(
text = index.toString(),
modifier = Modifier.align(Alignment.Center)
)
}
}
Using rememberSaveable solves the problem.
Thanks to https://stackoverflow.com/users/1424349/leland-richardson
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(items) { item ->
val state = rememberSaveable(item) { mutableStateOf(ItemState()) }
key(item) {
Item(index = item,
state = state.value,
onClick = { onItemClick(item) },
modifier = Modifier
.fillMaxSize()
.clickable {
state.value.changeState()
}
)
}
Divider()
}
}
But when we expand an item, click the button, go to details screen and then go back to items we loose expanded state.
Correct. remember() remembers for the scope of a specific composition. This means it remembers across recompositions, but not when the composition is replaced by a separate composition. In your case, navigation replaces your Greeting() composition with a DetailsScreen() composition.
What is the best way to save items state?
Hoist the state further, to a composition that does not get replaced by navigation. In this case, that would be your root composition, where you have your rememberNavController() call.
Or, have the state be stored in a viewmodel that is scoped to your activity, or at least to that root composition.
If you want to have this state persist beyond the life of your process, I think that the vision is that we should use effects to save the state via a repository to some persistent store (e.g., JSON file) and restore the state from that store. However, I have not experimented with this approach yet.

Resources