mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-24 21:15:48 +00:00
Replace TypingIndicatorItemDecoration with TypingIndicatorAdapter.
This commit is contained in:
@@ -27,6 +27,7 @@ import androidx.core.view.children
|
||||
import androidx.core.view.forEach
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.recyclerview.widget.ConcatAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.airbnb.lottie.SimpleColorFilter
|
||||
import com.google.android.material.animation.ArgbEvaluatorCompat
|
||||
@@ -124,7 +125,15 @@ class MultiselectItemDecoration(
|
||||
}
|
||||
|
||||
private fun getCurrentSelection(parent: RecyclerView): Set<MultiselectPart> {
|
||||
return (parent.adapter as ConversationAdapterBridge).selectedItems
|
||||
return parent.findAdapterBridge().selectedItems
|
||||
}
|
||||
|
||||
private fun RecyclerView.findAdapterBridge(): ConversationAdapterBridge {
|
||||
return when (val parentAdapter = adapter!!) {
|
||||
is ConversationAdapterBridge -> parentAdapter
|
||||
is ConcatAdapter -> (parentAdapter.adapters[1] as ConversationAdapterBridge)
|
||||
else -> error("Unexpected adapter configuration")
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
|
||||
@@ -156,14 +165,14 @@ class MultiselectItemDecoration(
|
||||
outRect.setEmpty()
|
||||
updateChildOffsets(parent, view)
|
||||
|
||||
consumePulseRequest(parent.adapter as ConversationAdapterBridge)
|
||||
consumePulseRequest(parent.findAdapterBridge())
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the background shade.
|
||||
*/
|
||||
override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
|
||||
val adapter = parent.adapter as ConversationAdapterBridge
|
||||
val adapter = parent.findAdapterBridge()
|
||||
|
||||
if (adapter.selectedItems.isEmpty()) {
|
||||
drawFocusShadeUnderIfNecessary(canvas, parent)
|
||||
@@ -231,7 +240,7 @@ class MultiselectItemDecoration(
|
||||
* Draws the selected check or empty circle.
|
||||
*/
|
||||
override fun onDrawOver(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
|
||||
val adapter = parent.adapter as ConversationAdapterBridge
|
||||
val adapter = parent.findAdapterBridge()
|
||||
if (adapter.selectedItems.isEmpty()) {
|
||||
drawFocusShadeOverIfNecessary(canvas, parent)
|
||||
}
|
||||
@@ -347,7 +356,7 @@ class MultiselectItemDecoration(
|
||||
* called in getItemOffsets to ensure the gutter goes away when multiselect mode ends.
|
||||
*/
|
||||
private fun updateChildOffsets(parent: RecyclerView, child: View) {
|
||||
val adapter = parent.adapter as ConversationAdapterBridge
|
||||
val adapter = parent.findAdapterBridge()
|
||||
val isLtr = ViewUtil.isLtr(child)
|
||||
val multiselectable: Multiselectable = resolveMultiselectable(parent, child) ?: return
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.recyclerview.widget.ConcatAdapter
|
||||
import androidx.recyclerview.widget.ConversationLayoutManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
@@ -468,6 +469,7 @@ class ConversationFragment :
|
||||
private lateinit var conversationActivityResultContracts: ConversationActivityResultContracts
|
||||
private lateinit var scrollToPositionDelegate: ScrollToPositionDelegate
|
||||
private lateinit var adapter: ConversationAdapterV2
|
||||
private lateinit var typingIndicatorAdapter: ConversationTypingIndicatorAdapter
|
||||
private lateinit var recyclerViewColorizer: RecyclerViewColorizer
|
||||
private lateinit var attachmentManager: AttachmentManager
|
||||
private lateinit var multiselectItemDecoration: MultiselectItemDecoration
|
||||
@@ -475,7 +477,6 @@ class ConversationFragment :
|
||||
private lateinit var threadHeaderMarginDecoration: ThreadHeaderMarginDecoration
|
||||
private lateinit var conversationItemDecorations: ConversationItemDecorations
|
||||
private lateinit var optionsMenuCallback: ConversationOptionsMenuCallback
|
||||
private lateinit var typingIndicatorDecoration: TypingIndicatorDecoration
|
||||
private lateinit var backPressedCallback: BackPressedDelegate
|
||||
|
||||
private var animationsAllowed = false
|
||||
@@ -1086,18 +1087,24 @@ class ConversationFragment :
|
||||
}
|
||||
|
||||
private fun presentTypingIndicator() {
|
||||
typingIndicatorDecoration = TypingIndicatorDecoration(requireContext(), binding.conversationItemRecycler)
|
||||
binding.conversationItemRecycler.addItemDecoration(typingIndicatorDecoration)
|
||||
typingIndicatorAdapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
|
||||
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
|
||||
if (positionStart == 0 && itemCount == 1 && layoutManager.findFirstCompletelyVisibleItemPosition() == 0) {
|
||||
scrollToPositionDelegate.resetScrollPosition()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ApplicationDependencies.getTypingStatusRepository().getTypists(args.threadId).observe(viewLifecycleOwner) {
|
||||
val recipient = viewModel.recipientSnapshot ?: return@observe
|
||||
|
||||
typingIndicatorDecoration.setTypists(
|
||||
GlideApp.with(this),
|
||||
it.typists,
|
||||
recipient.isGroup,
|
||||
recipient.hasWallpaper(),
|
||||
it.isReplacedByIncomingMessage
|
||||
typingIndicatorAdapter.setState(
|
||||
ConversationTypingIndicatorAdapter.State(
|
||||
typists = it.typists,
|
||||
isGroupThread = recipient.isGroup,
|
||||
hasWallpaper = recipient.hasWallpaper(),
|
||||
isReplacedByIncomingMessage = it.isReplacedByIncomingMessage
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1531,6 +1538,8 @@ class ConversationFragment :
|
||||
startExpirationTimeout = viewModel::startExpirationTimeout
|
||||
)
|
||||
|
||||
typingIndicatorAdapter = ConversationTypingIndicatorAdapter(GlideApp.with(this))
|
||||
|
||||
scrollToPositionDelegate = ScrollToPositionDelegate(
|
||||
recyclerView = binding.conversationItemRecycler,
|
||||
canJumpToPosition = adapter::canJumpToPosition
|
||||
@@ -1541,7 +1550,7 @@ class ConversationFragment :
|
||||
recyclerViewColorizer = RecyclerViewColorizer(binding.conversationItemRecycler)
|
||||
recyclerViewColorizer.setChatColors(args.chatColors)
|
||||
|
||||
binding.conversationItemRecycler.adapter = adapter
|
||||
binding.conversationItemRecycler.adapter = ConcatAdapter(typingIndicatorAdapter, adapter)
|
||||
multiselectItemDecoration = MultiselectItemDecoration(
|
||||
requireContext()
|
||||
) { viewModel.wallpaperSnapshot }
|
||||
|
||||
@@ -55,7 +55,14 @@ class ConversationItemDecorations(hasWallpaper: Boolean = false, private val sch
|
||||
}
|
||||
|
||||
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
|
||||
val position = parent.getChildAdapterPosition(view)
|
||||
val viewHolder = parent.getChildViewHolder(view)
|
||||
|
||||
if (viewHolder is ConversationTypingIndicatorAdapter.ViewHolder) {
|
||||
outRect.set(0, 0, 0, 0)
|
||||
return
|
||||
}
|
||||
|
||||
val position = viewHolder.bindingAdapterPosition
|
||||
|
||||
val unreadHeight = if (isFirstUnread(position)) {
|
||||
getUnreadViewHolder(parent).height
|
||||
@@ -76,9 +83,15 @@ class ConversationItemDecorations(hasWallpaper: Boolean = false, private val sch
|
||||
val count = parent.childCount
|
||||
for (layoutPosition in 0 until count) {
|
||||
val child = parent.getChildAt(count - 1 - layoutPosition)
|
||||
val position = parent.getChildAdapterPosition(child)
|
||||
val viewHolder = parent.getChildViewHolder(child)
|
||||
|
||||
val unreadOffset = if (isFirstUnread(position)) {
|
||||
if (viewHolder is ConversationTypingIndicatorAdapter.ViewHolder) {
|
||||
continue
|
||||
}
|
||||
|
||||
val bindingAdapterPosition = viewHolder.bindingAdapterPosition
|
||||
|
||||
val unreadOffset = if (isFirstUnread(bindingAdapterPosition)) {
|
||||
val unread = getUnreadViewHolder(parent)
|
||||
unread.itemView.drawAsTopItemDecoration(c, parent, child)
|
||||
unread.height
|
||||
@@ -86,8 +99,8 @@ class ConversationItemDecorations(hasWallpaper: Boolean = false, private val sch
|
||||
0
|
||||
}
|
||||
|
||||
if (hasHeader(position)) {
|
||||
val headerView = getHeader(parent, currentItems[position] as ConversationMessageElement).itemView
|
||||
if (hasHeader(bindingAdapterPosition)) {
|
||||
val headerView = getHeader(parent, currentItems[bindingAdapterPosition] as ConversationMessageElement).itemView
|
||||
headerView.drawAsTopItemDecoration(c, parent, child, unreadOffset)
|
||||
}
|
||||
}
|
||||
@@ -136,18 +149,18 @@ class ConversationItemDecorations(hasWallpaper: Boolean = false, private val sch
|
||||
}
|
||||
}
|
||||
|
||||
private fun isFirstUnread(position: Int): Boolean {
|
||||
private fun isFirstUnread(bindingAdapterPosition: Int): Boolean {
|
||||
val state = unreadState
|
||||
|
||||
return state is UnreadState.CompleteUnreadState &&
|
||||
state.firstUnreadTimestamp != null &&
|
||||
position in currentItems.indices &&
|
||||
(currentItems[position] as? ConversationMessageElement)?.timestamp() == state.firstUnreadTimestamp
|
||||
bindingAdapterPosition in currentItems.indices &&
|
||||
(currentItems[bindingAdapterPosition] as? ConversationMessageElement)?.timestamp() == state.firstUnreadTimestamp
|
||||
}
|
||||
|
||||
private fun hasHeader(position: Int): Boolean {
|
||||
val model = if (position in currentItems.indices) {
|
||||
currentItems[position]
|
||||
private fun hasHeader(bindingAdapterPosition: Int): Boolean {
|
||||
val model = if (bindingAdapterPosition in currentItems.indices) {
|
||||
currentItems[bindingAdapterPosition]
|
||||
} else {
|
||||
null
|
||||
}
|
||||
@@ -156,7 +169,7 @@ class ConversationItemDecorations(hasWallpaper: Boolean = false, private val sch
|
||||
return false
|
||||
}
|
||||
|
||||
val previousPosition = position + 1
|
||||
val previousPosition = bindingAdapterPosition + 1
|
||||
val previousDay: Long
|
||||
if (previousPosition in currentItems.indices) {
|
||||
val previousModel = currentItems[previousPosition]
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.conversation.v2
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.signal.core.util.toInt
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.ConversationTypingView
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
|
||||
class ConversationTypingIndicatorAdapter(
|
||||
private val glideRequests: GlideRequests
|
||||
) : RecyclerView.Adapter<ConversationTypingIndicatorAdapter.ViewHolder>() {
|
||||
|
||||
private var state: State = State()
|
||||
|
||||
fun setState(state: State) {
|
||||
val isInsert = this.state.typists.isEmpty() && state.typists.isNotEmpty()
|
||||
val isRemoval = state.typists.isEmpty() && this.state.typists.isNotEmpty()
|
||||
val isChange = state.typists.isNotEmpty() && this.state.typists.isNotEmpty()
|
||||
|
||||
this.state = state
|
||||
|
||||
when {
|
||||
isInsert -> notifyItemInserted(0)
|
||||
isRemoval -> notifyItemRemoved(0)
|
||||
isChange -> notifyItemChanged(0)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
return ViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.conversation_typing_view, parent, false) as ConversationTypingView)
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = state.typists.isNotEmpty().toInt()
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
holder.bind(glideRequests, state)
|
||||
}
|
||||
|
||||
class ViewHolder(private val conversationTypingView: ConversationTypingView) : RecyclerView.ViewHolder(conversationTypingView) {
|
||||
fun bind(
|
||||
glideRequests: GlideRequests,
|
||||
state: State
|
||||
) {
|
||||
conversationTypingView.setTypists(
|
||||
glideRequests,
|
||||
state.typists,
|
||||
state.isGroupThread,
|
||||
state.hasWallpaper
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class State(
|
||||
val typists: List<Recipient> = emptyList(),
|
||||
val hasWallpaper: Boolean = false,
|
||||
val isGroupThread: Boolean = false,
|
||||
val isReplacedByIncomingMessage: Boolean = false // TODO
|
||||
)
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.conversation.v2
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Rect
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.View.MeasureSpec
|
||||
import androidx.core.graphics.withTranslation
|
||||
import androidx.core.view.children
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.RecyclerView.ItemDecoration
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.ConversationTypingView
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
|
||||
/**
|
||||
* Displays a typing indicator as a part of the very last (first) item in the adapter.
|
||||
*/
|
||||
class TypingIndicatorDecoration(
|
||||
private val context: Context,
|
||||
private val rootView: RecyclerView
|
||||
) : ItemDecoration() {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(TypingIndicatorDecoration::class.java)
|
||||
}
|
||||
|
||||
private val typingView: ConversationTypingView by lazy(LazyThreadSafetyMode.NONE) {
|
||||
LayoutInflater.from(context).inflate(R.layout.conversation_typing_view, rootView, false) as ConversationTypingView
|
||||
}
|
||||
|
||||
private var displayIndicator = false
|
||||
private var animationFraction = 0f
|
||||
private var offsetAnimator: ValueAnimator? = null
|
||||
|
||||
init {
|
||||
rootView.addOnLayoutChangeListener { _, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom ->
|
||||
if (left != oldLeft || top != oldTop || right != oldRight || bottom != oldBottom) {
|
||||
remeasureTypingView()
|
||||
rootView.invalidateItemDecorations()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
|
||||
if (!displayIndicator && animationFraction == 0f) {
|
||||
return outRect.set(0, 0, 0, 0)
|
||||
}
|
||||
|
||||
if (parent.getChildAdapterPosition(view) == 0) {
|
||||
remeasureTypingView()
|
||||
outRect.set(0, 0, 0, (typingView.measuredHeight * animationFraction).toInt())
|
||||
} else {
|
||||
outRect.set(0, 0, 0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
|
||||
if (!displayIndicator && offsetAnimator?.isRunning != true) {
|
||||
return
|
||||
}
|
||||
|
||||
val firstChild = parent.children.firstOrNull() ?: return
|
||||
|
||||
if (parent.getChildAdapterPosition(firstChild) == 0) {
|
||||
c.withTranslation(
|
||||
x = firstChild.left.toFloat(),
|
||||
y = firstChild.bottom.toFloat()
|
||||
) {
|
||||
typingView.draw(this)
|
||||
}
|
||||
|
||||
if (typingView.isActive) {
|
||||
rootView.post { rootView.invalidateItemDecorations() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setTypists(
|
||||
glideRequests: GlideRequests,
|
||||
typists: List<Recipient>,
|
||||
isGroupThread: Boolean,
|
||||
hasWallpaper: Boolean,
|
||||
isReplacedByIncomingMessage: Boolean
|
||||
) {
|
||||
Log.d(TAG, "setTypists: Updating typists: ${typists.size} $isGroupThread $hasWallpaper $isReplacedByIncomingMessage")
|
||||
|
||||
val isEdge = displayIndicator != typists.isNotEmpty()
|
||||
displayIndicator = typists.isNotEmpty()
|
||||
|
||||
typingView.setTypists(
|
||||
glideRequests,
|
||||
typists,
|
||||
isGroupThread,
|
||||
hasWallpaper
|
||||
)
|
||||
remeasureTypingView()
|
||||
rootView.invalidateItemDecorations()
|
||||
|
||||
if (isReplacedByIncomingMessage) {
|
||||
offsetAnimator?.cancel()
|
||||
animationFraction = 0f
|
||||
} else if (isEdge) {
|
||||
animateOffset()
|
||||
}
|
||||
}
|
||||
|
||||
private fun animateOffset() {
|
||||
offsetAnimator?.cancel()
|
||||
|
||||
val (start, end) = if (displayIndicator) {
|
||||
animationFraction to 1f
|
||||
} else {
|
||||
animationFraction to 0f
|
||||
}
|
||||
|
||||
offsetAnimator = ValueAnimator.ofFloat(start, end).apply {
|
||||
addUpdateListener {
|
||||
animationFraction = it.animatedValue as Float
|
||||
rootView.invalidateItemDecorations()
|
||||
}
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun remeasureTypingView() {
|
||||
with(typingView) {
|
||||
measure(
|
||||
MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED),
|
||||
MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
|
||||
)
|
||||
layout(
|
||||
0,
|
||||
0,
|
||||
measuredWidth,
|
||||
measuredHeight
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user