3

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.

Alvin Dizon
  • 1,855
  • 14
  • 34
  • I do not have time to look at your code, but I think you should study this in order to understand better what is going on: https://developer.android.com/topic/architecture/ui-layer/events – F.Mysir Jun 28 '22 at 11:08

0 Answers0