Reputation: 67248
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
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
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