Add multi-select support to CFV2.

This commit is contained in:
Alex Hart 2023-07-06 13:27:42 -03:00 committed by Clark Chen
parent ebaa445bee
commit 38b2a2f5b7
8 changed files with 171 additions and 38 deletions

View file

@ -2122,6 +2122,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
return getSnapshotProjections(coordinateRoot, clipOutMedia, true);
}
@Override
public @NonNull ProjectionList getSnapshotProjections(@NonNull ViewGroup coordinateRoot, boolean clipOutMedia, boolean outgoingOnly) {
colorizerProjections.clear();

View file

@ -254,6 +254,11 @@ public final class ConversationUpdateItem extends FrameLayout
return background;
}
@Override
public @NonNull ViewGroup getRoot() {
return this;
}
static final class RecipientObserverManager {
private final Observer<Recipient> recipientObserver;

View file

@ -34,7 +34,7 @@ import org.signal.core.util.SetUtil
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.conversation.ConversationAdapterBridge
import org.thoughtcrime.securesms.conversation.ConversationAdapterBridge.PulseRequest
import org.thoughtcrime.securesms.conversation.ConversationItem
import org.thoughtcrime.securesms.conversation.v2.items.InteractiveConversationElement
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.ThemeUtil
import org.thoughtcrime.securesms.util.ViewUtil
@ -179,8 +179,8 @@ class MultiselectItemDecoration(
else -> ultramarine30
}
parent.children.filterIsInstance(Multiselectable::class.java).forEach { child ->
updateChildOffsets(parent, child as View)
parent.getMultiselectableChildren().forEach { child ->
updateChildOffsets(parent, child.root)
val parts: MultiselectCollection = child.conversationMessage.multiselectCollection
@ -206,7 +206,12 @@ class MultiselectItemDecoration(
val shadeAll = selectedParts.size == parts.size || (selectedPart is MultiselectPart.Text && child.hasNonSelectableMedia())
if (shadeAll) {
rect.set(0, child.top, child.right, child.bottom)
rect.set(
0,
child.root.top - ViewUtil.getTopMargin(child.root),
child.root.right,
child.root.bottom + ViewUtil.getBottomMargin(child.root)
)
} else {
rect.set(0, child.getTopBoundaryOfMultiselectPart(selectedPart), parent.right, child.getBottomBoundaryOfMultiselectPart(selectedPart))
}
@ -239,7 +244,7 @@ class MultiselectItemDecoration(
private fun drawChecks(parent: RecyclerView, canvas: Canvas, adapter: ConversationAdapterBridge) {
val drawCircleBehindSelector = chatWallpaperProvider()?.isPhoto == true
val multiselectChildren: Sequence<Multiselectable> = parent.children.filterIsInstance(Multiselectable::class.java)
val multiselectChildren: Sequence<Multiselectable> = parent.getMultiselectableChildren()
val isDarkTheme = ThemeUtil.isDarkTheme(parent.context)
@ -344,10 +349,11 @@ class MultiselectItemDecoration(
private fun updateChildOffsets(parent: RecyclerView, child: View) {
val adapter = parent.adapter as ConversationAdapterBridge
val isLtr = ViewUtil.isLtr(child)
val multiselectable: Multiselectable = resolveMultiselectable(parent, child) ?: return
val isAnimatingSelection = enterExitAnimation != null && isInitialAnimation()
if ((isAnimatingSelection || adapter.selectedItems.isNotEmpty()) && child is Multiselectable) {
val target = child.getHorizontalTranslationTarget()
if ((isAnimatingSelection || adapter.selectedItems.isNotEmpty())) {
val target = multiselectable.getHorizontalTranslationTarget()
if (target != null) {
val start = if (isLtr) {
@ -368,7 +374,7 @@ class MultiselectItemDecoration(
-translation
}
}
} else if (child is Multiselectable) {
} else {
child.translationX = 0f
}
}
@ -429,26 +435,28 @@ class MultiselectItemDecoration(
return
}
for (child in parent.children) {
if (child is ConversationItem) {
path.reset()
canvas.save()
for (child in parent.getInteractableChildren()) {
path.reset()
canvas.save()
val adapterPosition = parent.getChildAdapterPosition(child)
val request = pulseRequestAnimators.keys.firstOrNull { it.position == adapterPosition && it.isOutgoing == child.isOutgoing } ?: continue
val animator = pulseRequestAnimators[request] ?: continue
if (!animator.isRunning) {
continue
}
val adapterPosition = child.getAdapterPosition(parent)
child.getSnapshotProjections(parent, false, false).use { projectionList ->
projectionList.forEach { it.applyToPath(path) }
}
val request = pulseRequestAnimators.keys.firstOrNull {
it.position == adapterPosition && it.isOutgoing == child.conversationMessage.messageRecord.isOutgoing
} ?: continue
canvas.clipPath(path)
canvas.drawColor(animator.animatedValue)
canvas.restore()
val animator = pulseRequestAnimators[request] ?: continue
if (!animator.isRunning) {
continue
}
child.getSnapshotProjections(parent, false, false).use { projectionList ->
projectionList.forEach { it.applyToPath(path) }
}
canvas.clipPath(path)
canvas.drawColor(animator.animatedValue)
canvas.restore()
}
}
@ -499,6 +507,7 @@ class MultiselectItemDecoration(
animator?.end()
multiselectPartAnimatorMap[multiselectPart] = newAnimator
}
Difference.REMOVED -> {
val newAnimator = ValueAnimator.ofFloat(animator?.animatedFraction ?: 1f, 0f).apply {
duration = 150L
@ -560,6 +569,30 @@ class MultiselectItemDecoration(
}
}
private fun RecyclerView.getMultiselectableChildren(): Sequence<Multiselectable> {
return if (SignalStore.internalValues().useConversationFragmentV2()) {
children.map { getChildViewHolder(it) }.filterIsInstance<Multiselectable>()
} else {
children.filterIsInstance<Multiselectable>()
}
}
private fun RecyclerView.getInteractableChildren(): Sequence<InteractiveConversationElement> {
return if (SignalStore.internalValues().useConversationFragmentV2()) {
children.map { getChildViewHolder(it) }.filterIsInstance<InteractiveConversationElement>()
} else {
children.filterIsInstance<InteractiveConversationElement>()
}
}
private fun resolveMultiselectable(parent: RecyclerView, child: View): Multiselectable? {
return if (SignalStore.internalValues().useConversationFragmentV2()) {
parent.getChildViewHolder(child) as? Multiselectable
} else {
child as? Multiselectable
}
}
private class PulseAnimator(pulseColor: Int) {
companion object {

View file

@ -1,20 +1,44 @@
package org.thoughtcrime.securesms.conversation.mutiselect
import android.view.View
import android.view.ViewGroup
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.conversation.colors.Colorizable
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Playable
/**
* Describes a ConversationElement that can be included in multiselect mode.
*/
interface Multiselectable : Colorizable, GiphyMp4Playable {
val conversationMessage: ConversationMessage
val root: ViewGroup
/**
* For a given multiselect part, return the 'top' boundary of its corresponding region.
* This is required even if there is only a single region for the given message.
*/
fun getTopBoundaryOfMultiselectPart(multiselectPart: MultiselectPart): Int
/**
* For a given multiselect part, return the 'bottom' boundary of its corresponding region.
* This is required even if there is only a single region for the given message.
*/
fun getBottomBoundaryOfMultiselectPart(multiselectPart: MultiselectPart): Int
/**
* See [ConversationItem] for an implementation. This should return the part relative
* to the last "down" touch point, relative to the Y-Axis.
*/
fun getMultiselectPartForLatestTouch(): MultiselectPart
/**
* Gets the start-most view that we should translate to make room for the multiselect circle.
* Only relevant for incoming messages.
*/
fun getHorizontalTranslationTarget(): View?
/**
* Allows an item to denote itself as non-selectable, even though it implements this interface.
*/
fun hasNonSelectableMedia(): Boolean
}

View file

@ -24,6 +24,7 @@ import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.conversation.colors.Colorizable
import org.thoughtcrime.securesms.conversation.colors.Colorizer
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
import org.thoughtcrime.securesms.conversation.mutiselect.Multiselectable
import org.thoughtcrime.securesms.conversation.v2.data.ConversationElementKey
import org.thoughtcrime.securesms.conversation.v2.data.ConversationMessageElement
import org.thoughtcrime.securesms.conversation.v2.data.ConversationUpdate
@ -38,7 +39,6 @@ import org.thoughtcrime.securesms.conversation.v2.items.bridge
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.databinding.V2ConversationItemTextOnlyIncomingBinding
import org.thoughtcrime.securesms.databinding.V2ConversationItemTextOnlyOutgoingBinding
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Playable
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicyEnforcer
import org.thoughtcrime.securesms.groups.v2.GroupDescriptionUtil
import org.thoughtcrime.securesms.messagerequests.MessageRequestState
@ -315,10 +315,12 @@ class ConversationAdapterV2(
}
}
private abstract inner class ConversationViewHolder<T>(itemView: View) : MappingViewHolder<T>(itemView), GiphyMp4Playable, Colorizable {
private abstract inner class ConversationViewHolder<T>(itemView: View) : MappingViewHolder<T>(itemView), Multiselectable, Colorizable {
val bindable: BindableConversationItem
get() = itemView as BindableConversationItem
override val root: ViewGroup = bindable.root
protected val previousMessage: Optional<MessageRecord>
get() = getConversationMessage(bindingAdapterPosition + 1)?.messageRecord.toOptional()
@ -328,6 +330,9 @@ class ConversationAdapterV2(
protected val displayMode: ConversationItemDisplayMode
get() = condensedMode ?: ConversationItemDisplayMode.STANDARD
override val conversationMessage: ConversationMessage
get() = bindable.conversationMessage
init {
itemView.setOnClickListener {
clickListener.onItemClick(bindable.getMultiselectPartForLatestTouch())
@ -370,9 +375,17 @@ class ConversationAdapterV2(
return bindable.shouldProjectContent()
}
override fun getColorizerProjections(coordinateRoot: ViewGroup): ProjectionList {
return bindable.getColorizerProjections(coordinateRoot)
}
override fun hasNonSelectableMedia(): Boolean = bindable.hasNonSelectableMedia()
override fun getColorizerProjections(coordinateRoot: ViewGroup): ProjectionList = bindable.getColorizerProjections(coordinateRoot)
override fun getTopBoundaryOfMultiselectPart(multiselectPart: MultiselectPart): Int = bindable.getTopBoundaryOfMultiselectPart(multiselectPart)
override fun getBottomBoundaryOfMultiselectPart(multiselectPart: MultiselectPart): Int = bindable.getBottomBoundaryOfMultiselectPart(multiselectPart)
override fun getHorizontalTranslationTarget(): View? = bindable.getHorizontalTranslationTarget()
override fun getMultiselectPartForLatestTouch(): MultiselectPart = bindable.getMultiselectPartForLatestTouch()
}
inner class ThreadHeaderViewHolder(itemView: View) : MappingViewHolder<ThreadHeader>(itemView) {

View file

@ -42,4 +42,6 @@ interface InteractiveConversationElement {
* projection list. This will prevent artifacts when we draw the bitmap.
*/
fun getSnapshotProjections(coordinateRoot: ViewGroup, clipOutMedia: Boolean): ProjectionList
fun getSnapshotProjections(coordinateRoot: ViewGroup, clipOutMedia: Boolean, outgoingOnly: Boolean): ProjectionList
}

View file

@ -16,9 +16,9 @@ import org.signal.core.util.dp
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.conversation.colors.Colorizable
import org.thoughtcrime.securesms.conversation.mutiselect.Multiselect
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
import org.thoughtcrime.securesms.conversation.mutiselect.Multiselectable
import org.thoughtcrime.securesms.conversation.v2.data.ConversationMessageElement
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
import org.thoughtcrime.securesms.database.model.MessageRecord
@ -51,7 +51,7 @@ abstract class V2BaseViewHolder<Model : MappingModel<Model>>(
class V2TextOnlyViewHolder<Model : MappingModel<Model>>(
private val binding: V2ConversationItemTextOnlyBindingBridge,
private val conversationContext: V2ConversationContext
) : V2BaseViewHolder<Model>(binding.root, conversationContext), Colorizable, InteractiveConversationElement {
) : V2BaseViewHolder<Model>(binding.root, conversationContext), Multiselectable, InteractiveConversationElement {
private var messageId: Long = Long.MAX_VALUE
@ -66,6 +66,7 @@ class V2TextOnlyViewHolder<Model : MappingModel<Model>>(
)
override lateinit var conversationMessage: ConversationMessage
override val root: ViewGroup = binding.root
override val bubbleView: View = binding.conversationItemBodyWrapper
@ -96,19 +97,22 @@ class V2TextOnlyViewHolder<Model : MappingModel<Model>>(
conversationMessage.messageRecord.isMms
)
}
binding.root.setOnClickListener {
conversationContext.clickListener.onItemClick(getMultiselectPartForLatestTouch())
}
binding.root.setOnLongClickListener {
conversationContext.clickListener.onItemLongClick(binding.root, getMultiselectPartForLatestTouch())
true
}
}
override fun bind(model: Model) {
check(model is ConversationMessageElement)
conversationMessage = model.conversationMessage
itemView.setOnClickListener(null)
itemView.setOnLongClickListener {
conversationContext.clickListener.onItemLongClick(itemView, MultiselectPart.Message(conversationMessage))
true
}
val shape = shapeDelegate.setMessageShape(
isLtr = itemView.layoutDirection == View.LAYOUT_DIRECTION_LTR,
currentMessage = conversationMessage.messageRecord,
@ -140,6 +144,10 @@ class V2TextOnlyViewHolder<Model : MappingModel<Model>>(
override fun getAdapterPosition(recyclerView: RecyclerView): Int = bindingAdapterPosition
override fun getSnapshotProjections(coordinateRoot: ViewGroup, clipOutMedia: Boolean): ProjectionList {
return getSnapshotProjections(coordinateRoot, clipOutMedia, true)
}
override fun getSnapshotProjections(coordinateRoot: ViewGroup, clipOutMedia: Boolean, outgoingOnly: Boolean): ProjectionList {
projections.clear()
projections.add(
@ -179,6 +187,49 @@ class V2TextOnlyViewHolder<Model : MappingModel<Model>>(
return projections
}
override fun getTopBoundaryOfMultiselectPart(multiselectPart: MultiselectPart): Int {
return root.top
}
override fun getBottomBoundaryOfMultiselectPart(multiselectPart: MultiselectPart): Int {
return root.bottom
}
override fun getMultiselectPartForLatestTouch(): MultiselectPart {
return conversationMessage.multiselectCollection.asSingle().singlePart
}
override fun getHorizontalTranslationTarget(): View? {
return if (conversationMessage.messageRecord.isOutgoing) {
null
} else if (conversationMessage.threadRecipient.isGroup) {
binding.senderPhoto
} else {
binding.conversationItemBodyWrapper
}
}
override fun hasNonSelectableMedia(): Boolean = false
override fun showProjectionArea() = Unit
override fun hideProjectionArea() = Unit
override fun getGiphyMp4PlayableProjection(coordinateRoot: ViewGroup): Projection {
return Projection
.relativeToParent(
coordinateRoot,
binding.conversationItemBodyWrapper,
shapeDelegate.corners
)
.translateY(root.translationY)
.translateX(binding.conversationItemBodyWrapper.translationX)
.translateX(root.translationX)
}
override fun canPlayContent(): Boolean = false
override fun shouldProjectContent(): Boolean = false
private fun MessageRecord.buildMessageId(): Long {
return if (isMms) -id else id
}

View file

@ -261,6 +261,10 @@ public final class ViewUtil {
return ((ViewGroup.MarginLayoutParams) view.getLayoutParams()).topMargin;
}
public static int getBottomMargin(@NonNull View view) {
return ((ViewGroup.MarginLayoutParams) view.getLayoutParams()).bottomMargin;
}
public static void setLeftMargin(@NonNull View view, int margin) {
if (isLtr(view)) {
((ViewGroup.MarginLayoutParams) view.getLayoutParams()).leftMargin = margin;