Merge pull request #6470 from thundernest/swipe_select_state

Deselect message during swipe
This commit is contained in:
cketti 2022-11-14 12:59:23 +01:00 committed by GitHub
commit 23b68555fb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 298 additions and 109 deletions

View file

@ -480,11 +480,11 @@ class MessageListAdapter internal constructor(
}
}
private fun selectMessage(item: MessageListItem) {
fun selectMessage(item: MessageListItem) {
selected = selected + item.uniqueId
}
private fun deselectMessage(item: MessageListItem) {
fun deselectMessage(item: MessageListItem) {
selected = selected - item.uniqueId
}

View file

@ -810,7 +810,24 @@ class MessageListFragment :
private fun toggleMessageSelect(messageListItem: MessageListItem) {
adapter.toggleSelection(messageListItem)
updateAfterSelectionChange()
}
private fun selectMessage(messageListItem: MessageListItem) {
adapter.selectMessage(messageListItem)
updateAfterSelectionChange()
}
private fun deselectMessage(messageListItem: MessageListItem) {
adapter.deselectMessage(messageListItem)
updateAfterSelectionChange()
}
private fun isMessageSelected(messageListItem: MessageListItem): Boolean {
return adapter.isSelected(messageListItem)
}
private fun updateAfterSelectionChange() {
if (adapter.selectedCount == 0) {
actionMode?.finish()
actionMode = null
@ -1434,36 +1451,67 @@ class MessageListFragment :
private val isPullToRefreshAllowed: Boolean
get() = isRemoteSearchAllowed || isCheckMailAllowed
private val swipeListener = MessageListSwipeListener { item, action ->
when (action) {
SwipeAction.None -> Unit
SwipeAction.ToggleSelection -> {
toggleMessageSelect(item)
private var itemSelectedOnSwipeStart = false
private val swipeListener = object : MessageListSwipeListener {
override fun onSwipeStarted(item: MessageListItem, action: SwipeAction) {
itemSelectedOnSwipeStart = isMessageSelected(item)
if (itemSelectedOnSwipeStart && action != SwipeAction.ToggleSelection) {
deselectMessage(item)
}
SwipeAction.ToggleRead -> {
setFlag(item, Flag.SEEN, !item.isRead)
}
SwipeAction.ToggleStar -> {
setFlag(item, Flag.FLAGGED, !item.isStarred)
}
SwipeAction.Archive -> {
onArchive(item.messageReference)
}
SwipeAction.Delete -> {
if (K9.isConfirmDelete) {
notifyItemChanged(item)
}
override fun onSwipeActionChanged(item: MessageListItem, action: SwipeAction) {
if (action == SwipeAction.ToggleSelection) {
if (itemSelectedOnSwipeStart && !isMessageSelected(item)) {
selectMessage(item)
}
onDelete(listOf(item.messageReference))
} else if (isMessageSelected(item)) {
deselectMessage(item)
}
SwipeAction.Spam -> {
if (K9.isConfirmSpam) {
notifyItemChanged(item)
}
override fun onSwipeAction(item: MessageListItem, action: SwipeAction) {
if (action.removesItem || action == SwipeAction.ToggleSelection) {
itemSelectedOnSwipeStart = false
}
when (action) {
SwipeAction.None -> Unit
SwipeAction.ToggleSelection -> {
toggleMessageSelect(item)
}
SwipeAction.ToggleRead -> {
setFlag(item, Flag.SEEN, !item.isRead)
}
SwipeAction.ToggleStar -> {
setFlag(item, Flag.FLAGGED, !item.isStarred)
}
SwipeAction.Archive -> {
onArchive(item.messageReference)
}
SwipeAction.Delete -> {
if (K9.isConfirmDelete) {
notifyItemChanged(item)
}
onDelete(listOf(item.messageReference))
}
SwipeAction.Spam -> {
if (K9.isConfirmSpam) {
notifyItemChanged(item)
}
onSpam(listOf(item.messageReference))
}
SwipeAction.Move -> {
notifyItemChanged(item)
onMove(item.messageReference)
}
onSpam(listOf(item.messageReference))
}
SwipeAction.Move -> {
notifyItemChanged(item)
onMove(item.messageReference)
}
override fun onSwipeEnded(item: MessageListItem) {
if (itemSelectedOnSwipeStart && !isMessageSelected(item)) {
selectMessage(item)
}
}
}

View file

@ -9,9 +9,21 @@ class MessageListItemAnimator : DefaultItemAnimator() {
changeDuration = 120
}
override fun canReuseUpdatedViewHolder(viewHolder: ViewHolder, payloads: MutableList<Any>): Boolean {
// ItemTouchHelper expects swiped views to be removed from the view hierarchy. So we don't reuse views that are
// marked as having been swiped.
return !viewHolder.wasSwiped && super.canReuseUpdatedViewHolder(viewHolder, payloads)
override fun animateChange(
oldHolder: ViewHolder,
newHolder: ViewHolder,
fromX: Int,
fromY: Int,
toX: Int,
toY: Int
): Boolean {
if (oldHolder == newHolder && newHolder.wasSwiped) {
// Don't touch views currently being swiped
dispatchChangeFinished(oldHolder, true)
dispatchChangeFinished(newHolder, false)
return false
}
return super.animateChange(oldHolder, newHolder, fromX, fromY, toX, toY)
}
}

View file

@ -68,12 +68,30 @@ class MessageListSwipeCallback(
throw UnsupportedOperationException("not implemented")
}
override fun onSwiped(viewHolder: ViewHolder, direction: Int) {
val holder = viewHolder as MessageViewHolder
val item = adapter.getItemById(holder.uniqueId) ?: error("Couldn't find MessageListItem")
override fun onSwipeStarted(viewHolder: ViewHolder, direction: Int) {
val swipeAction = when (direction) {
ItemTouchHelper.RIGHT -> swipeRightAction
ItemTouchHelper.LEFT -> swipeLeftAction
else -> error("Unsupported direction: $direction")
}
// ItemTouchHelper expects swiped views to be removed from the view hierarchy. We mark this ViewHolder so that
// MessageListItemAnimator knows not to reuse it during an animation.
listener.onSwipeStarted(viewHolder.messageListItem, swipeAction)
}
override fun onSwipeDirectionChanged(viewHolder: ViewHolder, direction: Int) {
val swipeAction = when (direction) {
ItemTouchHelper.RIGHT -> swipeRightAction
ItemTouchHelper.LEFT -> swipeLeftAction
else -> error("Unsupported direction: $direction")
}
listener.onSwipeActionChanged(viewHolder.messageListItem, swipeAction)
}
override fun onSwiped(viewHolder: ViewHolder, direction: Int) {
val item = viewHolder.messageListItem
// Mark view to prevent MessageListItemAnimator from interfering with swipe animations
viewHolder.markAsSwiped(true)
when (direction) {
@ -83,6 +101,10 @@ class MessageListSwipeCallback(
}
}
override fun onSwipeEnded(viewHolder: ViewHolder) {
listener.onSwipeEnded(viewHolder.messageListItem)
}
override fun clearView(recyclerView: RecyclerView, viewHolder: ViewHolder) {
super.clearView(recyclerView, viewHolder)
viewHolder.markAsSwiped(false)
@ -99,26 +121,25 @@ class MessageListSwipeCallback(
dX: Float,
dY: Float,
actionState: Int,
isCurrentlyActive: Boolean
isCurrentlyActive: Boolean,
success: Boolean
) {
val view = viewHolder.itemView
val viewWidth = view.width
val viewHeight = view.height
val isViewAnimatingBack = !isCurrentlyActive
if (dX != 0F) {
canvas.withTranslation(x = view.left.toFloat(), y = view.top.toFloat()) {
if (isViewAnimatingBack) {
drawBackground(dX, viewWidth, viewHeight)
} else {
if (isCurrentlyActive || !success) {
val holder = viewHolder as MessageViewHolder
drawLayout(dX, viewWidth, viewHeight, holder)
} else {
drawBackground(dX, viewWidth, viewHeight)
}
}
}
super.onChildDraw(canvas, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
super.onChildDraw(canvas, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive, success)
}
private fun Canvas.drawBackground(dX: Float, width: Int, height: Int) {
@ -208,14 +229,21 @@ class MessageListSwipeCallback(
val percentage = abs(animateDx) / recyclerView.width
return (super.getAnimationDuration(recyclerView, animationType, animateDx, animateDy) * percentage).toLong()
}
private val ViewHolder.messageListItem: MessageListItem
get() = (this as? MessageViewHolder)?.uniqueId?.let { adapter.getItemById(it) }
?: error("Couldn't find MessageListItem")
}
fun interface SwipeActionSupportProvider {
fun isActionSupported(item: MessageListItem, action: SwipeAction): Boolean
}
fun interface MessageListSwipeListener {
interface MessageListSwipeListener {
fun onSwipeStarted(item: MessageListItem, action: SwipeAction)
fun onSwipeActionChanged(item: MessageListItem, action: SwipeAction)
fun onSwipeAction(item: MessageListItem, action: SwipeAction)
fun onSwipeEnded(item: MessageListItem)
}
private fun ViewHolder.markAsSwiped(value: Boolean) {

View file

@ -193,6 +193,11 @@ public class ItemTouchHelper extends RecyclerView.ItemDecoration
float mDy;
/**
* Current swipe direction. Used for {@link Callback#onSwipeDirectionChanged(ViewHolder, int)}
*/
private int mSwipeDirection = 0;
/**
* The coordinates of the selected view at the time it is selected. We record these values
* when action starts so that we can consistently position it even if LayoutManager moves the
@ -554,6 +559,14 @@ public class ItemTouchHelper extends RecyclerView.ItemDecoration
if ((mSelectedFlags & (LEFT | RIGHT)) != 0 && dx != 0) {
dx = limitDeltaX(parent, dx);
}
if (dx != 0) {
int direction = dx > 0 ? RIGHT : LEFT;
if (direction != mSwipeDirection) {
mSwipeDirection = direction;
mCallback.onSwipeDirectionChanged(mSelected, direction);
}
}
}
mCallback.onDraw(c, parent, mSelected,
@ -582,6 +595,7 @@ public class ItemTouchHelper extends RecyclerView.ItemDecoration
if (selected == mSelected && actionState == mActionState) {
return;
}
mDragScrollStartTimeInMs = Long.MIN_VALUE;
final int prevActionState = mActionState;
// prevent duplicate animations
@ -615,33 +629,7 @@ public class ItemTouchHelper extends RecyclerView.ItemDecoration
final float currentTranslateX = limitDeltaX(mRecyclerView, mTmpPosition[0]);
final float currentTranslateY = mTmpPosition[1];
// find where we should animate to
final float targetTranslateX, targetTranslateY;
int animationType;
switch (swipeDir) {
case LEFT:
case RIGHT:
case START:
case END:
if (mCallback.shouldAnimateOut(swipeDir)) {
targetTranslateX = Math.signum(mDx) * mRecyclerView.getWidth();
} else if (wasFling) {
int maxSwipeDistance = mCallback.getMaxSwipeDistance(mRecyclerView, swipeDir);
targetTranslateX = Math.signum(mDx) * maxSwipeDistance;
} else {
targetTranslateX = currentTranslateX;
}
targetTranslateY = 0;
break;
case UP:
case DOWN:
targetTranslateX = 0;
targetTranslateY = Math.signum(mDy) * mRecyclerView.getHeight();
break;
default:
targetTranslateX = 0;
targetTranslateY = 0;
}
final int animationType;
if (prevActionState == ACTION_STATE_DRAG) {
animationType = ANIMATION_TYPE_DRAG;
} else if (swipeDir > 0) {
@ -650,40 +638,59 @@ public class ItemTouchHelper extends RecyclerView.ItemDecoration
animationType = ANIMATION_TYPE_SWIPE_CANCEL;
}
final RecoverAnimation rv = new RecoverAnimation(prevSelected, animationType,
prevActionState, currentTranslateX, currentTranslateY,
targetTranslateX, targetTranslateY) {
@Override
public void onAnimationEnd(Animator animation) {
super.onAnimationEnd(animation);
if (this.mOverridden) {
return;
}
if (swipeDir <= 0) {
// this is a drag or failed swipe. recover immediately
mCallback.clearView(mRecyclerView, prevSelected);
// full cleanup will happen on onDrawOver
final RecoverAnimation animation;
final boolean useDefaultDuration;
switch (swipeDir) {
case LEFT:
case RIGHT:
case START:
case END:
if (mCallback.shouldAnimateOut(swipeDir)) {
float targetTranslateX = Math.signum(mDx) * mRecyclerView.getWidth();
animation = new MoveOutAnimation(prevSelected, animationType, prevActionState,
currentTranslateX, currentTranslateY, targetTranslateX, currentTranslateY, swipeDir,
/* moveBackAfterwards */ false);
useDefaultDuration = true;
} else if (wasFling) {
int maxSwipeDistance = mCallback.getMaxSwipeDistance(mRecyclerView, swipeDir);
float targetTranslateX = Math.signum(mDx) * maxSwipeDistance;
animation = new MoveOutAnimation(prevSelected, animationType, prevActionState,
currentTranslateX, currentTranslateY, targetTranslateX, currentTranslateY, swipeDir,
/* moveBackAfterwards */ true);
useDefaultDuration = true;
} else {
// wait until remove animation is complete.
mPendingCleanup.add(prevSelected.itemView);
mIsPendingCleanup = true;
if (swipeDir > 0) {
// Animation might be ended by other animators during a layout.
// We defer callback to avoid editing adapter during a layout.
postDispatchSwipe(this, swipeDir);
}
// This is a dummy animation to ensure mCallback.onChildDraw() calls will be made even if
// the animating back part is delayed.
animation = new MoveOutAnimation(prevSelected, animationType, prevActionState,
currentTranslateX, currentTranslateY, currentTranslateX, currentTranslateY,
swipeDir, /* moveBackAfterwards */ true);
animation.setDuration(0);
useDefaultDuration = false;
}
// removed from the list after it is drawn for the last time
if (mOverdrawChild == prevSelected.itemView) {
removeChildDrawingOrderCallbackIfNecessary(prevSelected.itemView);
}
}
};
final long duration = mCallback.getAnimationDuration(mRecyclerView, animationType,
targetTranslateX - currentTranslateX, targetTranslateY - currentTranslateY);
rv.setDuration(duration);
mRecoverAnimations.add(rv);
rv.start();
break;
case UP:
case DOWN:
throw new UnsupportedOperationException();
default:
animation = new MoveBackAnimation(prevSelected, animationType, prevActionState,
currentTranslateX, currentTranslateY);
useDefaultDuration = true;
}
if (useDefaultDuration) {
long duration = mCallback.getAnimationDuration(mRecyclerView, animationType,
animation.mTargetX - animation.mStartDx, animation.mTargetY - animation.mStartDy);
animation.setDuration(duration);
}
mRecoverAnimations.add(animation);
animation.start();
preventLayout = true;
} else {
removeChildDrawingOrderCallbackIfNecessary(prevSelected.itemView);
@ -715,7 +722,7 @@ public class ItemTouchHelper extends RecyclerView.ItemDecoration
}
@SuppressWarnings("WeakerAccess") /* synthetic access */
void postDispatchSwipe(final RecoverAnimation anim, final int swipeDir) {
void postDispatchSwipe(final RecoverAnimation anim, final int swipeDir, final boolean moveBackAfterwards) {
// wait until animations are complete.
mRecyclerView.post(new Runnable() {
@Override
@ -731,6 +738,11 @@ public class ItemTouchHelper extends RecyclerView.ItemDecoration
if ((animator == null || !animator.isRunning(null))
&& !hasRunningRecoverAnim()) {
mCallback.onSwiped(anim.mViewHolder, swipeDir);
if (moveBackAfterwards) {
startMoveBackAnimation(anim);
} else {
mCallback.onSwipeEnded(anim.mViewHolder);
}
} else {
mRecyclerView.post(this);
}
@ -739,6 +751,20 @@ public class ItemTouchHelper extends RecyclerView.ItemDecoration
});
}
private void startMoveBackAnimation(RecoverAnimation animation) {
MoveBackAnimation moveBackAnimation = new MoveBackAnimation(animation.mViewHolder, animation.mAnimationType,
animation.mActionState, animation.mTargetX, animation.mTargetY);
long duration = mCallback.getAnimationDuration(mRecyclerView, animation.mAnimationType,
-animation.mTargetX, -animation.mTargetY);
moveBackAnimation.setDuration(duration);
mRecoverAnimations.remove(animation);
mRecoverAnimations.add(moveBackAnimation);
moveBackAnimation.start();
}
@SuppressWarnings("WeakerAccess") /* synthetic access */
boolean hasRunningRecoverAnim() {
final int size = mRecoverAnimations.size();
@ -1051,6 +1077,10 @@ public class ItemTouchHelper extends RecyclerView.ItemDecoration
}
mDx = mDy = 0f;
mActivePointerId = motionEvent.getPointerId(0);
mSwipeDirection = dx > 0 ? RIGHT : LEFT;
mCallback.onSwipeStarted(vh, mSwipeDirection);
select(vh, ACTION_STATE_SWIPE);
}
@ -2005,13 +2035,15 @@ public class ItemTouchHelper extends RecyclerView.ItemDecoration
final ItemTouchHelper.RecoverAnimation anim = recoverAnimationList.get(i);
anim.update();
final int count = c.save();
boolean isCurrentlyActive = anim instanceof MoveOutAnimation;
boolean success = anim.mAnimationType == ANIMATION_TYPE_SWIPE_SUCCESS;
onChildDraw(c, parent, anim.mViewHolder, anim.mX, anim.mY, anim.mActionState,
anim.mAnimationType == ANIMATION_TYPE_SWIPE_SUCCESS && !anim.mIsPendingCleanup);
isCurrentlyActive, success);
c.restoreToCount(count);
}
if (selected != null) {
final int count = c.save();
onChildDraw(c, parent, selected, dX, dY, actionState, true);
onChildDraw(c, parent, selected, dX, dY, actionState, true, false);
c.restoreToCount(count);
}
}
@ -2053,7 +2085,7 @@ public class ItemTouchHelper extends RecyclerView.ItemDecoration
* This is a good place to clear all changes on the View that was done in
* {@link #onSelectedChanged(RecyclerView.ViewHolder, int)},
* {@link #onChildDraw(Canvas, RecyclerView, ViewHolder, float, float, int,
* boolean)} or
* boolean, boolean)} or
* {@link #onChildDrawOver(Canvas, RecyclerView, ViewHolder, float, float, int, boolean)}.
*
* @param recyclerView The RecyclerView which is controlled by the ItemTouchHelper.
@ -2092,7 +2124,7 @@ public class ItemTouchHelper extends RecyclerView.ItemDecoration
*/
public void onChildDraw(@NonNull Canvas c, @NonNull RecyclerView recyclerView,
@NonNull ViewHolder viewHolder,
float dX, float dY, int actionState, boolean isCurrentlyActive) {
float dX, float dY, int actionState, boolean isCurrentlyActive, boolean success) {
ItemTouchUIUtilImpl.INSTANCE.onDraw(c, recyclerView, viewHolder.itemView, dX, dY,
actionState, isCurrentlyActive);
}
@ -2228,9 +2260,18 @@ public class ItemTouchHelper extends RecyclerView.ItemDecoration
* @param direction The swipe direction.
* @return The maximum distance in pixels that a view can be moved during a swipe.
*/
public int getMaxSwipeDistance(RecyclerView recyclerView, int direction) {
public int getMaxSwipeDistance(@NonNull RecyclerView recyclerView, int direction) {
return recyclerView.getWidth();
}
public void onSwipeStarted(@NonNull ViewHolder viewHolder, int direction) {
}
public void onSwipeDirectionChanged(@NonNull ViewHolder viewHolder, int direction) {
}
public void onSwipeEnded(@NonNull ViewHolder viewHolder) {
}
}
/**
@ -2526,4 +2567,64 @@ public class ItemTouchHelper extends RecyclerView.ItemDecoration
}
}
private class MoveBackAnimation extends RecoverAnimation {
MoveBackAnimation(ViewHolder viewHolder, int animationType, int actionState, float startDx, float startDy) {
super(viewHolder, animationType, actionState, startDx, startDy, /* targetX */ 0, /* targetY */ 0);
}
@Override
public void onAnimationEnd(Animator animation) {
super.onAnimationEnd(animation);
if (this.mOverridden) {
return;
}
mCallback.onSwipeEnded(mViewHolder);
mCallback.clearView(mRecyclerView, mViewHolder);
// full cleanup will happen on onDrawOver
// removed from the list after it is drawn for the last time
if (mOverdrawChild == mViewHolder.itemView) {
removeChildDrawingOrderCallbackIfNecessary(mViewHolder.itemView);
}
}
}
private class MoveOutAnimation extends RecoverAnimation {
private final int mSwipeDirection;
private final boolean mMoveBackAfterwards;
MoveOutAnimation(ViewHolder viewHolder, int animationType, int actionState, float startDx, float startDy,
float targetX, float targetY, int swipeDirection, boolean moveBackAfterwards) {
super(viewHolder, animationType, actionState, startDx, startDy, targetX, targetY);
this.mSwipeDirection = swipeDirection;
this.mMoveBackAfterwards = moveBackAfterwards;
}
@Override
public void onAnimationEnd(Animator animation) {
super.onAnimationEnd(animation);
if (this.mOverridden) {
return;
}
if (!mMoveBackAfterwards) {
mPendingCleanup.add(mViewHolder.itemView);
}
mIsPendingCleanup = true;
// Animation might be ended by other animators during a layout.
// We defer callback to avoid editing adapter during a layout.
postDispatchSwipe(this, mSwipeDirection, mMoveBackAfterwards);
if (!mMoveBackAfterwards) {
// removed from the list after it is drawn for the last time
if (mOverdrawChild == mViewHolder.itemView) {
removeChildDrawingOrderCallbackIfNecessary(mViewHolder.itemView);
}
}
}
}
}