Floating date

Closes https://github.com/SchildiChat/SchildiChat-android/issues/41

Change-Id: I0eb9c6c3800309be40a1f5bc0c4420bd4066c098
This commit is contained in:
SpiritCroc 2022-05-08 12:07:28 +02:00
parent 85a26ae8be
commit a96d27cb81
9 changed files with 326 additions and 3 deletions

View file

@ -9,6 +9,7 @@ Here you can find some extra features and changes compared to Element Android (w
- Possibility to select themes for both light and dark system mode individually
- [UnifiedPush](https://unifiedpush.org/) support
- "Easy mode" which disables public room functionality
- Floating date
- Setting for room previews: show all events, hide membership changes, hide membership changes and reactions (individual settings for direct chats and groups)
- More prominent unread counter for chats in the room overview (bigger, different placement, more noticeable color in SchildiChat designs)
- Mark chats as unread ([MSC2867](https://github.com/matrix-org/matrix-spec-proposals/pull/2867), only works with compatible clients (SchildiChat, FluffyChat))

View file

@ -0,0 +1,210 @@
package de.spiritcroc.recyclerview
/**
* Source: https://gist.github.com/jonasbark/f1e1373705cfe8f6a7036763f7326f7c
* Modified to
* - make isHeader() abstract, so we don't need a predefined list of header ids
* - not require EpoxyRecyclerView
* - work with reverse layouts
* - hide the currently overlaid header for a smoother animation without duplicate headers
*/
import android.graphics.Canvas
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import com.airbnb.epoxy.EpoxyController
import org.matrix.android.sdk.api.extensions.orFalse
abstract class StickyHeaderItemDecoration(
private val epoxyController: EpoxyController,
private val reverse: Boolean = false
) : RecyclerView.ItemDecoration() {
private var mStickyHeaderHeight: Int = 0
private var lastHeaderPos: Int? = null
override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
super.onDrawOver(c, parent, state)
if (parent.childCount == 0) {
return
}
val topChild = if (reverse) {
parent.getChildAt(parent.childCount - 1) ?: return
} else {
parent.getChildAt(0) ?: return
}
val topChildPosition = parent.getChildAdapterPosition(topChild)
if (topChildPosition == RecyclerView.NO_POSITION) {
return
}
val headerPos = getHeaderPositionForItem(topChildPosition)
if (headerPos != RecyclerView.NO_POSITION) {
val currentHeader = getHeaderViewForItem(headerPos, parent)
fixLayoutSize(parent, currentHeader)
val contactPoint = currentHeader.bottom
val childInContact = getChildInContact(parent, contactPoint, headerPos)
if (childInContact != null && isHeader(parent.getChildAdapterPosition(childInContact))) {
updateOverlaidHeaders(parent, headerPos)
moveHeader(c, currentHeader, childInContact)
return
}
// Un-hide views early, so we don't get flashing headers while scrolling
val childBellow = getChildInContact(parent, currentHeader.top, headerPos)
val overlaidHeaderPos: Int? = if (childBellow != childInContact &&
childBellow != null &&
isHeader(parent.getChildAdapterPosition(childBellow)) &&
contactPoint - childBellow.bottom < (childBellow.bottom - childBellow.top)/8
) {
null
} else {
headerPos
}
updateOverlaidHeaders(parent, overlaidHeaderPos)
drawHeader(c, currentHeader)
} else {
// Show hidden header again
updateOverlaidHeaders(parent, null)
}
}
private fun updateOverlaidHeaders(parent: RecyclerView, headerPos: Int?) {
if (lastHeaderPos != headerPos) {
// Show hidden header again
lastHeaderPos?.let {
updateOverlaidHeader(parent, it, false)
}
// Remember new hidden header
lastHeaderPos = if (headerPos?.let { updateOverlaidHeader(parent, it, true) }.orFalse()) {
headerPos
} else {
null
}
}
}
/**
* Return true if successfully updated the view.
* Note: this has some issues when invisible views get recycled, better override this in subclasses.
*/
open fun updateOverlaidHeader(parent: RecyclerView, position: Int, isCurrentlyOverlaid: Boolean): Boolean {
val view = parent.findViewHolderForAdapterPosition(position)?.itemView
if (view != null) {
view.isVisible = !isCurrentlyOverlaid
return true
}
return false
}
open fun getHeaderViewForItem(headerPosition: Int, parent: RecyclerView): View {
val viewHolder = epoxyController.adapter.onCreateViewHolder(
parent,
epoxyController.adapter.getItemViewType(headerPosition)
)
epoxyController.adapter.onBindViewHolder(viewHolder, headerPosition)
return viewHolder.itemView
}
private fun drawHeader(c: Canvas, header: View) {
c.save()
c.translate(0f, 0f)
header.draw(c)
c.restore()
}
private fun moveHeader(c: Canvas, currentHeader: View, nextHeader: View) {
c.save()
c.translate(0f, (nextHeader.top - currentHeader.height).toFloat())
currentHeader.draw(c)
c.restore()
}
abstract fun isHeader(itemPosition: Int): Boolean
private fun getChildInContact(parent: RecyclerView, contactPoint: Int, currentHeaderPos: Int): View? {
var childInContact: View? = null
for (i in 0 until parent.childCount) {
var heightTolerance = 0
val child = parent.getChildAt(i)
//measure height tolerance with child if child is another header
if (currentHeaderPos != i) {
val isChildHeader = isHeader(parent.getChildAdapterPosition(child))
if (isChildHeader) {
heightTolerance = mStickyHeaderHeight - child.height
}
}
//add heightTolerance if child top be in display area
val childBottomPosition = if (child.top > 0) {
child.bottom + heightTolerance
} else {
child.bottom
}
if (childBottomPosition > contactPoint) {
if (child.top <= contactPoint) {
// This child overlaps the contactPoint
childInContact = child
break
}
}
}
return childInContact
}
/**
* This method gets called by [StickyHeaderItemDecoration] to fetch the position of the header item in the adapter
* that is used for (represents) item at specified position.
* @param itemPosition int. Adapter's position of the item for which to do the search of the position of the header item.
* @return int. Position of the header item in the adapter.
*/
private fun getHeaderPositionForItem(itemPosition: Int): Int {
var tempPosition = itemPosition
var headerPosition = RecyclerView.NO_POSITION
val directionAdd = if (reverse) 1 else -1
do {
if (isHeader(tempPosition)) {
headerPosition = tempPosition
break
}
tempPosition += directionAdd
} while (tempPosition >= -1 && tempPosition < epoxyController.adapter.itemCount)
return headerPosition
}
/**
* Properly measures and layouts the top sticky header.
* @param parent ViewGroup: RecyclerView in this case.
*/
private fun fixLayoutSize(parent: ViewGroup, view: View) {
// Specs for parent (RecyclerView)
val widthSpec = View.MeasureSpec.makeMeasureSpec(parent.width, View.MeasureSpec.EXACTLY)
val heightSpec = View.MeasureSpec.makeMeasureSpec(parent.height, View.MeasureSpec.UNSPECIFIED)
// Specs for children (headers)
val childWidthSpec =
ViewGroup.getChildMeasureSpec(widthSpec, parent.paddingLeft + parent.paddingRight, view.layoutParams.width)
val childHeightSpec = ViewGroup.getChildMeasureSpec(
heightSpec,
parent.paddingTop + parent.paddingBottom,
view.layoutParams.height
)
view.measure(childWidthSpec, childHeightSpec)
mStickyHeaderHeight = view.measuredHeight
view.layout(0, 0, view.measuredWidth, view.measuredHeight)
}
}

View file

@ -62,6 +62,7 @@ import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import androidx.transition.TransitionManager
import com.airbnb.epoxy.EpoxyModel
import com.airbnb.epoxy.EpoxyViewHolder
import com.airbnb.epoxy.OnModelBuildFinishedListener
import com.airbnb.epoxy.addGlidePreloader
import com.airbnb.epoxy.glidePreloader
@ -72,10 +73,12 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.vanniktech.emoji.EmojiPopup
import de.spiritcroc.matrixsdk.util.DbgUtil
import de.spiritcroc.matrixsdk.util.Dimber
import de.spiritcroc.recyclerview.StickyHeaderItemDecoration
import de.spiritcroc.recyclerview.widget.BetterLinearLayoutManager
import de.spiritcroc.recyclerview.widget.LinearLayoutManager
import im.vector.app.R
import im.vector.app.core.animations.play
import im.vector.app.core.date.DateFormatKind
import im.vector.app.core.dialogs.ConfirmationDialogBuilder
import im.vector.app.core.dialogs.GalleryOrCameraDialogHelper
import im.vector.app.core.epoxy.LayoutManagerStateRestorer
@ -167,6 +170,7 @@ import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlayb
import im.vector.app.features.home.room.detail.timeline.helper.MatrixItemColorProvider
import im.vector.app.features.home.room.detail.timeline.image.buildImageContentRendererData
import im.vector.app.features.home.room.detail.timeline.item.AbsMessageItem
import im.vector.app.features.home.room.detail.timeline.item.DaySeparatorItem
import im.vector.app.features.home.room.detail.timeline.item.ItemWithEvents
import im.vector.app.features.home.room.detail.timeline.item.MessageAudioItem
import im.vector.app.features.home.room.detail.timeline.item.MessageFileItem
@ -220,6 +224,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.billcarsonfr.jsonviewer.JSonViewerDialog
import org.commonmark.parser.Parser
import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.content.ContentAttachmentData
import org.matrix.android.sdk.api.session.events.model.EventType
@ -1478,6 +1483,46 @@ class TimelineFragment @Inject constructor(
timelineEventController.callback = this
timelineEventController.timeline = timelineViewModel.timeline
if (vectorPreferences.floatingDate()) {
views.timelineRecyclerView.addItemDecoration(
object : StickyHeaderItemDecoration(timelineEventController, reverse = true) {
override fun isHeader(itemPosition: Int): Boolean {
if (itemPosition != RecyclerView.NO_POSITION) {
val model = timelineEventController.adapter.getModelAtPosition(itemPosition)
return model is DaySeparatorItem
}
return false
}
override fun getHeaderViewForItem(headerPosition: Int, parent: RecyclerView): View {
// Same as super
val viewHolder = timelineEventController.adapter.onCreateViewHolder(
parent,
timelineEventController.adapter.getItemViewType(headerPosition)
)
timelineEventController.adapter.onBindViewHolder(viewHolder, headerPosition)
// Same as super -- end
// We want to hide the separator line for floating dates
(viewHolder.holder as? DaySeparatorItem.Holder)?.let { DaySeparatorItem.asFloatingDate(it) }
return viewHolder.itemView
}
// While the header has a sticky overlay, only hide its text, not the separator lines
override fun updateOverlaidHeader(parent: RecyclerView, position: Int, isCurrentlyOverlaid: Boolean): Boolean {
val model = tryOrNull { timelineEventController.adapter.getModelAtPosition(position) as? DaySeparatorItem }
if (model != null) {
val viewHolder = ((parent.findViewHolderForAdapterPosition(position) as? EpoxyViewHolder)?.holder) as? DaySeparatorItem.Holder
model.shouldBeVisible(!isCurrentlyOverlaid, viewHolder)
return true
}
return false
}
}
)
}
views.timelineRecyclerView.trackItemsVisibilityChange()
layoutManager = object : BetterLinearLayoutManager(context, RecyclerView.VERTICAL, true) {
override fun onLayoutCompleted(state: RecyclerView.State?) {

View file

@ -16,7 +16,9 @@
package im.vector.app.features.home.room.detail.timeline.item
import android.view.View
import android.widget.TextView
import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import com.airbnb.epoxy.EpoxyModelWithHolder
@ -28,12 +30,32 @@ abstract class DaySeparatorItem : EpoxyModelWithHolder<DaySeparatorItem.Holder>(
@EpoxyAttribute lateinit var formattedDay: String
private var shouldBeVisible: Boolean = true
override fun bind(holder: Holder) {
super.bind(holder)
holder.dayTextView.text = formattedDay
// Just as background space reservation. Use same text for proper measures.
holder.dayTextCutoutView.text = formattedDay
// As we may hide this for floating dates, ensure we un-hide it on bind
holder.dayTextView.isVisible = shouldBeVisible
}
fun shouldBeVisible(shouldBeVisible: Boolean, holder: Holder?) {
holder?.dayTextView?.isVisible = shouldBeVisible
this.shouldBeVisible = shouldBeVisible
}
companion object {
fun asFloatingDate(holder: Holder) {
holder.dayTextView.isVisible = true
holder.separatorView.isVisible = false
}
}
class Holder : VectorEpoxyHolder() {
val dayTextView by bind<TextView>(R.id.itemDayTextView)
val dayTextCutoutView by bind<TextView>(R.id.itemDayTextCutoutView)
val separatorView by bind<View>(R.id.itemDayTextSeparatorView)
}
}

View file

@ -222,6 +222,7 @@ class VectorPreferences @Inject constructor(
private const val SETTINGS_HIDE_CALL_BUTTONS = "SETTINGS_HIDE_CALL_BUTTONS"
private const val SETTINGS_READ_RECEIPT_FOLLOWS_READ_MARKER = "SETTINGS_READ_RECEIPT_FOLLOWS_READ_MARKER"
private const val SETTINGS_SHOW_OPEN_ANONYMOUS = "SETTINGS_SHOW_OPEN_ANONYMOUS"
private const val SETTINGS_FLOATING_DATE = "SETTINGS_FLOATING_DATE"
private const val DID_ASK_TO_ENABLE_SESSION_PUSH = "DID_ASK_TO_ENABLE_SESSION_PUSH"
@ -1133,6 +1134,10 @@ class VectorPreferences @Inject constructor(
return defaultPrefs.getBoolean(SETTINGS_SHOW_OPEN_ANONYMOUS, false)
}
fun floatingDate(): Boolean {
return defaultPrefs.getBoolean(SETTINGS_FLOATING_DATE, true)
}
/**
* I likely do more fresh installs of the app than anyone else, so a shortcut to change some of the default settings to
* my preferred values can safe me some time
@ -1158,6 +1163,7 @@ class VectorPreferences @Inject constructor(
.putBoolean(SETTINGS_ENABLE_SPACE_PAGER, true)
.putBoolean(SETTINGS_READ_RECEIPT_FOLLOWS_READ_MARKER, true)
.putBoolean(SETTINGS_SHOW_OPEN_ANONYMOUS, true)
.putBoolean(SETTINGS_FLOATING_DATE, true)
.apply()
}

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="?android:colorBackground" />
<corners android:radius="3dp" />
</shape>

View file

@ -6,6 +6,10 @@
android:padding="8dp"
tools:viewBindingIgnore="true">
<FrameLayout
android:id="@+id/itemDayTextSeparatorView"
android:layout_width="match_parent"
android:layout_height="match_parent">
<View
android:layout_width="match_parent"
android:layout_height="1dp"
@ -13,6 +17,20 @@
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:background="?vctr_list_separator" />
<!-- Just to reserve some extra space while not floating. So use invisible text. -->
<TextView
android:id="@+id/itemDayTextCutoutView"
style="@style/Widget.Vector.TextView.Subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:background="?android:colorBackground"
android:textColor="@android:color/transparent"
android:paddingStart="24dp"
android:paddingEnd="24dp"
android:textSize="15sp"
tools:text="@tools:sample/date/day_of_week" />
</FrameLayout>
<TextView
android:id="@+id/itemDayTextView"
@ -20,9 +38,9 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:background="?android:colorBackground"
android:paddingStart="24dp"
android:paddingEnd="24dp"
android:background="@drawable/date_background"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:textColor="?vctr_content_tertiary"
android:textSize="15sp"
tools:text="@tools:sample/date/day_of_week" />

View file

@ -191,4 +191,8 @@
<string name="room_list_quick_actions_open_anonymous">Open without reading</string>
<string name="settings_show_open_anonymous">Open without reading</string>
<string name="settings_show_open_anonymous_summary">Show option to open a room without automatically marking it read</string>
<string name="settings_floating_date">Floating date</string>
<string name="settings_floating_date_summary">Show the date on top of messages while scrolling</string>
</resources>

View file

@ -243,6 +243,12 @@
android:title="@string/settings_vibrate_on_mention"
app:isPreferenceVisible="@bool/false_not_implemented" />
<im.vector.app.core.preference.VectorSwitchPreference
android:key="SETTINGS_FLOATING_DATE"
android:title="@string/settings_floating_date"
android:summary="@string/settings_floating_date_summary"
android:defaultValue="true" />
</im.vector.app.core.preference.VectorPreferenceCategory>
<im.vector.app.core.preference.VectorPreferenceCategory