From 29cdac41f1352e7b36eb05f483417c5666048cbb Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 11 Apr 2019 16:40:29 -0700 Subject: [PATCH] Animate content fill animation to Recents Go This CL adds the animation that occurs when transitioning from a set of empty views to the actual task list after it is loaded. This is done by setting a one-shot item animator that animates changes, for item views that fade from empty to filled, and removes, for when we have too many empty views. Bug: 114136250 Test: Artificially increase task list load time and see animation fill Change-Id: Ibbc09db702e591063ceea61df2359f18a3fcf8f9 (cherry picked from commit 987799dfa15ebfd55c93e4c509cc200792b95fab) --- .../quickstep/ContentFillItemAnimator.java | 276 ++++++++++++++++++ .../com/android/quickstep/TaskAdapter.java | 33 ++- .../quickstep/views/IconRecentsView.java | 24 +- 3 files changed, 323 insertions(+), 10 deletions(-) create mode 100644 go/quickstep/src/com/android/quickstep/ContentFillItemAnimator.java diff --git a/go/quickstep/src/com/android/quickstep/ContentFillItemAnimator.java b/go/quickstep/src/com/android/quickstep/ContentFillItemAnimator.java new file mode 100644 index 0000000000..1b6f2e34d9 --- /dev/null +++ b/go/quickstep/src/com/android/quickstep/ContentFillItemAnimator.java @@ -0,0 +1,276 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.quickstep; + +import static android.view.View.ALPHA; + +import static com.android.quickstep.TaskAdapter.CHANGE_EVENT_TYPE_EMPTY_TO_CONTENT; +import static com.android.quickstep.views.TaskItemView.CONTENT_TRANSITION_PROGRESS; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ObjectAnimator; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView.ViewHolder; +import androidx.recyclerview.widget.SimpleItemAnimator; + +import com.android.quickstep.views.TaskItemView; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * An item animator that is only set and used for the transition from the empty loading UI to + * the filled task content UI. The animation starts from the bottom to top, changing all valid + * empty item views to be filled and removing all extra empty views. + */ +public final class ContentFillItemAnimator extends SimpleItemAnimator { + + private static final class PendingAnimation { + ViewHolder viewHolder; + int animType; + + PendingAnimation(ViewHolder vh, int type) { + viewHolder = vh; + animType = type; + } + } + + private static final int ANIM_TYPE_REMOVE = 0; + private static final int ANIM_TYPE_CHANGE = 1; + + private static final int ITEM_BETWEEN_DELAY = 40; + private static final int ITEM_CHANGE_DURATION = 150; + private static final int ITEM_REMOVE_DURATION = 150; + + /** + * Animations that have been registered to occur together at the next call of + * {@link #runPendingAnimations()} but have not started. + */ + private final ArrayList mPendingAnims = new ArrayList<>(); + + /** + * Animations that have started and are running. + */ + private final ArrayList mRunningAnims = new ArrayList<>(); + + private Runnable mOnFinishRunnable; + + /** + * Set runnable to run after the content fill animation is fully completed. + * + * @param runnable runnable to run on end + */ + public void setOnAnimationFinishedRunnable(Runnable runnable) { + mOnFinishRunnable = runnable; + } + + @Override + public void setChangeDuration(long changeDuration) { + throw new UnsupportedOperationException("Cascading item animator cannot have animation " + + "duration changed."); + } + + @Override + public void setRemoveDuration(long removeDuration) { + throw new UnsupportedOperationException("Cascading item animator cannot have animation " + + "duration changed."); + } + + @Override + public boolean animateRemove(ViewHolder holder) { + PendingAnimation pendAnim = new PendingAnimation(holder, ANIM_TYPE_REMOVE); + mPendingAnims.add(pendAnim); + return true; + } + + private void animateRemoveImpl(ViewHolder holder, long startDelay) { + final View view = holder.itemView; + if (holder.itemView.getAlpha() == 0) { + // View is already visually removed. We can just get rid of it now. + view.setAlpha(1.0f); + dispatchRemoveFinished(holder); + dispatchFinishedWhenDone(); + return; + } + final ObjectAnimator anim = ObjectAnimator.ofFloat( + holder.itemView, ALPHA, holder.itemView.getAlpha(), 0.0f); + anim.setDuration(ITEM_REMOVE_DURATION).setStartDelay(startDelay); + anim.addListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animation) { + dispatchRemoveStarting(holder); + } + + @Override + public void onAnimationEnd(Animator animation) { + view.setAlpha(1); + dispatchRemoveFinished(holder); + mRunningAnims.remove(anim); + dispatchFinishedWhenDone(); + } + } + ); + anim.start(); + mRunningAnims.add(anim); + } + + @Override + public boolean animateAdd(ViewHolder holder) { + dispatchAddFinished(holder); + return false; + } + + @Override + public boolean animateMove(ViewHolder holder, int fromX, int fromY, int toX, + int toY) { + dispatchMoveFinished(holder); + return false; + } + + @Override + public boolean animateChange(ViewHolder oldHolder, + ViewHolder newHolder, int fromLeft, int fromTop, int toLeft, int toTop) { + // Only support changes where the holders are the same + if (oldHolder == newHolder) { + PendingAnimation pendAnim = new PendingAnimation(oldHolder, ANIM_TYPE_CHANGE); + mPendingAnims.add(pendAnim); + return true; + } + dispatchChangeFinished(oldHolder, true /* oldItem */); + dispatchChangeFinished(newHolder, false /* oldItem */); + return false; + } + + private void animateChangeImpl(ViewHolder viewHolder, long startDelay) { + TaskItemView itemView = (TaskItemView) viewHolder.itemView; + final ObjectAnimator anim = + ObjectAnimator.ofFloat(itemView, CONTENT_TRANSITION_PROGRESS, 0.0f, 1.0f); + anim.setDuration(ITEM_CHANGE_DURATION).setStartDelay(startDelay); + anim.addListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animation) { + dispatchChangeStarting(viewHolder, true /* oldItem */); + } + + @Override + public void onAnimationEnd(Animator animation) { + dispatchChangeFinished(viewHolder, true /* oldItem */); + mRunningAnims.remove(anim); + dispatchFinishedWhenDone(); + } + } + ); + anim.start(); + mRunningAnims.add(anim); + } + + @Override + public void runPendingAnimations() { + // Run animations bottom to top. + mPendingAnims.sort(Comparator.comparingInt(o -> -o.viewHolder.itemView.getBottom())); + int delay = 0; + while (!mPendingAnims.isEmpty()) { + PendingAnimation curAnim = mPendingAnims.remove(0); + ViewHolder vh = curAnim.viewHolder; + switch (curAnim.animType) { + case ANIM_TYPE_REMOVE: + animateRemoveImpl(vh, delay); + break; + case ANIM_TYPE_CHANGE: + animateChangeImpl(vh, delay); + break; + default: + break; + } + delay += ITEM_BETWEEN_DELAY; + } + } + + @Override + public void endAnimation(@NonNull ViewHolder item) { + for (int i = mPendingAnims.size() - 1; i >= 0; i--) { + PendingAnimation pendAnim = mPendingAnims.get(i); + if (pendAnim.viewHolder == item) { + mPendingAnims.remove(i); + switch (pendAnim.animType) { + case ANIM_TYPE_REMOVE: + dispatchRemoveFinished(item); + break; + case ANIM_TYPE_CHANGE: + dispatchChangeFinished(item, true /* oldItem */); + break; + default: + break; + } + } + } + dispatchFinishedWhenDone(); + } + + @Override + public void endAnimations() { + for (int i = mPendingAnims.size() - 1; i >= 0; i--) { + PendingAnimation pendAnim = mPendingAnims.get(i); + ViewHolder item = pendAnim.viewHolder; + switch (pendAnim.animType) { + case ANIM_TYPE_REMOVE: + dispatchRemoveFinished(item); + break; + case ANIM_TYPE_CHANGE: + dispatchChangeFinished(item, true /* oldItem */); + break; + default: + break; + } + mPendingAnims.remove(i); + } + for (int i = 0; i < mRunningAnims.size(); i++) { + ObjectAnimator anim = mRunningAnims.get(i); + anim.end(); + } + dispatchAnimationsFinished(); + } + + @Override + public boolean isRunning() { + return !mPendingAnims.isEmpty() || !mRunningAnims.isEmpty(); + } + + @Override + public boolean canReuseUpdatedViewHolder(@NonNull ViewHolder viewHolder, + @NonNull List payloads) { + if (!payloads.isEmpty() + && (int) payloads.get(0) == CHANGE_EVENT_TYPE_EMPTY_TO_CONTENT) { + return true; + } + return super.canReuseUpdatedViewHolder(viewHolder, payloads); + } + + private void dispatchFinishedWhenDone() { + if (!isRunning()) { + dispatchAnimationsFinished(); + if (mOnFinishRunnable != null) { + mOnFinishRunnable.run(); + } + } + } +} diff --git a/go/quickstep/src/com/android/quickstep/TaskAdapter.java b/go/quickstep/src/com/android/quickstep/TaskAdapter.java index 02cbf4e010..5e0e8ff8b1 100644 --- a/go/quickstep/src/com/android/quickstep/TaskAdapter.java +++ b/go/quickstep/src/com/android/quickstep/TaskAdapter.java @@ -34,6 +34,8 @@ import java.util.Objects; */ public final class TaskAdapter extends Adapter { + public static final int CHANGE_EVENT_TYPE_EMPTY_TO_CONTENT = 0; + private static final int MAX_TASKS_TO_DISPLAY = 6; private static final String TAG = "TaskAdapter"; private final TaskListLoader mLoader; @@ -71,6 +73,28 @@ public final class TaskAdapter extends Adapter { @Override public void onBindViewHolder(TaskHolder holder, int position) { + onBindViewHolderInternal(holder, position, false /* willAnimate */); + } + + @Override + public void onBindViewHolder(@NonNull TaskHolder holder, int position, + @NonNull List payloads) { + if (payloads.isEmpty()) { + super.onBindViewHolder(holder, position, payloads); + return; + } + int changeType = (int) payloads.get(0); + if (changeType == CHANGE_EVENT_TYPE_EMPTY_TO_CONTENT) { + // Bind in preparation for animation + onBindViewHolderInternal(holder, position, true /* willAnimate */); + } else { + throw new IllegalArgumentException("Payload content is not a valid change event type: " + + changeType); + } + } + + private void onBindViewHolderInternal(@NonNull TaskHolder holder, int position, + boolean willAnimate) { if (mIsShowingLoadingUi) { holder.bindEmptyUi(); return; @@ -81,7 +105,7 @@ public final class TaskAdapter extends Adapter { return; } Task task = tasks.get(position); - holder.bindTask(task, false /* willAnimate */); + holder.bindTask(task, willAnimate /* willAnimate */); mLoader.loadTaskIconAndLabel(task, () -> { // Ensure holder still has the same task. if (Objects.equals(task, holder.getTask())) { @@ -96,13 +120,6 @@ public final class TaskAdapter extends Adapter { }); } - @Override - public void onBindViewHolder(@NonNull TaskHolder holder, int position, - @NonNull List payloads) { - // TODO: Bind task in preparation for animation. For now, we apply UI changes immediately. - super.onBindViewHolder(holder, position, payloads); - } - @Override public int getItemCount() { if (mIsShowingLoadingUi) { diff --git a/go/quickstep/src/com/android/quickstep/views/IconRecentsView.java b/go/quickstep/src/com/android/quickstep/views/IconRecentsView.java index 59755bcb39..41f25105ca 100644 --- a/go/quickstep/src/com/android/quickstep/views/IconRecentsView.java +++ b/go/quickstep/src/com/android/quickstep/views/IconRecentsView.java @@ -17,6 +17,8 @@ package com.android.quickstep.views; import static androidx.recyclerview.widget.LinearLayoutManager.VERTICAL; +import static com.android.quickstep.TaskAdapter.CHANGE_EVENT_TYPE_EMPTY_TO_CONTENT; + import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.AnimatorSet; @@ -34,6 +36,7 @@ import android.widget.FrameLayout; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.recyclerview.widget.DefaultItemAnimator; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; @@ -41,6 +44,7 @@ import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver; import androidx.recyclerview.widget.RecyclerView.OnChildAttachStateChangeListener; import com.android.launcher3.R; +import com.android.quickstep.ContentFillItemAnimator; import com.android.quickstep.RecentsToActivityHelper; import com.android.quickstep.TaskActionController; import com.android.quickstep.TaskAdapter; @@ -89,6 +93,9 @@ public final class IconRecentsView extends FrameLayout { private final TaskListLoader mTaskLoader; private final TaskAdapter mTaskAdapter; private final TaskActionController mTaskActionController; + private final DefaultItemAnimator mDefaultItemAnimator = new DefaultItemAnimator(); + private final ContentFillItemAnimator mLoadingContentItemAnimator = + new ContentFillItemAnimator(); private RecentsToActivityHelper mActivityHelper; private RecyclerView mTaskRecyclerView; @@ -134,6 +141,9 @@ public final class IconRecentsView extends FrameLayout { @Override public void onChildViewDetachedFromWindow(@NonNull View view) { } }); + mTaskRecyclerView.setItemAnimator(mDefaultItemAnimator); + mLoadingContentItemAnimator.setOnAnimationFinishedRunnable( + () -> mTaskRecyclerView.setItemAnimator(new DefaultItemAnimator())); mEmptyView = findViewById(R.id.recent_task_empty_view); mContentView = findViewById(R.id.recent_task_content_view); @@ -186,9 +196,19 @@ public final class IconRecentsView extends FrameLayout { mTaskAdapter.setIsShowingLoadingUi(true); mTaskAdapter.notifyDataSetChanged(); mTaskLoader.loadTaskList(tasks -> { + int numEmptyItems = mTaskAdapter.getItemCount(); mTaskAdapter.setIsShowingLoadingUi(false); - // TODO: Animate the loading UI out and the loaded data in. - mTaskAdapter.notifyDataSetChanged(); + int numActualItems = mTaskAdapter.getItemCount(); + if (numEmptyItems < numActualItems) { + throw new IllegalStateException("There are less empty item views than the number " + + "of items to animate to."); + } + // Set item animator for content filling animation. The item animator will switch back + // to the default on completion. + mTaskRecyclerView.setItemAnimator(mLoadingContentItemAnimator); + mTaskAdapter.notifyItemRangeRemoved(numActualItems, numEmptyItems - numActualItems); + mTaskAdapter.notifyItemRangeChanged( + 0, numActualItems, CHANGE_EVENT_TYPE_EMPTY_TO_CONTENT); }); }