Replace TypingIndicatorItemDecoration with TypingIndicatorAdapter.

This commit is contained in:
Alex Hart
2023-09-21 13:05:49 -04:00
committed by GitHub
parent 190b9da6c7
commit f8cb26ca74
5 changed files with 126 additions and 176 deletions

View File

@@ -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

View File

@@ -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 }

View File

@@ -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]

View File

@@ -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
)
}

View File

@@ -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
)
}
}
}