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