{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

Missing ScrollState Extensions

Made with Slides.com