Add proper click handling support to ConversationItem V2.

This commit is contained in:
Alex Hart 2023-08-22 15:18:17 -03:00 committed by Cody Henthorne
parent 21c70039f4
commit 3738997832
11 changed files with 201 additions and 13 deletions

View file

@ -2308,6 +2308,10 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
// Intentionally left blank.
}
@Override public @Nullable SnapshotStrategy getSnapshotStrategy() {
return null;
}
private class SharedContactEventListener implements SharedContactView.EventListener {
@Override
public void onAddToContactsClicked(@NonNull Contact contact) {

View file

@ -43,6 +43,13 @@ object ConversationItemSelection {
drawConversationItem: Boolean,
hasReaction: Boolean
): Bitmap {
val snapshotStrategy = target.getSnapshotStrategy()
if (snapshotStrategy != null) {
return createBitmap(target.root.width, target.root.height).applyCanvas {
snapshotStrategy.snapshot(this)
}
}
val bodyBubble = target.bubbleView
val reactionsView = target.reactionsView

View file

@ -2871,7 +2871,7 @@ class ConversationFragment :
snapshot,
itemView.x,
itemView.y + binding.conversationItemRecycler.translationY,
bodyBubble.x,
if (target.getSnapshotStrategy() != null) itemView.x else bodyBubble.x,
bodyBubble.y,
bodyBubble.width,
audioUri,

View file

@ -5,6 +5,7 @@
package org.thoughtcrime.securesms.conversation.v2.items
import android.graphics.Canvas
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
@ -44,4 +45,10 @@ interface InteractiveConversationElement : ChatColorsDrawable.ChatColorsDrawable
fun getSnapshotProjections(coordinateRoot: ViewGroup, clipOutMedia: Boolean): ProjectionList
fun getSnapshotProjections(coordinateRoot: ViewGroup, clipOutMedia: Boolean, outgoingOnly: Boolean): ProjectionList
fun getSnapshotStrategy(): SnapshotStrategy?
interface SnapshotStrategy {
fun snapshot(canvas: Canvas)
}
}

View file

@ -7,6 +7,8 @@ package org.thoughtcrime.securesms.conversation.v2.items
import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import androidx.constraintlayout.widget.ConstraintLayout
/**
@ -19,6 +21,25 @@ class V2ConversationItemLayout @JvmOverloads constructor(
) : ConstraintLayout(context, attrs) {
private var onMeasureListeners: Set<OnMeasureListener> = emptySet()
var onDispatchTouchEventListener: OnDispatchTouchEventListener? = null
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
if (ev != null) {
onDispatchTouchEventListener?.onDispatchTouchEvent(this, ev)
}
return super.dispatchTouchEvent(ev)
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
onMeasureListeners.forEach { it.onPreMeasure() }
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val remeasure = onMeasureListeners.map { it.onPostMeasure() }.any { it }
if (remeasure) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
}
}
/**
* Set the onMeasureListener to be invoked by this view whenever onMeasure is called.
@ -31,14 +52,8 @@ class V2ConversationItemLayout @JvmOverloads constructor(
this.onMeasureListeners -= onMeasureListener
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
onMeasureListeners.forEach { it.onPreMeasure() }
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val remeasure = onMeasureListeners.map { it.onPostMeasure() }.any { it }
if (remeasure) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
}
interface OnDispatchTouchEventListener {
fun onDispatchTouchEvent(view: View, motionEvent: MotionEvent)
}
interface OnMeasureListener {

View file

@ -82,9 +82,9 @@ class V2ConversationItemTheme(
Color.TRANSPARENT
} else {
if (conversationContext.hasWallpaper()) {
ContextCompat.getColor(context, R.color.signal_colorSurface)
ContextCompat.getColor(context, R.color.conversation_item_recv_bubble_color_wallpaper)
} else {
ContextCompat.getColor(context, R.color.signal_colorSurface2)
ContextCompat.getColor(context, R.color.conversation_item_recv_bubble_color_normal)
}
}
}

View file

@ -88,6 +88,7 @@ class V2TextOnlyViewHolder<Model : MappingModel<Model>>(
private val projections = ProjectionList()
private val footerDelegate = V2FooterPositionDelegate(binding)
private val dispatchTouchEventListener = V2OnDispatchTouchEventListener(conversationContext, binding)
override lateinit var conversationMessage: ConversationMessage
@ -115,6 +116,7 @@ class V2TextOnlyViewHolder<Model : MappingModel<Model>>(
init {
binding.root.addOnMeasureListener(footerDelegate)
binding.root.onDispatchTouchEventListener = dispatchTouchEventListener
binding.conversationItemReactions.setOnClickListener {
conversationContext.clickListener
@ -135,7 +137,10 @@ class V2TextOnlyViewHolder<Model : MappingModel<Model>>(
true
}
binding.conversationItemBody.isClickable = false
val passthroughClickListener = PassthroughClickListener()
binding.conversationItemBody.setOnClickListener(passthroughClickListener)
binding.conversationItemBody.setOnLongClickListener(passthroughClickListener)
binding.conversationItemBody.isFocusable = false
binding.conversationItemBody.setTextSize(TypedValue.COMPLEX_UNIT_SP, SignalStore.settings().messageFontSize.toFloat())
binding.conversationItemBody.movementMethod = LongClickMovementMethod.getInstance(context)
@ -220,6 +225,10 @@ class V2TextOnlyViewHolder<Model : MappingModel<Model>>(
return projections
}
override fun getSnapshotStrategy(): InteractiveConversationElement.SnapshotStrategy {
return V2TextOnlySnapshotStrategy(binding)
}
/**
* Note: This is not necessary for CFV2 Text-Only items because the background is rendered by
* [ChatColorsDrawable]
@ -603,4 +612,19 @@ class V2TextOnlyViewHolder<Model : MappingModel<Model>>(
return binding.conversationItemReactions.setReactions(conversationMessage.messageRecord.reactions, binding.conversationItemBodyWrapper.width)
}
}
private inner class PassthroughClickListener : View.OnClickListener, View.OnLongClickListener {
override fun onClick(v: View?) {
binding.root.performClick()
}
override fun onLongClick(v: View?): Boolean {
if (binding.conversationItemBody.hasSelection()) {
return false
}
binding.root.performLongClick()
return true
}
}
}

View file

@ -0,0 +1,75 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation.v2.items
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode
/**
* Responsible for the shrink-and-return feel of conversation bubbles when the user
* touches them.
*/
class V2OnDispatchTouchEventListener(
private val conversationContext: V2ConversationContext,
private val binding: V2ConversationItemTextOnlyBindingBridge
) : V2ConversationItemLayout.OnDispatchTouchEventListener {
companion object {
private const val LONG_PRESS_SCALE_FACTOR = 0.95f
private const val SHRINK_BUBBLE_DELAY_MILLIS = 100L
}
private val viewsToPivot = listOfNotNull(
binding.conversationItemFooterBackground,
binding.conversationItemFooterDate,
binding.conversationItemFooterExpiry,
binding.conversationItemDeliveryStatus,
binding.conversationItemReactions
)
private val shrinkBubble = Runnable {
binding.conversationItemBodyWrapper.animate()
.scaleX(LONG_PRESS_SCALE_FACTOR)
.scaleY(LONG_PRESS_SCALE_FACTOR)
.setUpdateListener {
(binding.root.parent as? ViewGroup)?.invalidate()
}
viewsToPivot.forEach {
it.animate()
.scaleX(LONG_PRESS_SCALE_FACTOR)
.scaleY(LONG_PRESS_SCALE_FACTOR)
}
}
override fun onDispatchTouchEvent(view: View, motionEvent: MotionEvent) {
if (conversationContext.displayMode == ConversationItemDisplayMode.CONDENSED) {
return
}
viewsToPivot.forEach {
val deltaX = it.x - binding.conversationItemBodyWrapper.x
val deltaY = it.y - binding.conversationItemBodyWrapper.y
it.pivotX = -(deltaX / 2f)
it.pivotY = -(deltaY / 2f)
}
when (motionEvent.action) {
MotionEvent.ACTION_DOWN -> view.handler.postDelayed(shrinkBubble, SHRINK_BUBBLE_DELAY_MILLIS)
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
view.handler.removeCallbacks(shrinkBubble)
(viewsToPivot + binding.conversationItemBodyWrapper).forEach {
it.animate()
.scaleX(1f)
.scaleY(1f)
}
}
}
}
}

View file

@ -0,0 +1,52 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation.v2.items
import android.graphics.Canvas
import androidx.core.view.isVisible
import org.thoughtcrime.securesms.util.visible
/**
* Responsible for drawing the conversation bubble when a user long-presses it and the reaction
* overlay appears.
*/
class V2TextOnlySnapshotStrategy(
private val binding: V2ConversationItemTextOnlyBindingBridge
) : InteractiveConversationElement.SnapshotStrategy {
private val viewsToRestoreScale = listOfNotNull(
binding.conversationItemBodyWrapper,
binding.conversationItemFooterBackground,
binding.conversationItemFooterDate,
binding.conversationItemFooterExpiry,
binding.conversationItemDeliveryStatus,
binding.conversationItemReactions
)
private val viewsToHide = listOfNotNull(
binding.senderPhoto,
binding.senderBadge
)
override fun snapshot(canvas: Canvas) {
val originalScales = viewsToRestoreScale.associateWith { Pair(it.scaleX, it.scaleY) }
viewsToRestoreScale.forEach {
it.scaleX = 1f
it.scaleY = 1f
}
val originalIsVisible = viewsToHide.associateWith { it.isVisible }
viewsToHide.forEach { it.visible = false }
binding.root.draw(canvas)
originalIsVisible.forEach { (view, isVisible) -> view.isVisible = isVisible }
originalScales.forEach { view, (scaleX, scaleY) ->
view.scaleX = scaleX
view.scaleY = scaleY
}
}
}

View file

@ -99,6 +99,8 @@
android:textColorLink="@color/conversation_item_sent_text_primary_color"
android:textSize="16sp"
app:emoji_maxLength="1000"
app:emoji_renderMentions="true"
app:emoji_renderSpoilers="true"
app:measureLastLine="true"
app:scaleEmojis="true"
tools:text="Testy test test test" />

View file

@ -55,6 +55,8 @@
android:textColorLink="@color/conversation_item_sent_text_primary_color"
android:textSize="16sp"
app:emoji_maxLength="1000"
app:emoji_renderMentions="true"
app:emoji_renderSpoilers="true"
app:measureLastLine="true"
app:scaleEmojis="true"
tools:text="Mango pickle lorem ipsum" />
@ -149,8 +151,8 @@
android:id="@+id/conversation_item_reactions"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="-4dp"
android:layout_marginStart="5dp"
android:layout_marginTop="-4dp"
android:orientation="horizontal"
app:layout_constraintStart_toStartOf="@id/conversation_item_body_wrapper"
app:layout_constraintTop_toBottomOf="@id/conversation_item_body_wrapper"