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.