RecyclerView remains empty with Paging Library and PositionalDataSource
Asked Answered
A

2

6

I am trying to configure the Android Paging library in my project to load a paginated list of messages into a RecyclerView. Since my API uses offset and max, I'm using a PositionalDataSource.

Here is my DataSource implementation, where DataStore is using RetroFit to load the messages, and I can see in the console that messages are being loaded properly, and converted to instances of MessageListItem:

class MessageDataSource: PositionalDataSource<MessageListItem>() {
    override fun loadRange(params: LoadRangeParams, callback: LoadRangeCallback<MessageListItem>) {
        DataStore.shared.loadMessages(params.startPosition, params.loadSize) { result, error ->
            if(result != null) {
                callback.onResult(result.items)
            } else {
                callback.onError(MessageDataSourceException(error))
            }
        }
    }

    override fun loadInitial(
        params: LoadInitialParams,
        callback: LoadInitialCallback<MessageListItem>
    ) {
        DataStore.shared.loadMessages(params.requestedStartPosition, params.requestedLoadSize) { response, error ->
            if(response != null) {
                callback.onResult(response.items, response.offset, response.total)
            } else {
                callback.onError(MessageDataSourceException(error))
            }
        }
    }
}

class MessageDataSourceException(rootCause: Throwable? = null): Exception(rootCause)

Here is my DataSourceFactory implementation:

class MessageDataSourceFactory: DataSource.Factory<Int, MessageListItem>() {
    val messageLiveDataSource = MutableLiveData<MessageDataSource>()
    private lateinit var messageDataSource: MessageDataSource

    override fun create(): DataSource<Int, MessageListItem> {
        messageDataSource = MessageDataSource()
        messageLiveDataSource.postValue(messageDataSource)
        return messageDataSource
    }
}

Here is my MessageListAdapter implementation:

object MessageListItemDiff: DiffUtil.ItemCallback<MessageListItem>() {
    override fun areItemsTheSame(oldItem: MessageListItem, newItem: MessageListItem): Boolean {
        return oldItem.id == newItem.id
    }

    override fun areContentsTheSame(oldItem: MessageListItem, newItem: MessageListItem): Boolean {
        return oldItem == newItem
    }
}

class MessageListAdapter(private val clickListener: View.OnClickListener):
    PagedListAdapter<MessageListItem, MessageListAdapter.MessageHolder>(MessageListItemDiff) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MessageHolder {
        val inflatedView = LayoutInflater.from(parent.context).inflate(R.layout.item_message, parent, false)
        return MessageHolder(inflatedView, clickListener)
    }

    override fun onBindViewHolder(holder: MessageHolder, position: Int) {
        holder.bind(getItem(position)!!)
    }

    class MessageHolder(itemView: View, private val clickListener: View.OnClickListener) : RecyclerView.ViewHolder(itemView) {
        val unreadIndicator = itemView.findViewById<ImageView>(R.id.unreadIndicator)
        val title = itemView.findViewById<TextView>(R.id.title)
        val dateSent = itemView.findViewById<TextView>(R.id.dateSent)
        val cardView = itemView.findViewById<CardView>(R.id.card_view)

        fun bind(message: MessageListItem) {
            cardView.tag = message
            cardView.setOnClickListener(clickListener)
            title.text = message.title
            dateSent.text = TimeAgo.using(message.dateSent.time)
            if(message.isRead) {
                unreadIndicator.setImageResource(0)
            } else {
                unreadIndicator.setImageResource(R.drawable.ic_unread)
            }
        }
    }
}

And finally my ViewModel:

class MessageListViewModel: ViewModel() {
    val messagePagedList: LiveData<PagedList<MessageListItem>>
    val liveDataSource: LiveData<MessageDataSource>

    init {
        val messageDataSourceFactory = MessageDataSourceFactory()
        liveDataSource = messageDataSourceFactory.messageLiveDataSource

        val pagedListConfig = PagedList.Config.Builder()
            .setEnablePlaceholders(false)
            .setPageSize(30)
            .setPrefetchDistance(90)
            .build()
        messagePagedList = LivePagedListBuilder(messageDataSourceFactory, pagedListConfig).build()
    }
}

And here is the onViewCreated implementation in the fragment that is supposed to display the recycler view called messageList:

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        messageList.layoutManager = LinearLayoutManager(context!!)
        messageList.setHasFixedSize(true)

        messageListViewModel = ViewModelProvider(this).get(MessageListViewModel::class.java)
        messageListAdapter = MessageListAdapter(this)

        messageListViewModel.messagePagedList.observe(this, Observer { messages ->
            messageListAdapter.submitList(messages)
        })

        messageList.adapter = messageListAdapter
    }

The problem is that I can see that data is being loaded from the server, but it never reaches the recycler view. If I add a breakpoint on the observer line (messageListAdapter.submitList(messages)), I get a call once with an empty message list, and that's it.

I have to admit I'm really confused with all these classes and what they are supposed to do, this is my first Paging implementation in Android, and I had to adapt code I found here and there because I didn't want to use a Room database, RxJava or a PageKeyedDataSource, which most samples out there do.

Any idea what might be going on?

Amphi answered 10/2, 2020 at 16:29 Comment(5)
I can't see any code where you call either loadRange() or loadInital(). Also, you only call postValue() once in the snippets you posted here (when the Datasource is created, so I suppose there won't be any values to show then). So it's hard to tell where something is missingTref
loadRange and loadInitial are supposed to be called inside the Paging library. Same for postValue.Amphi
Can you give a fixed size height to the recycleview, sometimes I placed some constrains with the recycleview and it did not showed up when loaded. Also try maybe to change the linearmanager to this LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false);Tor
The recycler view is in a ConstraintLayout, constrained to the parent on all sides. And the LinearLayoutManager has to be a vertical one. I don't think it has anything to do with the recyclerview itself.Amphi
You are using callback.onError() calls in your code. There is no proper error handling in the current version of the paging library (2.1.1). This method has been added recently but it's not yet documented anywhere and doesn't work for many cases including when using a PositionalDataSource. So you have to ignore errors or implement your own retry mechanism. Can you put breakpoints everywhere and confirm that callback.onResult() is being called in loadInitial() with the proper arguments (non-empty list and correct offset) and that callback.onError() is not called?Briefs
B
3

From what I know, for everything to work properly the PagedList instance must be preloaded with initial data as soon as it's dispatched by the LiveData. For this to occur, the data needs to be loaded when the loadInitial() method returns, which means that you need to perform the network call synchronously and call callback.onResult() from within the loadInitial() method call before the method returns, instead of using a callback. It's safe to perform network calls synchronously there because the LivePagedListBuilder will take care of calling the PagedList.Builder() from a background thread.

Also, error handling implementation is pretty much undocumented and incomplete at this point (in version 2.1.1) so calls to the recently added callback.onError() method will fail in many cases. For example, in version 2.1.1 error handling is not implemented at all in TiledPagedList, which is the type of PagedList used for a PositionalDataSource.

Finally, if you return an exact size for the list in loadInitial() (as you do here), then in loadRange() you need to make sure that you always return exactly the number of items that is requested. If the API requests 30 items and you only return 20, your app may crash. One workaround I found out is that you can pad the results list with null values so it always has the requested size, but then you need to enable placeholders. Alternatively, don't return an exact size in loadInitial() and the list will just grow dynamically.

This API is complex and tricky to use so don't blame yourself. Google is currently working on a new version 3.0 written in Kotlin which will hopefully fix all the issues of the old one.

Briefs answered 20/2, 2020 at 12:0 Comment(1)
I'm using rxjava to pull data. Tried to change my data retrieval to blocking inside loadiinital - no difference btw.Griseous
B
0

Change this:

messageListViewModel.messagePagedList.observe(this, Observer { messages ->
    messageListAdapter.submitList(messages)
})

with this:

messageListViewModel.messagePagedList.observe(viewLifeCycleOwner, PagedList(messageListAdapter::submitList))

Source: https://developer.android.com/topic/libraries/architecture/paging#ex-observe-livedata

Bluegill answered 16/2, 2020 at 3:22 Comment(4)
I get an error on submitList because of an overload resolution ambiguity (it doesn't know which one of the 3 overloaded versions of submitList to use), and then another error on the PagedList constructor call but I guess this one is a consequence of the first one.Amphi
If you change PagedList(messageListAdapter::submitList) to Observer(messageListAdapter::submitList), do you get the same error?Bluegill
No more compilation error, since it does pretty much what I had in my original code, but the results don't reach the RecyclerView, even though I can see them reaching the DataSourceAmphi
Actually I notice one thing from your code: I don't see any suspend function nor Observable type being handled. Do you handle the background processing correctly? Because if you don't, you will observe an empty list and set it to your adapter before you get any result from the HTTP request.Bluegill

© 2022 - 2024 — McMap. All rights reserved.