I'm building a jetpack compose app and I want my view model to tell my compose function to display a snack bar by sending it an event. I have read multiple blog posts about the Single Live Event case with Kotlin and I tried to implement it with Compose and Kotlin Flow. I managed to send the event from the view model (I see it in the logs) but I don't know how to receive it in the composable function. Can someone help me figure it out please? Here is my implementation.
class HomeViewModel() : ViewModel() {
sealed class Event {
object ShowSheet : Event()
object HideSheet : Event()
data class ShowSnackBar(val text: String) : Event()
}
private val eventChannel = Channel<Event>(Channel.BUFFERED)
val eventsFlow: Flow<Event> = eventChannel.receiveAsFlow()
fun showSnackbar() {
Timber.d("Show snackbar button pressed")
viewModelScope.launch {
eventChannel.send(Event.ShowSnackBar("SnackBar"))
}
}
}
#Composable
fun HomeScreen(
viewModel: HomeViewModel,
) {
val context = LocalContext.current
val scaffoldState = rememberScaffoldState()
val sheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden)
val lifecycleOwner = LocalLifecycleOwner.current
val eventsFlowLifecycleAware = remember(viewModel.eventsFlow, lifecycleOwner) {
eventsFlow.flowWithLifecycle(lifecycleOwner.lifecycle, Lifecycle.State.STARTED)
}
LaunchedEffect(sheetState, scaffoldState.snackbarHostState) {
eventsFlowLifecycleAware.onEach {
when (it) {
HomeViewModel.Event.ShowSheet -> {
Timber.d("Show sheet event received")
sheetState.show()
}
HomeViewModel.Event.HideSheet -> {
Timber.d("Hide sheet event received")
sheetState.hide()
}
is HomeViewModel.Event.ShowSnackBar -> {
Timber.d("Show snack bar received")
scaffoldState.snackbarHostState.showSnackbar(
context.getString(it.resId)
)
}
}
}
}
ModalBottomSheetLayout(
sheetState = sheetState,
sheetContent = {
Text("Sheet")
}
) {
Button(
onClick = {
viewModel.showSheet()
}
) {
Text("Show SnackBar")
}
}
}
For reference, I've used these blog posts:
Android SingleLiveEvent Redux with Kotlin Flow
A safer way to collect flows from Android UIs
Ok, I was using the wrong approach, I must not send events, I must update the view state and check if I should show the snackbar when recomposing. Something like that:
You store the SnackBar state in the view model
class HomeViewModel: ViewModel() {
var isSnackBarShowing: Boolean by mutableStateOf(false)
private set
private fun showSnackBar() {
isSnackBarShowing = true
}
fun dismissSnackBar() {
isSnackBarShowing = false
}
}
And in the view you use LaunchedEffect to check if you should show the snackbar when recomposing the view
#Composable
fun HomeScreen(
viewModel: HomeViewModel,
) {
val onDismissSnackBarState by rememberUpdatedState(newValue = onDismissSnackBar)
if (isSnackBarShowing) {
val snackBarMessage = "Message"
LaunchedEffect(isSnackBarShowing) {
try {
when (scaffoldState.snackbarHostState.showSnackbar(
snackBarMessage,
)) {
SnackbarResult.Dismissed -> {
}
}
} finally {
onDismissSnackBarState()
}
}
}
Row() {
Text(text = "Hello")
Spacer(modifier = Modifier.weight(1f))
Button(
onClick = {
viewModel.showSnackBar()
}
) {
Text(text = "Show SnackBar")
}
}
}
I think you have to collect eventsFlowLifecycleAware as a state to trigger a Composable correctly.
Try removing the LaunchedEffect block, and using it like this:
val event by eventsFlowLifecycleAware.collectAsState(null)
when (event) {
is HomeViewModel.Event.ShowSnackBar -> {
// Do stuff
}
}
Related
I'm trying to rewrite my old code in Jetpack Compose, but I have trouble with ContentResolver.addStatusChangeListener which is not firing sync events. I'm using the same business logic (viewmodel, repository) for both - classic UI and compose, but in compose it's not working.
Here is the classic UI code:
private var syncStatusHandle: Any? = null
override fun onResume() {
super.onResume()
syncStatusHandle = ContentResolver.addStatusChangeListener(
ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE or ContentResolver.SYNC_OBSERVER_TYPE_PENDING
)
{
Timber.d("sync event fired")
}
}
override fun onPause() {
super.onPause()
if (syncStatusHandle != null) {
ContentResolver.removeStatusChangeListener(syncStatusHandle)
}
}
And here is how it looks like in Compose (It's pretty much the same approach as mentioned in the docs: Interoperability APIs):
#Composable
fun SyncStatusManager(
onSyncEvent: (count: Int) -> Unit
) {
val context = LocalContext.current
val currentOnSyncEvent by rememberUpdatedState(onSyncEvent)
DisposableEffect(context) {
val syncStatusHandle = ContentResolver.addStatusChangeListener(
ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE or ContentResolver.SYNC_OBSERVER_TYPE_PENDING
)
{
currentOnSyncEvent(it)
}
onDispose {
ContentResolver.removeStatusChangeListener(syncStatusHandle)
}
}
}
and it's used in different composable like this:
SyncStatusManager {
Timber.d("sync event should be fired, but it's not")
}
I tried to use LaunchEffect, or not Effects at all, but no success. When I trigger sync, the callback should fire immediately but it doesn't.
am I missing something ? I'm new to Jetpack Compose. Thank you
(Necessary permissions are granted in both cases)
Example:
private fun myLifecycleObserver(onResume:()->Unit,onPause:()->Unit): LifecycleEventObserver =
LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> onResume()
Lifecycle.Event.ON_PAUSE -> onPause()
else -> { /* ignore */ }
}
}
#Composable
fun SyncStatusManager(
onSyncEvent: (count: Int) -> Unit
) {
val context = LocalContext.current
val lifecycle = LocalLifecycleOwner.current.lifecycle
val currentOnSyncEvent by rememberUpdatedState(onSyncEvent)
var syncStatusHandleState by remember { mutableStateOf<Any?>(null) }
DisposableEffect(context, lifecycle) {
val lifecycleObserver =myLifecycleObserver(onResume = {
syncStatusHandleState = ContentResolver.addStatusChangeListener(
ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE or ContentResolver.SYNC_OBSERVER_TYPE_PENDING
)
{
currentOnSyncEvent(it)
}
}, onPause = {
syncStatusHandleState?.apply {
ContentResolver.removeStatusChangeListener(this)
}
})
lifecycle.addObserver(lifecycleObserver)
onDispose {
lifecycle.removeObserver(lifecycleObserver)
}
}
}
I have a list which is stored inside a Viewmodel via Stateflow.
class FirstSettingViewModel : ViewModel() {
private val _mRoomList = MutableStateFlow<List<InitRoom>>(mutableListOf())
val mRoomList: StateFlow<List<InitRoom>> = _mRoomList
...
I observe the flow via collectAsState(). The LazyColumn consists of Boxes which can be clicked.
val roomList = mViewModel.mRoomList.collectAsState()
Dialog {
...
LazyColumn(...) {
items(roomList.value, key = { room -> room.room_seq}) { room ->
Box(Modifier.clickable {
**mViewModel.selectItem(room)**
}) {...}
}
}
}
When a click event occurs, the viewModel changes the 'isSelected' value via a copied list like this.
fun selectItem(room: InitRoom) = viewModelScope.launch(Dispatchers.IO) {
try {
val cpy = mutableListOf<InitRoom>()
mRoomList.value.forEach {
cpy.add(it.copy())
}
cpy.forEach {
it.isSelected = it.room_seq == room.room_seq
}
_mRoomList.emit(cpy)
} catch (e: Exception) {
ErrorController.showError(e)
}
}
When in an xml based view and a ListAdapter, this code will work well, but in the above compose code, it doesn't seem to recompose the LazyColumn at all. What can I do to re-compose the LazyColumn?
Use a SnapshotStateList instead of an ordinary List
change this,
private val _mRoomList = MutableStateFlow<List<InitRoom>>(mutableListOf())
val mRoomList: StateFlow<List<InitRoom>> = _mRoomList
to this
private val _mRoomList = MutableStateFlow<SnapshotStateList<InitRoom>>(mutableStateListOf())
val mRoomList: StateFlow<SnapshotStateList<InitRoom>> = _mRoomList
I am trying to implement Navigation using single activity and
multiple Composable Screens.
This is my NavHost:
#Composable
#ExperimentalFoundationApi
fun MyNavHost(
modifier: Modifier = Modifier,
navController: NavHostController = rememberNavController(),
startDestination: String = HOME.route,
viewModelProvider: ViewModelProvider,
speech: SpeechHelper
) = NavHost(
modifier = modifier,
navController = navController,
startDestination = startDestination
) {
composable(route = HOME.route) {
with(viewModelProvider[HomeViewModel::class.java]) {
HomeScreen(
speech = speech,
viewModel = this,
modifier = Modifier.onKeyEvent { handleKeyEvent(it, this) }
) {
navController.navigateTo(it)
}
}
}
composable(route = Destination.VOLUME_SETTINGS.route) {
VolumeSettingsScreen(
viewModelProvider[VolumeSettingsViewModel::class.java]
) { navController.navigateUp() }
}
}
fun NavHostController.navigateTo(
navigateRoute: String,
willGoBackTo: String = HOME.route
): Unit = navigate(navigateRoute) {
popUpTo(willGoBackTo) { inclusive = true }
}
My screen looks like this:
#Composable
fun HomeScreen(
speech: SpeechHelper,
viewModel: HomeViewModel,
modifier: Modifier,
onNavigationRequested: (String) -> Unit
) {
MyBlindAssistantTheme {
val requester = remember { FocusRequester() }
val uiState by viewModel.uiState.collectAsStateWithLifecycle(
initialValue = UiState.Speak(
R.string.welcome_
.withStrResPlaceholder(R.string.text_home_screen)
.toSpeechUiModel()
)
)
uiState?.let {
when (it) {
is UiState.Speak -> speech.speak(it.speechUiModel)
is UiState.SpeakRes -> speech.speak(it.speechResUiModel.speechUiModel())
is UiState.Navigate -> onNavigationRequested(it.route)
}
}
Column(
modifier
.focusRequester(requester)
.focusable(true)
.fillMaxSize()
) {
val rowModifier = Modifier.weight(1f)
Row(rowModifier) {...}
}
LaunchedEffect(Unit) {
requester.requestFocus()
}
}
}
This is the ViewModel:
class HomeViewModel : ViewModel() {
private val mutableUiState: MutableStateFlow<UiState?> = MutableStateFlow(null)
val uiState = mutableUiState.asStateFlow()
fun onNavigateButtonClicked(){
mutableUiState.tryEmit(Destination.VOLUME_SETTINGS.route.toNavigationState())
}
}
When a button is clicked the ViewModel is called and the NavigateUiState is emitted... but it keeps being emitted after the next screen is loaded and that causes infinite screen reloading. What should be done to avoid this?
I re-implemented your posted code with 2 screens, HomeScreen and SettingScreen and stripped out some part of the UiState class and its usages.
The issue is in your HomeScreen composable, not in the StateFlow emission.
You have this mutableState
val uiState by viewModel.uiState.collectAsStateWithLifecycle(
initialValue = UiState.Speak
)
that is being observed in one of your when block that executes a navigation callback.
uiState?.let {
when (it) {
is UiState.Navigate -> {
onNavigationRequested(it.route)
}
UiState.Speak -> {
Log.d("UiState", "Speaking....")
}
}
When your ViewModel function is called
fun onNavigateButtonClicked(){
mutableUiState.tryEmit(UiState.Navigate(Destination.SETTINGS_SCREEN.route))
}
it will update uiState, setting its value to Navigate, observed by HomeScreen, satisfies the when block and then triggers the callback to navigate to the next screen.
Now based on the official Docs,
You should only call navigate() as part of a callback and not as part
of your composable itself, to avoid calling navigate() on every
recomposition.
but in your case, the navigation is triggered by an observed mutableState, and the mutableState is part of your HomeScreen composable.
It seems like when the navController performs a navigation and the NavHost being a Composable
#Composable
public fun NavHost(
navController: NavHostController,
startDestination: String,
modifier: Modifier = Modifier,
route: String? = null,
builder: NavGraphBuilder.() -> Unit
) { ... }
it will execute a re-composition, because of it, it will call again the HomeScreen (HomeScreen is not re-composed, its state remains the same) and because the HomeScreen's UiState value is still set to Navigate, it satisfies the when block, triggers again the callback to navigate, and NavHost re-composes, an infinite cycle is then created.
What I did (and its very ugly) is I created a boolean flag inside the viewModel, used it to wrap the callback conditionally,
uiState?.let {
when (it) {
is UiState.Navigate -> {
if (!viewModel.navigated) {
onNavigationRequested(it.route)
viewModel.navigated = true
} else {
// dirty empty else
}
}
UiState.Speak -> {
Log.d("UiState", "Speaking....")
}
}
}
and setting it to true afterwards, preventing the cycle.
I can hardly guess your compose implementation structure but I usually don't mix my one-time event actions and UiState, instead I have a separate UiEvent sealed class that will group "one-time" events such as the following:
Snackbar
Toast
Navigation
and having them emitted as a SharedFlow emissions because these events doesn't need any initial state or initial value.
Continuing further, I created this class
sealed class UiEvent {
data class Navigate(val route: String) : UiEvent()
}
use it in the ViewModel as a type (Navigate in this case),
private val _event : MutableSharedFlow<UiEvent> = MutableSharedFlow()
val event = _event.asSharedFlow()
fun onNavigateButtonClicked(){
viewModelScope.launch {
_event.emit(UiEvent.Navigate(Destination.SETTINGS_SCREEN.route))
}
}
and observe it in HomeScreen this way via LaunchedEffect, triggering the navigation in it without the callback being bound to any observed state.
LaunchedEffect(Unit) {
viewModel.event.collectLatest {
when (it) {
is UiEvent.Navigate -> {
onNavigationRequested(it.route)
}
}
}
}
This approach doesn't introduce the infinite navigation cycle and the dirty boolean checking is not needed anymore.
Also have a look this S.O post, similar to your case
In the following viewModel code I am generating a list of items from graphQl server
private val _balloonsStatus =
MutableStateFlow<Status<List<BalloonsQuery.Edge>?>>(Status.Loading())
val balloonsStatus get() = _balloonsStatus
private val _endCursor = MutableStateFlow<String?>(null)
val endCursor get() = _endCursor
init {
loadBalloons(null)
}
fun loadBalloons(cursor: String?) {
viewModelScope.launch {
val data = repo.getBalloonsFromServer(cursor)
if (data.errors == null) {
_balloonsStatus.value = Status.Success(data.data?.balloons?.edges)
_endCursor.value = data.data?.balloons?.pageInfo?.endCursor
} else {
_balloonsStatus.value = Status.Error(data.errors!![0].message)
_endCursor.value = null
}
}
}
and in the composable function I am getting this data by following this code:
#Composable
fun BalloonsScreen(
navHostController: NavHostController? = null,
viewModel: SharedBalloonViewModel
) {
val endCursor by viewModel.endCursor.collectAsState()
val balloons by viewModel.balloonsStatus.collectAsState()
AssignmentTheme {
Column(modifier = Modifier.fillMaxSize()) {
when (balloons) {
is Status.Error -> {
Log.i("Reyjohn", balloons.message!!)
}
is Status.Loading -> {
Log.i("Reyjohn", "loading..")
}
is Status.Success -> {
BalloonList(edgeList = balloons.data!!, navHostController = navHostController)
}
}
Spacer(modifier = Modifier.weight(1f))
Button(onClick = { viewModel.loadBalloons(endCursor) }) {
Text(text = "Load More")
}
}
}
}
#Composable
fun BalloonList(
edgeList: List<BalloonsQuery.Edge>,
navHostController: NavHostController? = null,
) {
LazyColumn {
items(items = edgeList) { edge ->
UserRow(edge.node, navHostController)
}
}
}
but the problem is every time I click on Load More button it regenerates the view and displays a new set of list, but I want to append the list at the end of the previous list. As far I understand that the list is regenerated as the flow I am listening to is doing the work behind this, but I am stuck here to get a workaround about how to achieve my target here, a kind hearted help would be much appreciated!
You can create a private list in ViewModel that adds List<BalloonsQuery.Edge>?>
and instead of
_balloonsStatus.value = Status.Success(data.data?.balloons?.edges)
you can do something like
_balloonsStatus.value = Status.Success(myLiast.addAll(
data.data?.balloons?.edges))
should update Compose with the latest data appended to existing one
How do i ignore the viewModel's last state on the first composition of a composable?
The usecase is the following:
User enters MyDumbComposable
Clicks to add a song to a playlist
When the action is successful, popBack
This is my viewModel
#HiltViewModel
class AddToPlaylistViewModel #Inject constructor(
private val addToPlaylistUseCase: AddToPlaylistUseCase,
) : ViewModel() {
private val _state = mutableStateOf<AddToPlaylistState>(AddToPlaylistState.Initial)
val state: State<AddToPlaylistState> = _state
operator fun invoke(
params: AddToPlaylistParams
) {
addToPlaylistUseCase(params)
.flowOn(Dispatchers.IO)
.onEach { _state.value = it }
.launchIn(viewModelScope)
}
}
This is MyDumbComposable
fun MyDumbComposable(
addToPlaylistViewModel: AddToPlaylistViewModel = hiltViewModel(),
song: Song,
popBack: () -> Unit
) {
if (addToPlaylistViewModel.state.value is AddToPlaylistState.Loaded) {
LaunchedEffect(Unit) {
popBack()
}
}
fun onClick(playlist: PlaylistWithSongs) {
addToPlaylistViewModel(
AddToPlaylistParams(
selected = Selected(listOf(song)),
playlist = playlist.playlist
)
)
}
///...
It works the first time and pops correctly.
However, whenever the user returns to that
composable, the AddToPlaylistViewModel is cached
so the last value is still AddToPlaylistState.Loaded,
meaning it'll pop the screen right away.
I ended up resetting my state using a DisposableEffect:
DisposableEffect(Unit) {
onDispose {
addToPlaylistViewModel.resetState()
}
}