I'm trying to make a numeric input field that updates a backing field when a valid numeric is entered. When the backing field is updated, the UI should reflect that since it can also be updated by other things.
I've got an implementation where I have a local string that's being edited, and that is displayed, and every time the value changes that string's checked to see if an integer can be parsed out of it, in which case the backing field's updated. Problem is then the cursor resets to the start of the field - so if you're typing a multi-digit number, the digits go in out of sequence.
There doesn't seem to be anything I can use to know when the user leaves the control and editing has finished. Although I'm using a TextFieldValue
, I can't update the text in that object and otherwise preserve the editing state rather than recreating the whole object.
This can't be a new problem and yet discussion online is sparse. Am I doing something stupid and massively overcomplicating this?
Code:
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.example.numericinputtest.ui.theme.NumericInputTestTheme
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
class State : ViewModel()
{
private val _numCycles = MutableLiveData<Int>(0)
val numCycles: LiveData<Int> = _numCycles
fun onNewNumCycles(cycles: Int) {
_numCycles.value = cycles
}
}
class StringToInt {
companion object {
fun tryParse(s: String): Int? {
try {
return s.toInt()
} catch (e: java.lang.NumberFormatException) {
return null
}
}
}
}
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val state = State()
setContent {
NumericInputTestTheme {
// A surface container using the 'background' color from the theme
Surface(color = MaterialTheme.colors.background) {
TestNumeric(state = state)
}
}
}
}
}
@Composable
fun TestNumeric(state: State) {
val numCycles: Int by state.numCycles.observeAsState(0)
//To be able to edit the text normally, we need a local string and the backing field
//only gets updated when there's a valid number
val numCyclesString = remember { mutableStateOf(TextFieldValue(numCycles.toString())) }
//Since we're now displaying a local string, it doesn't get changed when the backing state
//changes. So we need to catch this occurrence and update manually.
state.numCycles.observeAsState()
.run { numCyclesString.value = TextFieldValue(numCycles.toString()) }
Surface()
{
TextField(
value = numCyclesString.value,
onValueChange = {
numCyclesString.value = it
val i = StringToInt.tryParse(it.text)
if (i != null) {
state.onNewNumCycles(i)
}
},
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
}
}
@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
val state = State()
TestNumeric(state)
}
CodePudding user response:
TextFieldValue
contains information about cursor position. You're clearing this information with the following line:
.run { numCyclesString.value = TextFieldValue(numCycles.toString()) }
You can fix it using copy
method to update text only:
numCyclesString.value = numCyclesString.value.copy(text = numCycles.toString())
But most of time when you don't need to access selection information in your app, you can use an other overload of Text
which accepts and updates String
value: it'll do this copy
logic inside and your code will be much cleaner.
Also a couple small tips:
only use
LiveData
in Compose if you have some dependencies which are using it. In other cases using mutable state directly is much cleaner:var numCycles by mutableStateOf<Int?>(null) private set fun onNewNumCycles(cycles: Int?) { numCycles = cycles }
You can use
toIntOrNull
instead of yourStringToInt.tryParse
helperYou don't need to create a view model inside an activity and pass it down through your views. You can retrieve it in any composable with
viewModel
: it'll create an object at the first call and reuse on the text times.
Final composable code:
val state = viewModel<State>()
TextField(
value = state.numCycles?.toString() ?: "",
onValueChange = {
if (it.isEmpty()) {
state.onNewNumCycles(null)
} else {
val i = it.toIntOrNull()
if (i != null) {
state.onNewNumCycles(i)
}
}
},
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)