How to add date separators in recycler view using Paging Library?
Asked Answered
P

5

23

After a lot of searching, I know its possible with regular adapter, but I have no idea how to do it using Paging Library. I don`t need code just a clue.

Example

Prospector answered 27/10, 2018 at 15:36 Comment(0)
S
19

To add separators, you essentially have 2 options:

  1. View-based, you explicitly include separators as an 'item' in the list and define a new viewtype for those separators. Allows the list to re-use the separator views but means you need to take the separators into account when defining the data.
  2. Data-based, each item actually has a separator view, but it only shows on specific items. Based on some criteria you show or hide it whilst binding the view-holder.

For the paging library only option 2 is viable since it only partially loads the data and inserting the separators becomes much more complicated. You will simply need to figure out a way to check if item x is a different day than item x-1 and show/hide the date section in the view depending on the result.

Sundry answered 27/10, 2018 at 16:3 Comment(3)
If I have to use option 1, any idea?Questioning
Excellent answer, and if we take the separation of concerns into account, option 2 is the only way. DataSource should not know that the View wants to show headers, it should only fetch the data.Frasco
On the 2 option there are several issues: - What if an item which has "header" is deleted from DB? - What if new item is added into the middle of the list? - What if the date field changed in the item which contains "header"?Dissected
H
12

You can achieve the same result using insertSeparators in Paging 3 library. Make sure your items are sorted by date.

Inside or viewmodel retrieve a Pager something like that

private val communicationResult: Flow<PagingData<CommunicationHistoryItem>> = Pager(
    PagingConfig(
        pageSize = 50,
        enablePlaceholders = false,
        maxSize = 400,
        initialLoadSize = 50
    )
) {
    CommunicationPagingSource(repository)
}.flow.cachedIn(viewModelScope)

After all insert separators like a header

val groupedCommunicationResult = communicationResult
        .map { pagingData -> pagingData.map { CommunicationHistoryModel.Body(it) } }
        .map {
            it.insertSeparators{ after, before ->
                if (before == null) {
                    //the end of the list
                    return@insertSeparators null
                }

                val afterDateStr = after?.createdDate
                val beforeDateStr = before.createdDate

                if (afterDateStr == null || beforeDateStr == null)
                    return@insertSeparators null

                val afterDate = DateUtil.parseAsCalendar(afterDateStr)?.cleanTime()?.time ?: 0
                val beforeDate = DateUtil.parseAsCalendar(beforeDateStr)?.cleanTime()?.time ?: 0

                if (afterDate > beforeDate) {
                    CommunicationHistoryModel.Header( DateUtil.format(Date(beforeDate))) // dd.MM.yyyy
                } else {
                    // no separator
                    null
                }
            }
        }

cleanTime is required for grouping by dd.MM.yyyy ignoring time

fun Calendar.cleanTime(): Date {
    set(Calendar.HOUR_OF_DAY, 0)
    set(Calendar.MINUTE, 0)
    set(Calendar.SECOND, 0)
    set(Calendar.MILLISECOND, 0)
    return this.time
}
Heavyset answered 5/11, 2020 at 22:26 Comment(1)
it will only work for separators in the middle of list items, if you want a separator at the top too then add this if (after == null) { CommunicationHistoryModel.Header( DateUtil.format(Date(beforeDate))) }Salute
W
7

I was in the same spot as you and I came up with this solution.

One important note though, in order to implement this I had to change my date converter to the database, from long to string to store a timestamp

these are my converters

class DateConverter {
    companion object {
        @JvmStatic
        val formatter = SimpleDateFormat("yyyyMMddHHmmss", Locale.ENGLISH)

        @TypeConverter
        @JvmStatic
        fun toDate(text: String): Date = formatter.parse(text)

        @TypeConverter
        @JvmStatic
        fun toText(date: Date): String = formatter.format(date)
    }
}

Some starting info though, I have a list of report headers that I wish to show , and page through and be able to filter

They are represented by this object:

data class ReportHeaderEntity(
@ColumnInfo(name = "id") override val id: UUID
, @ColumnInfo(name = "name") override val name: String
, @ColumnInfo(name = "description") override val description: String
, @ColumnInfo(name = "created") override val date: Date)

I also wanted to add separators between the items in the list to show them by date

I achieved this by doing the following:

I created a new query in room like this

 @Query(
    "SELECT id, name, description,created " +
            "FROM   (SELECT id, name, description, created, created AS sort " +
            "        FROM   reports " +
            "        WHERE  :filter = '' " +
            "                OR name LIKE '%' || :filter || '%' " +
            "                OR description LIKE '%' || :filter || '%' " +
            "        UNION " +
            "        SELECT '00000000-0000-0000-0000-000000000000' as id, Substr(created, 0, 9) as name, '' as description, Substr(created, 0, 9) || '000000' AS created, Substr(created, 0, 9) || '256060' AS sort " +
            "        FROM   reports " +
            "        WHERE  :filter = '' " +
            "                OR name LIKE '%' || :filter || '%' " +
            "                OR description LIKE '%' || :filter || '%' " +
            "        GROUP  BY Substr(created, 0, 9)) " +
            "ORDER  BY sort DESC ")

fun loadReportHeaders(filter: String = ""): DataSource.Factory<Int, ReportHeaderEntity>

This basically creates a separator line for all the items I have filtered through

it also creates a dummy date for sorting (with the time of 25:60:60 so that it will always appear in front of the other reports)

I then combine this with my list using union and sort them by the dummy date

The reason I had to change from long to string is because it is much easier to create dummy dates with string in sql and seperate the date part from the whole date time

The above creates a list like this:

00000000-0000-0000-0000-000000000000    20190522        20190522000000
e3b8fbe5-b8ce-4353-b85d-8a1160f51bac    name 16769  description 93396   20190522141926
6779fbea-f840-4859-a9a1-b34b7e6520be    name 86082  description 21138   20190522141925
00000000-0000-0000-0000-000000000000    20190521        20190521000000
6efa201f-d618-4819-bae1-5a0e907ddcfb    name 9702   description 84139   20190521103247

In my PagedListAdapter I changed it to be an implementation of PagedListAdapter<ReportHeader, RecyclerView.ViewHolder> (not a specific viewholder)

Added to the companion object:

companion object {
    private val EMPTY_ID = UUID(0L,0L)
    private const val LABEL = 0
    private const val HEADER = 1
}

and overrode get view type like so:

override fun getItemViewType(position: Int): Int = if (getItem(position)?.id ?: EMPTY_ID == EMPTY_ID) LABEL else HEADER

I then created two seperate view holders :

class ReportHeaderViewHolder(val binding: ListItemReportBinding) : RecyclerView.ViewHolder(binding.root) 

class ReportLabelViewHolder(val binding: ListItemReportLabelBinding) : RecyclerView.ViewHolder(binding.root)

and implemented the other overriden methods like so:

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
    val inflater = LayoutInflater.from(parent.context)
    return when (viewType) {
        HEADER -> ReportHeaderViewHolder(DataBindingUtil.inflate(inflater, R.layout.list_item_report, parent, false))
        else -> ReportLabelViewHolder(DataBindingUtil.inflate(inflater, R.layout.list_item_report_label, parent, false))
    }
}

override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
    val reportItem = getItem(position)
    when (getItemViewType(position)) {
        HEADER -> {
            (holder as ReportHeaderViewHolder).binding.apply {
                report = reportItem
                executePendingBindings()
            }
        }
        LABEL -> {
            (holder as ReportLabelViewHolder).binding.apply {
                date = reportItem?.name
                executePendingBindings()
            }
        }
    }
}

I hope this helps and inspires people to find even better solutions

Whiskey answered 22/5, 2019 at 15:16 Comment(8)
Thanks for great job! But there should be any column name in GROUP BY, isn't it?Frankfrankalmoign
group by is basically what type of separator you want to add, mine was the date, so I grouped by date, if you want to do something like a phone catalog you could do group by substr(upper(name) 0,1)Whiskey
I can't understand. According to documentation there is how it works: GROUP BY [column_name]. Substr returns just string which is not related to column names. Or there is something I've missed?Frankfrankalmoign
oh sorry I didn't understand your question, yeah group by seems to work with substr and other functions too, basically it evaluates the function for each row, and then groups them together according to that value, if you just added the column name it would perform the group according to that column's valueWhiskey
I think Group By is to avoid duplicated HEADER.Questioning
yes, ofcourse, if you don't group by, then you get one header for each row in your database, we want one header for each group of rows with the same dateWhiskey
Thanks for your answer, very helpful! I got stuck with the same problem and now I consider either using approach you posted or use v3 (alpha) version of paging library, where they've added 'insertSeparators()' APIPatrology
paging library is better nowadays, I would recommend (if you have the time to learn it) to use that, that's what I'm using from now onWhiskey
C
2

When binding the data pass in the previous item as well

  override fun onBindViewHolder(holder: ViewHolder, position: Int) {
    val item = getItem(position)
    val previousItem = if (position == 0) null else getItem(position - 1)
    holder.bind(item, previousItem)
  }

Every view then sets a header, which is only made visible if the previous item doesn't have the same header.

    val previousHeader =  previousItem?.name?.capitalize().first()
    val header = item?.name?.capitalize()?.first()
    view.cachedContactHeader.text = header
    view.cachedContactHeader.isVisible  = previousHeader != header
Category answered 29/3, 2019 at 10:21 Comment(0)
N
0

Kiskae's answer is excellent and for your case option 2 probably works well.

In my case I wanted to have one additional item that wasn't in the database, like this:

  • Show all
  • Item 1
  • Item 2

It needed to be clickable as well. There's the usual way of overriding getItemCount to return +1 and offsetting positions for the other methods.

But I stumbled on another way that I haven't seen documented yet that might be useful for some cases. You might be able to incorporate additional elements into your query using union:

@Query("select '' as name, 0 as id " +
        "union " +
        "select name, id from user " +
        "order by 1 asc")
DataSource.Factory<Integer, User> getAllDataSource();

That means the data source actually returns another item in the beginning, and there's no need to adjust positions. In your adapter, you can check for that item and handle it differently.

In your case the query would have to be different but I think it would be possible.

Natalianatalie answered 7/4, 2019 at 8:55 Comment(1)
The solution should be same as the one @Whiskey said.Questioning

© 2022 - 2024 — McMap. All rights reserved.