We know that sending event from child to parent is easy by using state hoisting pattern, but what is the best practice to send events from parent to child? I have tried to share the parent's ViewModel, it works but it's feels wrong to do.
Suppose I have this composable, with a child that's far nested and have a different ViewModelStoreOwner from the parent:
#Composable
fun Parent() {
Button(
onClick = {
// send event to child composable
},
content = {
Text(text = "Example")
}
)
NavHost(/*...*/) {
composable(/*...*/) {
A {
Very {
Nested {
Child {
Composable {
// i want to receive the event here
}
}
}
}
}
}
}
}
Related
I'm implementing registration in my application and, after filling in the respective fields, I click on a button that will make a registration request to the API. Meanwhile, I place a Loading View and when I receive the successful response, I execute the navigation to the OnBoarding screen. The issue is that the navController is always running the navigation and doing the navigation and popUp several times, when it should only do it once. I always get this warning on logs: Ignoring popBackStack to destination 29021787 as it was not found on the current back stack and I am not able to do any click or focus in the OnBoardingScreen.
My code:
val uiState by registerViewModel.uiState.collectAsState()
when (uiState) {
is BaseViewState.Data -> {
navController.navigate(NavigationItem.OnBoarding.route) {
popUpTo(NavigationItem.Register.route) {
inclusive = true
}
}
}
is BaseViewState.Loading -> LoadingView()
is BaseViewState.Error -> BannerView()
else -> {}
}
On button click I call the viewModel like this:
registerViewModel.onTriggerEvent(
RegisterEvent.CreateUser(
usernameInputState.value.text,
emailInputState.value.text,
passwordInputState.value.text
)
)
And, in ViewModel, I do my request like this:
override fun onTriggerEvent(eventType: RegisterEvent) {
when (eventType) {
is RegisterEvent.CreateUser -> createUser(eventType.username, eventType.email, eventType.password)
}
}
private fun createUser(username: String, email: String, password: String) = safeLaunch {
setState(BaseViewState.Loading)
execute(createUser(CreateUser.Params(username, email, password))) {
setState(BaseViewState.Data(RegisterViewState(it)))
}
}
I guess it should be caused by recomposition, because I put a breakpoint on first when scenario and it stops here multiple times, but only one on ViewModel. How can I fix this?
This issue is here
is BaseViewState.Data -> {
navController.navigate(NavigationItem.OnBoarding.route) {
popUpTo(NavigationItem.Register.route) {
inclusive = true
}
}
}
Every time you call navController.navigate NavHost will keep on passing through this block, executing an endless loop.
I suggest having the navigate call from a LaunchedEffect with a key (like this),
LaunchedEffect(key1 = "some key") {
navController.navigate(…)
}
or creating a separate structure namely "Events" where they are emitted as SharedFlow and observed via a Unit keyed LaunchedEffect
LaunchedEffect(Unit) {
viewModel.event.collectLatest {
when (it) {
is UiEvent.Navigate -> {
navController.navigate(…)
}
}
}
}
I have a navigation graph containing a HomeScreen and a MyBottomSheet. For bottom sheets I am using Accompanist Navigation.
Both of the destinations share a common ViewModel which is scoped to that navigation graph. From that ViewModel I am exposing a Flow<MyEvent> where MyEvent is:
sealed interface MyEvent
object MyEvent1: MyEvent
object MyEvent2: MyEvent
The two composables look like this:
#Composable
fun HomeScreen(viewModel: MyViewModel) {
LaunchedEffect(Unit) {
viewModel.eventsFlow.collect {
if(it is Event1) {
handleEvent1()
}
}
}
...
}
#Composable
fun MyBottomSheet(viewModel: MyViewModel) {
LaunchedEffect(Unit) {
viewModel.eventsFlow.collect {
if(it is Event2) {
handleEvent2()
}
}
}
...
}
Note that I want HomeScreen to handle Event1 and MyBottomSheet to handle Event2 (these events are basically navigation events).
The problem is that when MyBottomSheet is visible, both the composables are collecting the flow at the same time because of which Event2 also gets collected by HomeScreen. What I want is that when MyBottomSheet is the topmost destination in back stack, HomeScreen shouldn't be collecting flow. One of the possible solutions in my mind is:
#Composable
fun HomeScreen(viewModel: MyViewModel) {
LaunchedEffect(isHomeScreenOnTheTopOfBackStack) {
if(isHomeScreenOnTheTopOfBackStack) {
viewModel.eventsFlow.collect {
if(it is Event1) {
handleEvent1()
}
}
}
}
...
}
Now here, how can I check if HomeScreen is on top of the back stack or not?
Or is there a better way to approach this problem?
Turned out it was quite easy. We can use navController.currentBackStackEntryAsState() in the nav graph and pass the Boolean to the HomeScreen.
composable("home") {
val isOnTop = navController.currentBackStackEntryAsState().value?.destination?.route == "home"
HomeScreen(
viewModel = //,
isHomeScreenOnTheTopOfBackStack = isOnTop
)
}
I am having this issue where I have to navigate when given state gets updated after an asynchronous task gets executed. I am doing it like this:
At ViewModel.kt
fun executeRandomTask() {
viewModelScope.launch {
runAsyncTask()
state = Success
}
}
At Composable.kt
LaunchedEffect(viewModel.state) {
if(viewModel.state is Success) {
navController.navigate("nextScreen")
}
}
Then in the next screen, I click the back navigation button (onBackPressed) and what happens, is that the effect gets launched again. So I end up again in "nextScreen".
When I do this next workaround:
DisposableEffect(viewModel.state) {
if(viewModel.state is Success) {
navController.navigate("nextScreen")
}
onDispose {
viewModel.state = null
}
}
Like this, the viewmodel state gets cleared and it also proves that what is happening is that the navigation controller destroys the previous screen (not sure if it is the intended behavior).
I am not sure about what I should be doing to avoid this, since this is a pretty common scenario and having to clear the state after a certain state is reached looks dirty.
I use SharedFlow for emitting one-time events like this
class MyViewModel : ViewModel() {
private val _eventFlow = MutableSharedFlow<OneTimeEvent>()
val eventFlow = _eventFlow.asSharedFlow()
private fun emitEvent(event: OneTimeEvent) {
viewModelScope.launch { _eventFlow.emit(event) }
}
}
The sealed class for defining events
sealed class OneTimeEvent {
object SomeEvent: OneTimeEvent()
}
And finally in the Screen collect the flow like this
fun MyScreen(viewModel: MyViewModel = hiltViewModel()) {
LaunchedEffect(Unit) {
viewModel.eventFlow.collect { event ->
when(event){
SomeEvent -> {
//Do Something
}
}
}
}
}
So whenever your state changes you can emit some event and take action against it in your Screen.
I need to check when a certain LazyColumn item comes into view, and once it does, make a callback to onItemWithKeyViewed() only once to notify that this item has been viewed.
My attempt:
#Composable
fun SpecialList(
someItems: List<Things>,
onItemWithKeyViewed: () -> Unit
) {
val lazyListState = rememberLazyListState()
if (lazyListState.isScrollInProgress) {
val isItemWithKeyInView = lazyListState.layoutInfo
.visibleItemsInfo
.any { it.key == "specialKey" }
if (isItemWithKeyInView) {
onItemWithKeyViewed()
}
}
LazyColumn(
state = lazyListState
) {
items(items = someItems) { itemData ->
ComposableOfItem(itemData)
}
item(key = "specialKey") {
SomeOtherComposable()
}
}
}
Issue with my method is I notice the list scrolling performance degrades badly and loses frames. I realize this may be because it's checking all visible item keys on every frame?
Also, onItemWithKeyViewed() is currently being called multiple times instead of just the first time it's viewed.
Is there a more efficient way to make a single callback to onItemWithKeyViewed() only the first time "specialKey" item is viewed?
In such cases, when you have a state that is updated often, you should use derivedStateOf: this will cause recomposition only when the result of the calculation actually changes.
You should not call side effects (which is calling onItemWithKeyViewed) directly in the composable builder. You should use one of the special side-effect functions instead, usually LaunchedEffect - this ensures that the action is not repeated. You can find more information on this topic in Thinking in Compose and in side-effects documentation.
val isItemWithKeyInView by remember {
derivedStateOf {
lazyListState.isScrollInProgress &&
lazyListState.layoutInfo
.visibleItemsInfo
.any { it.key == "specialKey" }
}
}
if (isItemWithKeyInView) {
LaunchedEffect(Unit) {
onItemWithKeyViewed()
}
}
Currently I am using ModalBottomSheetLayout to display the bottom sheet.
I don't know is there a way to listen to the bottom page closing event?
In Compose to listen for changes you need to check current values of your mutable states.
In case with ModalBottomSheetLayout you have ModalBottomSheetState, and you can check on currentValue of this state. If you need you can modify your views state depending on this value.
If you wanna perform some action on state changes, you need to use side effects. The most basic one is LaunchedEffect, you can use it in combination with snapshotFlow to watch any state value:
LaunchedEffect(Unit) {
snapshotFlow { modalBottomSheetState.currentValue }
.collect {
println(it.toString())
}
}
Also I'll be called first time when you launch a screen, and you'll have Hidden there too, so depending on your needs it may not be an ideal solution.
To get the most close to listening on becoming hidden, you can use DisposableEffect.
if (modalBottomSheetState.currentValue != ModalBottomSheetValue.Hidden) {
DisposableEffect(Unit) {
onDispose {
println("hidden")
}
}
}
Here I'm launching DisposableEffect when your sheet appears, and when it disappears - I'm removing it from the view hierarchy, which will cause the onDispose to be called.
Full example of basically everything you can do with the state:
val modalBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
val scope = rememberCoroutineScope()
LaunchedEffect(modalBottomSheetState.currentValue) {
println(modalBottomSheetState.currentValue)
}
if (modalBottomSheetState.currentValue != ModalBottomSheetValue.Hidden) {
DisposableEffect(Unit) {
onDispose {
println("hidden")
}
}
}
ModalBottomSheetLayout(
sheetState = modalBottomSheetState,
sheetContent = {
Text(
"sheetContent",
modifier = Modifier.fillMaxHeight()
)
}
) {
Column {
Text(modalBottomSheetState.currentValue.toString())
Button(onClick = {
scope.launch {
modalBottomSheetState.show()
}
}) {
Text("Show bottom sheet")
}
}
}
You can just use confirmStateChange from rememberModalBottomSheetState:
val state = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden, confirmStateChange = {
if (it == ModalBottomSheetValue.Hidden) {
println(it)
}
true
})
Using confirmStateChange won't be invoked however if you show/hide the modal yourself with a clickable for example - it seems to only be invoked on clicking the scrim or swiping.
A way around this is to just observe the modalBottomSheetState.targetValue which will change anytime the modal is animating between two different states:
LaunchedEffect(modalBottomSheetState.targetValue) {
if (modalBottomSheetState.targetValue == ModalBottomSheetValue.Hidden) {
// do something when animating to Hidden state
} else {
// expanding
}
}
This is closer to the timing of confirmStateChange which is invoked before the call to show/hide. Observing modalBottomSheetState.currentValue will change at the end of the animation, while modalBottomSheetState.targetValue will change before the animation begins.