How can we test Jetpack Compose callback function on a click action? - android-jetpack-compose

The problem is:
We have a test case where we expect that a callback function was called in response to a click action in our Composable. How can we handle this test correctly?

You can try something like this:
#Test
fun test() {
var clicked = false
testRule.setContent {
YourComposable(
onClick = { clicked = true },
}
}
testRule.onNodeWithTag("YourButton")
.performClick()
assertTrue(clicked)
}

I'm not sure if you understand state hoisting,reference please try simple example:
#Composable
fun ButtonState(modifier : Modifier = Modifier,state : (flag : Boolean)->Unit) {
var isFlag by remember{ mutableStateOf(false)}
Button(onClick = {
isFlag = !isFlag
state(isFlag)
},modifier) {
Text(text = "text")
}
}
setContent {
var flag by remember { mutableStateOf(false) }
Column {
ButtonState {
flag = it
}
Text(text = "$flag")
}
}

#get:Rule
val composeTestRule = createComposeRule()
#Test
fun `should invoke callback on button click`() {
val callback : () -> Unit = mockk()
composeTestRule.setContent {
YourComposable(
onClick = callback,
)
}
composeTestRule.onNodeWithTag("BUTTON_TAG").performClick()
verify { callback() }
}

Related

Android, Jetpack Compose - Composable function not getting refreshed

I am new to Android Jetpack compose.
All i want to do is to update data in UI when it gets from API.
Here is my Composable function:
#Composable
fun getCurrentWeather(lat : Double, long : Double, nc : NavHostController,
vm: CurrentConditionsViewModel = hiltViewModel()) {
vm.getWeather(lat = lat, long = long)
var check by remember { mutableStateOf("") }
var text by remember { mutableStateOf("") }
val state = vm._state.value
when (state) {
is Resource.Loading -> {
CircularProgressIndicator(
modifier = Modifier
.fillMaxSize()
.wrapContentSize(align = Alignment.Center)
)
check = "hi"
}
is Resource.Success -> {
for (i in state.data!!.weather) {
print("called")
}
state.data.name.let {
text = it.toString()
}
}
is Resource.Error -> {
Log.d("StateError", state.message.toString())
}
}
}
And here is my ViewModel:
#HiltViewModel
class CurrentConditionsViewModel #Inject constructor(private val weatherRepository: WeatherRepositoryImpl) :
ViewModel() {
val _state: MutableState<Resource<CurrentConditionsDataModel>?> = mutableStateOf(null)
fun getWeather(lat : Double, long : Double) {
viewModelScope.launch {
weatherRepository.getWeather(latt = lat, longg = long).collect {
_state.value = it
}
}
}
}
Everything is going fine but the only problem is that Composable function is not getting called state variable is updated in view model. I have tried using other variables too such as check and text, yet Composable is only getting called once.
What am I doing wrong? I there anything I have missed?
Thanks in advance.

How to create a JSpinner-like widget in 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

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?

Jetpack Compose - Detect when LazyColumn's scroll position is at the first index

I want to have a Scroll back to top button for my LazyColumn. I successfully made the button work. But I want it to not be visible if I'm already at the top of the LazyColumn. How could I achieve this?
LazyColumn has state property, and if you pass your custom value instead of the default one, you can react on the state changes.
To prevent redundant recompositions, in such cases derivedStateOf should be used: it'll trigger recomposition only when the produced result, based on other state variables, is changed:
Box {
val state = rememberLazyListState()
LazyColumn(state = state) {
// ...
}
val firstItemVisible by remember {
derivedStateOf {
state.firstVisibleItemIndex == 0
}
}
if (!firstItemVisible) {
Button(onClick = { /*TODO*/ }) {
}
}
}
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
startActivity(intent)
setContent {
val scrollState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()
Column(modifier = Modifier.fillMaxSize()) {
if (scrollState.firstVisibleItemIndex > 0) {
Button(onClick = {
coroutineScope.launch {
scrollState.scrollToItem(0)
}
}, enabled = scrollState.firstVisibleItemIndex > 0) {
Text("Scroll to top")
}
}
LazyColumn(modifier = Modifier.fillMaxSize(), state = scrollState) {
items(MutableList(100) { it }) { i ->
Text(i.toString())
}
}
}
}
}
}

multi-dimensional state in jetpack compose

How can I make multidimensional state in compose that also effects the source the state came from?
I tried to ask this question without code previously and it wasn't clear. This is a little more code than necessary, but I will be able to communicate my problem
data class Record(
var duration: Int,
val timeStamp: Instant = Clock.System.now(),
var description: String? = null
)
data class Task(
var name: String,
var defaultAmount: Int,
val completions: MutableList<Record> = mutableListOf()
) {
fun createRecord(description: String? = null, duration: Int = defaultAmount): Record {
val record = Record(duration, description = description)
completions.add(record)
return record
}
}
class SourceOfTruth(private val thisSavesMeTyping: MutableList<Task> = mutableListOf()) :
MutableCollection<Task> by thisSavesMeTyping {
fun print() = thisSavesMeTyping.forEach { Log.d("ugh", it.toString()) }
}
class MyModel : ViewModel() {
val source: SourceOfTruth =
SourceOfTruth().apply {
add(
Task("exercise", 30).apply {
createRecord("push ups")
createRecord("sit ups")
})
add(Task("dishes", 10))
}
}
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val myModel: MyModel by viewModels()
val tasks = myModel.source.toMutableStateList()
Column {
tasks.forEach { task ->
Row {
Text(task.name)
task.completions.forEach { Text(it.timeStamp.toString().dropLast(15)) }
Button(
onClick = {
task.createRecord("dummy")
myModel.source.print()
}) { Text("now") }
}
}
Button(
onClick = {
tasks.add(Task("dummy task", 90))
myModel.source.print()
}) { Text("new Task") }
}
}
}
}
Pushing the now button updates the source in the viewmodel, but doesn't trigger recomposition. Pushing the add task button triggers recomposition, but doesn't add a task to the source of truth.
Any suggestions on how I can fix those?
If you read all that, Thank you very much. I wish I could buy you a coffee or beer.

Resources