{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