Floating date
Closes https://github.com/SchildiChat/SchildiChat-android/issues/41 Change-Id: I0eb9c6c3800309be40a1f5bc0c4420bd4066c098
This commit is contained in:
parent
85a26ae8be
commit
a96d27cb81
|
@ -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))
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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?) {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
|
@ -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" />
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue