How to go to a position with view pager 2 that uses paging 3 to load data?

2.2k Views Asked by At

I am using ViewPager2 to display data that I fetch from a server and save to a Room database using the Paging 3 library. My question is, how do I navigate to a specific view pager item through code? If I use viewPager.setCurrentItem(position, false) then this does not work. For example if there are 3000 items total which I dynamically load while swiping left/right, how do I set the position to 1000 and then navigate/load data in both directions from there? I cannot get this to work.

P.S: In the DogPagingMediator class below, I have also tried to set some starting number in the refresh block instead of the latest(highest) number, but when loading the app the view pager will only start at this position if higher numbered items don't exist locally in the database, otherwise it will always start at the highest numbered item regardless of the page returned in refresh(I assume since dogDao.getDogs() fetches all items in the database in descending order).

P.P.S: The reason why I am using live data and not flow is because flow for some reason causes NullPointerException when I swipe.

Code from onCreateView within the fragment containing the view pager:

    lifecycleScope.launch {
        // Fetch the latest dog item from the network (data is sorted by descending)
        if (!browseDogsViewModel.latestDogIsFetched()) {
            browseDogsViewModel.setLatestDogNumber()
        }

        browseDogsViewModel.pagingDataStream.observe(viewLifecycleOwner) {
            adapter.submitData(viewLifecycleOwner.lifecycle, it)
        }
    }

From the view model:

val pagingDataStream = repository.getAllDogsPagingData()

suspend fun setLatestDogNumber() {
    latestDogNumber = repository.getLatestDogNumber()
}

From the repository:

fun getAllDogsPagingData() = Pager(
    config = PagingConfig(pageSize = PAGE_SIZE),
    remoteMediator = dogPagingMediator,
    pagingSourceFactory = { dogDao.getDogs() }
).liveData

The Mediator (similar to googles paging3 codelab example except it sorts by descending): https://codelabs.developers.google.com/codelabs/android-paging/#0):

@OptIn(ExperimentalPagingApi::class)
class DogPagingMediator @Inject constructor(
    private val dogDatabase: DogDatabase,
    private val dogDao: DogDao,
    private val remoteKeysDao: RemoteKeysDao,
    private val service: DogService,
) : RemoteMediator<Int, Dog>() {
    override suspend fun load(loadType: LoadType, state: PagingState<Int, Dog>): MediatorResult {
        try {
            val page = when (loadType) {
                LoadType.REFRESH -> {
                    val remoteKeys = getRemoteKeyClosestToCurrentPosition(state)
                    remoteKeys?.nextKey?.plus(PAGE_SIZE) ?: BrowseDogsViewModel.latestDogNumber
                }
                LoadType.PREPEND -> {
                    val remoteKeys = getRemoteKeyForFirstItem(state)
                    if (remoteKeys == null) {
                        // The LoadType is PREPEND so some data was loaded before,
                        // so we should have been able to get remote keys
                        // If the remoteKeys are null, then we're an invalid state and we have a bug
                        throw InvalidObjectException("Remote key and the prevKey should not be null")
                    }
                    // If the previous key is null, then we can't request more data
                    remoteKeys.prevKey
                        ?: return MediatorResult.Success(endOfPaginationReached = true)
                    remoteKeys.prevKey
                }
                LoadType.APPEND -> {
                    val remoteKeys = getRemoteKeyForLastItem(state)
                    if (remoteKeys?.nextKey == null) {
                        throw InvalidObjectException("Remote key should not be null for $loadType")
                    }
                    remoteKeys.nextKey
                }
            }

            val dogs: MutableList<Dog> = mutableListOf()
            for (i in page downTo page - PAGE_SIZE) {
                try {
                    val response = service.geDogWithNumber(i)
                    dogs.add(convertFromDto(response))
                } catch (ex: HttpException) {
                    // Will be 404 when requesting a dog out of range
                    if (ex.code() != 404) {
                    throw ex
                    }
                }
            }

            val endOfPaginationReached = dogs.isEmpty()

            dogDatabase.withTransaction {
                val prevKey =
                    if (page == BrowseDogsViewModel.latestDogNumber) null else page + PAGE_SIZE
                val nextKey = if (endOfPaginationReached) null else page - PAGE_SIZE
                val keys = dogs.map {
                    RemoteKeys(dogNum = it.number, prevKey = prevKey, nextKey = nextKey)
                }

                remoteKeysDao.insertAll(keys)
                dogDao.insertAll(dogs)
            }

            return MediatorResult.Success(
                endOfPaginationReached = endOfPaginationReached
            )
        } catch (exception: IOException) {
            return MediatorResult.Error(exception)
        } catch (exception: HttpException) {
            return MediatorResult.Error(exception)
        }
    }

    private suspend fun getRemoteKeyForLastItem(state: PagingState<Int, Dog>): 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 { dog->
                // Get the remote keys of the last item retrieved
                remoteKeysDao.remoteKeysDogNum(dog.number)
            }
    }

    private suspend fun getRemoteKeyForFirstItem(state: PagingState<Int, Dog>): 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 { dog->
                // Get the remote keys of the first items retrieved
                remoteKeysDao.remoteKeysDogNum(dog.number)
            }
    }

    private suspend fun getRemoteKeyClosestToCurrentPosition(
        state: PagingState<Int, Dog>
    ): 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)?.number?.let { num ->
                remoteKeysDao.remoteKeysDogNum(num)
            }
        }
    }

    private fun convertFromDto(dogDto: DogDto): Dog {
        return Dog(...)
    }
}

adapter:

class DogPagingAdapter() :
    PagingDataAdapter<Dog, DogPagingAdapter.ViewPagerViewHolder>(DogDiffUtilCallback) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewPagerViewHolder {
        return ViewPagerViewHolder(
            ItemDogViewPagerBinding.inflate(
                LayoutInflater.from(parent.context),
                parent,
                false
            )
        )
    }

    override fun onBindViewHolder(holder: ViewPagerViewHolder, position: Int) {
        holder.bind(getItem(position))
    }

    inner class ViewPagerViewHolder(private val binding: ItemDogViewPagerBinding) :
        RecyclerView.ViewHolder(binding.root) {

        fun bind(dog: Dog?) {
            binding.dog = dog
            binding.executePendingBindings()
        }
    }
}
2

There are 2 best solutions below

2
On

The official documentation says:

Set the currently selected page. If the ViewPager has already been through its first layout with its current adapter there will be a smooth animated transition between the current item and the specified item. Silently ignored if the adapter is not set or empty. Clamps item to the bounds of the adapter.

Before you call this method you must ensure that Nth item exists in your adapter.

5
On

I managed to achieve this by creating my own paging source. As you already mentioned, the default paging source of a dao will start loading at the beginning of the table. Therefore you have to create a paging source which handles loading data from the database from a specific position.

To do that have to implement a paging source that queries the database with an offset and limit:

PagingSource

class DogsPagingSource(
    private val database: DogDatabase,
    private val dogDao: DogDao,
    private var startPos: Int
) : PagingSource<Int, Dog>() {

    // to show new loaded data, you have to invalidate the paging source after data in db changed
    init {
        val tableObserver = object : InvalidationTracker.Observer("dogs") {
            override fun onInvalidated(tables: MutableSet<String>) {
                invalidate()
            }
        }
        database.invalidationTracker.addObserver(tableObserver)
    }

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Dog> {
        return try {
            // params.key is null when paging source gets loaded the first time
            // for this case use your start position = number of rows that should be skipped in your table (offset)
            val position =
                if (params.key == null) startPos
                else maxOf(0, params.key ?: 0)

            // load one item from your db by using a limit-offset-query
            // limit is your page size which has to be 1 to get this working
            val dogs = dogDao.getDogsPage(params.loadSize, position)

            // to load further or previous data just in-/decrease position by 1
            // if you are at the start/end of the table set prev-/nextKey to null to notify RemoteMediator to load new data
            // nextKey = null will call APPEND, prevKey = null will call PREPEND in your RemoteMediator
            Page(
                data = dogs,
                prevKey = if (position == 0) null else position.minus(1),
                nextKey = if (position == dogDao.count()) null
                else position.plus(1),
            )
        } catch (e: IOException) {
            LoadResult.Error(e)
        } catch (e: HttpException) {
        LoadResult.Error(e)
        }
    }

    override fun getRefreshKey(state: PagingState<Int, Item>): Int? =
        state.anchorPosition?.let {
            state.closestPageToPosition(it)?.prevKey?.minus(1)?.minValue(0)
        }

    override val jumpingSupported: Boolean
        get() = true
}

For this you also will need a new query for your dogs table:

DAO

@Dao
interface DogDao {

    ...
    
    // just define the order which the data should be arranged by. limit and offset will do the rest
    @Query("SELECT * FROM `dogs` ORDER BY id ASC LIMIT :limit OFFSET :offset")
    suspend fun getDogsPage(limit: Int, offset: Int): List<Dog>

    @Query("SELECT COUNT(*) FROM `dogs`")
    suspend fun count(): Int
}

This only works if your page size is 1, but this should be the case if you use it for a viewpager. If not, you have to modify the paging source a little bit.