I have a Composable that has a Text and Button. Text will show P if the current orientation is portrait, and L otherwise. Clicking on the Button will change the orientation to landscape, (So after that, it should change the text from P to L)
Here's the Composable
#Composable
fun MyApp() {
val currentOrientation = LocalConfiguration.current.orientation
val orientation = if (currentOrientation == ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) {
"P"
} else {
"L"
}
val activity = LocalContext.current as Activity
Column {
Text(text = orientation)
Button(onClick = {
// change orientation to landscape
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
}) {
Text(text = "DO IT")
}
}
}
and here's how am testing it
#get:Rule
val composeRule = createComposeRule()
#Test
fun test() {
composeRule.setContent { MyApp() }
// Starts with portrait
composeRule.onNodeWithText("P").assertIsDisplayed()
// Change the orientation to Landscape
composeRule.onNodeWithText("DO IT").performClick()
// Now the text should be `L`
composeRule.onNodeWithText("L").assertIsDisplayed()
}
But I am getting the below error when I run the test to see if the text is updated or not. (Manual test works though)
java.lang.IllegalStateException: No compose views found in the app. Is your Activity resumed?
at androidx.compose.ui.test.TestContext.getAllSemanticsNodes$ui_test_release(TestOwner.kt:96)
at androidx.compose.ui.test.SemanticsNodeInteraction.fetchSemanticsNodes$ui_test_release(SemanticsNodeInteraction.kt:82)
at androidx.compose.ui.test.SemanticsNodeInteraction.fetchOneOrDie(SemanticsNodeInteraction.kt:155)
at androidx.compose.ui.test.SemanticsNodeInteraction.assertExists(SemanticsNodeInteraction.kt:147)
at androidx.compose.ui.test.SemanticsNodeInteraction.assertExists$default(SemanticsNodeInteraction.kt:146)
Here's the complete test file if you want to try it yourself.
Questions
What am I missing here and how can I fix it?
Was the screen on during the test?
In my case, this is easily reproduceable by simply turning the screen off. Note that I am using the emulator.
I faced the same exact problem when performing the click event.
At the time I'm testing with my device, at happened when your test device/emulator's screen is not awake (turn off).
My fix is just turn on the screen.
Related
My current Android Jetpack Compose application employs snapShotFlow to convert mutableStateOf() to flow and trigger user actions as follows
In ViewModel:-
var displayItemState by mutableStateOf(DisplayItemState())
#Immutable
data class DisplayItemState(
val viewIntent: Intent? = null
)
In composable:-
val displayItemState = viewModel.displayItemState
LaunchedEffect(key1 = displayItemState) {
snapshotFlow { displayItemState }
.distinctUntilChanged()
.filter { it.viewIntent != null }
.collectLatest { displayItemState ->
context.startActivity(displayItemState.viewIntent)
}
}
everything works as expected while I keep my test device in portrait or landscape.
However when I change the device orientation the last collected snapShotFlow value is resent.
If I reset the displayItemState as follows in the snapShotFlow this fixes the issue
however this feels like the wrong fix. What am i doing wrong? what is the correct approach to stop the snapShotFlow from re triggering on orientation change
val displayItemState = viewModel.displayItemState
LaunchedEffect(key1 = displayItemState) {
snapshotFlow { displayItemState }
.distinctUntilChanged()
.filter { it.viewIntent != null }
.collectLatest { displayItemState ->
context.startActivity(displayItemState.viewIntent)
viewModel.displayItemState = DisplayItemState()
}
}
That's intended behavior, you are not doing anything wrong. Compose's (Mutable)State holds the last value, similarly to StateFlow, so new collection from them always starts with the last value.
Your solution is ok, something very similar is actually recommended in Android's app architecture guide here:
For example, when showing transient messages on the screen to let the user know that something happened, the UI needs to notify the ViewModel to trigger another state update when the message has been shown on the screen.
Another possibility would be to use SharedFlow instead of MutableState in your viewModel - SharedFlow doesn't keep the last value so there won't be this problem.
I am wondering about some compose internals and how compose could be "taught" that one composable is the same composable as before.
Following example:
enum class State { S1, S2 }
#Composable
fun MyUi(state : State){
when (state) {
S1 -> FancyUi("coming from s1")
S2 -> FancyUi("coming from s2")
}
}
#Composable
fun FancyUi(text : String) {
// some fancy UI stuff is happening here :)
}
I don't know what the right terminology is in compose world but what is happening is that the first FancyUi("coming from s1") is stopped (I think correct terminology is that composition is left) and the second FancyUi("coming from s1") is started (I think correct terminology is composition is entered).
What I would like to achieve though is "recomposition", so basically teach compose runtime somehow that both FancyUi() are actually the same object, just with different parameters.
I know you could achieve this with something like this (instead of the example above):
#Composable
fun MyUi(state : State){
val str = when (state) {
S1 -> "coming from s1"
S2 -> "coming from s2"
}
FancyUi(str)
}
Now FancyUi() doesn't get stopped when the state changes but instead does "recomposition" as expected.
But I am really just curious: getting back to the first example, can I teach somehow compose that both FancyUiare actually the same? I thought key() does the trick, but apparently, it doesn't:
#Composable
fun MyUi(state : State){
when (state) {
S1 -> key("samekey") { FancyUi("coming from s1") }
S2 -> key("samekey") { FancyUi("coming from s2") }
}
}
Is it possible to "teach" compose that these elements are the same?
I have created a composable called ResolveAuth. ResolveAuth is the first screen when user opens the app after Splash. All it does is check whether an email is present in Datastore or not. If yes redirect to main screen and if not then redirect to tutorial screen
Here is my composable and viewmodel code
#Composable
fun ResolveAuth(resolveAuthViewModel: ResolveAuthViewModel, navController: NavController) {
Scaffold(content = {
ProgressBar()
when {
resolveAuthViewModel.userEmail.value != "" -> {
navController.navigate(Screen.Main.route) {
popUpTo(0)
}
resolveAuthViewModel.userEmail.value = null
}
resolveAuthViewModel.userEmail.value == "" -> {
navController.navigate(Screen.Tutorial.route) {
popUpTo(0)
}
resolveAuthViewModel.userEmail.value = null
}
}
})
}
#HiltViewModel
class ResolveAuthViewModel #Inject constructor(
private val dataStoreManager: DataStoreManager): ViewModel(){
val userEmail = MutableLiveData<String>()
init {
viewModelScope.launch{
val job = async {dataStoreManager.email.first()}
val email = job.await()
if(email != ""){
userEmail.value = email
}
}
}
}
But I keep getting an exception saying
java.lang.IllegalStateException: You cannot access the NavBackStackEntry's ViewModels until it is added to the NavController's back stack (i.e., the Lifecycle of the NavBackStackEntry reaches the CREATED state).
I am using below jetpack lib for navigation
implementation("androidx.navigation:navigation-compose:2.4.0-rc01")
There is no issue in my Main and Tutorial screen as I tried to run them separately and it works fine.
Easily resolvable, just add this when call to a Side-Effect instead.
LaunchedEffect(Unit){
while(!isNavStackReady) // Hold execution while the NavStack populates.
delay(16) // Keeps the resources free for other threads.
when {
resolveAuthViewModel.userEmail.value != "" -> {
navController.navigate(Screen.Main.route) {
popUpTo(0)
}
resolveAuthViewModel.userEmail.value = null
}
resolveAuthViewModel.userEmail.value == "" -> {
navController.navigate(Screen.Tutorial.route) {
popUpTo(0)
}
resolveAuthViewModel.userEmail.value = null
}
}
}
Here, the call to navigate is made only after the currentBackStackEntry has been completely filled, so it yields no error. The original error occurred since you were calling navigate before the concerned composable was even made available to the nav stack.
As for how to update the isNavStackReady variable to reflect the correct state of the navStack, it is fairly simple. Create the variable at a top-level declaration, such that only the required components may access it. May as well throw it inside a viewModel if you please. Set the default value of the var to false, for obvious reasons. Here's the update mechanism.
#Composable
fun StartDestination(){
isNavStackReady = true
}
That's it, that's really it. If you could successfully navigate to your start destination that you define in the nav graph, it means the navStack has likely been populated well. Hence, you just update this variable here, and the LaunchedEffect block up there will respond to this update, and the while loop that's been holding execution off, will finally break. It will then call the navigate on the appropriate destination route. Remember, however, that the isNavStackReady variable, for this mechanism to work, needs to be a state-holder, i.e., initialised with mutableStateOf(false). Using delegates, of course, is completely fine (personally encouraged).
Now, all this is fine, but actually, it's not quite the right implementation. You see, this entire thing is taken care of completely internally by the navigation APIs for us, but it breaks because we are trying to do its job, and we suck at it.
We are creating an intermediate route to land on, at the start of the app, and from there, immediately navigating to another screen based on calculations. So, all we want is to open the app at a desired page, that is, start the navigator on a desired page when it is first created. We have a handy parameter called startDestination, just for that.
Hence, the ideal, simple, beautiful solution would be to just
startDestination = when {
resolveAuthViewModel.userEmail.value != "" -> {
navController.navigate(Screen.Main.route) {
popUpTo(0)
}
resolveAuthViewModel.userEmail.value = null
}
resolveAuthViewModel.userEmail.value == "" -> {
navController.navigate(Screen.Tutorial.route) {
popUpTo(0)
}
resolveAuthViewModel.userEmail.value = null
}
}
in your NavBuilder's arguments. Tiniest silliest logical flaw, that so many people couldn't get. It's intriguing to think how the human mind works...
Happy New Year,
I have a login scren and when the login is successful and the view model updates the mutable state variable, my expectation is that a new composable function is called to show a new screen and the login one is removed. The problem is that when the new screen (aka Screen.AccountsScreen) is shown, its content keeps flashing/redrawing and same thing happen with the login form which never gets destroyed (I know this because the log message 'Recomponing...' gets printed endless). I assume this happens because the isLoginSuccessful state is always true. It seems I need an event that can be consumed only once, is this correct? If so, how can I do that?
LoginViewModel.kt
#HiltViewModel
class LoginViewModel #Inject constructor() : ViewModel() {
var isLoginSuccessful by mutableStateOf(false)
var errorMessage by mutableStateOf("")
fun onLoginClick(email: String, password:String) {
errorMessage = ""
if (credentialsValid(email, password)) {
isLoginSuccessful = true
} else {
errorMessage = "Email or password invalid"
isLoginSuccessful = false
}
}
}
LoginScreen.kt
#Composable
fun loginScreen(
navController: NavController,
viewModel: LoginViewModel = hiltViewModel()
) {
println("Recomponing...")
// Here gos the code for the login form
if (viewModel.isLoginSuccessful) {
navController.navigate(Screen.AccountsScreen.route) {
popUpTo(Screen.LoginScreen.route) { inclusive = true }
}
}
}
Composite navigation recomposes both disappearing and appearing views during transition. This is the expected behavior.
You're calling navigate on each recomposition. Your problem lays in these lines:
if (viewModel.isLoginSuccessful) {
navController.navigate(Screen.AccountsScreen.route) {
popUpTo(Screen.LoginScreen.route) { inclusive = true }
}
}
You shouldn't change state directly from view builders. In this case LaunchedEffect should be used:
if (viewModel.isLoginSuccessful) {
LaunchedEffect(Unit) {
navController.navigate(Screen.AccountsScreen.route) {
popUpTo(Screen.LoginScreen.route) { inclusive = true }
}
}
}
Check out more in side effects documentation.
For me, I see flicker because the activity background is white, but I am on dark mode.
Change your app theme to daynight, try adding
implementation 'com.google.android.material:material:1.5.0'
and change your theme to
<style name="Theme.MyStockApp" parent="Theme.Material3.DayNight.NoActionBar" />
I am working on a compose screen, where on application open, i redirect user to profile page. And if profile is complete, then redirect to user list page.
my code is like below
#Composable
fun UserProfile(navigateToProviderList: () -> Unit) {
val viewModel: MainActivityViewModel = viewModel()
if(viewModel.userProfileComplete == true) {
navigateToProviderList()
return
}
else {
//compose elements here
}
}
but the app is blinking and when logged, i can see its calling the above redirect condition again and again. when going through doc, its mentioned that we should navigate only through callbacks. How do i handle this condition here? i don't have onCLick condition here.
Content of composable function can be called many times.
If you need to do some action inside composable, you need to use side effects
In this case LaunchedEffect should work:
LaunchedEffect(viewModel.userProfileComplete == true) {
if(viewModel.userProfileComplete == true) {
navigateToProviderList()
}
}
In the key(first argument of LaunchedEffect) you need to specify some key. Each time this key changes since the last recomposition, the inner code will be called. You may put Unit there, in this case it'll only be called once, when the view appears at the first place
The LaunchedEffect did not work for me since I wanted to use it in UI thread but it wasn't for some reason :/
However, I made this for my self:
#Composable
fun <T> SelfDestructEvent(liveData: LiveData<T>, onEvent: (argument: T) -> Unit) {
val previousState = remember { mutableStateOf(false) }
val state by liveData.observeAsState(null)
if (state != null && !previousState.value) {
previousState.value = true
onEvent.invoke(state!!)
}
}
and you use it like this in any other composables:
SingleEvent(viewModel.someLiveData) {
//your action with that data, whenever it was triggered, but only once
}