diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt
index 9555a7f6d9..28e4e79362 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt
@@ -31,6 +31,7 @@ import android.view.View
import android.view.View.OnFocusChangeListener
import android.view.ViewGroup
import android.view.ViewTreeObserver
+import android.view.animation.AnimationUtils
import android.view.inputmethod.EditorInfo
import android.widget.ImageButton
import android.widget.TextView
@@ -274,6 +275,7 @@ import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.BubbleUtil
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.ContextUtil
+import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.Debouncer
import org.thoughtcrime.securesms.util.DeleteDialog
import org.thoughtcrime.securesms.util.Dialogs
@@ -332,6 +334,9 @@ class ConversationFragment :
private const val ACTION_PINNED_SHORTCUT = "action_pinned_shortcut"
private const val SAVED_STATE_IS_SEARCH_REQUESTED = "is_search_requested"
private const val EMOJI_SEARCH_FRAGMENT_TAG = "EmojiSearchFragment"
+
+ private const val SCROLL_HEADER_ANIMATION_DURATION: Long = 100L
+ private const val SCROLL_HEADER_CLOSE_DELAY: Long = SCROLL_HEADER_ANIMATION_DURATION * 4
}
private val args: ConversationIntents.Args by lazy {
@@ -1159,6 +1164,17 @@ class ConversationFragment :
R.color.signal_colorBackground
}
+ binding.scrollDateHeader.setBackgroundResource(
+ if (wallpaperEnabled) R.drawable.sticky_date_header_background_wallpaper else R.drawable.sticky_date_header_background
+ )
+
+ binding.scrollDateHeader.setTextColor(
+ ContextCompat.getColor(
+ requireContext(),
+ if (wallpaperEnabled) R.color.sticky_header_foreground_wallpaper else R.color.signal_colorOnSurfaceVariant
+ )
+ )
+
WindowUtil.setNavigationBarColor(requireActivity(), ContextCompat.getColor(requireContext(), navColor))
}
@@ -2019,9 +2035,63 @@ class ConversationFragment :
return layoutManager.findFirstCompletelyVisibleItemPosition() > 4
}
+ /**
+ * Controls animation and visibility of the scrollDateHeader.
+ */
+ private inner class ScrollDateHeaderHelper {
+
+ private val slideIn = AnimationUtils.loadAnimation(
+ requireContext(),
+ R.anim.slide_from_top
+ ).apply {
+ duration = SCROLL_HEADER_ANIMATION_DURATION
+ }
+
+ private val slideOut = AnimationUtils.loadAnimation(
+ requireContext(),
+ R.anim.conversation_scroll_date_header_slide_to_top
+ ).apply {
+ duration = SCROLL_HEADER_ANIMATION_DURATION
+ }
+
+ private var pendingHide = false
+
+ fun show() {
+ if (binding.scrollDateHeader.text.isNullOrEmpty()) {
+ return
+ }
+
+ if (pendingHide) {
+ pendingHide = false
+ } else {
+ ViewUtil.animateIn(binding.scrollDateHeader, slideIn)
+ }
+ }
+
+ fun bind(message: ConversationMessage?) {
+ if (message != null) {
+ binding.scrollDateHeader.text = DateUtils.getConversationDateHeaderString(requireContext(), Locale.getDefault(), message.conversationTimestamp)
+ } else {
+ binding.scrollDateHeader.text = null
+ }
+ }
+
+ fun hide() {
+ pendingHide = true
+
+ binding.scrollDateHeader.postDelayed({
+ if (pendingHide) {
+ pendingHide = false
+ ViewUtil.animateOut(binding.scrollDateHeader, slideOut)
+ }
+ }, SCROLL_HEADER_CLOSE_DELAY)
+ }
+ }
+
private inner class ScrollListener : RecyclerView.OnScrollListener() {
private var wasAtBottom = true
+ private val scrollDateHeaderHelper = ScrollDateHeaderHelper()
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
if (isScrolledToBottom()) {
@@ -2032,10 +2102,21 @@ class ConversationFragment :
presentComposeDivider()
+ val message = adapter.getConversationMessage(layoutManager.findLastVisibleItemPosition())
+ scrollDateHeaderHelper.bind(message)
+
val timestamp = MarkReadHelper.getLatestTimestamp(adapter, layoutManager)
timestamp.ifPresent(markReadHelper::onViewsRevealed)
}
+ override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
+ if (newState == RecyclerView.SCROLL_STATE_DRAGGING) {
+ scrollDateHeaderHelper.show()
+ } else {
+ scrollDateHeaderHelper.hide()
+ }
+ }
+
private fun presentComposeDivider() {
val isAtBottom = isScrolledToBottom()
if (isAtBottom && !wasAtBottom) {
diff --git a/app/src/main/res/anim/conversation_scroll_date_header_slide_to_top.xml b/app/src/main/res/anim/conversation_scroll_date_header_slide_to_top.xml
new file mode 100644
index 0000000000..a7e3fa94f1
--- /dev/null
+++ b/app/src/main/res/anim/conversation_scroll_date_header_slide_to_top.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/v2_conversation_fragment.xml b/app/src/main/res/layout/v2_conversation_fragment.xml
index 018a80c45c..c1217c8a87 100644
--- a/app/src/main/res/layout/v2_conversation_fragment.xml
+++ b/app/src/main/res/layout/v2_conversation_fragment.xml
@@ -51,6 +51,27 @@
tools:itemCount="20"
tools:listitem="@layout/conversation_item_sent_text_only" />
+
+