diff --git a/res/layout/conversation_item_received.xml b/res/layout/conversation_item_received.xml
index b9adf27b26..0baa3f2d32 100644
--- a/res/layout/conversation_item_received.xml
+++ b/res/layout/conversation_item_received.xml
@@ -23,6 +23,17 @@
android:clipToPadding="false"
android:clipChildren="false">
+
+
-
-
+
-
+
+
-
+
8dp
1dp
+ 20dp
36dp
10dp
diff --git a/src/org/thoughtcrime/securesms/components/AudioView.java b/src/org/thoughtcrime/securesms/components/AudioView.java
index 80d9730248..e6f6b79eb7 100644
--- a/src/org/thoughtcrime/securesms/components/AudioView.java
+++ b/src/org/thoughtcrime/securesms/components/AudioView.java
@@ -4,6 +4,7 @@ import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Color;
import android.graphics.PorterDuff;
+import android.graphics.Rect;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
@@ -212,6 +213,10 @@ public final class AudioView extends FrameLayout implements AudioSlidePlayer.Lis
this.seekBar.getThumb().setColorFilter(foregroundTint, PorterDuff.Mode.SRC_IN);
}
+ public void getSeekBarGlobalVisibleRect(@NonNull Rect rect) {
+ seekBar.getGlobalVisibleRect(rect);
+ }
+
private double getProgress() {
if (this.seekBar.getProgress() <= 0 || this.seekBar.getMax() <= 0) {
return 0;
diff --git a/src/org/thoughtcrime/securesms/components/InputPanel.java b/src/org/thoughtcrime/securesms/components/InputPanel.java
index 3fc35bd84b..546f430748 100644
--- a/src/org/thoughtcrime/securesms/components/InputPanel.java
+++ b/src/org/thoughtcrime/securesms/components/InputPanel.java
@@ -196,6 +196,10 @@ public class InputPanel extends LinearLayout
this.linkPreview.setCorners(cornerRadius, cornerRadius);
}
+ public void clickOnComposeInput() {
+ composeText.performClick();
+ }
+
public void setMediaKeyboard(@NonNull MediaKeyboard mediaKeyboard) {
this.mediaKeyboard.attach(mediaKeyboard);
}
diff --git a/src/org/thoughtcrime/securesms/conversation/ConversationActivity.java b/src/org/thoughtcrime/securesms/conversation/ConversationActivity.java
index 7713c3982d..760dd46b58 100644
--- a/src/org/thoughtcrime/securesms/conversation/ConversationActivity.java
+++ b/src/org/thoughtcrime/securesms/conversation/ConversationActivity.java
@@ -2697,6 +2697,8 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
messageRecord.getBody(),
slideDeck);
}
+
+ inputPanel.clickOnComposeInput();
}
@Override
diff --git a/src/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/src/org/thoughtcrime/securesms/conversation/ConversationFragment.java
index 50afa0a8d3..0ef94dfe49 100644
--- a/src/org/thoughtcrime/securesms/conversation/ConversationFragment.java
+++ b/src/org/thoughtcrime/securesms/conversation/ConversationFragment.java
@@ -183,6 +183,12 @@ public class ConversationFragment extends Fragment
typingView = (ConversationTypingView) inflater.inflate(R.layout.conversation_typing_view, container, false);
+ new ConversationItemSwipeCallback(
+ messageRecord -> actionMode == null &&
+ canReplyToMessage(isActionMessage(messageRecord), messageRecord),
+ this::handleReplyMessage
+ ).attachToRecyclerView(list);
+
return view;
}
@@ -359,10 +365,7 @@ public class ConversationFragment extends Fragment
}
for (MessageRecord messageRecord : messageRecords) {
- if (messageRecord.isGroupAction() || messageRecord.isCallLog() ||
- messageRecord.isJoined() || messageRecord.isExpirationTimerUpdate() ||
- messageRecord.isEndSession() || messageRecord.isIdentityUpdate() ||
- messageRecord.isIdentityVerified() || messageRecord.isIdentityDefault())
+ if (isActionMessage(messageRecord))
{
actionMessage = true;
}
@@ -394,14 +397,29 @@ public class ConversationFragment extends Fragment
menu.findItem(R.id.menu_context_forward).setVisible(!actionMessage && !sharedContact);
menu.findItem(R.id.menu_context_details).setVisible(!actionMessage);
- menu.findItem(R.id.menu_context_reply).setVisible(!actionMessage &&
- !messageRecord.isPending() &&
- !messageRecord.isFailed() &&
- messageRecord.isSecure());
+ menu.findItem(R.id.menu_context_reply).setVisible(canReplyToMessage(actionMessage, messageRecord));
}
menu.findItem(R.id.menu_context_copy).setVisible(!actionMessage && hasText);
}
+ private static boolean canReplyToMessage(boolean actionMessage, MessageRecord messageRecord) {
+ return !actionMessage &&
+ !messageRecord.isPending() &&
+ !messageRecord.isFailed() &&
+ messageRecord.isSecure();
+ }
+
+ private static boolean isActionMessage(MessageRecord messageRecord) {
+ return messageRecord.isGroupAction() ||
+ messageRecord.isCallLog() ||
+ messageRecord.isJoined() ||
+ messageRecord.isExpirationTimerUpdate() ||
+ messageRecord.isEndSession() ||
+ messageRecord.isIdentityUpdate() ||
+ messageRecord.isIdentityVerified() ||
+ messageRecord.isIdentityDefault();
+ }
+
private ConversationAdapter getListAdapter() {
return (ConversationAdapter) list.getAdapter();
}
diff --git a/src/org/thoughtcrime/securesms/conversation/ConversationItem.java b/src/org/thoughtcrime/securesms/conversation/ConversationItem.java
index 5ed32ff8f8..d19ff55eb0 100644
--- a/src/org/thoughtcrime/securesms/conversation/ConversationItem.java
+++ b/src/org/thoughtcrime/securesms/conversation/ConversationItem.java
@@ -24,6 +24,7 @@ import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.PorterDuff;
+import android.graphics.Rect;
import android.graphics.Typeface;
import android.net.Uri;
import androidx.annotation.DimenRes;
@@ -135,24 +136,27 @@ public class ConversationItem extends LinearLayout implements BindableConversati
private static final int MAX_MEASURE_CALLS = 3;
private static final int MAX_BODY_DISPLAY_LENGTH = 1000;
+ private static final Rect SWIPE_RECT = new Rect();
+
private MessageRecord messageRecord;
private Locale locale;
private boolean groupThread;
private LiveRecipient recipient;
private GlideRequests glideRequests;
- protected ViewGroup bodyBubble;
- private QuoteView quoteView;
- private EmojiTextView bodyText;
- private ConversationItemFooter footer;
- private ConversationItemFooter stickerFooter;
- private TextView groupSender;
- private TextView groupSenderProfileName;
- private View groupSenderHolder;
- private AvatarImageView contactPhoto;
- private ViewGroup contactPhotoHolder;
- private AlertView alertView;
- private ViewGroup container;
+ protected ConversationItemBodyBubble bodyBubble;
+ protected View reply;
+ protected ViewGroup contactPhotoHolder;
+ private QuoteView quoteView;
+ private EmojiTextView bodyText;
+ private ConversationItemFooter footer;
+ private ConversationItemFooter stickerFooter;
+ private TextView groupSender;
+ private TextView groupSenderProfileName;
+ private View groupSenderHolder;
+ private AvatarImageView contactPhoto;
+ private AlertView alertView;
+ private ViewGroup container;
private @NonNull Set batchSelected = new HashSet<>();
private @NonNull Outliner outliner = new Outliner();
@@ -218,6 +222,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati
this.groupSenderHolder = findViewById(R.id.group_sender_holder);
this.quoteView = findViewById(R.id.quote_view);
this.container = findViewById(R.id.container);
+ this.reply = findViewById(R.id.reply_icon);
setOnClickListener(new ClickListener(null));
@@ -268,11 +273,24 @@ public class ConversationItem extends LinearLayout implements BindableConversati
setFooter(messageRecord, nextMessageRecord, locale, groupThread);
}
+ @Override
+ protected void onDetachedFromWindow() {
+ ConversationSwipeAnimationHelper.update(this, 0f, 1f);
+ super.onDetachedFromWindow();
+ }
+
@Override
public void setEventListener(@Nullable EventListener eventListener) {
this.eventListener = eventListener;
}
+ public boolean disallowSwipe(float downX, float downY) {
+ if (!hasAudio(messageRecord)) return false;
+
+ audioViewStub.get().getSeekBarGlobalVisibleRect(SWIPE_RECT);
+ return SWIPE_RECT.contains((int) downX, (int) downY);
+ }
+
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
@@ -313,16 +331,6 @@ public class ConversationItem extends LinearLayout implements BindableConversati
}
}
- @Override
- protected void dispatchDraw(Canvas canvas) {
- super.dispatchDraw(canvas);
-
- if (!messageRecord.isOutgoing() && isViewOnceMessage(messageRecord) && ViewOnceUtil.isViewed((MmsMessageRecord) messageRecord)) {
- outliner.setColor(ThemeUtil.getThemedColor(context, R.attr.conversation_item_sent_text_secondary_color));
- outliner.draw(canvas, bodyBubble.getTop() + getPaddingTop(), bodyBubble.getRight(), bodyBubble.getBottom() + getPaddingTop(), bodyBubble.getLeft());
- }
- }
-
@Override
public void onRecipientChanged(@NonNull Recipient modified) {
setBubbleState(messageRecord);
@@ -382,6 +390,9 @@ public class ConversationItem extends LinearLayout implements BindableConversati
footer.setIconColor(ThemeUtil.getThemedColor(context, R.attr.conversation_item_received_text_secondary_color));
}
+ outliner.setColor(ThemeUtil.getThemedColor(getContext(), R.attr.conversation_item_sent_text_secondary_color));
+ bodyBubble.setOutliner(shouldDrawBodyBubbleOutline(messageRecord) ? outliner : null);
+
if (audioViewStub.resolved()) {
setAudioViewTint(messageRecord, this.conversationRecipient.get());
}
@@ -429,6 +440,10 @@ public class ConversationItem extends LinearLayout implements BindableConversati
}
}
+ private boolean shouldDrawBodyBubbleOutline(MessageRecord messageRecord) {
+ return !messageRecord.isOutgoing() && isViewOnceMessage(messageRecord) && ViewOnceUtil.isViewed((MmsMessageRecord) messageRecord);
+ }
+
private boolean isCaptionlessMms(MessageRecord messageRecord) {
return TextUtils.isEmpty(messageRecord.getDisplayBody(getContext())) && messageRecord.isMms() && ((MmsMessageRecord) messageRecord).getSlideDeck().getTextSlide() == null;
}
@@ -939,7 +954,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati
}
private void setGroupAuthorColor(@NonNull MessageRecord messageRecord) {
- if (!messageRecord.isOutgoing() && isViewOnceMessage(messageRecord) && ViewOnceUtil.isViewed((MmsMessageRecord) messageRecord)) {
+ if (shouldDrawBodyBubbleOutline(messageRecord)) {
groupSender.setTextColor(ThemeUtil.getThemedColor(context, R.attr.conversation_sticker_author_color));
groupSenderProfileName.setTextColor(ThemeUtil.getThemedColor(context, R.attr.conversation_sticker_author_color));
} else if (hasSticker(messageRecord)) {
diff --git a/src/org/thoughtcrime/securesms/conversation/ConversationItemBodyBubble.java b/src/org/thoughtcrime/securesms/conversation/ConversationItemBodyBubble.java
new file mode 100644
index 0000000000..8743b62a18
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/conversation/ConversationItemBodyBubble.java
@@ -0,0 +1,41 @@
+package org.thoughtcrime.securesms.conversation;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.util.AttributeSet;
+import android.widget.LinearLayout;
+
+import androidx.annotation.Nullable;
+
+import org.thoughtcrime.securesms.components.Outliner;
+
+public class ConversationItemBodyBubble extends LinearLayout {
+
+ private @Nullable Outliner outliner;
+
+ public ConversationItemBodyBubble(Context context) {
+ super(context);
+ }
+
+ public ConversationItemBodyBubble(Context context, @Nullable AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public ConversationItemBodyBubble(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ public void setOutliner(@Nullable Outliner outliner) {
+ this.outliner = outliner;
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ super.onDraw(canvas);
+
+ if (outliner == null) return;
+
+ outliner.draw(canvas, 0, getMeasuredWidth(), getMeasuredHeight(), 0);
+ }
+}
+
diff --git a/src/org/thoughtcrime/securesms/conversation/ConversationItemSwipeCallback.java b/src/org/thoughtcrime/securesms/conversation/ConversationItemSwipeCallback.java
new file mode 100644
index 0000000000..a01368108f
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/conversation/ConversationItemSwipeCallback.java
@@ -0,0 +1,178 @@
+package org.thoughtcrime.securesms.conversation;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.os.Vibrator;
+import android.view.MotionEvent;
+import android.view.View;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.ItemTouchHelper;
+import androidx.recyclerview.widget.RecyclerView;
+
+import org.thoughtcrime.securesms.database.model.MessageRecord;
+import org.thoughtcrime.securesms.util.ServiceUtil;
+
+class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallback {
+
+ private static float SWIPE_SUCCESS_PROGRESS = ConversationSwipeAnimationHelper.PROGRESS_TRIGGER_POINT;
+ private static long SWIPE_SUCCESS_VIBE_TIME_MS = 10;
+
+ private boolean swipeBack;
+ private boolean shouldTriggerSwipeFeedback = true;
+ private float latestDownX;
+ private float latestDownY;
+
+ private final SwipeAvailabilityProvider swipeAvailabilityProvider;
+ private final ConversationItemTouchListener itemTouchListener;
+ private final OnSwipeListener onSwipeListener;
+
+ ConversationItemSwipeCallback(@NonNull SwipeAvailabilityProvider swipeAvailabilityProvider,
+ @NonNull OnSwipeListener onSwipeListener)
+ {
+ super(0, ItemTouchHelper.END);
+ this.itemTouchListener = new ConversationItemTouchListener(this::updateLatestDownCoordinate);
+ this.swipeAvailabilityProvider = swipeAvailabilityProvider;
+ this.onSwipeListener = onSwipeListener;
+ }
+
+ void attachToRecyclerView(@NonNull RecyclerView recyclerView) {
+ recyclerView.addOnItemTouchListener(itemTouchListener);
+ new ItemTouchHelper(this).attachToRecyclerView(recyclerView);
+ }
+
+ @Override
+ public boolean onMove(@NonNull RecyclerView recyclerView,
+ @NonNull RecyclerView.ViewHolder viewHolder,
+ @NonNull RecyclerView.ViewHolder target)
+ {
+ return false;
+ }
+
+ @Override
+ public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) {
+ }
+
+ @Override
+ public int getSwipeDirs(@NonNull RecyclerView recyclerView,
+ @NonNull RecyclerView.ViewHolder viewHolder)
+ {
+ if (cannotSwipeViewHolder(viewHolder)) return 0;
+ return super.getSwipeDirs(recyclerView, viewHolder);
+ }
+
+ @Override
+ public int convertToAbsoluteDirection(int flags, int layoutDirection) {
+ if (swipeBack) {
+ swipeBack = false;
+ return 0;
+ }
+ return super.convertToAbsoluteDirection(flags, layoutDirection);
+ }
+
+ @Override
+ public void onChildDraw(
+ @NonNull Canvas c,
+ @NonNull RecyclerView recyclerView,
+ @NonNull RecyclerView.ViewHolder viewHolder,
+ float dX, float dY, int actionState, boolean isCurrentlyActive)
+ {
+ if (cannotSwipeViewHolder(viewHolder)) return;
+
+ float sign = getSignFromDirection(viewHolder.itemView);
+ boolean isCorrectSwipeDir = sameSign(dX, sign);
+
+ float progress = Math.abs(dX) / (float) viewHolder.itemView.getWidth();
+ if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE && isCorrectSwipeDir) {
+ ConversationSwipeAnimationHelper.update((ConversationItem) viewHolder.itemView, progress, sign);
+ handleSwipeFeedback((ConversationItem) viewHolder.itemView, progress);
+ setTouchListener(recyclerView, viewHolder, progress);
+ }
+
+ if (progress == 0) shouldTriggerSwipeFeedback = true;
+ }
+
+ private void handleSwipeFeedback(@NonNull ConversationItem item, float progress) {
+ if (progress > SWIPE_SUCCESS_PROGRESS && shouldTriggerSwipeFeedback) {
+ vibrate(item.getContext());
+ ConversationSwipeAnimationHelper.trigger(item);
+ shouldTriggerSwipeFeedback = false;
+ }
+ }
+
+ private void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder) {
+ if (cannotSwipeViewHolder(viewHolder)) return;
+
+ ConversationItem item = ((ConversationItem) viewHolder.itemView);
+ MessageRecord messageRecord = item.getMessageRecord();
+
+ onSwipeListener.onSwipe(messageRecord);
+ }
+
+ private void setTouchListener(@NonNull RecyclerView recyclerView,
+ @NonNull RecyclerView.ViewHolder viewHolder,
+ float progress)
+ {
+ recyclerView.setOnTouchListener((v, event) -> {
+ switch (event.getAction()) {
+ case MotionEvent.ACTION_DOWN:
+ shouldTriggerSwipeFeedback = true;
+ break;
+ case MotionEvent.ACTION_UP:
+ handleTouchActionUp(recyclerView, viewHolder, progress);
+ case MotionEvent.ACTION_CANCEL:
+ swipeBack = true;
+ shouldTriggerSwipeFeedback = false;
+ break;
+ }
+ return false;
+ });
+ }
+
+ private void handleTouchActionUp(@NonNull RecyclerView recyclerView,
+ @NonNull RecyclerView.ViewHolder viewHolder,
+ float progress)
+ {
+ if (progress > SWIPE_SUCCESS_PROGRESS) {
+ onSwiped(viewHolder);
+ if (shouldTriggerSwipeFeedback) {
+ vibrate(viewHolder.itemView.getContext());
+ }
+ recyclerView.setOnTouchListener(null);
+ }
+ }
+
+ private boolean cannotSwipeViewHolder(@NonNull RecyclerView.ViewHolder viewHolder) {
+ if (!(viewHolder.itemView instanceof ConversationItem)) return true;
+
+ ConversationItem item = ((ConversationItem) viewHolder.itemView);
+ return !swipeAvailabilityProvider.isSwipeAvailable(item.getMessageRecord()) ||
+ item.disallowSwipe(latestDownX, latestDownY);
+ }
+
+ private void updateLatestDownCoordinate(float x, float y) {
+ latestDownX = x;
+ latestDownY = y;
+ }
+
+ private static float getSignFromDirection(@NonNull View view) {
+ return view.getLayoutDirection() == View.LAYOUT_DIRECTION_RTL ? -1f : 1f;
+ }
+
+ private static boolean sameSign(float dX, float sign) {
+ return dX * sign > 0;
+ }
+
+ private static void vibrate(@NonNull Context context) {
+ Vibrator vibrator = ServiceUtil.getVibrator(context);
+ if (vibrator != null) vibrator.vibrate(SWIPE_SUCCESS_VIBE_TIME_MS);
+ }
+
+ interface SwipeAvailabilityProvider {
+ boolean isSwipeAvailable(MessageRecord messageRecord);
+ }
+
+ interface OnSwipeListener {
+ void onSwipe(MessageRecord messageRecord);
+ }
+}
diff --git a/src/org/thoughtcrime/securesms/conversation/ConversationItemTouchListener.java b/src/org/thoughtcrime/securesms/conversation/ConversationItemTouchListener.java
new file mode 100644
index 0000000000..4420ddb676
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/conversation/ConversationItemTouchListener.java
@@ -0,0 +1,27 @@
+package org.thoughtcrime.securesms.conversation;
+
+import android.view.MotionEvent;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+final class ConversationItemTouchListener extends RecyclerView.SimpleOnItemTouchListener {
+
+ private final Callback callback;
+
+ ConversationItemTouchListener(Callback callback) {
+ this.callback = callback;
+ }
+
+ @Override
+ public boolean onInterceptTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e) {
+ if (e.getAction() == MotionEvent.ACTION_DOWN) {
+ callback.onDownEvent(e.getRawX(), e.getRawY());
+ }
+ return false;
+ }
+
+ interface Callback {
+ void onDownEvent(float rawX, float rawY);
+ }
+}
diff --git a/src/org/thoughtcrime/securesms/conversation/ConversationSwipeAnimationHelper.java b/src/org/thoughtcrime/securesms/conversation/ConversationSwipeAnimationHelper.java
new file mode 100644
index 0000000000..7fa56be156
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/conversation/ConversationSwipeAnimationHelper.java
@@ -0,0 +1,108 @@
+package org.thoughtcrime.securesms.conversation;
+
+import android.animation.ValueAnimator;
+import android.content.res.Resources;
+import android.view.View;
+import android.view.animation.Interpolator;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.thoughtcrime.securesms.util.Util;
+
+final class ConversationSwipeAnimationHelper {
+
+ public static final float PROGRESS_TRIGGER_POINT = 0.375f;
+
+ private static final float PROGRESS_SCALE_FACTOR = 2.0f;
+ private static final float SCALED_PROGRESS_TRIGGER_POINT = PROGRESS_TRIGGER_POINT * PROGRESS_SCALE_FACTOR;
+ private static final float REPLY_SCALE_OVERSHOOT = 1.8f;
+ private static final float REPLY_SCALE_MAX = 1.2f;
+ private static final float REPLY_SCALE_MIN = 1f;
+ private static final long REPLY_SCALE_OVERSHOOT_DURATION = 200;
+
+ private static final Interpolator BUBBLE_INTERPOLATOR = new ClampingLinearInterpolator(0f, dpToPx(48));
+ private static final Interpolator REPLY_ALPHA_INTERPOLATOR = new ClampingLinearInterpolator(0f, 1f, 1f / SCALED_PROGRESS_TRIGGER_POINT);
+ private static final Interpolator REPLY_TRANSITION_INTERPOLATOR = new ClampingLinearInterpolator(0f, dpToPx(10));
+ private static final Interpolator AVATAR_INTERPOLATOR = new ClampingLinearInterpolator(0f, dpToPx(8));
+ private static final Interpolator REPLY_SCALE_INTERPOLATOR = new ClampingLinearInterpolator(REPLY_SCALE_MIN, REPLY_SCALE_MAX);
+
+ private ConversationSwipeAnimationHelper() {
+ }
+
+ public static void update(@NonNull ConversationItem conversationItem, float progress, float sign) {
+ float scaledProgress = Math.min(1f, progress * PROGRESS_SCALE_FACTOR);
+ updateBodyBubbleTransition(conversationItem.bodyBubble, scaledProgress, sign);
+ updateReplyIconTransition(conversationItem.reply, scaledProgress, sign);
+ updateContactPhotoHolderTransition(conversationItem.contactPhotoHolder, scaledProgress, sign);
+ }
+
+ public static void trigger(@NonNull ConversationItem conversationItem) {
+ triggerReplyIcon(conversationItem.reply);
+ }
+
+ private static void updateBodyBubbleTransition(@NonNull View bodyBubble, float progress, float sign) {
+ bodyBubble.setTranslationX(BUBBLE_INTERPOLATOR.getInterpolation(progress) * sign);
+ }
+
+ private static void updateReplyIconTransition(@NonNull View replyIcon, float progress, float sign) {
+ if (progress > 0.05f) {
+ replyIcon.setAlpha(REPLY_ALPHA_INTERPOLATOR.getInterpolation(progress));
+ } else replyIcon.setAlpha(0f);
+
+ replyIcon.setTranslationX(REPLY_TRANSITION_INTERPOLATOR.getInterpolation(progress) * sign);
+
+ if (progress < SCALED_PROGRESS_TRIGGER_POINT) {
+ float scale = REPLY_SCALE_INTERPOLATOR.getInterpolation(progress);
+ replyIcon.setScaleX(scale);
+ replyIcon.setScaleY(scale);
+ }
+ }
+
+ private static void updateContactPhotoHolderTransition(@Nullable View contactPhotoHolder,
+ float progress,
+ float sign)
+ {
+ if (contactPhotoHolder == null) return;
+ contactPhotoHolder.setTranslationX(AVATAR_INTERPOLATOR.getInterpolation(progress) * sign);
+ }
+
+ private static void triggerReplyIcon(@NonNull View replyIcon) {
+ ValueAnimator animator = ValueAnimator.ofFloat(REPLY_SCALE_MAX, REPLY_SCALE_OVERSHOOT, REPLY_SCALE_MAX);
+ animator.setDuration(REPLY_SCALE_OVERSHOOT_DURATION);
+ animator.addUpdateListener(animation -> {
+ replyIcon.setScaleX((float) animation.getAnimatedValue());
+ replyIcon.setScaleY((float) animation.getAnimatedValue());
+ });
+ animator.start();
+ }
+
+ private static int dpToPx(int dp) {
+ return (int) (dp * Resources.getSystem().getDisplayMetrics().density);
+ }
+
+ private static final class ClampingLinearInterpolator implements Interpolator {
+
+ private final float slope;
+ private final float yIntercept;
+ private final float max;
+ private final float min;
+
+ ClampingLinearInterpolator(float start, float end) {
+ this(start, end, 1.0f);
+ }
+
+ ClampingLinearInterpolator(float start, float end, float scale) {
+ slope = (end - start) * scale;
+ yIntercept = start;
+ max = Math.max(start, end);
+ min = Math.min(start, end);
+ }
+
+ @Override
+ public float getInterpolation(float input) {
+ return Util.clamp(slope * input + yIntercept, min, max);
+ }
+ }
+
+}