How to handle ViewModel clear focus event in JetPackCompose?
I have a coroutines channel that sometimes notify my screen to clear the TextField focus
How is the best way to notify my composable to clear focus?
I tried to create a mutableStateFlow, but is there a better way to do it?
#Composable
fun HomeScreen(
viewModel: MainViewModel = hiltViewModel()
) {
val clearFocus by viewModel.clearFocus.collectAsStateWithLifecycle()
AppTheme {
HomeScreenContent(
clearFocus
)
}
}
#HiltViewModel
class MainViewModel #Inject constructor() : ViewModel() {
val clearFocus = MutableStateFlow(false)
init {
viewModelScope.launch {
delay(3000)
clearFocus.value = true
}
}
}
#OptIn(ExperimentalMaterial3Api::class)
#Composable
fun HomeScreenContent(
clearFocus: Boolean
) {
val focusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
var value by rememberSaveable { mutableStateOf("initial value") }
TextField(
value = value,
onValueChange = {
value = it
}
)
if(clearFocus) {
focusManager.clearFocus()
}
}
When a coroutine channel notifies the ViewModel, I want to clear the TextField focus, how is the best way to achieve that?
Instead of delegating to the HomeScreenContent the duty of clearing the focus you could do it in HomeScreen.
You should not use a stateFlow if you want to do an action that does not affect the compose tree. Instead of using StateFlow use a SharedFlow when you want to trigger an Event.
Using a SharedFlow
#HiltViewModel
class MainViewModel #Inject constructor() : ViewModel() {
val clearFocusEvent = MutableSharedFlow<Unit>()
init {
viewModelScope.launch {
delay(3000)
clearFocusEvent.emit(Unit)
}
}
}
#Composable
fun HomeScreen(
viewModel: MainViewModel = hiltViewModel()
) {
val focusManager = LocalFocusManager.current
LaunchedEffect(Unit) {
viewModel.clearFocusEvent.collectLatest {
focusManager.clearFocus()
}
}
AppTheme {
HomeScreenContent()
}
}
Using a sealed interface as event
If you want to have more events between your VM and Composable or just a cleaner code, you can make a sealed interface that will represent the events
#HiltViewModel
class MainViewModel #Inject constructor() : ViewModel() {
val homeScreenEvent = MutableSharedFlow<HomeScreenEvent>()
init {
viewModelScope.launch {
delay(3000)
homeScreenEvent.emit(HomeScreenEvent.ClearFocus)
}
}
}
sealed interface HomeScreenEvent {
object ClearFocus: HomeScreenEvent
}
#Composable
fun HomeScreen(
viewModel: MainViewModel = hiltViewModel()
) {
val focusManager = LocalFocusManager.current
LaunchedEffect(Unit) {
viewModel.homeScreenEvent.collectLatest {
when(it) {
HomeScreenEvent.ClearFocus -> focusManager.clearFocus()
}
}
}
AppTheme {
HomeScreenContent()
}
}
Now when you'll add an event you just have to handle the new case in the when
Related
ViewModel:
class RulesViewModel : ViewModel() {
private val _sharedFlow = MutableSharedFlow<ScreenEvents>()
val sharedFlow = _sharedFlow.asSharedFlow()
sealed class ScreenEvents {
data class ShowSnackbar(val message: String) : ScreenEvents()
data class Navigate(val route: String) : ScreenEvents()
}
}
Composable:
#Composable
fun EventListener(
rulesVm: RulesViewModel,
) {
LaunchedEffect(key1 = true) {
rulesVm.sharedFlow.collect { event ->
when(event) {
is RulesViewModel.ScreenEvents.ShowSnackbar -> {
SnackbarScreen("snackbar ${event.message}")
}
is RulesViewModel.ScreenEvents.Navigate -> {
// todo
}
}
}
}
}
This gives an error message: #Composable invocations can only happen from the context of a #Composable function
What is the best practice for collecting flows then from viewModels and actioning them in composables?
It's better to use extension - Flow.collectAsState()
In Your case it will be:
val screenState = rulesVm.sharedFlow.collectAsState
Then in the body of a composable function You go:
#Composable
fun EventListener(
rulesVm: RulesViewModel,
) {
val screenState = rulesVm.sharedFlow.collectAsState()
when(screenState) {
is RulesViewModel.ScreenEvents.ShowSnackbar -> {
SnackbarScreen("snackbar ${event.message}")
}
is RulesViewModel.ScreenEvents.Navigate -> {
// todo
}
}
}
You can't invocate composable function inside LaunchedEffect body and inside Flow.collect() because it's an extension on coroutine scope, not a composable function.
I have been using StateFlow + sealed interfaces to represent the various UI states in my Android app. In my ViewModel I have a sealed interface UiState that does this, and the various states are exposed as a StateFlow:
sealed interface UiState {
class LocationFound(val location: CurrentLocation) : UiState
object Loading : UiState
// ...
class Error(val message: String?) : UiState
}
#HiltViewModel
class MyViewModel #Inject constructor(private val getLocationUseCase: GetLocationUseCase): ViewModel() {
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState
// ...
}
Then in a Composable, I observe the events in this manner:
#Composable
fun MyScreen(
viewModel: HomeScreenViewModel,
onLocationFound: (CurrentLocation) -> Unit,
onSnackbarButtonClick: () -> Unit
) {
// ...
LaunchedEffect(true) { viewModel.getLocation() }
when (val state = viewModel.uiState.collectAsState().value) {
is UiState.LocationFound -> {
Log.d(TAG, "MyScreen: LocationFound")
onLocationFound.invoke(state.location)
}
UiState.Loading -> LoadingScreen
// ...
}
}
In my MainActivity.kt, when onLocationFound callback is invoked, I am supposed to navigate to another destination (Screen2) in the NavGraph:
enum class Screens {
Screen1,
Screen2,
// ...
}
#AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val navController = rememberNavController()
MyTheme {
MyNavHost(navController = navController)
}
}
}
}
#Composable
fun MyNavHost(navController: NavHostController) {
val context = LocalContext.current
NavHost(navController = navController, startDestination = Screens.Home.name) {
composable(Screens.Screen1.name) {
val viewModel = hiltViewModel<MyViewModel>()
MyScreen(viewModel = viewModel, onLocationFound = {
navController.navigate(
"${Screens.Screen2.name}/${it.locationName}/${it.latitude}/${it.longitude}"
)
}, onSnackbarButtonClick = { // ... }
)
}
// ....
composable("${Screens.Screen2.name}/{location}/{latitude}/{longitude}", arguments = listOf(
navArgument("location") { type = NavType.StringType },
navArgument("latitude") { type = NavType.StringType },
navArgument("longitude") { type = NavType.StringType }
)) {
// ...
}
}
}
But what happens is that the onLocationFound callback seems to be hit multiple times as I can see the logging that I've placed show up multiple times in Logcat, thus I navigate to the same location multiple times resulting in an annoying flickering screen. I checked that in MyViewmodel, I am definitely not setting _uiState.value = LocationFound multiple times. Curiously enough, when I wrap the invocation of the callback with LaunchedEffect(true), LocationFound gets called only two times, which is still weird but at least there's no flicker.
But still, LocationFound should only get called once. I have a feeling that recomposition or some caveat with Compose navigation is in play here but I've researched and can't find the right terminology to look for.
I have read the Android official artical.
I see that MutableStateFlow is hot Flow and is observed by Compose to trigger recomposition when they change.
The Code A is from the the Android official artical, it's OK.
I'm very stranger why the author need to invoke collect to get latest value for Compose UI in Code A.
I think the Compose UI can always get the latest value of latestNewsViewModel.uiState, why can't I use Code B do the the same work?
Code A
class LatestNewsActivity : AppCompatActivity() {
private val latestNewsViewModel = // getViewModel()
override fun onCreate(savedInstanceState: Bundle?) {
...
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
latestNewsViewModel.uiState.collect { uiState ->
when (uiState) {
is LatestNewsUiState.Success -> showFavoriteNews(uiState.news)
is LatestNewsUiState.Error -> showError(uiState.exception)
}
}
}
}
}
}
class LatestNewsViewModel(
private val newsRepository: NewsRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(LatestNewsUiState.Success(emptyList()))
val uiState: StateFlow<LatestNewsUiState> = _uiState
init {
viewModelScope.launch {
newsRepository.favoriteLatestNews
.collect { favoriteNews ->
_uiState.value = LatestNewsUiState.Success(favoriteNews)
}
}
}
}
Code B
class LatestNewsActivity : ComponentActivity() {
private val latestNewsViewModel = // getViewModel()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
SoundMeterTheme {
Surface(color = MaterialTheme.colors.background) {
Greeting(latestNewsViewModel)
}
}
}
}
}
#Composable
fun Greeting(latestNewsViewModel: LatestNewsViewModel) {
val myUIState by remember{ latestNewsViewModel.uiState }
when (myUIState) {
is LatestNewsUiState.Success -> showFavoriteNews(uiState.news)
is LatestNewsUiState.Error -> showError(uiState.exception)
}
}
//The same
Add Content
To RaBaKa 78: Thanks!
By your opinion, can I use Code C instead of Code A?
Code C
class LatestNewsActivity : ComponentActivity() {
private val latestNewsViewModel = // getViewModel()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
SoundMeterTheme {
Surface(color = MaterialTheme.colors.background) {
Greeting(latestNewsViewModel)
}
}
}
}
}
#Composable
fun Greeting(latestNewsViewModel: LatestNewsViewModel) {
val myUIState by remember{ latestNewsViewModel.uiState.collectAsState() }
when (myUIState) {
is LatestNewsUiState.Success -> showFavoriteNews(uiState.news)
is LatestNewsUiState.Error -> showError(uiState.exception)
}
}
//The same
Compose need State not StateFlow to recompose accordingly,
you can easily convert StateFlow to State in compose
val myUiState = latestNewsViewModel.uiState.collectAsState()
There is no need of using a remember {} because your StateFlow is from your viewModel, so it can manage the recomposition without remember
So like CODE B you can manually check the state of the StateFLow or convert to State and automatically recompose when the state changes.
The Code A is XML way of doing things where you can call other functions but in Compose you should do that steps in your viewModel
CODE D
class LatestNewsViewModel(
private val newsRepository: NewsRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(LatestNewsUiState.Success(emptyList()))
val uiState: StateFlow<LatestNewsUiState> = _uiState
init {
viewModelScope.launch {
newsRepository.favoriteLatestNews
.collect { favoriteNews ->
_uiState.value = LatestNewsUiState.Success(favoriteNews)
}
}
}
}
#Composable
fun Greeting(latestNewsViewModel: LatestNewsViewModel) {
val myUIState = latestNewsViewModel.uiState.collectAsState()
Column(modifier = Modifier.fillMaxSIze()) {
when(myUIState) {
is LatestNewsUiState.Success -> SuccessComposable(uiState.news)
is LatestNewsUiState.Error -> showError(uiState.exception) -> ErrorComposable(uiState.exception)
}
}
}
In my app I want to send info to server and after receiving successful response I want to pass info to current screen to navigate to another screen.
Here's the flow:
From UI I call viewModel to send request to server. In ViewModel I have a callback:
#HiltViewModel
class CreateAccountViewModel #Inject constructor(
private val cs: CS
) : ViewModel() {
private val _screen = mutableStateOf("")
val screen: State<String> = _screen
fun setScreen(screen: Screen) {
_screen.value = screen.route
}
private val signUpCallback = object : SignUpHandler {
override fun onSuccess(user: User?, signUpResult: SignUpResult?) {
setScreen(Screen.VerifyAccountScreen)
Log.i(Constants.TAG, "sign up success")
}
override fun onFailure(exception: Exception?) {
Log.i(Constants.TAG, "sign up failure ")
}
}
}
As you can see I have also State responsible for Screen so when response is successful I want to update the state so UI layer (Screen) knows that it should navigate to another screen. My question is: how can I observer State in
#Composable
fun CreateAccountScreen(
navController: NavController,
viewModel: CreateAccountViewModel = hiltViewModel()
) {
}
Or is there a better way to achieve that?
I think your view model should know nothing about navigation routes. Simple verificationNeeded flag will be enough in this case:
var verificationNeeded by mutableStateOf(false)
private set
private val signUpCallback = object : SignUpHandler {
override fun onSuccess(user: User?, signUpResult: SignUpResult?) {
verificationNeeded = true
Log.i(Constants.TAG, "sign up success")
}
override fun onFailure(exception: Exception?) {
Log.i(Constants.TAG, "sign up failure ")
}
}
The best practice is not sharing navController outside of the view managing the NavHost, and only pass even handlers. It may be useful when you need to test or preview your screen.
Here's how you can navigate when this flag is changed:
#Composable
fun CreateAccountScreen(
onRequestVerification: () -> Unit,
viewModel: CreateAccountViewModel = hiltViewModel(),
) {
if (viewModel.verificationNeeded) {
LaunchedEffect(Unit) {
onRequestVerification()
}
}
}
in your navigation managing view:
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = Screen.CreateAccount
) {
composable(Screen.CreateAccount) {
CreateAccountScreen(
onRequestVerification = {
navController.navigate(Screen.VerifyAccountScreen)
}
)
}
}
The following code is from the project.
It seems that the project use Hilt to generate object automatically.
The class DetailsViewModel is the child class of ViewModel(), I think the paramater viewModel: DetailsViewModel in fun DetailsScreen() can be instanced automatically, but in fact it's assigned with viewModel: DetailsViewModel = viewModel(), why?
#Composable
fun DetailsScreen(
onErrorLoading: () -> Unit,
modifier: Modifier = Modifier,
viewModel: DetailsViewModel = viewModel()
) {
val uiState by produceState(initialValue = DetailsUiState(isLoading = true)) {
val cityDetailsResult = viewModel.cityDetails
value = if (cityDetailsResult is Result.Success<ExploreModel>) {
DetailsUiState(cityDetailsResult.data)
} else {
DetailsUiState(throwError = true)
}
}
when {
uiState.cityDetails != null -> {
DetailsContent(uiState.cityDetails!!, modifier.fillMaxSize())
}
uiState.isLoading -> {
Box(modifier.fillMaxSize()) {
CircularProgressIndicator(
color = MaterialTheme.colors.onSurface,
modifier = Modifier.align(Alignment.Center)
)
}
}
else -> { onErrorLoading() }
}
}
#HiltViewModel
class DetailsViewModel #Inject constructor(
private val destinationsRepository: DestinationsRepository,
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val cityName = savedStateHandle.get<String>(KEY_ARG_DETAILS_CITY_NAME)!!
val cityDetails: Result<ExploreModel>
get() {
val destination = destinationsRepository.getDestination(cityName)
return if (destination != null) {
Result.Success(destination)
} else {
Result.Error(IllegalArgumentException("City doesn't exist"))
}
}
}
When using Hilt, you should use hiltViewModel() instead of viewModel(): it creates an object with all injections or returns an object already created in the current scope.
Compose is not part of Hilt, so I don't know how you expect this object to be created without any call? hiltViewModel() is already very short and does all the work for you.
Passing the view model as a default argument is made for the convenience of testing and using #Preview: in the main application you do not pass this argument and let the default viewModel()/hiltViewModel() be called, but in a test call you can pass a simulated view model.