From 40c3665c3a0dfb002bcba9dbf6f64c993f05fba9 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Fri, 23 Aug 2019 13:41:51 +0200 Subject: [PATCH] improve task moving animation. Fixes #1211 --- .../tasks/TaskRecyclerViewFragment.kt | 11 +- .../ui/helpers/SafeDefaultItemAnimator.java | 657 ------------------ .../ui/helpers/SafeDefaultItemAnimator.kt | 609 ++++++++++++++++ 3 files changed, 617 insertions(+), 660 deletions(-) delete mode 100644 Habitica/src/main/java/com/habitrpg/android/habitica/ui/helpers/SafeDefaultItemAnimator.java create mode 100644 Habitica/src/main/java/com/habitrpg/android/habitica/ui/helpers/SafeDefaultItemAnimator.kt diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/tasks/TaskRecyclerViewFragment.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/tasks/TaskRecyclerViewFragment.kt index 994097700..f475b1171 100644 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/tasks/TaskRecyclerViewFragment.kt +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/tasks/TaskRecyclerViewFragment.kt @@ -45,6 +45,7 @@ import javax.inject.Named open class TaskRecyclerViewFragment : BaseFragment(), androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener { var recyclerAdapter: TaskRecyclerViewAdapter? = null + var itemAnimator = SafeDefaultItemAnimator() @field:[Inject Named(AppModule.NAMED_USER_ID)] lateinit var userID: String @Inject @@ -187,11 +188,15 @@ open class TaskRecyclerViewFragment : BaseFragment(), androidx.swiperefreshlayou val movingTaskID = movingTaskID if (fromPosition != null && movingTaskID != null) { recyclerAdapter?.ignoreUpdates = true + itemAnimator.skipAnimations = true compositeSubscription.add(taskRepository.updateTaskPosition(classType ?: "", movingTaskID, viewHolder.adapterPosition) .delay(1, TimeUnit.SECONDS) .observeOn(AndroidSchedulers.mainThread()) - .subscribe(Consumer { recyclerAdapter?.ignoreUpdates = false - recyclerAdapter?.notifyDataSetChanged()}, RxErrorHandler.handleEmptyError())) + .subscribe(Consumer { + recyclerAdapter?.ignoreUpdates = false + recyclerAdapter?.notifyDataSetChanged() + itemAnimator.skipAnimations = false + }, RxErrorHandler.handleEmptyError())) } this.fromPosition = null this.movingTaskID = null @@ -249,7 +254,7 @@ open class TaskRecyclerViewFragment : BaseFragment(), androidx.swiperefreshlayou val bottomPadding = (recyclerView.paddingBottom + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 60f, resources.displayMetrics)).toInt() recyclerView.setPadding(0, 0, 0, bottomPadding) - recyclerView.itemAnimator = SafeDefaultItemAnimator() + recyclerView.itemAnimator = itemAnimator refreshLayout.setOnRefreshListener(this) diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/helpers/SafeDefaultItemAnimator.java b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/helpers/SafeDefaultItemAnimator.java deleted file mode 100644 index 15f5ac947..000000000 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/helpers/SafeDefaultItemAnimator.java +++ /dev/null @@ -1,657 +0,0 @@ -package com.habitrpg.android.habitica.ui.helpers; - -import android.animation.Animator; -import android.animation.AnimatorListenerAdapter; -import android.animation.TimeInterpolator; -import android.animation.ValueAnimator; -import androidx.annotation.NonNull; -import androidx.core.view.ViewCompat; -import androidx.recyclerview.widget.RecyclerView; -import androidx.recyclerview.widget.SimpleItemAnimator; -import android.view.View; -import android.view.ViewPropertyAnimator; - -import java.util.ArrayList; -import java.util.List; - -/** - * Created by phillip on 02.10.17. - */ - -public class SafeDefaultItemAnimator extends SimpleItemAnimator { - private static final boolean DEBUG = false; - - private static TimeInterpolator sDefaultInterpolator; - - private ArrayList mPendingRemovals = new ArrayList<>(); - private ArrayList mPendingAdditions = new ArrayList<>(); - private ArrayList mPendingMoves = new ArrayList<>(); - private ArrayList mPendingChanges = new ArrayList<>(); - - ArrayList> mAdditionsList = new ArrayList<>(); - ArrayList> mMovesList = new ArrayList<>(); - ArrayList> mChangesList = new ArrayList<>(); - - ArrayList mAddAnimations = new ArrayList<>(); - ArrayList mMoveAnimations = new ArrayList<>(); - ArrayList mRemoveAnimations = new ArrayList<>(); - ArrayList mChangeAnimations = new ArrayList<>(); - - private static class MoveInfo { - public RecyclerView.ViewHolder holder; - public int fromX, fromY, toX, toY; - - MoveInfo(RecyclerView.ViewHolder holder, int fromX, int fromY, int toX, int toY) { - this.holder = holder; - this.fromX = fromX; - this.fromY = fromY; - this.toX = toX; - this.toY = toY; - } - } - - private static class ChangeInfo { - public RecyclerView.ViewHolder oldHolder, newHolder; - public int fromX, fromY, toX, toY; - private ChangeInfo(RecyclerView.ViewHolder oldHolder, RecyclerView.ViewHolder newHolder) { - this.oldHolder = oldHolder; - this.newHolder = newHolder; - } - - ChangeInfo(RecyclerView.ViewHolder oldHolder, RecyclerView.ViewHolder newHolder, - int fromX, int fromY, int toX, int toY) { - this(oldHolder, newHolder); - this.fromX = fromX; - this.fromY = fromY; - this.toX = toX; - this.toY = toY; - } - - @Override - public String toString() { - return "ChangeInfo{" - + "oldHolder=" + oldHolder - + ", newHolder=" + newHolder - + ", fromX=" + fromX - + ", fromY=" + fromY - + ", toX=" + toX - + ", toY=" + toY - + '}'; - } - } - - @Override - public void runPendingAnimations() { - boolean removalsPending = !mPendingRemovals.isEmpty(); - boolean movesPending = !mPendingMoves.isEmpty(); - boolean changesPending = !mPendingChanges.isEmpty(); - boolean additionsPending = !mPendingAdditions.isEmpty(); - if (!removalsPending && !movesPending && !additionsPending && !changesPending) { - // nothing to animate - return; - } - // First, remove stuff - for (RecyclerView.ViewHolder holder : mPendingRemovals) { - animateRemoveImpl(holder); - } - mPendingRemovals.clear(); - // Next, move stuff - if (movesPending) { - final ArrayList moves = new ArrayList<>(); - moves.addAll(mPendingMoves); - mMovesList.add(moves); - mPendingMoves.clear(); - Runnable mover = new Runnable() { - @Override - public void run() { - for (SafeDefaultItemAnimator.MoveInfo moveInfo : moves) { - animateMoveImpl(moveInfo.holder, moveInfo.fromX, moveInfo.fromY, - moveInfo.toX, moveInfo.toY); - } - moves.clear(); - mMovesList.remove(moves); - } - }; - if (removalsPending) { - RecyclerView.ViewHolder holder = moves.get(0).holder; - if (holder != null) { - View view = holder.itemView; - ViewCompat.postOnAnimationDelayed(view, mover, getRemoveDuration()); - } - } else { - mover.run(); - } - } - // Next, change stuff, to run in parallel with move animations - if (changesPending) { - final ArrayList changes = new ArrayList<>(); - changes.addAll(mPendingChanges); - mChangesList.add(changes); - mPendingChanges.clear(); - Runnable changer = new Runnable() { - @Override - public void run() { - for (SafeDefaultItemAnimator.ChangeInfo change : changes) { - animateChangeImpl(change); - } - changes.clear(); - mChangesList.remove(changes); - } - }; - if (removalsPending) { - RecyclerView.ViewHolder holder = changes.get(0).oldHolder; - if (holder != null) { - ViewCompat.postOnAnimationDelayed(holder.itemView, changer, getRemoveDuration()); - } - } else { - changer.run(); - } - } - // Next, add stuff - if (additionsPending) { - final ArrayList additions = new ArrayList<>(); - additions.addAll(mPendingAdditions); - mAdditionsList.add(additions); - mPendingAdditions.clear(); - Runnable adder = new Runnable() { - @Override - public void run() { - for (RecyclerView.ViewHolder holder : additions) { - animateAddImpl(holder); - } - additions.clear(); - mAdditionsList.remove(additions); - } - }; - if (removalsPending || movesPending || changesPending) { - long removeDuration = removalsPending ? getRemoveDuration() : 0; - long moveDuration = movesPending ? getMoveDuration() : 0; - long changeDuration = changesPending ? getChangeDuration() : 0; - long totalDelay = removeDuration + Math.max(moveDuration, changeDuration); - if (additions.get(0) != null) { - View view = additions.get(0).itemView; - ViewCompat.postOnAnimationDelayed(view, adder, totalDelay); - } - } else { - adder.run(); - } - } - } - - @Override - public boolean animateRemove(final RecyclerView.ViewHolder holder) { - resetAnimation(holder); - mPendingRemovals.add(holder); - return true; - } - - private void animateRemoveImpl(final RecyclerView.ViewHolder holder) { - final View view = holder.itemView; - final ViewPropertyAnimator animation = view.animate(); - mRemoveAnimations.add(holder); - animation.setDuration(getRemoveDuration()).alpha(0).setListener( - new AnimatorListenerAdapter() { - @Override - public void onAnimationStart(Animator animator) { - dispatchRemoveStarting(holder); - } - - @Override - public void onAnimationEnd(Animator animator) { - animation.setListener(null); - view.setAlpha(1); - dispatchRemoveFinished(holder); - mRemoveAnimations.remove(holder); - dispatchFinishedWhenDone(); - } - }).start(); - } - - @Override - public boolean animateAdd(final RecyclerView.ViewHolder holder) { - resetAnimation(holder); - holder.itemView.setAlpha(0); - mPendingAdditions.add(holder); - return true; - } - - void animateAddImpl(final RecyclerView.ViewHolder holder) { - final View view = holder.itemView; - final ViewPropertyAnimator animation = view.animate(); - mAddAnimations.add(holder); - animation.alpha(1).setDuration(getAddDuration()) - .setListener(new AnimatorListenerAdapter() { - @Override - public void onAnimationStart(Animator animator) { - dispatchAddStarting(holder); - } - - @Override - public void onAnimationCancel(Animator animator) { - view.setAlpha(1); - } - - @Override - public void onAnimationEnd(Animator animator) { - animation.setListener(null); - dispatchAddFinished(holder); - mAddAnimations.remove(holder); - dispatchFinishedWhenDone(); - } - }).start(); - } - - @Override - public boolean animateMove(final RecyclerView.ViewHolder holder, int fromX, int fromY, - int toX, int toY) { - final View view = holder.itemView; - fromX += (int) holder.itemView.getTranslationX(); - fromY += (int) holder.itemView.getTranslationY(); - resetAnimation(holder); - int deltaX = toX - fromX; - int deltaY = toY - fromY; - if (deltaX == 0 && deltaY == 0) { - dispatchMoveFinished(holder); - return false; - } - if (deltaX != 0) { - view.setTranslationX(-deltaX); - } - if (deltaY != 0) { - view.setTranslationY(-deltaY); - } - mPendingMoves.add(new SafeDefaultItemAnimator.MoveInfo(holder, fromX, fromY, toX, toY)); - return true; - } - - void animateMoveImpl(final RecyclerView.ViewHolder holder, int fromX, int fromY, int toX, int toY) { - final View view = holder.itemView; - final int deltaX = toX - fromX; - final int deltaY = toY - fromY; - if (deltaX != 0) { - view.animate().translationX(0); - } - if (deltaY != 0) { - view.animate().translationY(0); - } - // TODO: make EndActions end listeners instead, since end actions aren't called when - // vpas are canceled (and can't end them. why?) - // need listener functionality in VPACompat for this. Ick. - final ViewPropertyAnimator animation = view.animate(); - mMoveAnimations.add(holder); - animation.setDuration(getMoveDuration()).setListener(new AnimatorListenerAdapter() { - @Override - public void onAnimationStart(Animator animator) { - dispatchMoveStarting(holder); - } - - @Override - public void onAnimationCancel(Animator animator) { - if (deltaX != 0) { - view.setTranslationX(0); - } - if (deltaY != 0) { - view.setTranslationY(0); - } - } - - @Override - public void onAnimationEnd(Animator animator) { - animation.setListener(null); - dispatchMoveFinished(holder); - mMoveAnimations.remove(holder); - dispatchFinishedWhenDone(); - } - }).start(); - } - - @Override - public boolean animateChange(RecyclerView.ViewHolder oldHolder, RecyclerView.ViewHolder newHolder, - int fromX, int fromY, int toX, int toY) { - if (oldHolder == newHolder) { - // Don't know how to run change animations when the same view holder is re-used. - // run a move animation to handle position changes. - return animateMove(oldHolder, fromX, fromY, toX, toY); - } - final float prevTranslationX = oldHolder.itemView.getTranslationX(); - final float prevTranslationY = oldHolder.itemView.getTranslationY(); - final float prevAlpha = oldHolder.itemView.getAlpha(); - resetAnimation(oldHolder); - int deltaX = (int) (toX - fromX - prevTranslationX); - int deltaY = (int) (toY - fromY - prevTranslationY); - // recover prev translation state after ending animation - oldHolder.itemView.setTranslationX(prevTranslationX); - oldHolder.itemView.setTranslationY(prevTranslationY); - oldHolder.itemView.setAlpha(prevAlpha); - if (newHolder != null) { - // carry over translation values - resetAnimation(newHolder); - newHolder.itemView.setTranslationX(-deltaX); - newHolder.itemView.setTranslationY(-deltaY); - newHolder.itemView.setAlpha(0); - } - mPendingChanges.add(new SafeDefaultItemAnimator.ChangeInfo(oldHolder, newHolder, fromX, fromY, toX, toY)); - return true; - } - - void animateChangeImpl(final SafeDefaultItemAnimator.ChangeInfo changeInfo) { - final RecyclerView.ViewHolder holder = changeInfo.oldHolder; - final View view = holder == null ? null : holder.itemView; - final RecyclerView.ViewHolder newHolder = changeInfo.newHolder; - final View newView = newHolder != null ? newHolder.itemView : null; - if (view != null) { - final ViewPropertyAnimator oldViewAnim = view.animate().setDuration( - getChangeDuration()); - mChangeAnimations.add(changeInfo.oldHolder); - oldViewAnim.translationX(changeInfo.toX - changeInfo.fromX); - oldViewAnim.translationY(changeInfo.toY - changeInfo.fromY); - oldViewAnim.alpha(0).setListener(new AnimatorListenerAdapter() { - @Override - public void onAnimationStart(Animator animator) { - dispatchChangeStarting(changeInfo.oldHolder, true); - } - - @Override - public void onAnimationEnd(Animator animator) { - oldViewAnim.setListener(null); - view.setAlpha(1); - view.setTranslationX(0); - view.setTranslationY(0); - dispatchChangeFinished(changeInfo.oldHolder, true); - mChangeAnimations.remove(changeInfo.oldHolder); - dispatchFinishedWhenDone(); - } - }).start(); - } - if (newView != null) { - final ViewPropertyAnimator newViewAnimation = newView.animate(); - mChangeAnimations.add(changeInfo.newHolder); - newViewAnimation.translationX(0).translationY(0).setDuration(getChangeDuration()) - .alpha(1).setListener(new AnimatorListenerAdapter() { - @Override - public void onAnimationStart(Animator animator) { - dispatchChangeStarting(changeInfo.newHolder, false); - } - @Override - public void onAnimationEnd(Animator animator) { - newViewAnimation.setListener(null); - newView.setAlpha(1); - newView.setTranslationX(0); - newView.setTranslationY(0); - dispatchChangeFinished(changeInfo.newHolder, false); - mChangeAnimations.remove(changeInfo.newHolder); - dispatchFinishedWhenDone(); - } - }).start(); - } - } - - private void endChangeAnimation(List infoList, RecyclerView.ViewHolder item) { - for (int i = infoList.size() - 1; i >= 0; i--) { - SafeDefaultItemAnimator.ChangeInfo changeInfo = infoList.get(i); - if (endChangeAnimationIfNecessary(changeInfo, item)) { - if (changeInfo.oldHolder == null && changeInfo.newHolder == null) { - infoList.remove(changeInfo); - } - } - } - } - - private void endChangeAnimationIfNecessary(SafeDefaultItemAnimator.ChangeInfo changeInfo) { - if (changeInfo.oldHolder != null) { - endChangeAnimationIfNecessary(changeInfo, changeInfo.oldHolder); - } - if (changeInfo.newHolder != null) { - endChangeAnimationIfNecessary(changeInfo, changeInfo.newHolder); - } - } - private boolean endChangeAnimationIfNecessary(SafeDefaultItemAnimator.ChangeInfo changeInfo, RecyclerView.ViewHolder item) { - boolean oldItem = false; - if (changeInfo.newHolder == item) { - changeInfo.newHolder = null; - } else if (changeInfo.oldHolder == item) { - changeInfo.oldHolder = null; - oldItem = true; - } else { - return false; - } - item.itemView.setAlpha(1); - item.itemView.setTranslationX(0); - item.itemView.setTranslationY(0); - dispatchChangeFinished(item, oldItem); - return true; - } - - @Override - public void endAnimation(RecyclerView.ViewHolder item) { - final View view = item.itemView; - // this will trigger end callback which should set properties to their target values. - view.animate().cancel(); - // TODO if some other animations are chained to end, how do we cancel them as well? - for (int i = mPendingMoves.size() - 1; i >= 0; i--) { - SafeDefaultItemAnimator.MoveInfo moveInfo = mPendingMoves.get(i); - if (moveInfo.holder == item) { - view.setTranslationY(0); - view.setTranslationX(0); - dispatchMoveFinished(item); - mPendingMoves.remove(i); - } - } - endChangeAnimation(mPendingChanges, item); - if (mPendingRemovals.remove(item)) { - view.setAlpha(1); - dispatchRemoveFinished(item); - } - if (mPendingAdditions.remove(item)) { - view.setAlpha(1); - dispatchAddFinished(item); - } - - for (int i = mChangesList.size() - 1; i >= 0; i--) { - ArrayList changes = mChangesList.get(i); - endChangeAnimation(changes, item); - if (changes.isEmpty()) { - mChangesList.remove(i); - } - } - for (int i = mMovesList.size() - 1; i >= 0; i--) { - ArrayList moves = mMovesList.get(i); - for (int j = moves.size() - 1; j >= 0; j--) { - SafeDefaultItemAnimator.MoveInfo moveInfo = moves.get(j); - if (moveInfo.holder == item) { - view.setTranslationY(0); - view.setTranslationX(0); - dispatchMoveFinished(item); - moves.remove(j); - if (moves.isEmpty()) { - mMovesList.remove(i); - } - break; - } - } - } - for (int i = mAdditionsList.size() - 1; i >= 0; i--) { - ArrayList additions = mAdditionsList.get(i); - if (additions.remove(item)) { - view.setAlpha(1); - dispatchAddFinished(item); - if (additions.isEmpty()) { - mAdditionsList.remove(i); - } - } - } - - // animations should be ended by the cancel above. - //noinspection PointlessBooleanExpression,ConstantConditions - if (mRemoveAnimations.remove(item) && DEBUG) { - throw new IllegalStateException("after animation is cancelled, item should not be in " - + "mRemoveAnimations list"); - } - - //noinspection PointlessBooleanExpression,ConstantConditions - if (mAddAnimations.remove(item) && DEBUG) { - throw new IllegalStateException("after animation is cancelled, item should not be in " - + "mAddAnimations list"); - } - - //noinspection PointlessBooleanExpression,ConstantConditions - if (mChangeAnimations.remove(item) && DEBUG) { - throw new IllegalStateException("after animation is cancelled, item should not be in " - + "mChangeAnimations list"); - } - - //noinspection PointlessBooleanExpression,ConstantConditions - if (mMoveAnimations.remove(item) && DEBUG) { - throw new IllegalStateException("after animation is cancelled, item should not be in " - + "mMoveAnimations list"); - } - dispatchFinishedWhenDone(); - } - - private void resetAnimation(RecyclerView.ViewHolder holder) { - if (sDefaultInterpolator == null) { - sDefaultInterpolator = new ValueAnimator().getInterpolator(); - } - holder.itemView.animate().setInterpolator(sDefaultInterpolator); - endAnimation(holder); - } - - @Override - public boolean isRunning() { - return (!mPendingAdditions.isEmpty() - || !mPendingChanges.isEmpty() - || !mPendingMoves.isEmpty() - || !mPendingRemovals.isEmpty() - || !mMoveAnimations.isEmpty() - || !mRemoveAnimations.isEmpty() - || !mAddAnimations.isEmpty() - || !mChangeAnimations.isEmpty() - || !mMovesList.isEmpty() - || !mAdditionsList.isEmpty() - || !mChangesList.isEmpty()); - } - - /** - * Check the state of currently pending and running animations. If there are none - * pending/running, call {@link #dispatchAnimationsFinished()} to notify any - * listeners. - */ - void dispatchFinishedWhenDone() { - if (!isRunning()) { - dispatchAnimationsFinished(); - } - } - - @Override - public void endAnimations() { - int count = mPendingMoves.size(); - for (int i = count - 1; i >= 0; i--) { - SafeDefaultItemAnimator.MoveInfo item = mPendingMoves.get(i); - View view = item.holder.itemView; - view.setTranslationY(0); - view.setTranslationX(0); - dispatchMoveFinished(item.holder); - mPendingMoves.remove(i); - } - count = mPendingRemovals.size(); - for (int i = count - 1; i >= 0; i--) { - RecyclerView.ViewHolder item = mPendingRemovals.get(i); - dispatchRemoveFinished(item); - mPendingRemovals.remove(i); - } - count = mPendingAdditions.size(); - for (int i = count - 1; i >= 0; i--) { - RecyclerView.ViewHolder item = mPendingAdditions.get(i); - item.itemView.setAlpha(1); - dispatchAddFinished(item); - mPendingAdditions.remove(i); - } - count = mPendingChanges.size(); - for (int i = count - 1; i >= 0; i--) { - endChangeAnimationIfNecessary(mPendingChanges.get(i)); - } - mPendingChanges.clear(); - if (!isRunning()) { - return; - } - - int listCount = mMovesList.size(); - for (int i = listCount - 1; i >= 0; i--) { - ArrayList moves = mMovesList.get(i); - count = moves.size(); - for (int j = count - 1; j >= 0; j--) { - SafeDefaultItemAnimator.MoveInfo moveInfo = moves.get(j); - RecyclerView.ViewHolder item = moveInfo.holder; - View view = item.itemView; - view.setTranslationY(0); - view.setTranslationX(0); - dispatchMoveFinished(moveInfo.holder); - moves.remove(j); - if (moves.isEmpty()) { - mMovesList.remove(moves); - } - } - } - listCount = mAdditionsList.size(); - for (int i = listCount - 1; i >= 0; i--) { - ArrayList additions = mAdditionsList.get(i); - count = additions.size(); - for (int j = count - 1; j >= 0; j--) { - RecyclerView.ViewHolder item = additions.get(j); - View view = item.itemView; - view.setAlpha(1); - dispatchAddFinished(item); - additions.remove(j); - if (additions.isEmpty()) { - mAdditionsList.remove(additions); - } - } - } - listCount = mChangesList.size(); - for (int i = listCount - 1; i >= 0; i--) { - ArrayList changes = mChangesList.get(i); - count = changes.size(); - for (int j = count - 1; j >= 0; j--) { - endChangeAnimationIfNecessary(changes.get(j)); - if (changes.isEmpty()) { - mChangesList.remove(changes); - } - } - } - - cancelAll(mRemoveAnimations); - cancelAll(mMoveAnimations); - cancelAll(mAddAnimations); - cancelAll(mChangeAnimations); - - dispatchAnimationsFinished(); - } - - void cancelAll(List viewHolders) { - for (int i = viewHolders.size() - 1; i >= 0; i--) { - viewHolders.get(i).itemView.animate().cancel(); - } - } - - /** - * {@inheritDoc} - *

- * If the payload list is not empty, SafeDefaultItemAnimator returns true. - * When this is the case: - *

    - *
  • If you override {@link #animateChange(RecyclerView.ViewHolder, RecyclerView.ViewHolder, int, int, int, int)}, both - * ViewHolder arguments will be the same instance. - *
  • - *
  • - * If you are not overriding {@link #animateChange(RecyclerView.ViewHolder, RecyclerView.ViewHolder, int, int, int, int)}, - * then SafeDefaultItemAnimator will call {@link #animateMove(RecyclerView.ViewHolder, int, int, int, int)} and - * run a move animation instead. - *
  • - *
- */ - @Override - public boolean canReuseUpdatedViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, - @NonNull List payloads) { - return !payloads.isEmpty() || super.canReuseUpdatedViewHolder(viewHolder, payloads); - } -} \ No newline at end of file diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/helpers/SafeDefaultItemAnimator.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/helpers/SafeDefaultItemAnimator.kt new file mode 100644 index 000000000..2477698e1 --- /dev/null +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/helpers/SafeDefaultItemAnimator.kt @@ -0,0 +1,609 @@ +package com.habitrpg.android.habitica.ui.helpers + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.TimeInterpolator +import android.animation.ValueAnimator +import androidx.core.view.ViewCompat +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.SimpleItemAnimator +import com.habitrpg.shared.habitica.LogLevel +import com.habitrpg.shared.habitica.Logger +import java.util.* + +/** + * Created by phillip on 02.10.17. + */ + +class SafeDefaultItemAnimator : SimpleItemAnimator() { + + private val pendingRemovals = ArrayList() + private val pendingAdditions = ArrayList() + private val pendingMoves = ArrayList() + private val pendingChanges = ArrayList() + + private val additionsList = ArrayList>() + private val movesList = ArrayList>() + private val changesList = ArrayList>() + + private val addAnimations = ArrayList() + private val moveAnimations = ArrayList() + private val removeAnimations = ArrayList() + private val changeAnimations = ArrayList() + + var skipAnimations: Boolean = false + + private class MoveInfo internal constructor(var holder: RecyclerView.ViewHolder?, internal var fromX: Int, internal var fromY: Int, internal var toX: Int, internal var toY: Int) + + private class ChangeInfo private constructor(internal var oldHolder: RecyclerView.ViewHolder?, internal var newHolder: RecyclerView.ViewHolder?) { + internal var fromX: Int = 0 + internal var fromY: Int = 0 + internal var toX: Int = 0 + internal var toY: Int = 0 + + internal constructor(oldHolder: RecyclerView.ViewHolder, newHolder: RecyclerView.ViewHolder, + fromX: Int, fromY: Int, toX: Int, toY: Int) : this(oldHolder, newHolder) { + this.fromX = fromX + this.fromY = fromY + this.toX = toX + this.toY = toY + } + + override fun toString(): String { + return ("ChangeInfo{" + + "oldHolder=" + oldHolder + + ", newHolder=" + newHolder + + ", fromX=" + fromX + + ", fromY=" + fromY + + ", toX=" + toX + + ", toY=" + toY + + '}'.toString()) + } + } + + override fun runPendingAnimations() { + val removalsPending = pendingRemovals.isNotEmpty() + val movesPending = pendingMoves.isNotEmpty() + val changesPending = pendingChanges.isNotEmpty() + val additionsPending = pendingAdditions.isNotEmpty() + if (!removalsPending && !movesPending && !additionsPending && !changesPending) { + // nothing to animate + return + } + // First, remove stuff + for (holder in pendingRemovals) { + animateRemoveImpl(holder) + } + if (skipAnimations) { + return + } + pendingRemovals.clear() + // Next, move stuff + if (movesPending) { + val moves = ArrayList(pendingMoves) + movesList.add(moves) + pendingMoves.clear() + val mover = Runnable { + for (moveInfo in moves) { + moveInfo.holder?.let { animateMoveImpl(it, moveInfo.fromX, moveInfo.fromY, + moveInfo.toX, moveInfo.toY) } + } + moves.clear() + movesList.remove(moves) + } + if (removalsPending) { + val holder = moves[0].holder + if (holder != null) { + val view = holder.itemView + ViewCompat.postOnAnimationDelayed(view, mover, removeDuration) + } + } else { + mover.run() + } + } + // Next, change stuff, to run in parallel with move animations + if (changesPending) { + + val changes = ArrayList(pendingChanges) + changesList.add(changes) + pendingChanges.clear() + val changer = Runnable { + for (change in changes) { + animateChangeImpl(change) + } + changes.clear() + changesList.remove(changes) + } + if (removalsPending) { + val holder = changes[0].oldHolder + if (holder != null) { + ViewCompat.postOnAnimationDelayed(holder.itemView, changer, removeDuration) + } + } else { + changer.run() + } + } + // Next, add stuff + if (additionsPending) { + val additions = ArrayList(pendingAdditions) + additionsList.add(additions) + pendingAdditions.clear() + val adder = Runnable { + for (holder in additions) { + animateAddImpl(holder) + } + additions.clear() + additionsList.remove(additions) + } + if (removalsPending || movesPending || changesPending) { + val removeDuration = if (removalsPending) removeDuration else 0 + val moveDuration = if (movesPending) moveDuration else 0 + val changeDuration = if (changesPending) changeDuration else 0 + val totalDelay = removeDuration + moveDuration.coerceAtLeast(changeDuration) + if (additions[0] != null) { + val view = additions[0].itemView + ViewCompat.postOnAnimationDelayed(view, adder, totalDelay) + } + } else { + adder.run() + } + } + } + + override fun animateRemove(holder: RecyclerView.ViewHolder): Boolean { + resetAnimation(holder) + pendingRemovals.add(holder) + return true + } + + private fun animateRemoveImpl(holder: RecyclerView.ViewHolder) { + val view = holder.itemView + val animation = view.animate() + removeAnimations.add(holder) + animation.setDuration(removeDuration).alpha(0f).setListener( + object : AnimatorListenerAdapter() { + override fun onAnimationStart(animator: Animator) { + dispatchRemoveStarting(holder) + } + + override fun onAnimationEnd(animator: Animator) { + animation.setListener(null) + view.alpha = 1f + dispatchRemoveFinished(holder) + removeAnimations.remove(holder) + dispatchFinishedWhenDone() + } + }).start() + } + + override fun animateAdd(holder: RecyclerView.ViewHolder): Boolean { + resetAnimation(holder) + holder.itemView.alpha = 0f + pendingAdditions.add(holder) + return true + } + + private fun animateAddImpl(holder: RecyclerView.ViewHolder) { + val view = holder.itemView + val animation = view.animate() + addAnimations.add(holder) + animation.alpha(1f).setDuration(addDuration) + .setListener(object : AnimatorListenerAdapter() { + override fun onAnimationStart(animator: Animator) { + dispatchAddStarting(holder) + } + + override fun onAnimationCancel(animator: Animator) { + view.alpha = 1f + } + + override fun onAnimationEnd(animator: Animator) { + animation.setListener(null) + dispatchAddFinished(holder) + addAnimations.remove(holder) + dispatchFinishedWhenDone() + } + }).start() + } + + override fun animateMove(holder: RecyclerView.ViewHolder, fromX: Int, fromY: Int, + toX: Int, toY: Int): Boolean { + var newFromX = fromX + var newFromY = fromY + val view = holder.itemView + newFromX += holder.itemView.translationX.toInt() + newFromY += holder.itemView.translationY.toInt() + resetAnimation(holder) + val deltaX = toX - newFromX + val deltaY = toY - newFromY + if (deltaX == 0 && deltaY == 0) { + dispatchMoveFinished(holder) + return false + } + if (deltaX != 0) { + view.translationX = (-deltaX).toFloat() + } + if (deltaY != 0) { + view.translationY = (-deltaY).toFloat() + } + Logger.log(LogLevel.INFO, "Moving1", "$toX, $fromX, $deltaX") + Logger.log(LogLevel.INFO, "Moving1", "$toX, $fromX, $deltaX") + pendingMoves.add(MoveInfo(holder, newFromX, newFromY, toX, toY)) + return true + } + + private fun animateMoveImpl(holder: RecyclerView.ViewHolder, fromX: Int, fromY: Int, toX: Int, toY: Int) { + val view = holder.itemView + val deltaX = toX - fromX + val deltaY = toY - fromY + if (deltaX != 0) { + view.animate().translationX(0f) + } + if (deltaY != 0) { + view.animate().translationY(0f) + } + Logger.log(LogLevel.INFO, "Moving", "$toX, $fromX, $deltaX") + Logger.log(LogLevel.INFO, "Moving", "$toX, $fromX, $deltaX") + // vpas are canceled (and can't end them. why?) + // need listener functionality in VPACompat for this. Ick. + val animation = view.animate() + moveAnimations.add(holder) + animation.setDuration(moveDuration).setListener(object : AnimatorListenerAdapter() { + override fun onAnimationStart(animator: Animator) { + dispatchMoveStarting(holder) + } + + override fun onAnimationCancel(animator: Animator) { + if (deltaX != 0) { + view.translationX = 0f + } + if (deltaY != 0) { + view.translationY = 0f + } + } + + override fun onAnimationEnd(animator: Animator) { + animation.setListener(null) + dispatchMoveFinished(holder) + moveAnimations.remove(holder) + dispatchFinishedWhenDone() + } + }).start() + } + + override fun animateChange(oldHolder: RecyclerView.ViewHolder, newHolder: RecyclerView.ViewHolder?, + fromX: Int, fromY: Int, toX: Int, toY: Int): Boolean { + if (oldHolder === newHolder) { + // Don't know how to run change animations when the same view holder is re-used. + // run a move animation to handle position changes. + return animateMove(oldHolder, fromX, fromY, toX, toY) + } + val prevTranslationX = oldHolder.itemView.translationX + val prevTranslationY = oldHolder.itemView.translationY + val prevAlpha = oldHolder.itemView.alpha + resetAnimation(oldHolder) + val deltaX = (toX.toFloat() - fromX.toFloat() - prevTranslationX).toInt() + val deltaY = (toY.toFloat() - fromY.toFloat() - prevTranslationY).toInt() + // recover prev translation state after ending animation + oldHolder.itemView.translationX = prevTranslationX + oldHolder.itemView.translationY = prevTranslationY + oldHolder.itemView.alpha = prevAlpha + if (newHolder != null) { + // carry over translation values + resetAnimation(newHolder) + newHolder.itemView.translationX = (-deltaX).toFloat() + newHolder.itemView.translationY = (-deltaY).toFloat() + newHolder.itemView.alpha = 0f + } + Logger.log(LogLevel.INFO, "Changing", "$toX, $fromX, $deltaX") + Logger.log(LogLevel.INFO, "Changing", "$toX, $fromX, $deltaX") + newHolder?.let { pendingChanges.add(ChangeInfo(oldHolder, it, fromX, fromY, toX, toY)) } + return true + } + + private fun animateChangeImpl(changeInfo: ChangeInfo) { + val holder = changeInfo.oldHolder + val view = holder?.itemView + val newHolder = changeInfo.newHolder + val newView = newHolder?.itemView + if (view != null) { + val oldViewAnim = view.animate().setDuration( + changeDuration) + changeInfo.oldHolder?.let { changeAnimations.add(it) } + oldViewAnim.translationX((changeInfo.toX - changeInfo.fromX).toFloat()) + oldViewAnim.translationY((changeInfo.toY - changeInfo.fromY).toFloat()) + oldViewAnim.alpha(0f).setListener(object : AnimatorListenerAdapter() { + override fun onAnimationStart(animator: Animator) { + dispatchChangeStarting(changeInfo.oldHolder, true) + } + + override fun onAnimationEnd(animator: Animator) { + oldViewAnim.setListener(null) + view.alpha = 1f + view.translationX = 0f + view.translationY = 0f + dispatchChangeFinished(changeInfo.oldHolder, true) + changeInfo.oldHolder?.let { changeAnimations.add(it) } + dispatchFinishedWhenDone() + } + }).start() + } + if (newView != null) { + val newViewAnimation = newView.animate() + changeInfo.newHolder?.let { changeAnimations.add(it) } + newViewAnimation.translationX(0f).translationY(0f).setDuration(changeDuration) + .alpha(1f).setListener(object : AnimatorListenerAdapter() { + override fun onAnimationStart(animator: Animator) { + dispatchChangeStarting(changeInfo.newHolder, false) + } + + override fun onAnimationEnd(animator: Animator) { + newViewAnimation.setListener(null) + newView.alpha = 1f + newView.translationX = 0f + newView.translationY = 0f + dispatchChangeFinished(changeInfo.newHolder, false) + changeInfo.newHolder?.let { changeAnimations.add(it) } + dispatchFinishedWhenDone() + } + }).start() + } + } + + private fun endChangeAnimation(infoList: MutableList, item: RecyclerView.ViewHolder) { + for (i in infoList.indices.reversed()) { + val changeInfo = infoList[i] + if (endChangeAnimationIfNecessary(changeInfo, item)) { + if (changeInfo.oldHolder == null && changeInfo.newHolder == null) { + infoList.remove(changeInfo) + } + } + } + } + + private fun endChangeAnimationIfNecessary(changeInfo: ChangeInfo) { + if (changeInfo.oldHolder != null) { + endChangeAnimationIfNecessary(changeInfo, changeInfo.oldHolder) + } + if (changeInfo.newHolder != null) { + endChangeAnimationIfNecessary(changeInfo, changeInfo.newHolder) + } + } + + private fun endChangeAnimationIfNecessary(changeInfo: ChangeInfo, item: RecyclerView.ViewHolder?): Boolean { + var oldItem = false + when { + changeInfo.newHolder === item -> changeInfo.newHolder = null + changeInfo.oldHolder === item -> { + changeInfo.oldHolder = null + oldItem = true + } + else -> return false + } + item?.itemView?.alpha = 1f + item?.itemView?.translationX = 0f + item?.itemView?.translationY = 0f + dispatchChangeFinished(item, oldItem) + return true + } + + override fun endAnimation(item: RecyclerView.ViewHolder) { + val view = item.itemView + // this will trigger end callback which should set properties to their target values. + view.animate().cancel() + // TODO if some other animations are chained to end, how do we cancel them as well? + for (i in pendingMoves.indices.reversed()) { + val moveInfo = pendingMoves[i] + if (moveInfo.holder === item) { + view.translationY = 0f + view.translationX = 0f + dispatchMoveFinished(item) + pendingMoves.removeAt(i) + } + } + endChangeAnimation(pendingChanges, item) + if (pendingRemovals.remove(item)) { + view.alpha = 1f + dispatchRemoveFinished(item) + } + if (pendingAdditions.remove(item)) { + view.alpha = 1f + dispatchAddFinished(item) + } + + for (i in changesList.indices.reversed()) { + val changes = changesList[i] + endChangeAnimation(changes, item) + if (changes.isEmpty()) { + changesList.removeAt(i) + } + } + for (i in movesList.indices.reversed()) { + val moves = movesList[i] + for (j in moves.indices.reversed()) { + val moveInfo = moves[j] + if (moveInfo.holder === item) { + view.translationY = 0f + view.translationX = 0f + dispatchMoveFinished(item) + moves.removeAt(j) + if (moves.isEmpty()) { + movesList.removeAt(i) + } + break + } + } + } + for (i in additionsList.indices.reversed()) { + val additions = additionsList[i] + if (additions.remove(item)) { + view.alpha = 1f + dispatchAddFinished(item) + if (additions.isEmpty()) { + additionsList.removeAt(i) + } + } + } + + // animations should be ended by the cancel above. + check(!(removeAnimations.remove(item) && DEBUG)) { "after animation is cancelled, item should not be in " + "removeAnimations list" } + + check(!(addAnimations.remove(item) && DEBUG)) { "after animation is cancelled, item should not be in " + "addAnimations list" } + + check(!(changeAnimations.remove(item) && DEBUG)) { "after animation is cancelled, item should not be in " + "changeAnimations list" } + + check(!(moveAnimations.remove(item) && DEBUG)) { "after animation is cancelled, item should not be in " + "moveAnimations list" } + dispatchFinishedWhenDone() + } + + private fun resetAnimation(holder: RecyclerView.ViewHolder) { + if (sDefaultInterpolator == null) { + sDefaultInterpolator = ValueAnimator().interpolator + } + holder.itemView.animate().interpolator = sDefaultInterpolator + endAnimation(holder) + } + + override fun isRunning(): Boolean { + return (pendingAdditions.isNotEmpty() + || pendingChanges.isNotEmpty() + || pendingMoves.isNotEmpty() + || pendingRemovals.isNotEmpty() + || moveAnimations.isNotEmpty() + || removeAnimations.isNotEmpty() + || addAnimations.isNotEmpty() + || changeAnimations.isNotEmpty() + || movesList.isNotEmpty() + || additionsList.isNotEmpty() + || changesList.isNotEmpty()) + } + + /** + * Check the state of currently pending and running animations. If there are none + * pending/running, call [.dispatchAnimationsFinished] to notify any + * listeners. + */ + private fun dispatchFinishedWhenDone() { + if (!isRunning) { + dispatchAnimationsFinished() + } + } + + override fun endAnimations() { + var count = pendingMoves.size + for (i in count - 1 downTo 0) { + val item = pendingMoves[i] + val view = item.holder?.itemView + view?.translationY = 0f + view?.translationX = 0f + dispatchMoveFinished(item.holder) + pendingMoves.removeAt(i) + } + count = pendingRemovals.size + for (i in count - 1 downTo 0) { + val item = pendingRemovals[i] + dispatchRemoveFinished(item) + pendingRemovals.removeAt(i) + } + count = pendingAdditions.size + for (i in count - 1 downTo 0) { + val item = pendingAdditions[i] + item.itemView.alpha = 1f + dispatchAddFinished(item) + pendingAdditions.removeAt(i) + } + count = pendingChanges.size + for (i in count - 1 downTo 0) { + endChangeAnimationIfNecessary(pendingChanges[i]) + } + pendingChanges.clear() + if (!isRunning) { + return + } + + var listCount = movesList.size + for (i in listCount - 1 downTo 0) { + val moves = movesList[i] + count = moves.size + for (j in count - 1 downTo 0) { + val moveInfo = moves[j] + val item = moveInfo.holder + val view = item?.itemView + view?.translationY = 0f + view?.translationX = 0f + dispatchMoveFinished(moveInfo.holder) + moves.removeAt(j) + if (moves.isEmpty()) { + movesList.remove(moves) + } + } + } + listCount = additionsList.size + for (i in listCount - 1 downTo 0) { + val additions = additionsList[i] + count = additions.size + for (j in count - 1 downTo 0) { + val item = additions[j] + val view = item.itemView + view.alpha = 1f + dispatchAddFinished(item) + additions.removeAt(j) + if (additions.isEmpty()) { + additionsList.remove(additions) + } + } + } + listCount = changesList.size + for (i in listCount - 1 downTo 0) { + val changes = changesList[i] + count = changes.size + for (j in count - 1 downTo 0) { + endChangeAnimationIfNecessary(changes[j]) + if (changes.isEmpty()) { + changesList.remove(changes) + } + } + } + + cancelAll(removeAnimations) + cancelAll(moveAnimations) + cancelAll(addAnimations) + cancelAll(changeAnimations) + + dispatchAnimationsFinished() + } + + private fun cancelAll(viewHolders: List) { + for (i in viewHolders.indices.reversed()) { + viewHolders[i].itemView.animate().cancel() + } + } + + /** + * {@inheritDoc} + * + * + * If the payload list is not empty, SafeDefaultItemAnimator returns `true`. + * When this is the case: + * + * * If you override [.animateChange], both + * ViewHolder arguments will be the same instance. + * + * * + * If you are not overriding [.animateChange], + * then SafeDefaultItemAnimator will call [.animateMove] and + * run a move animation instead. + * + * + */ + override fun canReuseUpdatedViewHolder(viewHolder: RecyclerView.ViewHolder, + payloads: List): Boolean { + return payloads.isNotEmpty() || super.canReuseUpdatedViewHolder(viewHolder, payloads) + } + + companion object { + private const val DEBUG = false + + private var sDefaultInterpolator: TimeInterpolator? = null + } +} \ No newline at end of file