androidandroid-pagingandroid-paging-libraryandroid-paging-3

Android Paging 3 library PagingSource invalidation, causes the list to jump due to wrong refresh key (not using room)


Since I'm currently working on a Project with custom database (not Room), I'm testing whether we could use the Paging 3 library in the Project. However, I run into the issue, that if you make changes to the data and therefore invalidate the paging source, the recreation of the list is buggy and jumps to a different location. This is happening because the Refresh Key calculation seems to be wrong, which is most likely caused by the fact that the initial load loads three pages worth of data, but puts it into one page.

The default Paging Source looks like this:

    override fun getRefreshKey(state: PagingState<Int, CustomData>): Int? {
        // Try to find the page key of the closest page to anchorPosition, from
        // either the prevKey or the nextKey, but you need to handle nullability
        // here:
        //  * prevKey == null -> anchorPage is the first page.
        //  * nextKey == null -> anchorPage is the last page.
        //  * both prevKey and nextKey null -> anchorPage is the initial page, so
        //    just return null.
        return state.anchorPosition?.let { anchorPosition ->
            val anchorPage = state.closestPageToPosition(anchorPosition)
            anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
        }
    }

override suspend fun load(params: LoadParams<Int>): LoadResult<Int, CustomData> {
    var pagePosition = params.key ?: STARTING_PAGE_INDEX
    var loadSize = params.loadSize
    return try {
        val dataResult = dataRepository.getPagedData(
            pagePosition = pagePosition,
            loadSize = loadSize,
            pageSize = pageSize
        )
        val nextKey = if (dataResult.isEmpty() || dataResult.size < pageSize) {
            null
        } else {
            pagePosition + (loadSize / pageSize)
        }
        Log.i(
            "RoomFreePagingSource",
            "page $pagePosition with size $loadSize publish ${dataResult.size} routes"
        )
        return LoadResult.Page(
            data = dataResult,
            prevKey = if (pagePosition == STARTING_PAGE_INDEX) null else pagePosition - 1,
            nextKey = nextKey
        )

    } catch (exception: Exception) {
        LoadResult.Error(exception)
    }
}

The dataRepository.getPagedData() function simply accesses a in memory list and returns a subsection of the list, simulating the paged data. For completeness here is the implementation of this function:

fun getPagedData(pageSize:Int, loadSize:Int, pagePosition: Int): List<CustomData>{
    val startIndex = pagePosition * pageSize
    val endIndexExl =startIndex + loadSize
    return data.safeSubList(startIndex,endIndexExl).map { it.copy() }
}

private fun <T> List<T>.safeSubList(fromIndex: Int, toIndex: Int) : List<T>{
    // only returns list with valid range, to not throw exception
    if(fromIndex>= this.size)
        return emptyList()
    val endIndex = if(toIndex> this.size) this.size else toIndex
    return subList(fromIndex,endIndex)
}

The main Problem I currently face is, that the getRefreshKey function doesn't return a correct refresh page key, which results in the wrong page being refreshed and the list jumping to the loaded page.

Knowing the cause of the issue I tried to adapt the default implementation to resolve it and came up with a lot of different versions. The one below currently works best, but still causes the jumping from time to time. And also sometimes a part of the list flickers, which is probably caused when not enough items are fetched to fill the view port.

override fun getRefreshKey(state: PagingState<Int, CustomData>): Int? {
    return state.anchorPosition?.let { anchorPosition ->
        val closestPage = state.closestPageToPosition(anchorPosition)?.prevKey
            ?: STARTING_PAGE_INDEX
        val refKey = if(anchorPosition>(closestPage)*pageSize + pageSize)
            closestPage+1
        else
            closestPage

        Log.i(
            "RoomFreePagingSource",
            "getRefreshKey $refKey from anchorPosition $anchorPosition closestPage $closestPage"
        )
        refKey
    }
}

override suspend fun load(params: LoadParams<Int>): LoadResult<Int, CustomData> {
    var pagePosition = params.key ?: STARTING_PAGE_INDEX
    var loadSize = pageSize
    return try {
        when (params) {
            is LoadParams.Refresh -> {
                if (pagePosition > STARTING_PAGE_INDEX) {
                    loadSize *= 3
                } else if (pagePosition == STARTING_PAGE_INDEX) {
                    loadSize *= 2
                }
            }
            else -> {}
        }
        val dataResult = dataRepository.getPagedData(
            pagePosition = pagePosition,
            loadSize = loadSize,
            pageSize = pageSize
        )
        val nextKey = if (dataResult.isEmpty() || dataResult.size < pageSize) {
            null
        } else {
            pagePosition + (loadSize / pageSize)
        }
        Log.i(
            "RouteRepository",
            "page $pagePosition with size $loadSize publish ${dataResult.size} routes"
        )
        return LoadResult.Page(
            data = dataResult,
            prevKey = if (pagePosition == STARTING_PAGE_INDEX) null else pagePosition - 1,
            nextKey = nextKey
        )

    } catch (exception: Exception) {
        LoadResult.Error(exception)
    }
}

In theory I found that the best solution would be to just calculate the refresh PageKey like this anchorPosition/pageSize then load this page, the one before and after it. Hower, calculating the refresh key like this does not work since the anchorPosition isn't the actual position of the item in the list. After some invalidations, the anchor could be 5 even if you are currently looking at item 140 in the list.

So to sum it up:

How can I calculate the correct refresh page after invalidation when I don't use Room, but another data source, like in this sample case a in memory list which I access through getPagedData?


Solution

  • Found the solution my self in the meantime, but forgot to add the answer here.

    1. The anchorPosition in getRefreshKey() was wrong, because the the itemsBefore argument was not set for each Page returned by the paging source. This behaviour does not seem to be documented.

      LoadResult.Page(
          itemsBefore = pagePosition * pageSize,
          data = dataResult,
          prevKey = if (pagePosition == STARTING_PAGE_INDEX) null else pagePosition - 1,
          nextKey = nextKey
      )
      
    2. Now one can actually determine the refresh key based on the anchorPosition and the pageSize(defined as PagingSource constructor parameter in this example, but can also be a constant etc.)

      override fun getRefreshKey(state: PagingState<Int, CustomData>): Int? {
          return state.anchorPosition?.let { anchorPosition ->
              anchorPosition / pageSize
          }
      }
      
    3. No the list does not jump any longer when refreshing/ invalidating it. However sometimes the list might flickr or a whole part of the list won't be visible when invalidating. This is due to the fact, that the refresh page is too small and does not cover the whole view port. Hence one can see the paging in action for the items outside of the refresh page. This behaviour can be fixed by making the refresh page (loadSize) 3 times larger and reducing the pagePosition by one. 3 times is necessary, as the page before and after the actual refresh page could be visible at in the view port.

      if (params is LoadParams.Refresh) {
          loadSize *= 3
          pagePosition = max(0, pagePosition - 1)
      }
      

    This solution works just fine now, however, it feels like this can't/ shouldn't be the official solution to this problem. As all these adaptations are not necessary when using paging and room. So, I'm open to other/ better solutions.

    Here the full code of working PagingSource with all mentioned changes:

    override fun getRefreshKey(state: PagingState<Int, CustomData>): Int? {
        return state.anchorPosition?.let { anchorPosition ->
            anchorPosition / pageSize
        }
    }
    
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, CustomData> {
        var pagePosition = params.key ?: STARTING_PAGE_INDEX
        var loadSize = pageSize
        return try {
            if (params is LoadParams.Refresh) {
                // make sure everything visible in the view port is updated / loaded
                loadSize *= 3
                pagePosition = max(0, pagePosition - 1)
            }
            val dataResult = dataRepository.getPagedData(
                pagePosition = pagePosition,
                loadSize = loadSize,
                pageSize = pageSize
            )
            val nextKey = if (dataResult.isEmpty() || dataResult.size < pageSize) {
                null
            } else {
                pagePosition + (loadSize / pageSize)
            }
            LoadResult.Page(
                itemsBefore = pagePosition * pageSize,
                data = dataResult,
                prevKey = if (pagePosition == STARTING_PAGE_INDEX) null else pagePosition - 1,
                nextKey = nextKey
            )
        } catch (exception: Exception) {
            LoadResult.Error(exception)
        }
    }