Search code examples
androidandroid-jetpack-composeandroid-paging-3

Android Paging3 - refresh from a ViewModel with Compose


I'm using the Paging 3 library with Jetpack Compose and have just implemented swipe to dismiss on some paged data (using the Material library's SwipeToDismiss composable).

Once a swipe action has completed, I call a method in my ViewModel to send an update to the server (either to mark a message as read or to delete a message). Once this action has taken place, I obviously need to refresh the paging data.

My current approach is to have a call back from my ViewModel function which will then handle the refresh on the LazyPagingItems, but this feels wrong.

Is there a better approach?

My ViewModel basically looks like:

@HiltViewModel
class MessageListViewModel @Inject constructor(
    private val repository: Repository
): ViewModel() {
    companion object {
        private const val TAG = "MessageListViewModel"
    }

    val messages : Flow<PagingData<Message>> = Pager(
        PagingConfig(
            enablePlaceholders = false,
        )
    ) {
        MessagePagingSource(repository)
    }.flow.cachedIn(viewModelScope)

    fun markRead(guid: String, onComplete: () -> Unit) {
        viewModelScope.launch(Dispatchers.IO) {
            try {
                repository.markMessageRead(guid)

                onComplete()
            } catch (e: Throwable) {
                Log.e(TAG, "Error marking message read: $guid", e)
            }
        }
    }
}

And in my Composable for the message list, it looks a bit like the following:

@Composable
fun MessageListScreen(
    vm: MessageListViewModel = viewModel(),
) {
    val messages: LazyPagingItems<MessageSummary> = vm.messages.collectAsLazyPagingItems()
    val refreshState = rememberSwipeRefreshState(
        isRefreshing = messages.loadState.refresh is LoadState.Loading,
    )

    Scaffold(
        topBar = {
            SmallTopAppBar (
                title = {
                    Text(stringResource(R.string.message_list_title))
                },
            )
        }
    ) { paddingValues ->
        Box(
            modifier = Modifier
                .fillMaxSize()
                .padding(paddingValues),
        ) {
            SwipeRefresh(
                state = refreshState,
                onRefresh = {
                    messages.refresh()
                },
            ) {
                LazyColumn(
                    modifier = Modifier.fillMaxSize(),
                    horizontalAlignment = Alignment.CenterHorizontally,
                    verticalArrangement = Arrangement.Top,
                ) {
                    items(
                        items = messages,
                        key = { it.guid }
                    ) { message ->
                        message?.let {
                            MessageRow(
                                onMarkRead = {
                                    vm.markRead(message.guid) {
                                        messages.refresh()
                                    }
                                },
                            )
                        }
                    }
                }
            }
        }
    }
}

As I say, this does work, it just doesn't quite feel like the cleanest approach.

I'm fairly new to working with flows, so I don't know if there's some other trick I'm missing...


Solution

  • I ended up implementing something like this:

    View Model:

    class MessageListViewModel @Inject constructor(
        private val repository: Repository,
    ): ViewModel() {
        sealed class UiAction {
            class MarkReadError(val error: Throwable): UiAction()
            class MarkedRead(val id: Long): UiAction()
        }
    
        private val _uiActions = MutableSharedFlow<UiAction>()
    
        val uiActions = _uiActions.asSharedFlow()
            .shareIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(),
            )
    
    
        fun markRead(id: Long) {
            viewModelScope.launch(Dispatchers.IO) {
                try {
                    repository.markMessageRead(id)
    
                    _uiActions.emit(UiAction.MarkedRead(id))
                } catch (e: Throwable) {
                    Log.e(TAG, "Error marking message read: $id", e)
    
                    _uiActions.emit(UiAction.MarkReadError(e))
                }
            }
        }
    }
    

    View:

    @Composable
    fun MessageListScreen(
        vm: MessageListViewModel = viewModel(),
        onMarkReadFailed: (String) -> Unit,
    ) {
        val context = LocalContext.current
        val lifecycleOwner = LocalLifecycleOwner.current
    
        val messages: LazyPagingItems<Message> = vm.messages.collectAsLazyPagingItems()
        val refreshState = rememberSwipeRefreshState(
            isRefreshing = messages.loadState.refresh is LoadState.Loading,
        )
    
        LaunchedEffect(lifecycleOwner) {
            lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                vm.uiActions.collectLatest {
                    when (it) {
                        is MessageListViewModel.UiAction.MarkReadError -> {
                            val msg = it.error.localizedMessage ?: it.error.message
    
                            val message = if (!msg.isNullOrEmpty()) {
                                context.getString(R.string.error_unknown_error_with_message, msg)
                            } else {
                                context.getString(R.string.error_unknown_error_without_message)
                            }
    
                            onMarkReadFailed(message)
                        }
                        is MessageListViewModel.UiAction.MarkedRead -> {
                            messages.refresh()
                        }
                    }
                }
            }
        }
    
        SwipeRefresh(
            state = refreshState,
            onRefresh = {
                messages.refresh()
            },
        ) {
            LazyColumn(
                modifier = Modifier.fillMaxSize(),
                horizontalAlignment = Alignment.CenterHorizontally,
                verticalArrangement = Arrangement.Top,
                state = listState,
            ) {
                items(
                    items = messages,
                    key = { it.id }
                ) { message ->
                    message?.let {
                        MessageRow(
                            onMarkRead = {
                                vm.markRead(message.id)
                            },
                        )
                    }
    
                    FadedDivider()
                }
    
                messages.apply {
                    when (loadState.append) {
                        is LoadState.Loading -> {
                            item {
                                LoadingRow(R.string.messages_loading)
                            }
                        }
                        else -> {}
                    }
                }
            }
        }
    }