androidandroid-pagingandroid-paging-library

Paging 3, recycler view flickers when i call adapter.retry on footer load state


pagination seems to work well, but when disconnect the internet and call adapter.retry everything works as expected, except that the recycler view flicks,

here's a video https://youtube.com/shorts/9Fw9VyEPGLE?feature=share

I followed android paging codelab to the detail, I just adapted somethings to match my api, nytimes api for movies.

when just scrolling the pagination works as expected, but if i for exemple the user loses connection while scrolling, when/if he reaches the end of the list and tries to reload then the recycler view flicks, but the new load of movies is added perfectly where it should be, so I don't know why the recycler view flickers.

My code;

RemoteMediator

override suspend fun load(
    loadType: LoadType,
    state: PagingState<Int, Movie>
): MediatorResult {

    val page = when(loadType) {
        LoadType.APPEND -> {
            val remoteKeys = getRemoteKeyForLastItem(state)
            val nextKey = remoteKeys?.nextKey
            if (nextKey == null) {
                return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
            }
            nextKey
        }
        LoadType.PREPEND -> {
            val remoteKeys = getRemoteKeyForFirstItem(state)
            val prevKey = remoteKeys?.prevKey
            if (prevKey == null) {
                return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
            }
            prevKey
        }
        LoadType.REFRESH -> {
            val remoteKeys = getRemoteKeyClosestToCurrentPosition(state)
            remoteKeys?.nextKey?.minus(1) ?: STARTING_PAGE_INDEX
        }
    }

    try {
        val response = api.fetchMovieCatalog(offset).toMovieCatalog()
        offset+=20
        val movies = response.movieCatalog
        val endOfPaginationReached = movies.isEmpty()
        db.withTransaction {
            if (loadType == LoadType.REFRESH) {
                db.remoteKeysDao.clearRemoteKeys()
                db.moviesDao.clearMovies()
            }
            val prevKey = if (page == STARTING_PAGE_INDEX) null else page - 1
            val nextKey = if (endOfPaginationReached) null else page + 1
            val keys = movies.map {
                RemoteKeys(movie = it.title, prevKey = prevKey, nextKey = nextKey)
            }
            db.remoteKeysDao.insertAll(keys)
            db.moviesDao.insertMovies(movies)
        }
        return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
    }  catch (e: IOException) {
        return MediatorResult.Error(e)
    } catch (e: HttpException) {
        return MediatorResult.Error(e)
    }
}

private suspend fun getRemoteKeyForLastItem(state: PagingState<Int, Movie>): RemoteKeys? {
    // Get the last page that was retrieved, that contained items.
    // From that last page, get the last item
    return state.pages.lastOrNull() { it.data.isNotEmpty() }?.data?.lastOrNull()
        ?.let { movie ->
            // Get the remote keys of the last item retrieved
            Log.e("TAG", "Key - ${db.remoteKeysDao.remoteKeysMovieId(movie.title)}, last item")
            db.remoteKeysDao.remoteKeysMovieId(movie.title)
        }
}

private suspend fun getRemoteKeyForFirstItem(state: PagingState<Int, Movie>): RemoteKeys? {
    // GEt the first page that was retrieved, that contained items.
    // From that first page, get the first item
    return state.pages.firstOrNull() { it.data.isNotEmpty() }?.data?.firstOrNull()
        ?.let { movie ->
            // GEt the remote keys of the first items retrieved
            Log.e("TAG", "Key - ${db.remoteKeysDao.remoteKeysMovieId(movie.title)}, first item")
            db.remoteKeysDao.remoteKeysMovieId(movie.title)
        }
}

private suspend fun getRemoteKeyClosestToCurrentPosition(state: PagingState<Int, Movie>): RemoteKeys? {
    // The paging library is trying to load data after the anchor position
    // Get the item closest to the anchor position
    return state.anchorPosition?.let { position ->
        state.closestItemToPosition(position)?.title?.let { movie ->
            Log.e("TAG", "Key - ${db.remoteKeysDao.remoteKeysMovieId(movie)}, refresh")
            db.remoteKeysDao.remoteKeysMovieId(movie)
        }
    }
}

}

Fragment

    override fun onCreateView(
    inflater: LayoutInflater,
    container: ViewGroup?,
    savedInstanceState: Bundle?
): View {
    _binding = FragmentMovieCatalogBinding.inflate(inflater, container, false)

    binding.recyclerView.addItemDecoration(
        DividerItemDecoration(
            context,
            DividerItemDecoration.VERTICAL
        )
    )
    binding.recyclerView.itemAnimator = DefaultItemAnimator()

    binding.bindState(
        uiAction = viewModel.accept,
        uiState = viewModel.state,
        pagingData = viewModel.pagingDataFLow
    )

    return binding.root
}

private fun FragmentMovieCatalogBinding.bindState(
    uiAction: (UiAction) -> Unit,
    pagingData: Flow<PagingData<UiModel>>,
    uiState: StateFlow<UiState>
) {
    val adapter =
        MovieCatalogAdapter(requireActivity())

    val header = MoviesLoadStateAdapter {
        adapter.retry()
    }
    val footer = MoviesLoadStateAdapter {
        adapter.retry()
    }

    recyclerView.adapter = adapter.withLoadStateHeaderAndFooter(
        header = header,
        footer = footer
    )

    bindList(
        adapter, pagingData, header
    )

}

private fun FragmentMovieCatalogBinding.bindList(
    adapter: MovieCatalogAdapter,
    pagingData: Flow<PagingData<UiModel>>,
    header: MoviesLoadStateAdapter
) {
    swipeRefresh.setOnRefreshListener { adapter.refresh() }

    lifecycleScope.launch {
        pagingData.collectLatest(adapter::submitData)
    }

    lifecycleScope.launch {
        adapter.loadStateFlow.collect { loadState ->

            header.loadState = loadState.mediator
                ?.refresh
                ?.takeIf { it is LoadState.Error && adapter.itemCount > 0 }
                ?: loadState.prepend

            // show empty list.
            emptyList.isVisible =
                loadState.refresh is LoadState.NotLoading && adapter.itemCount == 0
            // Only show the list if refresh succeeds.
            recyclerView.isVisible =
                loadState.source.refresh is LoadState.NotLoading || loadState.mediator?.refresh is LoadState.NotLoading
            // show progress bar during initial load or refresh.
            swipeRefresh.isRefreshing = loadState.mediator?.refresh is LoadState.Loading

        }
    }
}

Solution

  • The problem was that after calling adapter.retry() it eventually also ran the code to scroll recycler view to top, but it scrolls before the diffUtil has finished diffing, so it scrolled to a random position at the top of the list. The solution and the problem has been better presented here