Add multi-select support to CFV2.
This commit is contained in:
parent
ebaa445bee
commit
38b2a2f5b7
8 changed files with 171 additions and 38 deletions
|
@ -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();
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Add table
Reference in a new issue