From 396742f3ada3cf278490d23dda234d5c8a6a33b3 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Fri, 6 Jan 2023 16:31:29 -0400 Subject: [PATCH] Add new chat bubble pulse. --- .../conversation/ConversationAdapter.java | 40 ++++++ .../conversation/ConversationItem.java | 42 ++----- .../mutiselect/MultiselectItemDecoration.kt | 119 +++++++++++++++++- app/src/main/res/values-night/dark_colors.xml | 3 + app/src/main/res/values/light_colors.xml | 3 + 5 files changed, 173 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java index 8969eed452..091c9dcb2f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java @@ -120,6 +120,7 @@ public class ConversationAdapter private Colorizer colorizer; private boolean isTypingViewEnabled; private boolean condensedMode; + private PulseRequest pulseRequest; public ConversationAdapter(@NonNull Context context, @NonNull LifecycleOwner lifecycleOwner, @@ -487,10 +488,18 @@ public class ConversationAdapter int correctedPosition = isHeaderPosition(position) ? position + 1 : position; recordToPulse = getItem(correctedPosition); + pulseRequest = new PulseRequest(position, recordToPulse.getMessageRecord().isOutgoing()); notifyItemChanged(correctedPosition); } } + @Nullable + public PulseRequest consumePulseRequest() { + PulseRequest request = pulseRequest; + pulseRequest = null; + return request; + } + /** * Conversation search query updated. Allows rendering of text highlighting. */ @@ -770,6 +779,37 @@ public class ConversationAdapter } } + public static class PulseRequest { + private final int position; + private final boolean isOutgoing; + + PulseRequest(int position, boolean isOutgoing) { + this.position = position; + this.isOutgoing = isOutgoing; + } + + public int getPosition() { + return position; + } + + public boolean isOutgoing() { + return isOutgoing; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final PulseRequest that = (PulseRequest) o; + return position == that.position; + } + + @Override + public int hashCode() { + return Objects.hash(position); + } + } + public interface ItemClickListener extends BindableConversationItem.EventListener { void onItemClick(MultiselectPart item); void onItemLongClick(View itemView, MultiselectPart item); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java index d90666d117..ef2a58bb1c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -16,7 +16,6 @@ */ package org.thoughtcrime.securesms.conversation; -import android.animation.ValueAnimator; import android.annotation.SuppressLint; import android.content.ActivityNotFoundException; import android.content.Context; @@ -189,7 +188,6 @@ public final class ConversationItem extends RelativeLayout implements BindableCo private boolean groupThread; private LiveRecipient recipient; private GlideRequests glideRequests; - private ValueAnimator pulseOutlinerAlphaAnimator; private Optional previousMessage; private ConversationItemDisplayMode displayMode; @@ -667,8 +665,6 @@ public final class ConversationItem extends RelativeLayout implements BindableCo bodyBubble.setVideoPlayerProjection(null); bodyBubble.setQuoteViewProjection(null); - - cancelPulseOutlinerAnimation(); } @Override @@ -764,6 +760,10 @@ public final class ConversationItem extends RelativeLayout implements BindableCo return conversationMessage; } + public boolean isOutgoing() { + return conversationMessage.getMessageRecord().isOutgoing(); + } + /// MessageRecord Attribute Parsers private void setBubbleState(MessageRecord messageRecord, @NonNull Recipient recipient, boolean hasWallpaper, @NonNull Colorizer colorizer) { @@ -848,7 +848,6 @@ public final class ConversationItem extends RelativeLayout implements BindableCo setSelected(true); } else if (pulseMention) { setSelected(false); - startPulseOutlinerAnimation(); } else { setSelected(false); } @@ -871,29 +870,6 @@ public final class ConversationItem extends RelativeLayout implements BindableCo } } - private void startPulseOutlinerAnimation() { - pulseOutlinerAlphaAnimator = ValueAnimator.ofInt(0, 0x66, 0).setDuration(600); - pulseOutlinerAlphaAnimator.setRepeatCount(1); - pulseOutlinerAlphaAnimator.addUpdateListener(animator -> { - pulseOutliner.setAlpha((Integer) animator.getAnimatedValue()); - bodyBubble.invalidate(); - - if (mediaThumbnailStub.resolved()) { - mediaThumbnailStub.require().invalidate(); - } - }); - pulseOutlinerAlphaAnimator.start(); - } - - private void cancelPulseOutlinerAnimation() { - if (pulseOutlinerAlphaAnimator != null) { - pulseOutlinerAlphaAnimator.cancel(); - pulseOutlinerAlphaAnimator = null; - } - - pulseOutliner.setAlpha(0); - } - private boolean shouldDrawBodyBubbleOutline(MessageRecord messageRecord, boolean hasWallpaper) { if (hasWallpaper) { return false; @@ -2065,13 +2041,17 @@ public final class ConversationItem extends RelativeLayout implements BindableCo @Override public @NonNull ProjectionList getColorizerProjections(@NonNull ViewGroup coordinateRoot) { - return getSnapshotProjections(coordinateRoot, true); + return getSnapshotProjections(coordinateRoot, true, true); } public @NonNull ProjectionList getSnapshotProjections(@NonNull ViewGroup coordinateRoot, boolean clipOutMedia) { + return getSnapshotProjections(coordinateRoot, clipOutMedia, true); + } + + public @NonNull ProjectionList getSnapshotProjections(@NonNull ViewGroup coordinateRoot, boolean clipOutMedia, boolean outgoingOnly) { colorizerProjections.clear(); - if (messageRecord.isOutgoing() && + if ((messageRecord.isOutgoing() || !outgoingOnly) && !hasNoBubble(messageRecord) && !messageRecord.isRemoteDelete() && bodyBubbleCorners != null && @@ -2133,7 +2113,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo } } - if (messageRecord.isOutgoing() && + if ((messageRecord.isOutgoing() || !outgoingOnly) && hasNoBubble(messageRecord) && hasWallpaper && bodyBubble.getVisibility() == VISIBLE) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectItemDecoration.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectItemDecoration.kt index 68f2f86390..325f40f4f2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectItemDecoration.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectItemDecoration.kt @@ -1,5 +1,7 @@ package org.thoughtcrime.securesms.conversation.mutiselect +import android.animation.Animator +import android.animation.AnimatorSet import android.animation.ArgbEvaluator import android.animation.ValueAnimator import android.content.Context @@ -15,15 +17,19 @@ import android.view.ViewGroup import androidx.appcompat.content.res.AppCompatResources import androidx.core.animation.doOnEnd import androidx.core.content.ContextCompat +import androidx.core.view.animation.PathInterpolatorCompat import androidx.core.view.children import androidx.core.view.forEach import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.recyclerview.widget.RecyclerView import com.airbnb.lottie.SimpleColorFilter +import com.google.android.material.animation.ArgbEvaluatorCompat import org.signal.core.util.SetUtil import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.conversation.ConversationAdapter +import org.thoughtcrime.securesms.conversation.ConversationAdapter.PulseRequest +import org.thoughtcrime.securesms.conversation.ConversationItem import org.thoughtcrime.securesms.util.ThemeUtil import org.thoughtcrime.securesms.util.ViewUtil import org.thoughtcrime.securesms.wallpaper.ChatWallpaper @@ -59,6 +65,10 @@ class MultiselectItemDecoration( private var hideShadeAnimation: ValueAnimator? = null private val multiselectPartAnimatorMap: MutableMap = mutableMapOf() + private val pulseIncomingColor = ContextCompat.getColor(context, R.color.pulse_incoming_message) + private val pulseOutgoingColor = ContextCompat.getColor(context, R.color.pulse_outgoing_message) + private val pulseRequestAnimators: MutableMap = mutableMapOf() + private var checkedBitmap: Bitmap? = null private var focusedItem: MultiselectPart? = null @@ -139,6 +149,8 @@ class MultiselectItemDecoration( outRect.setEmpty() updateChildOffsets(parent, view) + + consumePulseRequest(parent.adapter as ConversationAdapter) } /** @@ -214,7 +226,10 @@ class MultiselectItemDecoration( drawFocusShadeOverIfNecessary(canvas, parent) } - invalidateIfAnimatorsAreRunning(parent) + drawPulseShadeOverIfNecessary(canvas, parent) + + invalidateIfPulseRequestAnimatorsAreRunning(parent) + invalidateIfEnterExitAnimatorsAreRunning(parent) } private fun drawChecks(parent: RecyclerView, canvas: Canvas, adapter: ConversationAdapter) { @@ -400,6 +415,34 @@ class MultiselectItemDecoration( } } + private fun drawPulseShadeOverIfNecessary(canvas: Canvas, parent: RecyclerView) { + if (!hasRunningPulseRequestAnimators()) { + return + } + + for (child in parent.children) { + if (child is ConversationItem) { + 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 + } + + child.getSnapshotProjections(parent, false, false).use { projectionList -> + projectionList.forEach { it.applyToPath(path) } + } + + canvas.clipPath(path) + canvas.drawColor(animator.animatedValue) + canvas.restore() + } + } + } + private fun Canvas.drawShade() { val progress = hideShadeAnimation?.animatedValue as? Float if (progress == null) { @@ -417,7 +460,7 @@ class MultiselectItemDecoration( duration = 150L addUpdateListener { - invalidateIfAnimatorsAreRunning(list) + invalidateIfEnterExitAnimatorsAreRunning(list) } doOnEnd { @@ -474,7 +517,23 @@ class MultiselectItemDecoration( } } - private fun invalidateIfAnimatorsAreRunning(parent: RecyclerView) { + private fun cleanPulseAnimators() { + val toRemove = pulseRequestAnimators.filter { !it.value.isRunning }.keys + toRemove.forEach { pulseRequestAnimators.remove(it) } + } + + private fun hasRunningPulseRequestAnimators(): Boolean { + cleanPulseAnimators() + return pulseRequestAnimators.any { (_, v) -> v.isRunning } + } + + private fun invalidateIfPulseRequestAnimatorsAreRunning(parent: RecyclerView) { + if (hasRunningPulseRequestAnimators()) { + parent.invalidateItemDecorations() + } + } + + private fun invalidateIfEnterExitAnimatorsAreRunning(parent: RecyclerView) { if (enterExitAnimation?.isRunning == true || multiselectPartAnimatorMap.values.any { it.isRunning } || hideShadeAnimation?.isRunning == true @@ -483,6 +542,60 @@ class MultiselectItemDecoration( } } + private fun consumePulseRequest(adapter: ConversationAdapter) { + val pulseRequest = adapter.consumePulseRequest() + if (pulseRequest != null) { + val pulseColor = if (pulseRequest.isOutgoing) pulseOutgoingColor else pulseIncomingColor + pulseRequestAnimators[pulseRequest]?.cancel() + pulseRequestAnimators[pulseRequest] = PulseAnimator(pulseColor).apply { start() } + } + } + + private class PulseAnimator(pulseColor: Int) { + + companion object { + private val PULSE_BEZIER = PathInterpolatorCompat.create(0.17f, 0.17f, 0f, 1f) + } + + private val animator = AnimatorSet().apply { + playSequentially( + pulseInAnimator(pulseColor), + pulseOutAnimator(pulseColor), + pulseInAnimator(pulseColor), + pulseOutAnimator(pulseColor) + ) + interpolator = PULSE_BEZIER + } + + val isRunning: Boolean get() = animator.isRunning + var animatedValue: Int = Color.TRANSPARENT + private set + + fun start() = animator.start() + fun cancel() = animator.cancel() + + private fun pulseInAnimator(pulseColor: Int): Animator { + return ValueAnimator.ofInt(Color.TRANSPARENT, pulseColor).apply { + duration = 200 + setEvaluator(ArgbEvaluatorCompat.getInstance()) + addUpdateListener { + this@PulseAnimator.animatedValue = animatedValue as Int + } + } + } + + private fun pulseOutAnimator(pulseColor: Int): Animator { + return ValueAnimator.ofInt(pulseColor, Color.TRANSPARENT).apply { + startDelay = 200 + duration = 200 + setEvaluator(ArgbEvaluatorCompat.getInstance()) + addUpdateListener { + this@PulseAnimator.animatedValue = animatedValue as Int + } + } + } + } + private enum class Difference { REMOVED, ADDED, diff --git a/app/src/main/res/values-night/dark_colors.xml b/app/src/main/res/values-night/dark_colors.xml index 49149f70f2..25caade833 100644 --- a/app/src/main/res/values-night/dark_colors.xml +++ b/app/src/main/res/values-night/dark_colors.xml @@ -9,6 +9,9 @@ @color/core_ultramarine_light @color/core_white + @color/transparent_white_15 + @color/transparent_white_25 + @color/signal_colorBackground @color/signal_colorSurface1 @color/core_grey_90 diff --git a/app/src/main/res/values/light_colors.xml b/app/src/main/res/values/light_colors.xml index 5146f9d8e2..953ac96dca 100644 --- a/app/src/main/res/values/light_colors.xml +++ b/app/src/main/res/values/light_colors.xml @@ -8,6 +8,9 @@ @color/core_black + @color/transparent_black_10 + @color/transparent_black_25 + @color/signal_colorBackground @color/signal_colorSurface1 @color/core_grey_02