From 47b97aafc683db7741febf52beafae3349430702 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Fri, 30 Jun 2023 14:37:09 -0300 Subject: [PATCH] Add TypingIndicatorDecoration to CFV2. --- .../components/ConversationTypingView.java | 4 + .../components/TypingIndicatorView.java | 4 + .../conversation/v2/ConversationFragment.kt | 20 +++ .../v2/TypingIndicatorDecoration.kt | 142 ++++++++++++++++++ .../res/layout/conversation_typing_view.xml | 4 +- 5 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/TypingIndicatorDecoration.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationTypingView.java b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationTypingView.java index a56bcf2f82..c7174022f0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationTypingView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationTypingView.java @@ -80,6 +80,10 @@ public class ConversationTypingView extends ConstraintLayout { indicator.startAnimation(); } + public boolean isActive() { + return indicator.isActive(); + } + private void presentGroupThreadAvatars(@NonNull GlideRequests glideRequests, @NonNull List typists) { avatar1.setAvatar(glideRequests, typists.get(0), typists.size() == 1); avatar1.setVisibility(VISIBLE); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/TypingIndicatorView.java b/app/src/main/java/org/thoughtcrime/securesms/components/TypingIndicatorView.java index 404a39da23..ae7965cbf2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/TypingIndicatorView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/TypingIndicatorView.java @@ -125,4 +125,8 @@ public class TypingIndicatorView extends LinearLayout { public void stopAnimation() { isActive = false; } + + public boolean isActive() { + return isActive; + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index 997541d35d..3d17c2529e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -427,6 +427,7 @@ class ConversationFragment : private lateinit var threadHeaderMarginDecoration: ThreadHeaderMarginDecoration private lateinit var dateHeaderDecoration: DateHeaderDecoration private lateinit var optionsMenuCallback: ConversationOptionsMenuCallback + private lateinit var typingIndicatorDecoration: TypingIndicatorDecoration private var animationsAllowed = false private var actionMode: ActionMode? = null @@ -859,6 +860,8 @@ class ConversationFragment : .getScheduledMessagesCount() .subscribeBy { count -> handleScheduledMessagesCountChange(count) } .addTo(disposables) + + presentTypingIndicator() } private fun initializeInlineSearch() { @@ -889,6 +892,23 @@ class ConversationFragment : .addTo(disposables) } + private fun presentTypingIndicator() { + typingIndicatorDecoration = TypingIndicatorDecoration(requireContext(), binding.conversationItemRecycler) + binding.conversationItemRecycler.addItemDecoration(typingIndicatorDecoration) + + ApplicationDependencies.getTypingStatusRepository().getTypists(args.threadId).observe(viewLifecycleOwner) { + val recipient = viewModel.recipientSnapshot ?: return@observe + + typingIndicatorDecoration.setTypists( + GlideApp.with(this), + it.typists, + recipient.isGroup, + recipient.hasWallpaper(), + it.isReplacedByIncomingMessage + ) + } + } + private fun presentStoryRing() { if (SignalStore.storyValues().isFeatureDisabled) { return diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/TypingIndicatorDecoration.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/TypingIndicatorDecoration.kt new file mode 100644 index 0000000000..ee0e002ed8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/TypingIndicatorDecoration.kt @@ -0,0 +1,142 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation.v2 + +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.Canvas +import android.graphics.Rect +import android.view.LayoutInflater +import android.view.View +import android.view.View.MeasureSpec +import androidx.core.graphics.withTranslation +import androidx.core.view.children +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ItemDecoration +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.ConversationTypingView +import org.thoughtcrime.securesms.mms.GlideRequests +import org.thoughtcrime.securesms.recipients.Recipient + +/** + * Displays a typing indicator as a part of the very last (first) item in the adapter. + */ +class TypingIndicatorDecoration( + private val context: Context, + private val rootView: RecyclerView +) : ItemDecoration() { + + private val typingView: ConversationTypingView by lazy(LazyThreadSafetyMode.NONE) { + LayoutInflater.from(context).inflate(R.layout.conversation_typing_view, rootView, false) as ConversationTypingView + } + + private var displayIndicator = false + private var animationFraction = 0f + private var offsetAnimator: ValueAnimator? = null + + init { + rootView.addOnLayoutChangeListener { _, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom -> + if (left != oldLeft || top != oldTop || right != oldRight || bottom != oldBottom) { + remeasureTypingView() + rootView.invalidateItemDecorations() + } + } + } + + override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { + if (!displayIndicator && animationFraction == 0f) { + return outRect.set(0, 0, 0, 0) + } + + if (parent.getChildAdapterPosition(view) == 0) { + remeasureTypingView() + outRect.set(0, 0, 0, (typingView.measuredHeight * animationFraction).toInt()) + } else { + outRect.set(0, 0, 0, 0) + } + } + + override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) { + if (!displayIndicator && offsetAnimator?.isRunning != true) { + return + } + + val firstChild = parent.children.firstOrNull() ?: return + + if (parent.getChildAdapterPosition(firstChild) == 0) { + c.withTranslation( + x = firstChild.left.toFloat(), + y = firstChild.bottom.toFloat() + ) { + typingView.draw(this) + } + + if (typingView.isActive) { + rootView.post { rootView.invalidateItemDecorations() } + } + } + } + + fun setTypists( + glideRequests: GlideRequests, + typists: List, + isGroupThread: Boolean, + hasWallpaper: Boolean, + isReplacedByIncomingMessage: Boolean + ) { + val isEdge = displayIndicator != typists.isNotEmpty() + displayIndicator = typists.isNotEmpty() + + typingView.setTypists( + glideRequests, + typists, + isGroupThread, + hasWallpaper + ) + remeasureTypingView() + rootView.invalidateItemDecorations() + + if (isReplacedByIncomingMessage) { + offsetAnimator?.cancel() + animationFraction = 0f + } else if (isEdge) { + animateOffset() + } + } + + private fun animateOffset() { + offsetAnimator?.cancel() + + val (start, end) = if (displayIndicator) { + animationFraction to 1f + } else { + animationFraction to 0f + } + + offsetAnimator = ValueAnimator.ofFloat(start, end).apply { + addUpdateListener { + animationFraction = it.animatedValue as Float + rootView.invalidateItemDecorations() + } + start() + } + } + + private fun remeasureTypingView() { + with(typingView) { + measure( + MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED), + MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED) + ) + layout( + 0, + 0, + typingView.measuredWidth, + typingView.measuredHeight + ) + } + } +} diff --git a/app/src/main/res/layout/conversation_typing_view.xml b/app/src/main/res/layout/conversation_typing_view.xml index 69432bca8f..eeeb3864ef 100644 --- a/app/src/main/res/layout/conversation_typing_view.xml +++ b/app/src/main/res/layout/conversation_typing_view.xml @@ -117,7 +117,7 @@ app:barrierDirection="end" app:constraint_referenced_ids="typing_avatar_1, typing_avatar_2, typing_avatar_3, typing_count" /> - - + \ No newline at end of file