浏览代码

Swipe to reply for chat messages (incoming/outgoing/preview)

Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
Andy Scherzinger 4 年之前
父节点
当前提交
292bee8396

+ 76 - 56
app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt

@@ -58,6 +58,7 @@ import androidx.appcompat.view.ContextThemeWrapper
 import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory
 import androidx.emoji.text.EmojiCompat
 import androidx.emoji.widget.EmojiTextView
+import androidx.recyclerview.widget.ItemTouchHelper
 import androidx.recyclerview.widget.LinearLayoutManager
 import androidx.recyclerview.widget.RecyclerView
 import androidx.work.Data
@@ -107,6 +108,8 @@ import com.nextcloud.talk.models.json.generic.GenericOverall
 import com.nextcloud.talk.models.json.mention.Mention
 import com.nextcloud.talk.presenters.MentionAutocompletePresenter
 import com.nextcloud.talk.ui.dialog.AttachmentDialog
+import com.nextcloud.talk.ui.recyclerview.MessageSwipeActions
+import com.nextcloud.talk.ui.recyclerview.MessageSwipeCallback
 import com.nextcloud.talk.utils.ApiUtils
 import com.nextcloud.talk.utils.ConductorRemapping
 import com.nextcloud.talk.utils.ConductorRemapping.remapChatController
@@ -448,6 +451,21 @@ class ChatController(args: Bundle) :
         adapter?.setDateHeadersFormatter { format(it) }
         adapter?.setOnMessageViewLongClickListener { view, message -> onMessageViewLongClick(view, message) }
 
+        if (context != null) {
+            val messageSwipeController = MessageSwipeCallback(
+                activity!!,
+                object : MessageSwipeActions {
+                    override fun showReplyUI(position: Int) {
+                        val chatMessage = adapter?.items?.get(position)?.item as ChatMessage?
+                        replyToMessage(chatMessage, chatMessage?.jsonMessageId)
+                    }
+                }
+            )
+
+            val itemTouchHelper = ItemTouchHelper(messageSwipeController)
+            itemTouchHelper.attachToRecyclerView(messagesListView)
+        }
+
         layoutManager = binding.messagesListView.layoutManager as LinearLayoutManager?
 
         binding.popupBubbleView.setRecyclerView(binding.messagesListView)
@@ -1357,10 +1375,8 @@ class ChatController(args: Bundle) :
                         if (TextUtils.isEmpty(chatMessageList[i].systemMessage) &&
                             TextUtils.isEmpty(chatMessageList[i + 1].systemMessage) &&
                             chatMessageList[i + 1].actorId == chatMessageList[i].actorId &&
-                            countGroupedMessages < 4 && DateFormatter.isSameDay(
-                                    chatMessageList[i].createdAt,
-                                    chatMessageList[i + 1].createdAt
-                                )
+                            countGroupedMessages < 4 &&
+                            DateFormatter.isSameDay(chatMessageList[i].createdAt, chatMessageList[i + 1].createdAt)
                         ) {
                             chatMessageList[i].isGrouped = true
                             countGroupedMessages++
@@ -1624,58 +1640,7 @@ class ChatController(args: Bundle) :
                     }
                     R.id.action_reply_to_message -> {
                         val chatMessage = message as ChatMessage?
-                        chatMessage?.let {
-                            binding.messageInputView.findViewById<ImageButton>(R.id.attachmentButton)?.visibility =
-                                View.GONE
-                            binding.messageInputView.findViewById<Space>(R.id.attachmentButtonSpace)?.visibility =
-                                View.GONE
-                            binding.messageInputView.findViewById<ImageButton>(R.id.cancelReplyButton)?.visibility =
-                                View.VISIBLE
-
-                            val quotedMessage = binding
-                                .messageInputView
-                                .findViewById<EmojiTextView>(R.id.quotedMessage)
-
-                            quotedMessage?.maxLines = 2
-                            quotedMessage?.ellipsize = TextUtils.TruncateAt.END
-                            quotedMessage?.text = it.text
-                            binding.messageInputView.findViewById<EmojiTextView>(R.id.quotedMessageAuthor)?.text =
-                                it.actorDisplayName ?: context!!.getText(R.string.nc_nick_guest)
-
-                            conversationUser?.let { currentUser ->
-                                val quotedMessageImage = binding
-                                    .messageInputView
-                                    .findViewById<ImageView>(R.id.quotedMessageImage)
-                                chatMessage.imageUrl?.let { previewImageUrl ->
-                                    quotedMessageImage?.visibility = View.VISIBLE
-
-                                    val px = TypedValue.applyDimension(
-                                        TypedValue.COMPLEX_UNIT_DIP,
-                                        96f,
-                                        resources?.displayMetrics
-                                    )
-
-                                    quotedMessageImage?.maxHeight = px.toInt()
-                                    val layoutParams = quotedMessageImage?.layoutParams as FlexboxLayout.LayoutParams
-                                    layoutParams.flexGrow = 0f
-                                    quotedMessageImage.layoutParams = layoutParams
-                                    quotedMessageImage.load(previewImageUrl) {
-                                        addHeader("Authorization", credentials!!)
-                                    }
-                                } ?: run {
-                                    binding
-                                        .messageInputView
-                                        .findViewById<ImageView>(R.id.quotedMessageImage)
-                                        ?.visibility = View.GONE
-                                }
-                            }
-
-                            val quotedChatMessageView = binding
-                                .messageInputView
-                                .findViewById<RelativeLayout>(R.id.quotedChatMessageView)
-                            quotedChatMessageView?.tag = message?.jsonMessageId
-                            quotedChatMessageView?.visibility = View.VISIBLE
-                        }
+                        replyToMessage(chatMessage, message?.jsonMessageId)
                         true
                     }
                     R.id.action_reply_privately -> {
@@ -1820,6 +1785,61 @@ class ChatController(args: Bundle) :
         }
     }
 
+    private fun replyToMessage(chatMessage: ChatMessage?, jsonMessageId: Int?) {
+        chatMessage?.let {
+            binding.messageInputView.findViewById<ImageButton>(R.id.attachmentButton)?.visibility =
+                View.GONE
+            binding.messageInputView.findViewById<Space>(R.id.attachmentButtonSpace)?.visibility =
+                View.GONE
+            binding.messageInputView.findViewById<ImageButton>(R.id.cancelReplyButton)?.visibility =
+                View.VISIBLE
+
+            val quotedMessage = binding
+                .messageInputView
+                .findViewById<EmojiTextView>(R.id.quotedMessage)
+
+            quotedMessage?.maxLines = 2
+            quotedMessage?.ellipsize = TextUtils.TruncateAt.END
+            quotedMessage?.text = it.text
+            binding.messageInputView.findViewById<EmojiTextView>(R.id.quotedMessageAuthor)?.text =
+                it.actorDisplayName ?: context!!.getText(R.string.nc_nick_guest)
+
+            conversationUser?.let { currentUser ->
+                val quotedMessageImage = binding
+                    .messageInputView
+                    .findViewById<ImageView>(R.id.quotedMessageImage)
+                chatMessage.imageUrl?.let { previewImageUrl ->
+                    quotedMessageImage?.visibility = View.VISIBLE
+
+                    val px = TypedValue.applyDimension(
+                        TypedValue.COMPLEX_UNIT_DIP,
+                        96f,
+                        resources?.displayMetrics
+                    )
+
+                    quotedMessageImage?.maxHeight = px.toInt()
+                    val layoutParams = quotedMessageImage?.layoutParams as FlexboxLayout.LayoutParams
+                    layoutParams.flexGrow = 0f
+                    quotedMessageImage.layoutParams = layoutParams
+                    quotedMessageImage.load(previewImageUrl) {
+                        addHeader("Authorization", credentials!!)
+                    }
+                } ?: run {
+                    binding
+                        .messageInputView
+                        .findViewById<ImageView>(R.id.quotedMessageImage)
+                        ?.visibility = View.GONE
+                }
+            }
+
+            val quotedChatMessageView = binding
+                .messageInputView
+                .findViewById<RelativeLayout>(R.id.quotedChatMessageView)
+            quotedChatMessageView?.tag = jsonMessageId
+            quotedChatMessageView?.visibility = View.VISIBLE
+        }
+    }
+
     private fun setMessageAsDeleted(message: IMessage?) {
         val messageTemp = message as ChatMessage
         messageTemp.isDeleted = true

+ 37 - 0
app/src/main/java/com/nextcloud/talk/ui/recyclerview/MessageSwipeActions.kt

@@ -0,0 +1,37 @@
+/*
+ * Nextcloud Talk application
+ *
+ * @author Shain Singh
+ * @author Andy Scherzinger
+ * Copyright (C) 2021 Shain Singh <shainsingh89@gmail.com>
+ * Copyright (C) 2021 Andy Scherzinger <info@andy-scherzinger.de>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * Based on the MessageSwipeController by Shain Singh at:
+ * https://github.com/shainsingh89/SwipeToReply/blob/master/app/src/main/java/com/shain/messenger/SwipeControllerActions.kt
+ */
+
+package com.nextcloud.talk.ui.recyclerview
+
+/**
+ * Actions executed within a swipe gesture.
+ */
+interface MessageSwipeActions {
+
+    /**
+     * Display reply message including the original, quoted message of/at [position].
+     */
+    fun showReplyUI(position: Int)
+}

+ 268 - 0
app/src/main/java/com/nextcloud/talk/ui/recyclerview/MessageSwipeCallback.kt

@@ -0,0 +1,268 @@
+/*
+ * Nextcloud Talk application
+ *
+ * @author Shain Singh
+ * @author Andy Scherzinger
+ * Copyright (C) 2021 Shain Singh <shainsingh89@gmail.com>
+ * Copyright (C) 2021 Andy Scherzinger <info@andy-scherzinger.de>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * Based on the MessageSwipeController by Shain Singh at:
+ * https://github.com/shainsingh89/SwipeToReply/blob/master/app/src/main/java/com/shain/messenger/MessageSwipeController.kt
+ */
+
+package com.nextcloud.talk.ui.recyclerview
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.PorterDuff
+import android.graphics.PorterDuffColorFilter
+import android.graphics.drawable.Drawable
+import android.util.Log
+import android.view.HapticFeedbackConstants
+import android.view.MotionEvent
+import android.view.View
+import androidx.appcompat.content.res.AppCompatResources
+import androidx.core.content.ContextCompat
+import androidx.recyclerview.widget.ItemTouchHelper
+import androidx.recyclerview.widget.ItemTouchHelper.ACTION_STATE_IDLE
+import androidx.recyclerview.widget.ItemTouchHelper.ACTION_STATE_SWIPE
+import androidx.recyclerview.widget.ItemTouchHelper.RIGHT
+import androidx.recyclerview.widget.RecyclerView
+import com.nextcloud.talk.R
+import com.nextcloud.talk.adapters.messages.MagicIncomingTextMessageViewHolder
+import com.nextcloud.talk.adapters.messages.MagicOutcomingTextMessageViewHolder
+import com.nextcloud.talk.adapters.messages.MagicPreviewMessageViewHolder
+import kotlin.math.abs
+import kotlin.math.ceil
+import kotlin.math.min
+
+/**
+ * Callback implementation for swipe-right-gesture on messages.
+ *
+ * @property context activity's context to load resources like drawables.
+ * @property messageSwipeActions the actions to be executed upon swipe-right.
+ * @constructor Creates as swipe-right callback for messages
+ */
+class MessageSwipeCallback(private val context: Context, private val messageSwipeActions: MessageSwipeActions) :
+    ItemTouchHelper.Callback() {
+
+    companion object {
+        const val TAG = "MessageSwipeCallback"
+    }
+
+    private var density = 1f
+
+    private lateinit var imageDrawable: Drawable
+    private lateinit var shareRound: Drawable
+
+    private var currentItemViewHolder: RecyclerView.ViewHolder? = null
+    private lateinit var view: View
+    private var dX = 0f
+
+    private var replyButtonProgress: Float = 0.toFloat()
+    private var lastReplyButtonAnimationTime: Long = 0
+    private var swipeBack = false
+    private var isVibrate = false
+    private var startTracking = false
+
+    override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int {
+        if (viewHolder is MagicPreviewMessageViewHolder ||
+            viewHolder is MagicIncomingTextMessageViewHolder ||
+            viewHolder is MagicOutcomingTextMessageViewHolder
+        ) {
+            view = viewHolder.itemView
+            imageDrawable = AppCompatResources.getDrawable(context, R.drawable.ic_reply)!!
+            shareRound = AppCompatResources.getDrawable(context, R.drawable.round_bgnd)!!
+            return makeMovementFlags(ACTION_STATE_IDLE, RIGHT)
+        }
+
+        // disable swiping any other message type
+        return 0
+    }
+
+    override fun onMove(
+        recyclerView: RecyclerView,
+        viewHolder: RecyclerView.ViewHolder,
+        target: RecyclerView.ViewHolder
+    ): Boolean {
+        return false
+    }
+
+    override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {}
+
+    override fun convertToAbsoluteDirection(flags: Int, layoutDirection: Int): Int {
+        if (swipeBack) {
+            swipeBack = false
+            return 0
+        }
+        return super.convertToAbsoluteDirection(flags, layoutDirection)
+    }
+
+    override fun onChildDraw(
+        c: Canvas,
+        recyclerView: RecyclerView,
+        viewHolder: RecyclerView.ViewHolder,
+        dX: Float,
+        dY: Float,
+        actionState: Int,
+        isCurrentlyActive: Boolean
+    ) {
+
+        if (actionState == ACTION_STATE_SWIPE) {
+            setTouchListener(recyclerView, viewHolder)
+        }
+
+        if (view.translationX < convertToDp(130) || dX < this.dX) {
+            super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
+            this.dX = dX
+            startTracking = true
+        }
+        currentItemViewHolder = viewHolder
+        drawReplyButton(c)
+    }
+
+    @SuppressLint("ClickableViewAccessibility")
+    private fun setTouchListener(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
+        recyclerView.setOnTouchListener { _, event ->
+            swipeBack = event.action == MotionEvent.ACTION_CANCEL || event.action == MotionEvent.ACTION_UP
+            if (swipeBack) {
+                if (abs(view.translationX) >= this@MessageSwipeCallback.convertToDp(100)) {
+                    messageSwipeActions.showReplyUI(viewHolder.adapterPosition)
+                }
+            }
+            false
+        }
+    }
+
+    private fun drawReplyButton(canvas: Canvas) {
+        if (currentItemViewHolder == null) {
+            return
+        }
+        val translationX = view.translationX
+        val newTime = System.currentTimeMillis()
+        val dt = min(17, newTime - lastReplyButtonAnimationTime)
+        lastReplyButtonAnimationTime = newTime
+        val showing = translationX >= convertToDp(30)
+        if (showing) {
+            if (replyButtonProgress < 1.0f) {
+                replyButtonProgress += dt / 180.0f
+                if (replyButtonProgress > 1.0f) {
+                    replyButtonProgress = 1.0f
+                } else {
+                    view.invalidate()
+                }
+            }
+        } else if (translationX <= 0.0f) {
+            replyButtonProgress = 0f
+            startTracking = false
+            isVibrate = false
+        } else {
+            if (replyButtonProgress > 0.0f) {
+                replyButtonProgress -= dt / 180.0f
+                if (replyButtonProgress < 0.1f) {
+                    replyButtonProgress = 0f
+                } else {
+                    view.invalidate()
+                }
+            }
+        }
+
+        val alpha: Int
+        val scale: Float
+        if (showing) {
+            scale = if (replyButtonProgress <= 0.8f) {
+                1.2f * (replyButtonProgress / 0.8f)
+            } else {
+                1.2f - 0.2f * ((replyButtonProgress - 0.8f) / 0.2f)
+            }
+            alpha = min(255f, 255 * (replyButtonProgress / 0.8f)).toInt()
+        } else {
+            scale = replyButtonProgress
+            alpha = min(255f, 255 * replyButtonProgress).toInt()
+        }
+        shareRound.alpha = alpha
+        imageDrawable.alpha = alpha
+
+        if (startTracking) {
+            if (!isVibrate && view.translationX >= convertToDp(100)) {
+                view.performHapticFeedback(
+                    HapticFeedbackConstants.KEYBOARD_TAP,
+                    HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING
+                )
+                isVibrate = true
+            }
+        }
+
+        val x: Int = if (view.translationX > convertToDp(130)) {
+            convertToDp(130) / 2
+        } else {
+            (view.translationX / 2).toInt()
+        }
+
+        val y = (view.top + view.measuredHeight / 2).toFloat()
+        shareRound.colorFilter = PorterDuffColorFilter(
+            ContextCompat.getColor(context, R.color.bg_message_list_incoming_bubble),
+            PorterDuff.Mode.SRC_IN
+        )
+        imageDrawable.colorFilter = PorterDuffColorFilter(
+            ContextCompat.getColor(context, R.color.high_emphasis_text),
+            PorterDuff.Mode.SRC_IN
+        )
+
+        shareRound.setBounds(
+            (x - convertToDp(18) * scale).toInt(),
+            (y - convertToDp(18) * scale).toInt(),
+            (x + convertToDp(18) * scale).toInt(),
+            (y + convertToDp(18) * scale).toInt()
+        )
+        shareRound.draw(canvas)
+
+        imageDrawable.setBounds(
+            (x - convertToDp(12) * scale).toInt(),
+            (y - convertToDp(13) * scale).toInt(),
+            (x + convertToDp(12) * scale).toInt(),
+            (y + convertToDp(11) * scale).toInt()
+        )
+        imageDrawable.draw(canvas)
+
+        shareRound.alpha = 255
+        imageDrawable.alpha = 255
+    }
+
+    private fun convertToDp(pixel: Int): Int {
+        return dp(pixel.toFloat(), context)
+    }
+
+    private fun dp(value: Float, context: Context): Int {
+        if (density == 1f) {
+            checkDisplaySize(context)
+        }
+        return if (value == 0f) {
+            0
+        } else {
+            ceil((density * value).toDouble()).toInt()
+        }
+    }
+
+    private fun checkDisplaySize(context: Context) {
+        try {
+            density = context.resources.displayMetrics.density
+        } catch (e: Exception) {
+            Log.w(TAG, "Error calculating density", e)
+        }
+    }
+}