Thracian
Thracian

Reputation: 67248

Jetpack Compose recompose with success state twice when exiting current Composable with Navigation

I have this ViewModel which returns Users from api and bundles Loading, Error and Success states with the help of UiState<T> data class as

class UsersViewModel : ViewModel() {
    private val _state = MutableStateFlow<UiState<List<User>>>(UiState(status = Status.LOADING))
    val state: StateFlow<UiState<List<User>>>
        get() = _state

    private val usersUseCase by lazy {
        UsersUseCase(UsersRepository(getUserApi()))
    }

    fun fetchUsers(page: Int = 1) {
        println(" UsersViewModel fetchUsers() page: $page")
        viewModelScope.launch {
            try {
                _state.value = UiState(status = Status.LOADING)
                val users = usersUseCase.getUserList(page)
                _state.value = UiState(status = Status.SUCCESS, data = users)
                println(" UsersViewModel fetchUsers() SUCCESS")
            } catch (e: Exception) {
                _state.value = UiState(status = Status.ERROR, error = e)
            }
        }
    }
}

UiState as

data class UiState<T>(
    val status: Status,
    val data: T? = null,
    val error: Throwable? = null
)

enum class Status {
    LOADING,
    SUCCESS,
    ERROR
}

Then it fetches items on NavGraph and collets state with

@Composable
fun MainScreen() {
    println("MainScreen()")
    val scaffoldState = rememberScaffoldState()
    val navController = rememberNavController()

    Scaffold(
        scaffoldState = scaffoldState,
        topBar = {
            TopAppBar(
                title = { Text("EffectHandlers") },
                navigationIcon = {
                    IconButton(onClick = {}) {
                        Icon(Icons.Default.Menu, "Menu")
                    }
                },
                actions = {
                    IconButton(onClick = { /* doSomething() */ }) {
                        Icon(Icons.Filled.Chat, contentDescription = null)
                    }

                    IconButton(onClick = { /* doSomething() */ }) {
                        Icon(Icons.Filled.ChatBubble, contentDescription = null)
                    }
                }
            )
        }
    ) { paddingValues ->
        NavGraph(navController, scaffoldState, paddingValues)
    }
}

@Composable
private fun NavGraph(
    navController: NavHostController,
    scaffoldState: ScaffoldState,
    paddingValues: PaddingValues
) {

    val userViewModel by remember { mutableStateOf(UsersViewModel()) }
    val uiState: UiState<List<User>> by userViewModel.state.collectAsState()

    userViewModel.fetchUsers(1)

    NavHost(
        navController = navController,
        startDestination = "start_destination",
        modifier = Modifier.padding(paddingValues)
    ) {
        composable(route = "start_destination") {
            ListScreen(scaffoldState, uiState) { user ->
                navController.navigate("detail/${user.id}")
            }
        }

        composable(route = "detail/{userId}", arguments = listOf(
            navArgument("userId") {
                type = NavType.StringType
            }
        )) { backStackEntry ->

            val arguments = requireNotNull(backStackEntry.arguments)
            val userId = arguments.getString("userId")
            val user = uiState.data?.find {
                it.id.toString() == userId
            }

            user?.let {
                DetailScreen(user = user)
            }
        }
    }
}

And CircularProgressIndicator is displayed if it's in loading state, items displayed in success state and error state is for displaying snackbar.

@Composable
private fun ListScreen(
    scaffoldState: ScaffoldState,
    uiState: UiState<List<User>>,
    onUserClicked: (User) -> Unit
) {

    println("ListScreen() uiState: ${uiState.status}")

    when (uiState.status) {

        Status.SUCCESS -> {
            val users = requireNotNull(uiState.data)
            UserList(users, onUserClicked)
        }

        Status.LOADING -> {
            Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
                CircularProgressIndicator()
            }
        }

        Status.ERROR -> {
            // `LaunchedEffect` will cancel and re-launch if
            // `scaffoldState.snackbarHostState` changes
            LaunchedEffect(scaffoldState.snackbarHostState) {
                // Show snackbar using a coroutine, when the coroutine is cancelled the
                // snackbar will automatically dismiss. This coroutine will cancel whenever
                // `state.hasError` is false, and only start when `state.hasError` is true
                // (due to the above if-check), or if `scaffoldState.snackbarHostState` changes.
                scaffoldState.snackbarHostState.showSnackbar(
                    message = "Error message",
                    actionLabel = "Retry message"
                )
            }
        }
    }
}


@OptIn(ExperimentalMaterialApi::class)
@Composable
private fun UserList(
    users: List<User>,
    onUserClicked: (User) -> Unit
) {

    LazyColumn() {
        items(users) { user ->

            println("UserList() LazyColumn user: ${user.id}")

            ListItem(
                modifier = Modifier
                    .fillMaxWidth()
                    .clickable {
                        onUserClicked(user)
                    },
                icon = {
                    Image(
                        modifier = Modifier
                            .size(50.dp)
                            .clip(CircleShape),
                        painter = rememberImagePainter(data = user.avatar),
                        contentDescription = null
                    )
                },
                overlineText = {
                    Text("${user.first_name} ${user.last_name}")
                },
                text = {
                    Text(user.email)
                }
            )
        }
    }
}

As expected it works fine as can be seen from println outputs

I: MainScreen()
I: UsersViewModel fetchUsers() page: 1
I: ListScreen() uiState: LOADING
I: ListScreen() uiState: LOADING
I: UsersViewModel fetchUsers() SUCCESS
I: ListScreen() uiState: SUCCESS
I: UserList() LazyColumn user: 1
I: UserList() LazyColumn user: 2
I: UserList() LazyColumn user: 3
I: UserList() LazyColumn user: 4
I: UserList() LazyColumn user: 5
I: UserList() LazyColumn user: 6

But when i click an item to go to detail screen i see List screen being recomposed with SUCCESS state twice, what could be the reason for this?

I: ListScreen() uiState: SUCCESS
I: DetailScreen user: User(id=4, [email protected], first_name=Eve, last_name=Holt, avatar=https://reqres.in/img/faces/4-image.jpg)
I: UserList() LazyColumn user: 1
I: UserList() LazyColumn user: 2
I: UserList() LazyColumn user: 3
I: UserList() LazyColumn user: 4
I: UserList() LazyColumn user: 5
I: UserList() LazyColumn user: 6
I: ListScreen() uiState: SUCCESS
I: UserList() LazyColumn user: 1
I: UserList() LazyColumn user: 2
I: UserList() LazyColumn user: 3
I: UserList() LazyColumn user: 4
I: UserList() LazyColumn user: 5
I: UserList() LazyColumn user: 6

Upvotes: 4

Views: 5115

Answers (2)

Thracian
Thracian

Reputation: 67248

This happens because NavHost is compose and recomposed afterwards and as in this question it can be multiple times either. Since i call

userViewModel.fetchUsers(1)

it gets called twice.

Right thing to do one time actions is to wrap code with LaunchedEffect

LaunchedEffect(key=some key unique to condition) {

     userViewModel.fetchUsers(1)

}

Upvotes: 5

S Haque
S Haque

Reputation: 7281

In NavHost save navhostController as

import androidx.compose.runtime.rememberUpdatedState
.
.

NavHost(
    startDestination = "start_destination",
    modifier = Modifier.padding(paddingValues)
) {
  val navigationController by rememberUpdatedState(newValue = rememberNavController())
  .
  .

and use it like

composable(route = "start_destination") {
     ListScreen(scaffoldState, uiState) { user ->
         navigationController.navigate("detail/${user.id}")
     }
 }

Upvotes: 0

Related Questions