I'm following Android Studio's Documentation for learning Kotlin and Jetpacks Compose. For Unit 2, Pathway 3, we are supposed to practice writing both local tests and instrumented tests for the Tip Calculator App that was developed.
When I manually tested the App, it works but it fails the Instrumented Test as the entire app closes during UI Testing. As a result, I encounter an Assertion Error,
java.lang.AssertionError: Failed: assertExists.
Reason: Expected exactly '1' node but could not find any node that satisfies: (Text + EditableText contains 'Tip Amount: $2.00' (ignoreCase: false))
at androidx.compose.ui.test.SemanticsNodeInteraction.fetchOneOrDie(SemanticsNodeInteraction.kt:162)
at androidx.compose.ui.test.SemanticsNodeInteraction.assertExists(SemanticsNodeInteraction.kt:137)
at androidx.compose.ui.test.SemanticsNodeInteraction.assertExists$default(SemanticsNodeInteraction.kt:136)
at com.example.tipcalculator.TipCalculatorInstrumentedTestUI.calculate_20_percent_tip(TipCalculatorInstrumentedTestUI.kt:46)
Does anyone know why this maybe happening? I have attached both my Tip Calculator App Code and Instrumented Tests codes below:
Instrumented Tests
package com.example.tipcalculator
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performTextInput
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.example.tipcalculator.ui.theme.TipCalculatorTheme
import org.junit.Test
import org.junit.Rule
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
#RunWith(AndroidJUnit4::class)
class TipCalculatorInstrumentedTestUI {
#get: Rule
val composeTestRule = createComposeRule()
// Compiler knows #Test in androidTest refers to Instrumented Tests, while in Test Directory refers to Local Tests
#Test
fun calculate_20_percent_tip() {
// Set the UI Content, Code looks similar to Main Activity SetContent where we render the Screen and App
composeTestRule.setContent {
TipCalculatorTheme {
TipCalculatorScreen()
}
}
// Accessing the UI Component as a Node to access its particular text with onNodeWithText() method to access TextField Composable
composeTestRule.onNodeWithText("Bill Amount")
.performTextInput("10") // Pass in the Value of the Text that we wants to populate it with
// Apply Same Approach for Tip Percentage
composeTestRule.onNodeWithText("Tip (%)")
.performTextInput("20")
// Use Assertion to ensure that the Text Composable reflects the accurate Tip to be given
composeTestRule.onNodeWithText("Tip Amount: $2.00").assertExists()
}
}
Application Code
package com.example.tipcalculator
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.annotation.StringRes
import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.tipcalculator.ui.theme.TipCalculatorTheme
import java.text.NumberFormat
import kotlin.math.round
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
TipCalculatorTheme {
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
TipCalculatorScreen()
}
}
}
}
}
#Composable
fun TipCalculatorScreen() {
// Mutable State that receives 0 as a parameter wrapped in a State Object, making its value observable
var amountInput by remember {
// Importing remember setter and getter functions allows us to read and set amountInput
mutableStateOf("")
}
// Mutable State for Tip
var tipInput by remember {
mutableStateOf("")
}
// Variable to remember State of the Switch
var roundUp by remember {
mutableStateOf(false)
}
// Interface to Control Focus in Compose
val focusManager = LocalFocusManager.current
// Convert to Double or a Null. If Null, return 0 after the Elvis Operator
val amount = amountInput.toDoubleOrNull() ?: 0.0
val tipPercentage = tipInput.toDoubleOrNull() ?: 0.0
// Calculate Tip
val tip = CalculateTip(amount = amount, tipPercent = tipPercentage, roundUp = roundUp)
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.padding(32.dp)
) {
// Screen Title
Text(
text = stringResource(id = R.string.calculate_tip),
fontSize = 24.sp,
modifier = Modifier
.align(Alignment.CenterHorizontally)
)
Spacer(modifier = Modifier.height(16.dp))
// Text Field for User
// Pass the Hoisted State back into the Child Function
EditNumberField(
value = amountInput,
onValueChange = { amountInput = it },
label = R.string.bill_amount,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Next,
keyboardType = KeyboardType.Number
),
keyboardActions = KeyboardActions(
// Move Focus downwards to the next composable when the Next Button is clicked
onNext = {
focusManager.moveFocus(FocusDirection.Down)
}
)
)
// Input Field for Tip Percentage
EditNumberField(
label = R.string.how_was_the_service,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
// Closes the Keyboard when Done is pressed
onDone = { focusManager.clearFocus() }
),
value = tipInput,
onValueChange = { tipInput = it }
)
// Rounding Function
RoundTipRow(
// Setting Initial State
roundUp = roundUp,
// Updating the State when the Switch is clicked
onRoundUpChanged = { roundUp = it }
)
Spacer(modifier = Modifier.height(24.dp))
// Display the Tip Amount to be given
Text(
// Can use tip to sub into placeholder as the String has a %s placeholder
text = stringResource(id = R.string.tip_amount, tip),
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.align(Alignment.CenterHorizontally)
)
}
}
#Composable
fun EditNumberField(
// Hoist the State by introducing 2 Parameters
#StringRes label: Int, // To indicate that it is meant to be a String Resource
keyboardOptions: KeyboardOptions,
keyboardActions: KeyboardActions,
value: String,
onValueChange: (String) -> Unit, // Takes a string as input but has no output
modifier: Modifier = Modifier
) {
TextField(
value = value, // Set to Empty String; Since TextBox that displays the Value
onValueChange = onValueChange, // Set to Empty Lambda Function; Callback that is triggered when User enters text
label = { Text(text = stringResource(label))}, // Using Label instead of Hardcoding
modifier = Modifier
.fillMaxWidth(),
singleLine = true, // Ensures text box is a single horizontal textbox that is scrollable
keyboardOptions = keyboardOptions, // Changing the look of the keyboard
keyboardActions = keyboardActions // Functionality for the Action Buttons i.e. Next/Done
)
}
// Rounding Tip Switch Function
#Composable
private fun RoundTipRow(
modifier: Modifier = Modifier,
// Allowing us to hoist the state of the switch
roundUp: Boolean,
onRoundUpChanged: (Boolean) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.size(48.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Text for Rounding Tip
Text(text = stringResource(id = R.string.round_up_tip))
Switch(
// Determines whether the Switch is Checked, i.e. the Current State
checked = roundUp,
// Callback called when the Switch is clicked
onCheckedChange = onRoundUpChanged,
modifier = Modifier
.fillMaxWidth()
.wrapContentWidth(Alignment.End),
colors = SwitchDefaults.colors(
// Bad Practice since we are hardcoding the color and will be affected if Dark Mode is implemented for example
uncheckedThumbColor = Color.DarkGray
)
)
}
}
// Calculate Tip; Cannot be Private or the Local Tests will not have access to them
#VisibleForTesting // Makes the Function Public but only for Testing purposes
internal fun CalculateTip(
amount: Double,
tipPercent: Double = 15.0,
roundUp: Boolean
): String {
var tip = tipPercent / 100 * amount
if (roundUp == true) {
// Rounding Up
tip = kotlin.math.ceil(tip)
}
// After calculating the tip, format and display the tip with the Number Class
return NumberFormat.getCurrencyInstance().format(tip)
}
#Preview(showBackground = true)
#Composable
fun DefaultPreview() {
TipCalculatorTheme {
TipCalculatorScreen()
}
}
Thank you
I'm following the same course and my code crashed at the same point with a similar error:
java.lang.AssertionError: Failed: assertExists.
Reason: Expected exactly '1' node but could not find any node that satisfies: (Text + EditableText contains 'Tip Amount: $2.00' (ignoreCase: false))
I tracked it down to having a mismatch with the text value in the test and the text value on the UI. This is your test code:
composeTestRule.onNodeWithText("Bill Amount")
.performTextInput("10")
composeTestRule.onNodeWithText("Tip (%)")
.performTextInput("20")
composeTestRule.onNodeWithText("Tip Amount: $2.00").assertExists()
Check that the text values "Bill Amount", "Tip (%)" and "Tip Amount: $2.00" exactly match the same values on the UI. In this case, if the UI shows "Tip amount: $2.00" but the test text value is "Tip Amount: $2.00", the test will fail. Your original error message shows that ignoreCase is false. I hope this helps.
Related
I am currently trying to write an Android Application that makes API calls to retrieve the estimated arrival timings of the incoming buses. The ViewModel files shows me using my Bus Repository to make the API Call, where listResult contains the data I want.
How can I pass the API Call I made to one of my Android Application's screens? I'm supposed to use the UiState Variable to do so right? Thank you.
The Pastebin contains my code as well if it's easier to see there!!
AppViewModel.kt
https://pastebin.com/qPVrDF9i
package com.example.busexpress.ui.screens
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.example.busexpress.BusExpressApplication
import com.example.busexpress.data.SingaporeBusRepository
import com.example.busexpress.network.SingaporeBus
import kotlinx.coroutines.launch
import retrofit2.HttpException
import java.io.IOException
/**
* [AppViewModel] holds information about a cupcake order in terms of quantity, flavor, and
* pickup date. It also knows how to calculate the total price based on these order details.
*/
class AppViewModel(private val singaporeBusRepository: SingaporeBusRepository): ViewModel() {
/** The mutable State that stores the status of the most recent request */
var busUiState: BusUiState by mutableStateOf(BusUiState.Loading) // Loading as Default Value
// Setter is private to protect writes to the busUiState
private set
/**
* Call init so we can display status immediately.
*/
init {
getBusTimings(null)
}
fun getBusTimings(userInput: String?) {
// Determine if UserInput is a BusStopCode
var busStopCode: String?
var busServiceNumber: String?
val userInputLength = userInput?.length ?: 0
if (userInputLength == 5) {
// Bus Stop Code
busStopCode = userInput
busServiceNumber = null
}
else {
// Bus Service Number
busStopCode = null
busServiceNumber = userInput
}
// Launch the Coroutine using a ViewModelScope
viewModelScope.launch {
busUiState = BusUiState.Loading
// Might have Connectivity Issues
busUiState = try {
// Within this Scope, use the Repository, not the Object to access the Data, abstracting the data within the Data Layer
val listResult: SingaporeBus = singaporeBusRepository.getBusTimings(
busServiceNumber = busServiceNumber,
busStopCode = busStopCode
)
// Assign results from backend server to busUiState {A mutable state object that represents the status of the most recent web request}
BusUiState.Success(timings = listResult)
}
catch (e: IOException) {
BusUiState.Error
}
catch (e: HttpException) {
BusUiState.Error
}
}
}
// Factory Object to retrieve the singaporeBusRepository and pass it to the ViewModel
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val application = (this[APPLICATION_KEY] as BusExpressApplication)
val singaporeBusRepository = application.container.singaporeBusRepository
AppViewModel(singaporeBusRepository = singaporeBusRepository)
}
}
}
// private val _uiState = MutableStateFlow(AppUiState(65199))
// val uiState: StateFlow<AppUiState> = _uiState.asStateFlow()
}
// Simply saving the UiState as a Mutable State prevents us from saving the different status
// like Loading, Error, and Success
sealed interface BusUiState {
data class Success(val timings: SingaporeBus) : BusUiState
// The 2 States below need not set new data and create new objects, which is why an object is sufficient for the web response
object Error: BusUiState
object Loading: BusUiState
// Sealed Interface used instead of Interface to remove Else Branch
}
Example: DefaultScreen.kt
https://pastebin.com/UiZPwZHG
package com.example.busexpress.ui.screens
import androidx.annotation.StringRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.example.busexpress.BusExpressApp
import com.example.busexpress.BusExpressScreen
import com.example.busexpress.R
import com.example.busexpress.network.SingaporeBus
import com.example.busexpress.ui.component.BusStopComposable
#Composable
fun DefaultScreen(
busUiState: BusUiState,
modifier: Modifier = Modifier,
appViewModel: AppViewModel = viewModel(),
// navController: NavController
) {
// Mutable State for User Input
var userInput = remember {
mutableStateOf(TextFieldValue(""))
}
Column(
modifier = modifier
.fillMaxWidth()
.padding(16.dp),
) {
// Search Field for Bus Stop or Bus Numbers
SearchView(
label = R.string.search_field_instructions,
state = userInput,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Search
),
onKeyboardSearch = {
appViewModel.getBusTimings(userInput.value.text)
// navController.navigate(BusExpressScreen.Search.name)
}
)
val busArrivalsJson = appViewModel.getBusTimings(userInput.value.text)
when(busUiState) {
is BusUiState.Success -> ResultScreen(busUiState = busUiState, busArrivalsJSON = busArrivalsJson)
is BusUiState.Loading -> LoadingScreen()
is BusUiState.Error -> ErrorScreen()
else -> ErrorScreen()
}
}
}
#Composable
fun LoadingScreen(modifier: Modifier = Modifier) {
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.fillMaxSize()
) {
Image(
modifier = Modifier.size(200.dp),
painter = painterResource(id = R.drawable.loading_img),
contentDescription = stringResource(R.string.loading_flavor_text)
)
}
}
#Composable
fun ErrorScreen(modifier: Modifier = Modifier) {
Box(
contentAlignment = Alignment.Center,
modifier = modifier.fillMaxSize()
) {
Text(text = stringResource(R.string.loading_failed_flavor_text))
}
}
/**
* The home screen displaying result of fetching photos.
*/
#Composable
fun ResultScreen(
busUiState: BusUiState,
busArrivalsJSON: SingaporeBus
modifier: Modifier = Modifier,
) {
// Results of Search
BusStopComposable(
busArrivalsJSON = busArrivalsJSON,
modifier = modifier
)
// Box(
// contentAlignment = Alignment.Center,
// modifier = modifier.fillMaxSize()
// ) {
// Text(busUiState.toString())
// }
}
#Composable
fun SearchView(
#StringRes label: Int,
state: MutableState<TextFieldValue>,
modifier: Modifier = Modifier,
keyboardOptions: KeyboardOptions,
onKeyboardSearch: () -> Unit,
) {
Column() {
TextField(
value = state.value,
onValueChange = {value ->
state.value = value
},
label = {
if (state.value == TextFieldValue("")) {
Text(
stringResource(id = label),
modifier = Modifier
.fillMaxWidth(),
style = MaterialTheme.typography.h6
)
}
},
modifier = modifier
.fillMaxWidth(),
singleLine = true,
// Search Icon at the Start for Aesthetics
leadingIcon = {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = null,
modifier = Modifier.padding(10.dp)
)
},
// Cancel Button to delete all Input
trailingIcon = {
// Icon appears iif the Search Field is not Empty
if (state.value != TextFieldValue("")) {
IconButton(onClick = {
// Clear the Search Field
state.value = TextFieldValue("")
}) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Delete all User Input",
modifier = Modifier.padding(10.dp)
)
}
}
},
keyboardActions = KeyboardActions(
onSearch = { onKeyboardSearch() }
),
keyboardOptions = keyboardOptions,
shape = RoundedCornerShape(25)
)
Row() {
Spacer(modifier = modifier.weight(3f))
// Button for User to Click to begin Search
Button(
onClick = {
// TODO Pass the User Query to the Search Function
onKeyboardSearch()
},
modifier = modifier
.align(Alignment.CenterVertically)
.padding(2.dp)
.weight(1f),
) {
Text(text = stringResource(R.string.search_button_flavor_text))
}
}
}
}
BusApi.kt (Contains the function to make the API Call)
https://pastebin.com/miAt8x8H
package com.example.busexpress.network
import com.example.busexpress.LTA_API_SECRET_KEY
import retrofit2.http.GET
import retrofit2.http.Headers
import retrofit2.http.Query
interface BusApiService {
/**
* Function to get JSON Objects from URI by specifying Type of Request and Endpoint like "/photos" a URL of sorts
*/
// 1. Returns Bus Timings for Bus Stop and/or Service Number
#Headers(
"accept: application/json",
"AccountKey: $LTA_API_SECRET_KEY"
)
#GET("BusArrivalv2")
suspend fun getTimingsOfBusStop(
#Query("BusStopCode") BusStopCode: String? = null,
#Query("ServiceNo") ServiceNo: String? = null
): SingaporeBus
// 2. Returns the details for all the Bus Stops in Singapore
#Headers(
"accept: application/json",
"AccountKey: $LTA_API_SECRET_KEY"
)
#GET("BusStops")
suspend fun getDetailsOfBusStop(): BusStop
}
You can use SharedViewModel to share the data between the Fragments hosted by a common parent activity or between parent activity and its child fragments as per your need.
For eg, You can declare a MutableStateFlow variable in the SharedViewModel, set its value when you receive response from API call (which you trigger from one Fragment or parent activity) and then collect the value of that flow via the same SharedViewModel inside child Fragment and update the UI accordingly.
Refer https://developer.android.com/codelabs/basic-android-kotlin-training-shared-viewmodel#0
I have implemented 2 bottom sheets in the ModalBottomSheetLayout, both bottom sheets has a list of item checkable with checkbox.
The state of the screen is managed by the viewModel and when the selection changes is invoked a function that copies the state with the new value of the selected text.
When the bottom sheet opens the selection is correct but when I click to change the selection, the bottomsheet is not recomposed and the selection does not change, but in the main screen the state change is correctly read and the value is updated.
Here my code:
MainScreen:
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
#ExperimentalMaterialApi
#Composable
fun MainScreen(
viewModel: MainViewModel = androidx.lifecycle.viewmodel.compose.viewModel()
) {
val screenState = viewModel.screenState
val scope = rememberCoroutineScope()
val bottomSheetState = rememberModalBottomSheetState(
initialValue = ModalBottomSheetValue.Hidden
)
var bottomSheetContent: (#Composable () -> Unit)? by remember {
mutableStateOf(null)
}
ModalBottomSheetLayout(
sheetState = bottomSheetState,
sheetContent = {
Box(
modifier = Modifier.defaultMinSize(minHeight = 1.dp)
) {
bottomSheetContent?.let { it() }
}
}
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Text(text = "First BottomSheet", style = MaterialTheme.typography.h6)
Text(
text = "Selected: ${screenState.selectedTextFromFirstBottomSheet}",
Modifier.padding(16.dp)
)
Button(onClick = {
bottomSheetContent = {
FirstBottomSheet(
selectedText = screenState.selectedTextFromFirstBottomSheet,
onSelected = { text ->
viewModel.onEvent(
MainScreenEvent.OnFirstBottomSheetSelectedTextChanged(text)
)
},
textList = screenState.firstBottomSheetTextList
)
}
scope.launch {
bottomSheetState.show()
}
}, modifier = Modifier.padding(16.dp)) {
Text(text = " Open First BottomSheet")
}
Text(text = "Second BottomSheet", style = MaterialTheme.typography.h6)
Text(
text = "Selected: ${screenState.selectedTextFromSecondBottomSheet}",
Modifier.padding(16.dp)
)
Button(
onClick = {
bottomSheetContent = {
SecondBottomSheet(
selectedText = screenState.selectedTextFromSecondBottomSheet,
onSelected = { text ->
viewModel.onEvent(
MainScreenEvent.OnSecondBottomSheetSelectedTextChanged(text)
)
},
textList = screenState.secondBottomSheetTextList
)
}
scope.launch {
bottomSheetState.show()
}
}, modifier = Modifier
.padding(16.dp)
) {
Text(text = " Open Second BottomSheet")
}
}
}
}
ViewModel:
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
class MainViewModel : ViewModel(){
var screenState by mutableStateOf(MainScreenState())
fun onEvent(event: MainScreenEvent){
when(event){
is MainScreenEvent.OnFirstBottomSheetSelectedTextChanged -> {
screenState = screenState.copy(
selectedTextFromFirstBottomSheet = event.text
)
}
is MainScreenEvent.OnSecondBottomSheetSelectedTextChanged -> {
screenState = screenState.copy(
selectedTextFromSecondBottomSheet = event.text
)
}
}
}
}
ScreenState
data class MainScreenState(
val selectedTextFromFirstBottomSheet: String = "First Text b1",
val selectedTextFromSecondBottomSheet: String = "Third Text b2",
val firstBottomSheetTextList: List<String> = listOf(
"First Text b1",
"Second Text b1",
"Third Text b1",
"Fourth Text b1",
"Five Text b1"
),
val secondBottomSheetTextList: List<String> = listOf(
"First Text b2",
"Second Text b2",
"Third Text b2",
"Fourth Text b2",
"Five Text b2"
)
)
Screen Event
sealed class MainScreenEvent(){
data class OnFirstBottomSheetSelectedTextChanged(val text: String): MainScreenEvent()
data class OnSecondBottomSheetSelectedTextChanged(val text: String): MainScreenEvent()
}
First Bottom Sheet
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.toggleable
import androidx.compose.material.Checkbox
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
#Composable
fun FirstBottomSheet(
selectedText: String,
textList: List<String>,
onSelected: (text: String) -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
textList.forEach { text ->
Row(modifier = Modifier
.fillMaxWidth()
.toggleable(
value = selectedText == text,
role = Role.Checkbox,
onValueChange = { isSelected ->
if (isSelected) {
onSelected(text)
}
}
)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(text = text, modifier = Modifier.weight(1f))
Checkbox(checked = selectedText == text, onCheckedChange = null)
}
}
}
}
Second Bottom Sheet
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.toggleable
import androidx.compose.material.Checkbox
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
#Composable
fun SecondBottomSheet(
selectedText: String,
textList: List<String>,
onSelected: (text: String) -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
textList.forEach { text ->
Row(modifier = Modifier
.fillMaxWidth()
.toggleable(
value = selectedText == text,
role = Role.Checkbox,
onValueChange = { isSelected ->
if (isSelected) {
onSelected(text)
}
}
)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically) {
Text(text = text, modifier = Modifier.weight(1f))
Checkbox(checked = selectedText == text, onCheckedChange = null)
}
}
}
}
Thanks for your help!
I copied and pasted your code. The only changes I made were:
Remove this line from MainScreen
val screenState = viewModel.screenState
Access the state directly.
Text(
text = "Selected: ${viewModel.screenState.selectedTextFromFirstBottomSheet}",
Modifier.padding(16.dp)
)
Button(onClick = {
bottomSheetContent = {
FirstBottomSheet(
selectedText = viewModel.screenState.selectedTextFromFirstBottomSheet,
onSelected = { text ->
viewModel.onEvent(
MainScreenEvent.OnFirstBottomSheetSelectedTextChanged(text)
)
},
textList = viewModel.screenState.firstBottomSheetTextList
)
}
scope.launch {
bottomSheetState.show()
}
}, modifier = Modifier.padding(16.dp)) {
Text(text = " Open First BottomSheet")
}
Boom! It worked :)
My understanding is: you're creating a variable containing the value of a state, but you're not listening to the state changes, so the Compose doesn't know the state has changed, therefore the recomposition doesn't happen. The by keyword in your state declaration is a property delegate which set/get the current value of state, but not register the composable to react to these changes.
There are another solutions you can use to observe the state without repeat viewModel.screenState:
Using derivedStateOf:
val screenState by remember {
derivedStateOf {
viewModel.screenState
}
}
Changing the screenState declaration.
// Using "=" instead of "by"
var screenState = mutableStateOf(MainScreenState())
and then use screenState.value to set/get the state value.
And in the screen, use like below:
val screenState = viewModel.screenState
I had the exact same use case in my app but everything was working fine until I updated some of the compose libraries.
Actually I'm not even sure if this is a timing issue, but let's begin with the code first.
I start out in my MainActivity where I prepare a simple data structure containing letters from A to Z.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val model = mutableStateListOf<Char>()
model.addAll(('A'..'Z').toList())
val swipeComplete = {
model.removeFirst()
}
CardStack(elements = model, onSwipeComplete = { swipeComplete() })
}
}
}
Here I am calling CardStack, which looks like the following:
#Composable
fun CardStack(elements: List<Char>, onSwipeComplete: () -> Unit) {
elements.take(2).reversed().forEachIndexed { _, character ->
Box {
SwipeCard(
character.toString(),
onSwipeComplete = onSwipeComplete
)
}
}
}
When swiping a card, I want to view the card underneath it as well. Therefore I am only taking the two top-most cards and display them. Then comes the SwipeCard itself.
#OptIn(ExperimentalMaterialApi::class)
#Composable
fun SwipeCard(text: String, onSwipeComplete: () -> Unit) {
val color by remember {
val random = Random()
mutableStateOf(Color(random.nextInt(256), random.nextInt(256), random.nextInt(256)))
}
val screenWidth = LocalConfiguration.current.screenWidthDp.dp.value
val screenDensity = LocalConfiguration.current.densityDpi
var offsetXTarget by remember { mutableStateOf(0f) }
var offsetYTarget by remember { mutableStateOf(0f) }
val swipeThreshold = abs(screenWidth * screenDensity / 100)
var dragInProgress by remember {
mutableStateOf(false)
}
val offsetX by animateFloatAsState(
targetValue = offsetXTarget,
animationSpec = tween(
durationMillis = screenDensity / 3,
easing = LinearEasing
),
finishedListener = {
if (!dragInProgress) {
onSwipeComplete()
}
}
)
val offsetY by animateFloatAsState(
targetValue = offsetYTarget,
animationSpec = tween(
durationMillis = screenDensity / 3,
easing = LinearEasing
)
)
val rotationZ = (offsetX / 60).coerceIn(-40f, 40f) * -1
Card(
shape = RoundedCornerShape(20.dp),
elevation = 0.dp,
backgroundColor = color,
modifier = Modifier
.fillMaxSize()
.padding(50.dp)
.graphicsLayer(
translationX = offsetX,
translationY = offsetY,
rotationZ = rotationZ
)
.pointerInput(Unit) {
detectDragGestures(
onDrag = { change, dragAmount ->
dragInProgress = true
change.consumeAllChanges()
offsetXTarget += dragAmount.x
offsetYTarget += dragAmount.y
},
onDragEnd = {
if (abs(offsetX) < swipeThreshold / 20) {
offsetXTarget = 0f
offsetYTarget = 0f
} else {
offsetXTarget = swipeThreshold
offsetYTarget = swipeThreshold
if (offsetX < 0) {
offsetXTarget *= -1
}
}
dragInProgress = false
}
)
}
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = text,
style = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 52.sp
),
color = Color.White
)
}
}
}
This is how it looks in action:
A few key points, let's consider the initial state with all letters from A to Z:
When I start to drag the card with letter "A", I can see card with letter "B" underneath it.
When the drag motion ends the card for letter "A" shall be animated away to either the left or the right side, depending on what side the user has chosen.
When the animation has been finished, the onSwipeComplete shall be called in order to remove the top-most element, the letter "A", of my data model.
After the top-most element has been removed from the data model I expect the stack of cards to be recomposed with letters "B" and "C".
The problem is when the card "A" is animated away, then suddenly the letter "B" is drawn on this animated card and where "B" has been is now "C".
It seems the data model is already updated while the first card with letter "A" is still being animated away.
This leaves me with only one card for letter "C" left. Underneath "C" is no other card.
For me there seems something wrong with the timing, but I can't figure out what exactly.
Here are all the imports to add:
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Card
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.consumeAllChanges
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.swipecard.ui.theme.SwipeCardTheme
import java.util.*
import kotlin.math.abs
This also requires the following dependencies:
implementation "androidx.compose.runtime:runtime:1.0.1"
implementation "androidx.compose.runtime:runtime-livedata:1.0.1"
When you delete an item from the array, from the Compose point of view it looks as if you deleted the last view and changed the data of the other views. The view that was view A is reused for content B, and because that view has non-zero offset values, it is not visible on the screen, so you only see view C.
Using key, you can tell Compose which view is associated with which data, so that they are reused correctly:
#Composable
fun CardStack(elements: List<Char>, onSwipeComplete: () -> Unit) {
elements.take(2).reversed().forEach { character ->
key(character) {
SwipeCard(
character.toString(),
onSwipeComplete = onSwipeComplete
)
}
}
}
p.s. some comments about your code:
It's quite strange that you pass screenDensity to durationMillis. It's pretty small value in terms of millis, which makes your animation almost instant, and looks kind of strange it terms of logic.
If you don't need index from forEachIndexed, just use forEach instead of specifying _ placeholder.
Using Box as you did here, when you only have a single child and don't specify any modifiers for Box has no effect.
Following the Pathway code labs from Google about Jetpack compose, I was trying out this code
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Divider
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.codelab.basics.ui.BasicsCodelabTheme
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyApp {
MyScreenContent()
}
}
}
}
#Composable
fun MyApp(content: #Composable () -> Unit) {
BasicsCodelabTheme {
Surface(color = Color.Yellow) {
content()
}
}
}
#Composable
fun MyScreenContent(names: List<String> = List(1000) { "Hello Android #$it" }) {
val counterState = remember { mutableStateOf(0) }
Column(modifier = Modifier.fillMaxHeight()) {
NameList(names, Modifier.weight(1f))
Counter(
count = counterState.value,
updateCount = { newCount ->
counterState.value = newCount
}
)
}
}
#Composable
fun NameList(names: List<String>, modifier: Modifier = Modifier) {
LazyColumn(modifier = modifier) {
items(items = names) { name ->
Greeting(name = name)
Divider(color = Color.Black)
}
}
}
#Composable
fun Greeting(name: String) {
var isSelected by remember { mutableStateOf(false) }
val backgroundColor by animateColorAsState(if (isSelected) Color.Red else Color.Transparent)
Text(
text = "Hello $name!",
modifier = Modifier
.padding(24.dp)
.background(color = backgroundColor)
.clickable(onClick = { isSelected = !isSelected })
)
}
#Composable
fun Counter(count: Int, updateCount: (Int) -> Unit) {
Button(
onClick = { updateCount(count + 1) },
colors = ButtonDefaults.buttonColors(
backgroundColor = if (count > 5) Color.Green else Color.White
)
) {
Text("I've been clicked $count times")
}
}
#Preview("MyScreen preview")
#Composable
fun DefaultPreview() {
MyApp {
MyScreenContent()
}
}
I have noticed that LazyColumn will recompose the items whenever they become Visible on the screen (intended behaviour!) however, the Local state of Greeting widget is completely lost!
I believe this is a bug in Compose, Ideally the composer should consider the remember cached state. Is there an elegant way to fix this?
Thanks in advance!
[Update]
Using rememberSaveable {...} gives the power to survive the android change of configuration as well as the scrollability
Docs
Remember the value produced by init.
It behaves similarly to remember, but the stored value will survive the activity or process recreation using the saved instance state mechanism (for example it happens when the screen is rotated in the Android application).
The code now is more elegant and shorter, we don't even need to hoist the state, it can be kept internal. The only thing I am not so sure of now with using rememberSaveable is if there will be any performance penalties when the list grows bigger and bigger, say 1000 items.
#Composable
fun Greeting(name: String) {
val isSelected = rememberSaveable { mutableStateOf(false) }
val backgroundColor by animateColorAsState(if (isSelected.value) Color.Red else Color.Transparent)
Text(
text = "Hello $name! selected: ${isSelected.value}",
modifier = Modifier
.padding(24.dp)
.background(color = backgroundColor)
.clickable(onClick = {
isSelected.value = !isSelected.value
})
)
}
[Original Answer]
Based on #CommonWare's answer The LazyColumn will dispose the composables along with their states when they are off-screen this means when LazyColumn recomposes the Compsoables again it will have fresh start state. To fix this issue all that has to be done is to hoist the state to the consumer's scope, LazyColumn in this case.
Also we need to use mutableStateMapOf() instead of MutableMapOf inside the remember { ... } lambda or Compose-core engine will not be aware of this change.
So far here is the code:
#Composable
fun NameList(names: List<String>, modifier: Modifier = Modifier) {
val selectedStates = remember {
mutableStateMapOf<Int, Boolean>().apply {
names.mapIndexed { index, _ ->
index to false
}.toMap().also {
putAll(it)
}
}
}
LazyColumn(modifier = modifier) {
itemsIndexed(items = names) { index, name ->
Greeting(
name = name,
isSelected = selectedStates[index] == true,
onSelected = {
selectedStates[index] = !it
}
)
Divider(color = Color.Black)
}
}
}
Happy composing!
In the process of migrating my app to Jetpack compose, I've come to a part of my app where a TextField needs autocompletion functionality.
However, as of version 1.0.0-alpha05, I couldn't find any functionality to achieve this using the Compose API. The closest thing I've found is the DropdownMenu and DropdownMenuItem composeables, but it seems like it would be a lot of manual plumbing required to create an autocomplete menu out of these.
The obvious thing to do is just wait for future updates to Jetpack Compose, of course. But I'm wondering, has anyone who encountered a this issue in their migrations found a solution?
No at least till v1.0.2
so I implemented a nice working one in compose available in this gist
I also put it here:
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.window.PopupProperties
#Composable
fun TextFieldWithDropdown(
modifier: Modifier = Modifier,
value: TextFieldValue,
setValue: (TextFieldValue) -> Unit,
onDismissRequest: () -> Unit,
dropDownExpanded: Boolean,
list: List<String>,
label: String = ""
) {
Box(modifier) {
TextField(
modifier = Modifier
.fillMaxWidth()
.onFocusChanged { focusState ->
if (!focusState.isFocused)
onDismissRequest()
},
value = value,
onValueChange = setValue,
label = { Text(label) },
colors = TextFieldDefaults.outlinedTextFieldColors()
)
DropdownMenu(
expanded = dropDownExpanded,
properties = PopupProperties(
focusable = false,
dismissOnBackPress = true,
dismissOnClickOutside = true
),
onDismissRequest = onDismissRequest
) {
list.forEach { text ->
DropdownMenuItem(onClick = {
setValue(
TextFieldValue(
text,
TextRange(text.length)
)
)
}) {
Text(text = text)
}
}
}
}
}
How to use it
val all = listOf("aaa", "baa", "aab", "abb", "bab")
val dropDownOptions = mutableStateOf(listOf<String>())
val textFieldValue = mutableStateOf(TextFieldValue())
val dropDownExpanded = mutableStateOf(false)
fun onDropdownDismissRequest() {
dropDownExpanded.value = false
}
fun onValueChanged(value: TextFieldValue) {
dropDownExpanded.value = true
textFieldValue.value = value
dropDownOptions.value = all.filter { it.startsWith(value.text) && it != value.text }.take(3)
}
#Composable
fun TextFieldWithDropdownUsage() {
TextFieldWithDropdown(
modifier = Modifier.fillMaxWidth(),
value = textFieldValue.value,
setValue = ::onValueChanged,
onDismissRequest = ::onDropdownDismissRequest,
dropDownExpanded = dropDownExpanded.value,
list = dropDownOptions.value,
label = "Label"
)
As of compose 1.1.0-alpha06, Compose Material now offers an ExposedDropdownMenu composable, API here, which can be used to implement a dropdown menu which facilitates the autocompletion process. The actual autocompletion logic has to be implemented yourself.
The API docs give the following usage example, for an editable field:
val options = listOf("Option 1", "Option 2", "Option 3", "Option 4", "Option 5")
var exp by remember { mutableStateOf(false) }
var selectedOption by remember { mutableStateOf("") }
ExposedDropdownMenuBox(expanded = exp, onExpandedChange = { exp = !exp }) {
TextField(
value = selectedOptionText,
onValueChange = { selectedOptionText = it },
label = { Text("Label") },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = exp)
},
colors = ExposedDropdownMenuDefaults.textFieldColors()
)
// filter options based on text field value (i.e. crude autocomplete)
val filterOpts = options.filter { it.contains(selectedOption, ignoreCase = true) }
if (filterOpts.isNotEmpty()) {
ExposedDropdownMenu(expanded = exp, onDismissRequest = { exp = false }) {
filterOpts.forEach { option ->
DropdownMenuItem(
onClick = {
selectedOption = option
exp = false
}
) {
Text(text = option)
}
}
}
}
}
Checkout this code that I made using XML and using that layout inside compose
using AndroidView. We can use this solution until it is included by default in compose.
You can customize it and style it as you want. I have personally tried it in my project and it works fine
<!-- text_input_field.xml -->
<!-- You can style your textfield here in XML with styles -->
<!-- this file should be in res/layout -->
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.textfield.TextInputLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
style="#style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<AutoCompleteTextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Label"
android:inputType="none" />
</com.google.android.material.textfield.TextInputLayout>
// TextFieldWithDropDown.kt
// TextField with dropdown was not included by default in jetpack compose (1.0.2) and less
#Composable
fun TextFieldWithDropDown(
items: List<String>,
selectedValue: String?,
modifier: Modifier = Modifier,
onSelect: (Int) -> Unit
) {
AndroidView(
factory = { context ->
val textInputLayout = TextInputLayout
.inflate(context, R.layout.text_input_field, null) as TextInputLayout
// If you need to use different styled layout for light and dark themes
// you can create two different xml layouts one for light and another one for dark
// and inflate the one you need here.
val autoCompleteTextView = textInputLayout.editText as? AutoCompleteTextView
val adapter = ArrayAdapter(context, android.R.layout.simple_list_item_1, items)
autoCompleteTextView?.setAdapter(adapter)
autoCompleteTextView?.setText(selectedValue, false)
autoCompleteTextView?.setOnItemClickListener { _, _, index, _ -> onSelect(index) }
textInputLayout
},
update = { textInputLayout ->
// This block will be called when recomposition happens
val autoCompleteTextView = textInputLayout.editText as? AutoCompleteTextView
val adapter = ArrayAdapter(textInputLayout.context, android.R.layout.simple_list_item_1, items)
autoCompleteTextView?.setAdapter(adapter)
autoCompleteTextView?.setText(selectedValue, false)
},
modifier = modifier
)
}
// MainActivity.kt
// It's important to use AppCompatActivity instead of ComponentActivity to get the material
// look on our XML based textfield
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Column {
TextFieldWithDropDown(
items = listOf("One", "Two", "Three"),
selectedValue = "Two",
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
// You can also set the value to a state
index -> println("$index was selected")
}
}
}
}
}
As you said, there is no such component yet. You have two options: create your own custom using DropDownMenu and BaseTextField or using hybrid xml-autocomplete and compose screen through androidx.compose.ui.platform.ComposeView