{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