I am building a movies app which uses Paging3 to page from the network and from a local database at the same time using Remote Mediator.
which gets the data from TMDB api
and save them to room database.
But I am experiencing some blinking or flickers in the recycler view
when I scroll down some items change their position and jumps up and down.
here's a video of the issue : https://youtu.be/TzV9Mf85uzk
Working with paging source only for api or database works fine.
but when using remote mediator the blinking happens after inserting any page data from api to the database.
I don't know what is causing this hopefully I can find a solution.
Here's some of my code snippets:
RemoteMediator
class MovieRemoteMediator(
private val query: String ="",
private val repository: MovieRepository
) :
RemoteMediator<Int, Movie>() {
companion object {
private const val STARTING_PAGE_INDEX = 1
}
private val searchQuery = query.ifEmpty { "DEFAULT_QUERY" }
override suspend fun initialize(): InitializeAction {
// Require that remote REFRESH is launched on initial load and succeeds before launching
// remote PREPEND / APPEND.
return InitializeAction.LAUNCH_INITIAL_REFRESH
}
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, Movie>
): MediatorResult {
val page = when (loadType) {
LoadType.REFRESH -> STARTING_PAGE_INDEX
LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
LoadType.APPEND -> {
val remoteKey = getRemoteKeyForLastItem(state)
val nextPage = remoteKey?.nextPage
?: return MediatorResult.Success(endOfPaginationReached = remoteKey != null)
nextPage
}
}
val response = repository.getMoviesFromApi(page)
if (response is NetworkResult.Success) {
val movies = response.data.results ?: emptyList()
val nextPage: Int? =
if (response.data.page < response.data.totalPages) response.data.page + 1 else null
val remoteKeys: List<MovieRemoteKey> = movies.map { movie ->
MovieRemoteKey(searchQuery, movie.id, nextPage)
}
repository.insertAndDeleteMoviesAndRemoteKeysToDB(
searchQuery,
movies,
remoteKeys,
loadType
)
return MediatorResult.Success(
endOfPaginationReached = nextPage == null
)
} else {
val error = (response as NetworkResult.Error).errorMessage
return MediatorResult.Error(Exception(error))
}
}
private suspend fun getRemoteKeyForLastItem(state: PagingState<Int, Movie>): MovieRemoteKey? {
return state.pages.lastOrNull() { it.data.isNotEmpty() }?.data?.lastOrNull()
?.let { movie ->
repository.getMovieRemoteKey(movie.id.toInt(), searchQuery)
}
}
}
Repository
class MovieRepository @Inject constructor(
private val apiClient: ApiService,
private val movieDao: MovieDao,
private val movieRemoteKeyDao: MovieRemoteKeyDao
) {
companion object {
private const val PAGE_SIZE =20
val config = PagingConfig(pageSize = PAGE_SIZE,
enablePlaceholders = false)
}
@OptIn(ExperimentalPagingApi::class)
fun getPagingMovies() = Pager(config,
remoteMediator = MovieRemoteMediator(repository = this)
) {
getPagedMoviesFromDB(SortType.DEFAULT, "")
}.flow
suspend fun insertAndDeleteMoviesAndRemoteKeysToDB(
query: String,
movies: List<Movie>,
remoteKeys: List<MovieRemoteKey>,
loadType: LoadType
)= withContext(Dispatchers.IO) {
movieRemoteKeyDao.insertAndDeleteMoviesAndRemoteKeys(query,movies, remoteKeys, loadType)
}
suspend fun getMovieRemoteKey(itemId:Int,query:String):MovieRemoteKey? {
return movieRemoteKeyDao.getRemoteKey(itemId,query)
}
MovieDao
fun getSortedMovies(sortType: SortType, searchQuery: String) : Flow<List<Movie>> =
when(sortType){
SortType.ASC -> getSortedMoviesASC(searchQuery)
SortType.DESC -> getSortedMoviesDESC(searchQuery)
SortType.DEFAULT -> getMovies()
}
fun getPagedMovies(sortType: SortType, searchQuery: String) : PagingSource<Int,Movie> =
when(sortType){
SortType.ASC -> getPagedSortedMoviesASC(searchQuery)
SortType.DESC -> getPagedSortedMoviesDESC(searchQuery)
SortType.DEFAULT -> getDefaultPagedMovies(searchQuery.ifEmpty { "DEFAULT_QUERY" })
}
@Query("SELECT * FROM movies ORDER BY popularity DESC")
fun getMovies(): Flow<List<Movie>>
@Query("SELECT * FROM movies WHERE title LIKE '%' || :search || '%'" +
" OR originalTitle LIKE :search" +
" ORDER BY title ASC")
fun getSortedMoviesASC(search:String): Flow<List<Movie>>
@Query("SELECT * FROM movies WHERE title LIKE '%' || :search || '%'" +
" OR originalTitle LIKE :search" +
" ORDER BY title DESC")
fun getSortedMoviesDESC(search:String): Flow<List<Movie>>
@Transaction
@Query("SELECT * FROM movies" +
" INNER JOIN movie_remote_key_table on movies.id = movie_remote_key_table.movieId" +
" WHERE searchQuery = :search" +
" ORDER BY movie_remote_key_table.id")
fun getDefaultPagedMovies(search:String): PagingSource<Int,Movie>
@Query("SELECT * FROM movies WHERE title LIKE '%' || :search || '%'" +
" OR originalTitle LIKE :search" +
" ORDER BY title ASC")
fun getPagedSortedMoviesASC(search:String): PagingSource<Int,Movie>
@Query("SELECT * FROM movies WHERE title LIKE '%' || :search || '%'" +
" OR originalTitle LIKE :search" +
" ORDER BY title DESC")
fun getPagedSortedMoviesDESC(search:String): PagingSource<Int,Movie>
@Query("SELECT * FROM movies WHERE id = :id")
fun getMovieById(id: Int): Flow<Movie>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertMovie(movie: Movie)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertMovies(movies: List<Movie>)
@Query("DELETE FROM movies")
fun deleteAllMovies()
@Query("DELETE FROM movies WHERE id = :id")
fun deleteMovieById(id: Int)
}
RemoteKeyDao
@Dao
interface MovieRemoteKeyDao {
@Query("SELECT * FROM movie_remote_key_table WHERE movieId = :movieId AND searchQuery = :query LIMIT 1")
suspend fun getRemoteKey(movieId: Int, query: String): MovieRemoteKey?
@Query("DELETE FROM movie_remote_key_table WHERE searchQuery = :query")
suspend fun deleteRemoteKeys(query: String)
@Transaction
@Query("DELETE FROM movies WHERE id IN ( SELECT movieId FROM movie_remote_key_table WHERE searchQuery = :query)")
suspend fun deleteMoviesByRemoteKeys(query: String)
@Transaction
suspend fun insertAndDeleteMoviesAndRemoteKeys(
query: String,
movies: List<Movie>,
remoteKeys: List<MovieRemoteKey>,
loadType: LoadType
) {
if (loadType == LoadType.REFRESH) {
Timber.d("REMOTE SOURCE DELETING:")
deleteMoviesByRemoteKeys(query)
deleteRemoteKeys(query)
}
Timber.d("REMOTE SOURCE INSERTING ${movies.size} Movies and ${remoteKeys.size} RemoteKeys :")
insertMovies(movies)
insertRemoteKey(remoteKeys)
}
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertRemoteKey(movieRemoteKey: List<MovieRemoteKey>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertMovies(movies: List<Movie>)
}
MoviesViewModel
@HiltViewModel
class MoviesViewModel @Inject constructor(
private val repository: MovieRepository, private val preferenceManger: PreferenceManger
) : ViewModel() {
private val searchFlow = MutableStateFlow("")
private val sortFlow = preferenceManger.preferencesFlow
val movies = repository.getPagingMovies().cachedIn(viewModelScope)
//
// val movies: StateFlow<Resource<List<Movie>>> = sortFlow.combine(searchFlow) { sort, search ->
// Pair(sort, search)
// } //For having timeouts for search query so not overload the server
// .debounce(600)
// .distinctUntilChanged()
// .flatMapLatest { (sort, search) ->
// repository.getMovies(sort, search)
// }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), Resource.Loading())
//
fun setSearchQuery(query: String) {
searchFlow.value = query
}
fun saveSortType(type: SortType) {
viewModelScope.launch {
preferenceManger.saveSortType(type)
}
}
private val _currentMovie = MutableLiveData<Movie?>()
val currentMovie: LiveData<Movie?>
get() = _currentMovie
fun setMovie(movie: Movie?) {
_currentMovie.value = movie
}
}
MovieFragment
@AndroidEntryPoint
class MoviesFragment : Fragment(), MovieClickListener {
private lateinit var moviesBinding: FragmentMoviesBinding
private lateinit var pagingMovieAdapter: PagingMovieAdapter
private val viewModel: MoviesViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View {
moviesBinding = FragmentMoviesBinding.inflate(inflater, container, false)
return moviesBinding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupUI()
getMovies()
}
private fun setupUI() {
pagingMovieAdapter = PagingMovieAdapter(this)
moviesBinding.moviesRv.layoutManager = GridLayoutManager(context, 3)
moviesBinding.moviesRv.adapter = pagingMovieAdapter
moviesBinding.moviesRv.setHasFixedSize(true)
setHasOptionsMenu(true)
}
private fun getMovies() {
// repeatOnLifeCycle(pagingMovieAdapter.loadStateFlow) { loadStates ->
// val state = loadStates.refresh
// moviesBinding.loadingView.isVisible = state is LoadState.Loading
//
// if (state is LoadState.Error) {
// val errorMsg = state.error.message
// Toast.makeText(context, errorMsg, Toast.LENGTH_LONG).show()
// }
//
// }
lifecycleScope.launchWhenCreated{
viewModel.movies.collectLatest { pagingMovieAdapter.submitData(it) }
}
// repeatOnLifeCycle(viewModel.movies,pagingMovieAdapter::submitData)
// //scroll to top after updating the adapter
// repeatOnLifeCycle(pagingMovieAdapter.loadStateFlow
// .distinctUntilChangedBy { it.refresh }
// .filter { it.refresh is LoadState.NotLoading }
// ) {
// moviesBinding.moviesRv.scrollToPosition(0)
// }
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.main, menu)
val searchView = menu.findItem(R.id.action_search).actionView as SearchView
searchView.onQueryTextChanged() { query ->
viewModel.setSearchQuery(query)
}
super.onCreateOptionsMenu(menu, inflater)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_sort_asc -> {
viewModel.saveSortType(SortType.ASC)
true
}
R.id.action_sort_desc -> {
viewModel.saveSortType(SortType.DESC)
true
}
R.id.action_sort_default -> {
viewModel.saveSortType(SortType.DEFAULT)
true
}
else -> super.onOptionsItemSelected(item)
}
}
override fun onMovieClickListener(movie: Movie?) {
Toast.makeText(context, movie?.title, Toast.LENGTH_SHORT).show()
viewModel.setMovie(movie)
movie?.id?.let {
val action = MoviesFragmentDirections.actionMoviesFragmentToDetailsFragment2(it)
findNavController().navigate(action)
}
}
}
PagingMovieAdapter
class PagingMovieAdapter(private val movieClickListener: MovieClickListener)
: PagingDataAdapter<Movie, PagingMovieAdapter.PagingMovieViewHolder>(diffUtil) {
companion object{
val diffUtil = object : DiffUtil.ItemCallback<Movie>() {
override fun areItemsTheSame(oldItem: Movie, newItem: Movie): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: Movie, newItem: Movie): Boolean {
return oldItem == newItem
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PagingMovieViewHolder {
return PagingMovieViewHolder.from(parent,movieClickListener)
}
override fun onBindViewHolder(holder: PagingMovieViewHolder, position: Int) {
val movie = getItem(position)
holder.bind(movie)
}
class PagingMovieViewHolder(private val movieBinding: ItemMovieBinding,private val movieClickListener: MovieClickListener) :
RecyclerView.ViewHolder(movieBinding.root) , View.OnClickListener{
init {
movieBinding.root.setOnClickListener(this)
}
fun bind(movie: Movie?) {
movie.let { movieBinding.movie = movie }
}
companion object {
fun from(parent: ViewGroup, movieClickListener: MovieClickListener): PagingMovieViewHolder {
val inflater = LayoutInflater.from(parent.context)
val movieBinding = ItemMovieBinding.inflate(inflater, parent, false)
return PagingMovieViewHolder(movieBinding,movieClickListener)
}
}
override fun onClick(p0: View?) {
val movie = movieBinding.movie
movieClickListener.onMovieClickListener(movie)
}
}
}
Thanks.
For anyone that might face this problem,
The blinking was because my diffUtil
callback returning false on areContentTheSame
because I had a long array parameter on my data model class
and kotlin data class equals method compare arrays based on their reference not the value so I had to manually override equals method.
and for items moving in their position, it was because I was disabling placeholders
on the paging config
which made the paging library to return a wrong offset after updating the database
so making enablePlaceholders = false
solves the issue.
also of the order of data coming from the api not the same of the data coming from the database might cause this issue.
Thanks