mirror of
https://github.com/oxen-io/session-android.git
synced 2025-06-10 10:28:39 +00:00
Add Swipe-To-Reply in conversations.
Swipe-To-Reply is a progress based animation that is controlled by the ConversationItemSwipeCallback. We use the TouchListener to keep track of the latest down coordinates, so that we can check whether we are over a seek bar. The SwipeAnimationHelper is responsible for actually performing the transitions as the swipe progresses.
This commit is contained in:
parent
582028f2c2
commit
007ea43dc8
@ -23,6 +23,17 @@
|
||||
android:clipToPadding="false"
|
||||
android:clipChildren="false">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/reply_icon"
|
||||
android:layout_width="@dimen/conversation_item_reply_size"
|
||||
android:layout_height="@dimen/conversation_item_reply_size"
|
||||
android:src="@drawable/ic_reply_white_24dp"
|
||||
android:tint="?compose_icon_tint"
|
||||
android:alpha="0"
|
||||
android:layout_alignTop="@id/body_bubble"
|
||||
android:layout_alignBottom="@id/body_bubble"
|
||||
android:layout_alignStart="@id/body_bubble" />
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/contact_photo_container"
|
||||
android:layout_width="36dp"
|
||||
@ -40,7 +51,7 @@
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
<LinearLayout
|
||||
<org.thoughtcrime.securesms.conversation.ConversationItemBodyBubble
|
||||
android:id="@+id/body_bubble"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
@ -206,7 +217,7 @@
|
||||
app:footer_text_color="?conversation_sticker_footer_text_color"
|
||||
app:footer_icon_color="?conversation_sticker_footer_icon_color"/>
|
||||
|
||||
</LinearLayout>
|
||||
</org.thoughtcrime.securesms.conversation.ConversationItemBodyBubble>
|
||||
|
||||
<org.thoughtcrime.securesms.components.AlertView
|
||||
android:id="@+id/indicators_parent"
|
||||
|
@ -22,7 +22,18 @@
|
||||
android:clipToPadding="false"
|
||||
android:clipChildren="false">
|
||||
|
||||
<LinearLayout
|
||||
<ImageView
|
||||
android:id="@+id/reply_icon"
|
||||
android:layout_width="@dimen/conversation_item_reply_size"
|
||||
android:layout_height="@dimen/conversation_item_reply_size"
|
||||
android:alpha="0"
|
||||
android:src="@drawable/ic_reply_white_24dp"
|
||||
android:tint="?compose_icon_tint"
|
||||
android:layout_alignTop="@id/body_bubble"
|
||||
android:layout_alignBottom="@id/body_bubble"
|
||||
android:layout_alignStart="@id/body_bubble" />
|
||||
|
||||
<org.thoughtcrime.securesms.conversation.ConversationItemBodyBubble
|
||||
android:id="@+id/body_bubble"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
@ -164,7 +175,7 @@
|
||||
app:footer_text_color="?conversation_sticker_footer_text_color"
|
||||
app:footer_icon_color="?conversation_sticker_footer_icon_color"/>
|
||||
|
||||
</LinearLayout>
|
||||
</org.thoughtcrime.securesms.conversation.ConversationItemBodyBubble>
|
||||
|
||||
<org.thoughtcrime.securesms.components.AlertView
|
||||
android:id="@+id/indicators_parent"
|
||||
|
@ -64,6 +64,7 @@
|
||||
<dimen name="conversation_vertical_message_spacing_default">8dp</dimen>
|
||||
<dimen name="conversation_vertical_message_spacing_collapse">1dp</dimen>
|
||||
|
||||
<dimen name="conversation_item_reply_size">20dp</dimen>
|
||||
<dimen name="conversation_item_avatar_size">36dp</dimen>
|
||||
|
||||
<dimen name="quote_corner_radius_large">10dp</dimen>
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -2697,6 +2697,8 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
messageRecord.getBody(),
|
||||
slideDeck);
|
||||
}
|
||||
|
||||
inputPanel.clickOnComposeInput();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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<MessageRecord> 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)) {
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user