diff --git a/FEATURES.md b/FEATURES.md index 1f638ba8a3..67afe65116 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -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)) diff --git a/vector/src/main/java/de/spiritcroc/recyclerview/StickyHeaderItemDecoration.kt b/vector/src/main/java/de/spiritcroc/recyclerview/StickyHeaderItemDecoration.kt new file mode 100644 index 0000000000..758070f4b8 --- /dev/null +++ b/vector/src/main/java/de/spiritcroc/recyclerview/StickyHeaderItemDecoration.kt @@ -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) + } + +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt index dde54bf55f..d3d254ec9c 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt @@ -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?) { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/DaySeparatorItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/DaySeparatorItem.kt index 4c9664e612..7aebee73a6 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/DaySeparatorItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/DaySeparatorItem.kt @@ -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( @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(R.id.itemDayTextView) + val dayTextCutoutView by bind(R.id.itemDayTextCutoutView) + val separatorView by bind(R.id.itemDayTextSeparatorView) } } diff --git a/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt b/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt index c13d5dfbe6..77abcb4599 100755 --- a/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt +++ b/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt @@ -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() } diff --git a/vector/src/main/res/drawable/date_background.xml b/vector/src/main/res/drawable/date_background.xml new file mode 100644 index 0000000000..f7dfcf5402 --- /dev/null +++ b/vector/src/main/res/drawable/date_background.xml @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/vector/src/main/res/layout/item_timeline_event_day_separator.xml b/vector/src/main/res/layout/item_timeline_event_day_separator.xml index 6eb5f1312f..07bb91e795 100644 --- a/vector/src/main/res/layout/item_timeline_event_day_separator.xml +++ b/vector/src/main/res/layout/item_timeline_event_day_separator.xml @@ -6,6 +6,10 @@ android:padding="8dp" tools:viewBindingIgnore="true"> + + + + diff --git a/vector/src/main/res/values/strings_sc.xml b/vector/src/main/res/values/strings_sc.xml index 467c8e6584..4a35f43e89 100644 --- a/vector/src/main/res/values/strings_sc.xml +++ b/vector/src/main/res/values/strings_sc.xml @@ -191,4 +191,8 @@ Open without reading Open without reading Show option to open a room without automatically marking it read + + Floating date + Show the date on top of messages while scrolling + diff --git a/vector/src/main/res/xml/vector_settings_preferences.xml b/vector/src/main/res/xml/vector_settings_preferences.xml index 25647845bf..cee4a39e30 100644 --- a/vector/src/main/res/xml/vector_settings_preferences.xml +++ b/vector/src/main/res/xml/vector_settings_preferences.xml @@ -243,6 +243,12 @@ android:title="@string/settings_vibrate_on_mention" app:isPreferenceVisible="@bool/false_not_implemented" /> + +