{Paging}
Without paging library.
Jan Rabe - TTL Android Profis
Requirements

- Responsive UX
	
- smooth endless scrolling (append/prepend/prefetch)
 - cached chat
 
 - Staying up-to-date
	
- refresh on push
 - refresh on scroll
 
 
- Either remote OR local data source and not both*
 - RemoteMediator: anchor position not helping with refreshing current page
 
Why not Paging Library?
*due to invalidation race conditions & loops
@Dao
internal interface MessageItemDao {
    @Query(
        """
        SELECT * FROM native_messenger_messages 
        WHERE conversation_id = :conversationId 
        AND hidden_at = ''
        ORDER BY created_at_in_millis 
        ASC
        """
    )
    fun getAllMessages(conversationId: String): Flow<List<MessageLocal>?>
}
fun collectAllMessages() {
    viewLifecycleOwner
        .lifecycleScope
        .launch {
            viewModel
                ?.getAllMessages(id)
                ?.map { it.toDomain() }
                ?.collectLatest { conversationAdapter?.submitList(it) }
        }
}
# PRESENTING CODE
Subscribing to a specific chat
# PRESENTING CODE
Subscribing to a specific chat
fun RecyclerView.subscribeToPageOnScroll() {
    viewLifecycleOwner.lifecycleScope.launch {
        pagingOnScroll()
            .distinctUntilChanged()
            .collectLatest { viewModel.loadMessages(it) }
    }
}
fun RecyclerView.subscribeToRefreshOnScroll() {
    viewLifecycleOwner.lifecycleScope.launch {
        refreshOnScroll()
            .distinctUntilChanged()
            .collectLatest { viewModel.loadMessages(it) }
    }
}
suspend fun loadMessages(instruction: PageLoadInstruction): Unit = when (instruction) {
    is PageLoadInstruction.Prepend -> prependMessagesFor(instruction.cursor)
    is PageLoadInstruction.Append -> appendMessagesFor(instruction.cursor)
}
    
suspend fun prependMessagesFor(cursor: String): Unit = withContext(Dispatchers.IO) {
    val response = getMessagesBeforeFromRemote(cursor, NativeMessengerViewModel.MESSAGE_LOAD_COUNT)
    updateDatabase(response)
}
suspend fun appendMessagesFor(cursor: String): Unit = withContext(Dispatchers.IO) {
  val response = getMessagesAfterFromRemote(cursor, NativeMessengerViewModel.MESSAGE_LOAD_COUNT)
  updateDatabase(response)
}# PRESENTING CODE
Subscribe to paging
private class PagingScrollListener(
    @IntRange(from = 1L, to = Long.MAX_VALUE) private val pageSize: Int,
    private val onSend: (PageLoadInstruction) -> Unit
) : RecyclerView.OnScrollListener() {
    override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
        val isScrollingUp = 0 > dy
        val isScrollingDown = 0 < dy
        val adapter = recyclerView.adapter as? BindingListAdapter ?: return
        if (recyclerView.firstVisibleItemPosition <= pageSize && isScrollingUp) {
            val cursor = adapter.getCursorOrAfter(0)
            if (cursor != null) onSend(Prepend(cursor))
        }
        if (recyclerView.lastVisibleItemPosition >= (adapter.itemCount - pageSize).coerceAtLeast(0) && isScrollingDown) {
            val cursor = adapter.getCursorOrBefore(adapter.itemCount - 1)
            if (cursor != null) onSend(Append(cursor))
        }
    }
}
internal fun RecyclerView.pagingOnScroll(
    pageSize: Int = NativeMessengerViewModel.MESSAGE_LOAD_COUNT
): Flow<PageLoadInstruction> = callbackFlow {
    val listener = PagingScrollListener(pageSize) {
        trySend(it)
    }
    addOnScrollListener(listener)
    awaitClose { removeOnScrollListener(listener) }
}
# PRESENTING CODE
Paging while scrolling
# PRESENTING CODE
Paging while scrolling
private class PagingRefreshScrollListener(
    @IntRange(from = 1L, to = Long.MAX_VALUE) private val pageSize: Int,
    private val onRefresh: (PageLoadInstruction) -> Unit
) : RecyclerView.OnScrollListener() {
    private var currentPage = -1
    override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
        val isScrollingUp = 0 > dy
        val isScrollingDown = 0 < dy
        if (isScrollingUp) recyclerView.onScrollingUp()
        if (isScrollingDown) recyclerView.onScrollingDown()
    }
    private fun RecyclerView.onScrollingUp() {
        val adapter = (adapter as? BindingListAdapter) ?: return
        val pageNumber = firstVisibleItemPosition / pageSize
        val pageBottomItemPosition = pageNumber * pageSize + pageSize - 1
        if (pageNumber == currentPage) return
        currentPage = pageNumber
        val cursor = adapter.getCursorOrBefore(pageBottomItemPosition) ?: return
        onRefresh(Prepend(cursor))
    }
    private fun RecyclerView.onScrollingDown() {
        val adapter = (adapter as? BindingListAdapter) ?: return
        val pageNumber = lastVisibleItemPosition / pageSize
        val pageTopItemPosition = pageNumber * pageSize
        if (pageNumber == currentPage) return
        val cursor = adapter.getCursorOrAfter(pageTopItemPosition) ?: return
        onRefresh(Append(cursor))
    }
}# PRESENTING CODE
Refresh on scroll
internal fun RecyclerView.refreshOnScroll(
    pageSize: Int = NativeMessengerViewModel.MESSAGE_LOAD_COUNT
): Flow<PageLoadInstruction> = callbackFlow {
    val listener = PagingRefreshScrollListener(pageSize) {
        trySend(it)
    }
    addOnScrollListener(listener)
    awaitClose { removeOnScrollListener(listener) }
}# PRESENTING CODE
Refresh on scroll
# PRESENTING CODE
Refresh on Push
{Bonus}
Automatic Scrolling to Bottom
internal suspend fun RecyclerView.autoScrollToBottom(
    isEnabled: () -> Boolean
) = suspendCancellableCoroutine<Unit> { continuation ->
    val listener = BottomScrollListener()
    val dataObserver = OnChangeAdapterObserver {
        if (!listener.isScrolledToBottom) return@OnChangeAdapterObserver
        if (!isEnabled()) return@OnChangeAdapterObserver
        scrollToPosition(lastIndex)
    }
    adapter?.registerAdapterDataObserver(dataObserver)
    addOnScrollListener(listener)
    continuation.invokeOnCancellation {
        adapter?.unregisterAdapterDataObserver(dataObserver)
        removeOnScrollListener(listener)
    }
}
        # PRESENTING CODE
# PRESENTING CODE
Automatic Scrolling to Bottom
internal suspend fun RecyclerView.scrollToUnread(
    hasNewInitialPage: (() -> Boolean),
    consumeInitialPage: () -> Unit
) = suspendCancellableCoroutine<Unit> { continuation ->
    val dataObserver = OnChangeAdapterObserver {
        val unreadMessageBannerPosition = (adapter as? BindingListAdapter)
            ?.currentList
            ?.indexOfLast { it.layout == R.layout.profis_native_messenger_item_unread_banner }
            ?: RecyclerView.NO_POSITION
        val onInitialCompleted = hasNewInitialPage()
        val hasUnreadMessageBanner = unreadMessageBannerPosition > 0
        when {
            onInitialCompleted && hasUnreadMessageBanner -> {
                // we have initial page
                scrollToPositionWithOffset(unreadMessageBannerPosition, center)
                findViewTreeLifecycleOwner()
                    ?.lifecycleScope
                    ?.launch {
                        awaitScrollEnd()
                        consumeInitialPage()
                    }
            }
            !hasUnreadMessageBanner -> consumeInitialPage()
        }
    }
    adapter?.registerAdapterDataObserver(dataObserver)
    continuation.invokeOnCancellation {
        adapter?.unregisterAdapterDataObserver(dataObserver)
    }
}# PRESENTING CODE
Scroll to position
# PRESENTING CODE
Scroll to position
internal suspend fun RecyclerView.awaitAdapterUpdate() = suspendCancellableCoroutine { continuation ->
    var dataObserver: OnChangeAdapterObserver? = null
    dataObserver = OnChangeAdapterObserver {
        dataObserver?.let {
            adapter?.unregisterAdapterDataObserver(it)
            dataObserver = null
        }
        continuation.resume(Unit)
    }
    dataObserver?.let { adapter?.registerAdapterDataObserver(it) }
    continuation.invokeOnCancellation {
        dataObserver?.let { adapter?.unregisterAdapterDataObserver(it) }
    }
}# PRESENTING CODE
Await Adapter Update
internal suspend fun RecyclerView.awaitScrollEnd() {
    // If a smooth scroll has just been started, it won't actually start until the next
    // animation frame, so we'll await that first
    awaitAnimationFrame()
    // Now we can check if we're actually idle. If so, return now
    if (scrollState == RecyclerView.SCROLL_STATE_IDLE) return
    suspendCancellableCoroutine { continuation ->
        val onScrollListener = object : RecyclerView.OnScrollListener() {
            override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
                if (newState == RecyclerView.SCROLL_STATE_IDLE) {
                    // Make sure we remove the listener so we don't leak the coroutine continuation
                    recyclerView.removeOnScrollListener(this)
                    // Finally, resume the coroutine
                    continuation.resume(Unit)
                }
            }
        }
        continuation.invokeOnCancellation {
            // If the coroutine is cancelled, remove the scroll listener
            removeOnScrollListener(onScrollListener)
        }
        addOnScrollListener(onScrollListener)
    }
}
internal suspend fun View.awaitAnimationFrame(): Unit = suspendCancellableCoroutine { continuation ->
    val runnable = Runnable {
        continuation.resume(Unit)
    }
    // If the coroutine is cancelled, remove the callback
    continuation.invokeOnCancellation { removeCallbacks(runnable) }
    // And finally post the runnable
    postOnAnimation(runnable)
}# PRESENTING CODE
Await Scroll End
{Helper}
internal class BottomScrollListener(
    private val onBottom: ((Boolean) -> Unit)? = null
) : RecyclerView.OnScrollListener() {
    var isScrolledToBottom: Boolean by Delegates.observable(false) { _, oldValue, newValue ->
        if (oldValue != newValue) onBottom?.invoke(isScrolledToBottom)
    }
    override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
        isScrolledToBottom = recyclerView.isScrolledToBottom
    }
}
internal fun RecyclerView.isBottom(): Flow<Boolean> = callbackFlow {
    if (adapter?.itemCount != 0) {
        val isScrolledToBottom = isScrolledToBottom
        trySend(isScrolledToBottom)
    }
    val listener = BottomScrollListener {
        trySend(it)
    }
    addOnScrollListener(listener)
    awaitClose { removeOnScrollListener(listener) }
}
# PRESENTING CODE
Bottom Scroll Listener
internal class OnChangeAdapterObserver(private val onDataChanged: () -> Unit) : RecyclerView.AdapterDataObserver() {
    override fun onChanged() = onDataChanged()
    override fun onItemRangeChanged(positionStart: Int, itemCount: Int) = onDataChanged()
    override fun onItemRangeChanged(positionStart: Int, itemCount: Int, payload: Any?) = onDataChanged()
    override fun onItemRangeInserted(positionStart: Int, itemCount: Int) = onDataChanged()
    override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) = onDataChanged()
    override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) = onDataChanged()
}# PRESENTING CODE
Adapter Observer
- Suspend over Views
https://medium.com/androiddevelopers/suspending-over-views-19de9ebd7020 - Official Paging Documentation https://developer.android.com/topic/libraries/architecture/paging/v3-overview
 - Github androidx paging
https://github.com/androidx/androidx/tree/androidx-main/paging 
Sources
Arif Akdogan, Jannis König, Fabian Jung, Sergej Istomin, Yasar Naci Gündüz, Fabian Szymanczyk
Thanks to
{Compose Paging}
Without Paging Library
Jan Rabe - TTL Android Profis
// viewmodel
val state = MyStateHolder(items: MutableState<List<Items>> = mutableStateOf(emptyList())
        repository
            .getMyItems()
            .collectLatest { items ->
                withContext(Dispatchers.Main) {
                    state.items.value = items
                }
            }
            
// composable 
        LazyColumn(
            modifier = Modifier
                .fillMaxSize()
                .nestedScroll(nestedScrollConnection),
            state = scrollState,
        ) {
            items(
                items = state.items.value,
                key = { it.id },
                contentType = { 0 }
            ) { item ->
                // my composable item
            }
        }# PRESENTING CODE
Subscribing to a specific list
        val scrollState: LazyListState = state.lazyListState
        val nestedScrollConnection = remember {
            PageRefreshNestedScrollConnection(
                scrollState = scrollState,
                cursor = state.cursorForPosition,
                onRefresh = { state.load(it) }
            )
        }
        LazyColumn(
            modifier = Modifier
                .fillMaxSize()
                .nestedScroll(nestedScrollConnection),
            state = scrollState,
        ) {
            items(
                items = state.items.value,
                key = { it.id },
                contentType = { 0 }
            ) { item ->
                // my composable item
            }
        }# PRESENTING CODE
Add Paging Nested Scroll Connection
    val nestedScrollConnection = remember {
        PageRefreshNestedScrollConnection(
            scrollState = scrollState,
            cursor = state.cursorForPosition,
            onRefresh = { state.load(it) }
        )
    }
    val cursorForPosition: (index: Int) -> String? = {
        if (it in 0..items.value.lastIndex) items.value[it].cursorId // could be updatedAt
        else null
    }# PRESENTING CODE
Provide cursor for position
class PageRefreshNestedScrollConnection(
    @IntRange(from = 1L, to = Long.MAX_VALUE) private val pageSize: Int = DEFAULT_PAGE_SIZE,
    private val scrollState: LazyListState,
    private val cursor: (index: Int) -> String?,
    private val onRefresh: (LoadParams) -> Unit
) : NestedScrollConnection {
    private var currentPage = -1
    override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
        if (available.getDistance() <= 4.dp) return Offset.Zero
        val dy = available.y
        val isScrollingUp = 0 < dy
        val isScrollingDown = 0 > dy
        if (isScrollingUp) onScrollingUp()
        if (isScrollingDown) onScrollingDown()
        return Offset.Zero
    }
    private fun onScrollingUp() {
        val firstVisibleItemPosition = scrollState.firstVisibleItemIndex
        val pageNumber = firstVisibleItemPosition / pageSize
        val pageBottomItemPosition = pageNumber * pageSize + pageSize - 1
        if (pageNumber == currentPage) return
        currentPage = pageNumber
        val cursor = cursor(pageBottomItemPosition)
        if (cursor != null) {
            onRefresh(LoadParams.Prepend(cursor))
        }
    }
    private fun onScrollingDown() {
        val lastVisibleItemPosition = scrollState.lastVisibleItemIndex
        val pageNumber = lastVisibleItemPosition / pageSize
        val pageTopItemPosition = pageNumber * pageSize
        if (pageNumber == currentPage) return
        currentPage = pageNumber
        val cursor = cursor(pageTopItemPosition)
        if (cursor != null) {
            onRefresh(LoadParams.Append(cursor))
        }
    }
}# PRESENTING CODE
Detect scrolling
    private fun onScrollingUp() {
        val firstVisibleItemPosition = scrollState.firstVisibleItemIndex
        val pageNumber = firstVisibleItemPosition / pageSize
        val pageBottomItemPosition = pageNumber * pageSize + pageSize - 1
        if (pageNumber == currentPage) return
        currentPage = pageNumber
        val cursor = cursor(pageBottomItemPosition)
        if (cursor != null) {
            onRefresh(LoadParams.Prepend(cursor))
        }
    }
    private fun onScrollingDown() {
        val lastVisibleItemPosition = scrollState.lastVisibleItemIndex
        val pageNumber = lastVisibleItemPosition / pageSize
        val pageTopItemPosition = pageNumber * pageSize
        if (pageNumber == currentPage) return
        currentPage = pageNumber
        val cursor = cursor(pageTopItemPosition)
        if (cursor != null) {
            onRefresh(LoadParams.Append(cursor))
        }
    }
}# PRESENTING CODE
Fire Prepend / Append event
# PRESENTING CODE
Sample Page Refresh on Scrolling
class VerticalNestedScrollConnection(
    private val onScrollingUp: (() -> Unit)? = null,
    private val onScrollingDown: (() -> Unit)? = null,
) : NestedScrollConnection {
    override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
        val dy = available.y
        val isScrollingUp = 0 < dy
        val isScrollingDown = 0 > dy
        if (isScrollingUp) onScrollingUp?.invoke()
        if (isScrollingDown) onScrollingDown?.invoke()
        return super.onPreScroll(available, source)
    }
}
    val verticalNestedScrollConnection = remember {
        VerticalNestedScrollConnection(
            onScrollingDown = {
                scope.launch { state.hideKeyboard() }
            }
        )
    }
    
    LazyColumn(
        modifier = Modifier
            .fillMaxSize()
            .nestedScroll(nestedScrollConnection)
            .nestedScroll(verticalNestedScrollConnection),
        state = scrollState,
    ) {
        items(
            items = state.items.value,
            key = { it.id },
            contentType = { 0 }
        ) { item ->
            // my composable item
        }
    }# PRESENTING CODE
Hide Keyboard on Vertical Scrolling
    
    val scope = rememberCoroutineScope()
    val keyboardController = LocalSoftwareKeyboardController.current
    val focusManager = LocalFocusManager.current
    LaunchedEffect(Unit) {
        state.showKeyboard.collectLatest { show ->
            if (show) {
                keyboardController?.show()
            } else {
                keyboardController?.hide()
                focusManager.clearFocus()
            }
        }
    }# PRESENTING CODE
Hide Keyboard on Vertical Scrolling
# PRESENTING CODE
Sample Hide Keyboard
{Bonus #2}
val LazyListState.lastVisibleItemIndex: Int
    get() = layoutInfo.visibleItemsInfo.lastIndex + firstVisibleItemIndex
val LazyListState.isScrolledToEnd: Boolean
    get() = layoutInfo.visibleItemsInfo.lastOrNull()?.index == layoutInfo.totalItemsCount - 1
val LazyListState.isScrolledToEndCompletely: Boolean
    get() {
        val last = layoutInfo.visibleItemsInfo.lastOrNull()
        return last?.index == layoutInfo.totalItemsCount - 1 && last.offset == 0
    }
val LazyListState.isScrolledToStart: Boolean
    get() = layoutInfo.visibleItemsInfo.firstOrNull()?.index == 0
val LazyListState.isScrolledToStartCompletely: Boolean
    get() {
        val first = layoutInfo.visibleItemsInfo.firstOrNull()
        return first?.index == 0 && first.offset == 0
    }# PRESENTING CODE