Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

RecyclerView remains empty with Paging Library and PositionalDataSource

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?

like image 436
Sebastien Avatar asked Feb 10 '20 16:02

Sebastien


Video Answer


1 Answers

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.

like image 75
BladeCoder Avatar answered Sep 19 '22 12:09

BladeCoder