package org.thoughtcrime.securesms.conversation; import android.annotation.SuppressLint; 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.AccessibilityUtil; import org.thoughtcrime.securesms.util.ServiceUtil; class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallback { private static float SWIPE_SUCCESS_DX = ConversationSwipeAnimationHelper.TRIGGER_DX; private static long SWIPE_SUCCESS_VIBE_TIME_MS = 10; private boolean swipeBack; private boolean shouldTriggerSwipeFeedback; private boolean canTriggerSwipe; 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; this.shouldTriggerSwipeFeedback = true; this.canTriggerSwipe = true; } 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); if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE && isCorrectSwipeDir) { ConversationSwipeAnimationHelper.update((ConversationItem) viewHolder.itemView, Math.abs(dx), sign); handleSwipeFeedback((ConversationItem) viewHolder.itemView, Math.abs(dx)); if (canTriggerSwipe) { setTouchListener(recyclerView, viewHolder, Math.abs(dx)); } } else if (actionState == ItemTouchHelper.ACTION_STATE_IDLE || dx == 0) { ConversationSwipeAnimationHelper.update((ConversationItem) viewHolder.itemView, 0, 1); } if (dx == 0) { shouldTriggerSwipeFeedback = true; canTriggerSwipe = true; } } private void handleSwipeFeedback(@NonNull ConversationItem item, float dx) { if (dx > SWIPE_SUCCESS_DX && 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); } @SuppressLint("ClickableViewAccessibility") private void setTouchListener(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder, float dx) { recyclerView.setOnTouchListener((v, event) -> { switch (event.getAction()) { case MotionEvent.ACTION_DOWN: shouldTriggerSwipeFeedback = true; break; case MotionEvent.ACTION_UP: handleTouchActionUp(recyclerView, viewHolder, dx); case MotionEvent.ACTION_CANCEL: swipeBack = true; shouldTriggerSwipeFeedback = false; resetProgressIfAnimationsDisabled(viewHolder); break; } return false; }); } private void handleTouchActionUp(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder, float dx) { if (dx > SWIPE_SUCCESS_DX) { canTriggerSwipe = false; onSwiped(viewHolder); if (shouldTriggerSwipeFeedback) { vibrate(viewHolder.itemView.getContext()); } recyclerView.setOnTouchListener(null); } recyclerView.cancelPendingInputEvents(); } private static void resetProgressIfAnimationsDisabled(RecyclerView.ViewHolder viewHolder) { if (AccessibilityUtil.areAnimationsDisabled(viewHolder.itemView.getContext())) { ConversationSwipeAnimationHelper.update((ConversationItem) viewHolder.itemView, 0f, getSignFromDirection(viewHolder.itemView)); } } 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); } }