jschlepp
jschlepp

Reputation: 121

How should Lazy list leaving UI be handled so RemoteMediator continues pagination upon returning to Lazy list?

I have a lazy list of content I'm showing via RemoteMediator in Jetpack Compose. It works well if I remain on the view, and can successfully scroll and load many pages of content. Upon tapping an item I navigate away, sometimes triggering an

androidx.compose.runtime.LeftCompositionCancellationException 

within the RemoteMediator. That is currently handled by returning MediatorResult.Error(t); no crash. Navigating back, to the paging list, I continue to scroll, but it fails to paginate any further and append more data. The exception may be irrelevant, I'm not sure.

I can also reproduce this without navigation, but instead in a horizontal pager. Where if one page has the Lazy list, swiping to another causes the same issue. Swiping back to the list, pagination no longer triggers load() to append more data.

Refreshing the entire view does reset the issue and allow pagination again, but loses the user's place in the list.

I've set my pageSize and initialLoadSize to higher values, each 40. I've attempted to take extra care that my RemoteMediator and Pager are not re-created, as well as that "collectAsLazyPagingItems()" is not called unless it's the first time, or if a user invoked refresh occurs. I've found that by not limiting how collectAsLazyPagingItems is called, the list items will flicker, refresh and lose user's spot in list simply when navigating away.

I'm expecting to be able to continue scrolling from my last spot in the list and paginate more content, despite that list leaving the UI temporarily, or an LeftCompositionCancellationException occurring.

Bulk excerpts below. Complete minimal sample application to reproduce issue via HorizontalPager can be found here: https://github.com/jschleppy/PagingFailureTest/tree/main

MainActivity

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            MyApplicationTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    NavHost(
                        modifier = Modifier.padding(innerPadding),
                        navController = rememberNavController(),
                        startDestination = "root2",
                        route = "root1"
                    ) {
                        navigation(
                            startDestination = "route1",
                            route = "root2"
                        ) {
                            composable(
                                route = "route1"
                            ) {
                                TestList(
                                    modifier = Modifier.fillMaxSize(),
                                )
                            }
                        }
                    }
                }
            }
        }
    }
}


@OptIn(ExperimentalFoundationApi::class)
@Composable
fun TestList(modifier: Modifier) {
    val viewModel: MyViewModel = hiltViewModel()
    DefaultPagerStateInit(viewModel) { page ->
        viewModel.pageIndex = page
    }

    DefaultPullRefreshBox(modifier = modifier.fillMaxSize(), viewModel = viewModel) {
        LazyColumn(modifier = Modifier.fillMaxSize()) {

            item {
                DefaultHorizontalPager(
                    modifier = Modifier.height(400.dp),
                    viewModel = viewModel,
                    verticalAlignment = Alignment.Top,
                ) { index ->
                    when (index) {
                        0 -> {
                            Box(modifier = Modifier.height(400.dp)) {
                                DataRow(
                                    modifier = Modifier.fillMaxWidth(),
                                    viewModel = viewModel,
                                )
                            }
                        }

                        1 -> {
                            Box(modifier = Modifier.height(400.dp).background(Color.Black))
                        }
                    }
                }
            }
        }

    }
}

@Composable
fun DataRow(modifier: Modifier,
            viewModel: MyViewModel) {
    val lazyListState = rememberLazyListState()

    if (viewModel.doesLazyPagingNeedRecollected()) {
        viewModel.lazyPagingItems =
            viewModel.getData().collectAsLazyPagingItems()
    }
    LazyRow(
        modifier = modifier
            .border(width = 2.dp, color = Color.Black)
            .height(100.dp),
        state = lazyListState,
        contentPadding = PaddingValues(5.dp),
    ) {
        items(viewModel.lazyPagingItemCount) { i ->
            val item = viewModel.lazyPagingItems[i]
            item?.let {
                Greeting(
                    name = it.title,
                    modifier = Modifier.fillMaxSize().clickable {
                        viewModel.onClickItem(it)
                    }
                )
            }
        }
    }
}

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    Text(
        text = "Hello $name!",
        modifier = modifier
    )
}

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun DefaultPullRefreshBox(
    modifier: Modifier,
    viewModel: MyViewModel,
    content: @Composable BoxScope.() -> Unit
) {
    val pullRefreshState = pullRefreshState(viewModel) {
        viewModel.refresh()
    }

    Box(
        modifier = modifier.pullRefresh(pullRefreshState, enabled = viewModel.isRefreshEnabled),
    ) {
        content()
    }
}

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun pullRefreshState(viewModel: MyViewModel,
                     onRefresh: () -> Unit): PullRefreshState {
    return rememberPullRefreshState(
        refreshing = viewModel.isRefreshing, onRefresh = onRefresh
    )
}

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun DefaultPagerStateInit(
    viewModel: MyViewModel,
    onPageChanged: (page: Int) -> Unit,
) {
    viewModel.pagerState = rememberPagerState(
        initialPage = viewModel.pageIndex
    ) {
        viewModel.pageCount
    }
    ScrollToPageLaunchedEffect(
        viewModel = viewModel,
        pageOffsetFraction = 0f,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioNoBouncy,
            stiffness = Spring.StiffnessMediumLow,
        )
    )
    LaunchedEffect(viewModel.pagerState) {
        snapshotFlow { viewModel.pagerState.currentPage }.collect { page ->
            onPageChanged(page)
        }
    }
}

@Composable
fun ScrollToPageLaunchedEffect(
    viewModel: MyViewModel,
    pageOffsetFraction: Float,
    animationSpec: AnimationSpec<Float>,
) {
    LaunchedEffect(viewModel.pageIndex) {
        viewModel.scrollToPage(
            page = viewModel.pageIndex,
            pageOffsetFraction = pageOffsetFraction,
            animationSpec = animationSpec
        )
    }
}

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun DefaultHorizontalPager(
    modifier: Modifier,
    viewModel: MyViewModel,
    verticalAlignment: Alignment.Vertical,
    pageContent: @Composable PagerScope.(page: Int) -> Unit
) {
    AnimatedVisibility(viewModel.pageCount > 0) {
        Column(modifier = modifier) {
            HorizontalPager(
                modifier = Modifier.fillMaxWidth(),
                state = viewModel.pagerState,
                userScrollEnabled = viewModel.userScrollEnabled,
                verticalAlignment = verticalAlignment,
                pageContent = pageContent,
            )
        }
    }
}

MyViewModel

@OptIn(ExperimentalFoundationApi::class)
@HiltViewModel
class MyViewModel @Inject constructor(
    val appDatabase: AppDatabase,
) : ViewModel() {
    var mediator: DataRemoteMediator? = null
    lateinit var pager: Pager<Int, DataClassEntity>
    private var _lazyPagingItems: LazyPagingItems<DataClass>? = null
    var lazyPagingItems: LazyPagingItems<DataClass>
        get() = _lazyPagingItems
            ?: throw UninitializedPropertyAccessException("\"lazyPagingItems\" was queried before being initialized")
        set(value) {
            _lazyPagingItems = value
        }
    var refreshPagingItems: Boolean by mutableStateOf(false)
    lateinit var pagerState: PagerState
    var pageIndex: Int by mutableIntStateOf(0)
    var pageCount: Int by mutableIntStateOf(2)
    var userScrollEnabled: Boolean by mutableStateOf(true)
    var animatePageChanges: Boolean by mutableStateOf(true)
    var isRefreshing: Boolean by mutableStateOf(false)
    var isRefreshEnabled: Boolean by mutableStateOf(true)
    val lazyPagingItemCount: Int
        get() {
            return if(_lazyPagingItems != null) {
                lazyPagingItems.itemCount
            }else {
                0
            }
        }

    init {
        refresh()
    }

    fun refresh() {
        viewModelScope.launch {
            appDatabase.dataClassDao().clearAll()
            appDatabase.dataClassDao().clearRemoteKeys()

            refreshLazyPagingItems()
        }
    }

    @OptIn(ExperimentalPagingApi::class)
    fun getData(): Flow<PagingData<DataClass>> {
        if(mediator == null) {
            mediator = DataRemoteMediator(
                appDatabase = appDatabase
            )
        }
        return mediator?.let {
            if(!this::pager.isInitialized) {
                pager = Pager(
                    PagingConfig(pageSize = 40, initialLoadSize = 40),
                    remoteMediator = it
                ) {
                    val keyValue = it.keyValue
                    appDatabase.dataClassDao().loadPagingSource(keyValue)
                }
            }
            pager.flow.map { pagingData ->
                pagingData.map { entity -> DataClass(id = entity.externalId, title = entity.title) }
            }
        } ?: emptyFlow()
    }

    fun onClickItem(item: DataClass) {
        pageIndex = 1
    }

    fun doesLazyPagingNeedRecollected(): Boolean {
        // always if it was never initialized
        if (_lazyPagingItems == null) {
            return true
        }
        // otherwise, set to any manual override
        var needsRecollected = refreshPagingItems

        // if itemCount == 0, it may have failed to load, retry
        if (lazyPagingItems.itemCount == 0) {
            needsRecollected = true
        }

        // reset this, have it only return true once for this value
        refreshPagingItems = false

        return needsRecollected
    }

    fun refreshLazyPagingItems() {
        // nothing needed to do, isLazyPagingItemsNeedRefreshed will return true
        if (_lazyPagingItems == null) {
            return
        }

        // this gets isLazyPagingItemsNeedRefreshed to return true, so
        // view can know to call collectAsLazyPagingItems again
        refreshPagingItems = true
        lazyPagingItems.refresh()
    }

    @OptIn(ExperimentalFoundationApi::class)
    suspend fun scrollToPage(
        page: Int,
        pageOffsetFraction: Float = 0f,
        animationSpec: AnimationSpec<Float> = spring(
            dampingRatio = Spring.DampingRatioNoBouncy,
            stiffness = Spring.StiffnessMediumLow,
        ),
    ) {
        if (animatePageChanges) {
            pagerState.animateScrollToPage(page, pageOffsetFraction, animationSpec)
        } else {
            pagerState.scrollToPage(page, pageOffsetFraction)
        }
    }
}

DataRemoteMediator

@OptIn(ExperimentalPagingApi::class)
class DataRemoteMediator @Inject constructor(
    val appDatabase: AppDatabase,
) : RemoteMediator<Int, DataClassEntity>() {
    val dao = appDatabase.dataClassDao()
    val keyValue = "UniqueKeyValue"

    override suspend fun load(
        loadType: LoadType,
        state: PagingState<Int, DataClassEntity>
    ): MediatorResult {
        val page = when(loadType) {
            REFRESH -> {
                val remoteKeys = getRemoteKeyClosestToCurrentPosition(state)
                remoteKeys?.nextKey?.minus(1) ?: DEFAULT_PAGE_INDEX
            }
            PREPEND -> {
                val remoteKeys = getRemoteKeyForFirstItem(state)
                val prevKey = remoteKeys?.prevKey
                    ?: return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
                prevKey
            }
            APPEND -> {
                val remoteKeys = getRemoteKeyForLastItem(state)
                val nextKey = remoteKeys?.nextKey
                    ?: return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
                nextKey
            }
        }

        val start = page * state.config.pageSize
        val limit = state.config.pageSize

        Log.d("DataRemoteMediator", "load() start=$start, limit=$limit")

        try {
            val data = List(limit) { index ->
                val trueIndex = start + index
                DataClass(
                    id = trueIndex.toLong(),
                    title = "Title: $trueIndex"
                )
            }
            val entityList = data.map { data ->
                DataClassEntity.from(data).also { it.key = keyValue }
            }

            val endOfPaginationReached = false
            appDatabase.withTransaction {
                val prevKey = if(page == DEFAULT_PAGE_INDEX ) null else page - 1
                val nextKey = if(endOfPaginationReached) null else page + 1

                val keys = entityList.map { item ->
                    RemoteKeys(
                        dataId = item.externalId,
                        dataKey = keyValue,
                        prevKey = prevKey,
                        nextKey = nextKey,
                    )
                }
                dao.insertRemoteKeys(keys)
                dao.insertList(entityList)
            }
            return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
        }catch (t: Throwable) {
            Log.e("DataRemoteMediator", "Exception occurred", t)
            return MediatorResult.Error(t)
        }
    }

    private suspend fun getRemoteKeyClosestToCurrentPosition(state: PagingState<Int, DataClassEntity>): RemoteKeys? {
        return state.anchorPosition?.let { position ->
            state.closestItemToPosition(position)?.externalId?.let { id ->
                dao.remoteKeysById(id, keyValue)
            }
        }
    }

    private suspend fun getRemoteKeyForFirstItem(state: PagingState<Int, DataClassEntity>): RemoteKeys? {
        return state.pages.firstOrNull { it.data.isNotEmpty() }?.data?.firstOrNull()
            ?.let { entity  ->
                dao.remoteKeysById(entity.externalId, keyValue)
            }
    }

    private suspend fun getRemoteKeyForLastItem(state: PagingState<Int, DataClassEntity>): RemoteKeys? {
        return state.pages.lastOrNull { it.data.isNotEmpty() }?.data?.lastOrNull()
            ?.let { entity ->
                dao.remoteKeysById(entity.externalId, keyValue)
            }
    }

    companion object {
        const val DEFAULT_PAGE_INDEX = 0
    }
}

DataClass

data class DataClass(
    val id: Long,
    val title: String
)

AppDatabase

@Database(
    entities = [
        DataClassEntity::class,
        RemoteKeys::class,
    ],
    version = 1,
    exportSchema = false
)
abstract class AppDatabase : RoomDatabase() {
    abstract fun dataClassDao() : DataClassDao
}

DataClassDao

@Dao
interface DataClassDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertList(list: List<DataClassEntity>)

    @Query("SELECT * FROM DataClassEntity WHERE data_id in (SELECT dataId FROM remote_keys where dataKey = :key) and data_key = :key ORDER BY id ASC")
    fun loadPagingSource(key: String?) : PagingSource<Int, DataClassEntity>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertRemoteKeys(remoteKeys: List<RemoteKeys>)

    @Query("SELECT * FROM remote_keys WHERE dataId = :id and dataKey = :key")
    suspend fun remoteKeysById(id: Long, key: String?): RemoteKeys?

    @Query("DELETE FROM remote_keys")
    suspend fun clearRemoteKeys()

    @Query("DELETE FROM DataClassEntity")
    suspend fun clearAll()
}

DataClassEntity

@Entity(
    indices = [
        Index(value = ["data_id"], unique = true)
    ]
)
data class DataClassEntity constructor(
    @ColumnInfo(name = "data_id") val externalId: Long,
    @ColumnInfo(name = "data_title") val title: String,
    @ColumnInfo(name = "data_key") var key: String?,
) {

    @PrimaryKey(autoGenerate = true) var id: Long = 0L

    companion object {
        fun from(dataClass: DataClass) : DataClassEntity {
            return DataClassEntity(
                externalId = dataClass.id,
                title = dataClass.title,
                key = null,
            )
        }
    }
}

RemoteKeys

@Entity(tableName = "remote_keys",
    indices = [
        Index(value = ["dataId"],),
        Index(value = ["dataKey"],),
        Index(value = ["dataId", "dataKey"], unique = true),
    ]
)
class RemoteKeys constructor(
    val dataId: Long,
    val dataKey: String?,
    val prevKey: Int?,
    val nextKey: Int?,
) {
    @PrimaryKey(autoGenerate = true) var id: Long = 0L
}

Upvotes: 3

Views: 140

Answers (0)

Related Questions