Move UnionDecorationHandler to Launcher

- Create base SectionDecorationInfo.java and RecyclerViewAnimationController.java
- SearchTransitionController now inherits from RecyclerViewAnimationController where RecyclerViewAnimationController will control
animation for private space

Bug: 299294792
Test: Verified SearchTransitionController didn't regress by turning off BACKGROUND_DRAWABLES flag.
- Verified QL highlight still works
- video: https://drive.google.com/file/d/15yjBWofebn6m7VgEnLK6kEYqhC_adJQ3/view?usp=sharing

Flag: None
Change-Id: If34f4bb199be0e113485279931d2927cb9fad397
diff --git a/res/values/dimens.xml b/res/values/dimens.xml
index 242c439..8cb6c71 100644
--- a/res/values/dimens.xml
+++ b/res/values/dimens.xml
@@ -125,6 +125,9 @@
     <dimen name="all_apps_tip_bottom_margin">8dp</dimen>
     <dimen name="all_apps_height_extra">6dp</dimen>
     <dimen name="all_apps_paged_view_top_padding">40dp</dimen>
+    <dimen name="all_apps_recycler_view_decorator_padding">1dp</dimen>
+    <dimen name="all_apps_recycler_view_decorator_group_radius">28dp</dimen>
+    <dimen name="all_apps_recycler_view_decorator_result_radius">4dp</dimen>
 
     <dimen name="all_apps_icon_drawable_padding">8dp</dimen>
     <dimen name="all_apps_predicted_icon_vertical_padding">8dp</dimen>
diff --git a/src/com/android/launcher3/allapps/ActivityAllAppsContainerView.java b/src/com/android/launcher3/allapps/ActivityAllAppsContainerView.java
index e5a223a..7f1d216 100644
--- a/src/com/android/launcher3/allapps/ActivityAllAppsContainerView.java
+++ b/src/com/android/launcher3/allapps/ActivityAllAppsContainerView.java
@@ -407,7 +407,7 @@
             // If exiting search, revert predictive back scale on all apps
             mAllAppsTransitionController.animateAllAppsToNoScale();
         }
-        mSearchTransitionController.animateToSearchState(goingToSearch, durationMs,
+        mSearchTransitionController.animateToState(goingToSearch, durationMs,
                 /* onEndRunnable = */ () -> {
                     mIsSearching = goingToSearch;
                     updateSearchResultsVisibility();
diff --git a/src/com/android/launcher3/allapps/AllAppsRecyclerView.java b/src/com/android/launcher3/allapps/AllAppsRecyclerView.java
index b0f13ef..36a44cc 100644
--- a/src/com/android/launcher3/allapps/AllAppsRecyclerView.java
+++ b/src/com/android/launcher3/allapps/AllAppsRecyclerView.java
@@ -36,7 +36,11 @@
 import android.graphics.Canvas;
 import android.util.AttributeSet;
 import android.util.Log;
+import android.view.View;
 
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.core.util.Consumer;
 import androidx.recyclerview.widget.RecyclerView;
 
 import com.android.launcher3.DeviceProfile;
@@ -57,6 +61,7 @@
     protected static final String TAG = "AllAppsRecyclerView";
     private static final boolean DEBUG = false;
     private static final boolean DEBUG_LATENCY = Utilities.isPropertyEnabled(SEARCH_LOGGING);
+    private Consumer<View> mChildAttachedConsumer;
 
     protected final int mNumAppsPerRow;
     private final AllAppsFastScrollHelper mFastScrollHelper;
@@ -282,6 +287,22 @@
         }
     }
 
+    /**
+     * This will be called just before a new child is attached to the window. Passing in null will
+     * remove the consumer.
+     */
+    protected void setChildAttachedConsumer(@Nullable Consumer<View> childAttachedConsumer) {
+        mChildAttachedConsumer = childAttachedConsumer;
+    }
+
+    @Override
+    public void onChildAttachedToWindow(@NonNull View child) {
+        if (mChildAttachedConsumer != null) {
+            mChildAttachedConsumer.accept(child);
+        }
+        super.onChildAttachedToWindow(child);
+    }
+
     @Override
     public int getScrollBarTop() {
         return ActivityContext.lookupContext(getContext()).getAppsView().isSearchSupported()
diff --git a/src/com/android/launcher3/allapps/BaseAllAppsAdapter.java b/src/com/android/launcher3/allapps/BaseAllAppsAdapter.java
index 5e26ea5..035c30f 100644
--- a/src/com/android/launcher3/allapps/BaseAllAppsAdapter.java
+++ b/src/com/android/launcher3/allapps/BaseAllAppsAdapter.java
@@ -25,6 +25,7 @@
 import android.widget.RelativeLayout;
 import android.widget.TextView;
 
+import androidx.annotation.Nullable;
 import androidx.recyclerview.widget.RecyclerView;
 
 import com.android.launcher3.BubbleTextView;
@@ -92,7 +93,8 @@
         public int rowAppIndex;
         // The associated ItemInfoWithIcon for the item
         public AppInfo itemInfo = null;
-
+        // Private App Decorator
+        public SectionDecorationInfo decorationInfo = null;
         public AdapterItem(int viewType) {
             this.viewType = viewType;
         }
@@ -125,9 +127,17 @@
             return itemInfo == null && other.itemInfo == null;
         }
 
-        /** Sets the alpha of the decorator for this item. Returns true if successful. */
-        public boolean setDecorationFillAlpha(int alpha) {
-            return false;
+        @Nullable
+        public SectionDecorationInfo getDecorationInfo() {
+            return decorationInfo;
+        }
+
+        /** Sets the alpha of the decorator for this item. */
+        protected void setDecorationFillAlpha(int alpha) {
+            if (decorationInfo == null || decorationInfo.getDecorationHandler() == null) {
+                return;
+            }
+            decorationInfo.getDecorationHandler().setFillAlpha(alpha);
         }
     }
 
diff --git a/src/com/android/launcher3/allapps/RecyclerViewAnimationController.java b/src/com/android/launcher3/allapps/RecyclerViewAnimationController.java
new file mode 100644
index 0000000..6209393
--- /dev/null
+++ b/src/com/android/launcher3/allapps/RecyclerViewAnimationController.java
@@ -0,0 +1,309 @@
+/*
+ * Copyright (C) 2023 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.launcher3.allapps;
+
+import static androidx.recyclerview.widget.RecyclerView.NO_POSITION;
+
+import static com.android.app.animation.Interpolators.DECELERATE_1_7;
+import static com.android.app.animation.Interpolators.INSTANT;
+import static com.android.app.animation.Interpolators.clampToProgress;
+import static com.android.launcher3.LauncherSettings.Favorites.ITEM_TYPE_APPLICATION;
+import static com.android.launcher3.anim.AnimatorListeners.forEndCallback;
+import static com.android.launcher3.anim.AnimatorListeners.forSuccessCallback;
+
+import android.animation.ObjectAnimator;
+import android.animation.TimeInterpolator;
+import android.graphics.drawable.Drawable;
+import android.util.FloatProperty;
+import android.util.Log;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.animation.Interpolator;
+
+import com.android.launcher3.BubbleTextView;
+import com.android.launcher3.Utilities;
+import com.android.launcher3.model.data.ItemInfo;
+
+import java.util.List;
+
+public class RecyclerViewAnimationController {
+
+    private static final String LOG_TAG = "AnimationCtrl";
+
+    /**
+     * These values represent points on the [0, 1] animation progress spectrum. They are used to
+     * animate items in the {@link SearchRecyclerView} and private space container in
+     * {@link AllAppsRecyclerView}.
+     */
+    protected static final float TOP_CONTENT_FADE_PROGRESS_START = 0.133f;
+    protected static final float CONTENT_FADE_PROGRESS_DURATION = 0.083f;
+    protected static final float TOP_BACKGROUND_FADE_PROGRESS_START = 0.633f;
+    protected static final float BACKGROUND_FADE_PROGRESS_DURATION = 0.15f;
+    // Progress before next item starts fading.
+    protected static final float CONTENT_STAGGER = 0.01f;
+
+    protected static final FloatProperty<RecyclerViewAnimationController> PROGRESS =
+            new FloatProperty<RecyclerViewAnimationController>("expansionProgress") {
+                @Override
+                public Float get(RecyclerViewAnimationController controller) {
+                    return controller.getAnimationProgress();
+                }
+
+                @Override
+                public void setValue(RecyclerViewAnimationController controller, float progress) {
+                    controller.setAnimationProgress(progress);
+                }
+            };
+
+    protected final ActivityAllAppsContainerView<?> mAllAppsContainerView;
+    protected ObjectAnimator mAnimator = null;
+    private float mAnimatorProgress = 1f;
+
+    public RecyclerViewAnimationController(ActivityAllAppsContainerView<?> allAppsContainerView) {
+        mAllAppsContainerView = allAppsContainerView;
+    }
+
+    /**
+     * Updates the children views of the current recyclerView based on the current animation
+     * progress.
+     *
+     * @return the total height of animating views (may exclude at most one row of app icons
+     * depending on which recyclerView is being acted upon).
+     */
+    protected int onProgressUpdated(float expansionProgress) {
+        int numItemsAnimated = 0;
+        int totalHeight = 0;
+        int appRowHeight = 0;
+        boolean appRowComplete = false;
+        Integer top = null;
+        AllAppsRecyclerView allAppsRecyclerView = getRecyclerView();
+
+        for (int i = 0; i < allAppsRecyclerView.getChildCount(); i++) {
+            View currentView = allAppsRecyclerView.getChildAt(i);
+            if (currentView == null) {
+                continue;
+            }
+            if (top == null) {
+                top = currentView.getTop();
+            }
+            int adapterPosition = allAppsRecyclerView.getChildAdapterPosition(currentView);
+            List<BaseAllAppsAdapter.AdapterItem> allAppsAdapters = allAppsRecyclerView.getApps()
+                    .getAdapterItems();
+            if (adapterPosition < 0 || adapterPosition >= allAppsAdapters.size()) {
+                continue;
+            }
+            BaseAllAppsAdapter.AdapterItem adapterItemAtPosition =
+                    allAppsAdapters.get(adapterPosition);
+            int spanIndex = getSpanIndex(allAppsRecyclerView, adapterPosition);
+            appRowComplete |= appRowHeight > 0 && spanIndex == 0;
+
+            float backgroundAlpha = 1f;
+            boolean hasDecorationInfo = adapterItemAtPosition.getDecorationInfo() != null;
+            boolean shouldAnimate = shouldAnimate(currentView, hasDecorationInfo, appRowComplete);
+
+            if (shouldAnimate) {
+                if (spanIndex > 0) {
+                    // Animate this item with the previous item on the same row.
+                    numItemsAnimated--;
+                }
+                // Adjust background (or decorator) alpha based on start progress and stagger.
+                backgroundAlpha = getAdjustedBackgroundAlpha(numItemsAnimated);
+            }
+
+            Drawable background = currentView.getBackground();
+            if (background != null && currentView instanceof ViewGroup currentViewGroup) {
+                currentView.setAlpha(1f);
+                // Apply content alpha to each child, since the view needs to be fully opaque for
+                // the background to show properly.
+                for (int j = 0; j < currentViewGroup.getChildCount(); j++) {
+                    setViewAdjustedContentAlpha(currentViewGroup.getChildAt(j), numItemsAnimated,
+                            shouldAnimate);
+                }
+
+                // Apply background alpha to the background drawable directly.
+                background.setAlpha((int) (255 * backgroundAlpha));
+            } else {
+                // Adjust content alpha based on start progress and stagger.
+                setViewAdjustedContentAlpha(currentView, numItemsAnimated, shouldAnimate);
+
+                // Apply background alpha to decorator if possible.
+                setAdjustedAdapterItemDecorationBackgroundAlpha(
+                        allAppsRecyclerView.getApps().getAdapterItems().get(adapterPosition),
+                        numItemsAnimated);
+
+                // Apply background alpha to view's background (e.g. for Search Edu card).
+                if (background != null) {
+                    background.setAlpha((int) (255 * backgroundAlpha));
+                }
+            }
+
+            float scaleY = 1;
+            if (shouldAnimate) {
+                scaleY = 1 - getAnimationProgress();
+                // Update number of search results that has been animated.
+                numItemsAnimated++;
+            }
+            int scaledHeight = (int) (currentView.getHeight() * scaleY);
+            currentView.setScaleY(scaleY);
+
+            // For rows with multiple elements, only count the height once and translate elements to
+            // the same y position.
+            int y = top + totalHeight;
+            if (spanIndex > 0) {
+                // Continuation of an existing row; move this item into the row.
+                y -= scaledHeight;
+            } else {
+                // Start of a new row contributes to total height.
+                totalHeight += scaledHeight;
+                if (!shouldAnimate) {
+                    appRowHeight = scaledHeight;
+                }
+            }
+            currentView.setY(y);
+        }
+        return totalHeight - appRowHeight;
+    }
+
+    protected void animateToState(boolean expand, long duration, Runnable onEndRunnable) {
+        float targetProgress = expand ? 0 : 1;
+        if (mAnimator != null) {
+            mAnimator.cancel();
+        }
+        mAnimator = ObjectAnimator.ofFloat(this, PROGRESS, targetProgress);
+
+        TimeInterpolator timeInterpolator = getInterpolator();
+        if (timeInterpolator == INSTANT) {
+            duration = 0;
+        }
+
+        mAnimator.addListener(forEndCallback(() -> mAnimator = null));
+        mAnimator.setDuration(duration).setInterpolator(timeInterpolator);
+        mAnimator.addListener(forSuccessCallback(onEndRunnable));
+        mAnimator.start();
+        getRecyclerView().setChildAttachedConsumer(this::onChildAttached);
+    }
+
+    /** Called just before a child is attached to the RecyclerView. */
+    private void onChildAttached(View child) {
+        // Avoid allocating hardware layers for alpha changes.
+        child.forceHasOverlappingRendering(false);
+        child.setPivotY(0);
+        if (getAnimationProgress() > 0 && getAnimationProgress() < 1) {
+            // Before the child is rendered, apply the animation including it to avoid flicker.
+            onProgressUpdated(getAnimationProgress());
+        } else {
+            // Apply default states without processing the full layout.
+            child.setAlpha(1);
+            child.setScaleY(1);
+            child.setTranslationY(0);
+            int adapterPosition = getRecyclerView().getChildAdapterPosition(child);
+            List<BaseAllAppsAdapter.AdapterItem> allAppsAdapters =
+                    getRecyclerView().getApps().getAdapterItems();
+            if (adapterPosition >= 0 && adapterPosition < allAppsAdapters.size()) {
+                allAppsAdapters.get(adapterPosition).setDecorationFillAlpha(255);
+            }
+            if (child instanceof ViewGroup childGroup) {
+                for (int i = 0; i < childGroup.getChildCount(); i++) {
+                    childGroup.getChildAt(i).setAlpha(1f);
+                }
+            }
+            if (child.getBackground() != null) {
+                child.getBackground().setAlpha(255);
+            }
+        }
+    }
+
+    /** @return the column that the view at this position is found (0 assumed if indeterminate). */
+    protected int getSpanIndex(AllAppsRecyclerView appsRecyclerView, int adapterPosition) {
+        if (adapterPosition == NO_POSITION) {
+            Log.w(LOG_TAG, "Can't determine span index - child not found in adapter");
+            return 0;
+        }
+        if (!(appsRecyclerView.getAdapter() instanceof AllAppsGridAdapter<?>)) {
+            Log.e(LOG_TAG, "Search RV doesn't have an AllAppsGridAdapter?");
+            // This case shouldn't happen, but for debug devices we will continue to create a more
+            // visible crash.
+            if (!Utilities.IS_DEBUG_DEVICE) {
+                return 0;
+            }
+        }
+        AllAppsGridAdapter<?> adapter = (AllAppsGridAdapter<?>) appsRecyclerView.getAdapter();
+        return adapter.getSpanIndex(adapterPosition);
+    }
+
+    protected TimeInterpolator getInterpolator() {
+        return DECELERATE_1_7;
+    }
+
+    protected AllAppsRecyclerView getRecyclerView() {
+        return mAllAppsContainerView.mAH.get(ActivityAllAppsContainerView.AdapterHolder.MAIN)
+                .mRecyclerView;
+    }
+
+    /** Returns true if a transition animation is currently in progress. */
+    protected boolean isRunning() {
+        return mAnimator != null;
+    }
+
+    /** Should only animate if the view is an app icon and if it has a decoration info. */
+    protected boolean shouldAnimate(View view, boolean hasDecorationInfo,
+            boolean firstAppRowComplete) {
+        return isAppIcon(view) && hasDecorationInfo;
+    }
+
+    private float getAdjustedContentAlpha(int itemsAnimated) {
+        float startContentFadeProgress = Math.max(0,
+                TOP_CONTENT_FADE_PROGRESS_START - CONTENT_STAGGER * itemsAnimated);
+        float endContentFadeProgress = Math.min(1,
+                startContentFadeProgress + CONTENT_FADE_PROGRESS_DURATION);
+        return 1 - clampToProgress(mAnimatorProgress,
+                startContentFadeProgress, endContentFadeProgress);
+    }
+
+    private float getAdjustedBackgroundAlpha(int itemsAnimated) {
+        float startBackgroundFadeProgress = Math.max(0,
+                TOP_BACKGROUND_FADE_PROGRESS_START - CONTENT_STAGGER * itemsAnimated);
+        float endBackgroundFadeProgress = Math.min(1,
+                startBackgroundFadeProgress + BACKGROUND_FADE_PROGRESS_DURATION);
+        return 1 - clampToProgress(mAnimatorProgress,
+                startBackgroundFadeProgress, endBackgroundFadeProgress);
+    }
+
+    private void setViewAdjustedContentAlpha(View view, int numberOfItemsAnimated,
+            boolean shouldAnimate) {
+        view.setAlpha(shouldAnimate ? getAdjustedContentAlpha(numberOfItemsAnimated) : 1f);
+    }
+
+    private void setAdjustedAdapterItemDecorationBackgroundAlpha(
+            BaseAllAppsAdapter.AdapterItem adapterItem, int numberOfItemsAnimated) {
+        adapterItem.setDecorationFillAlpha((int)
+                (255 * getAdjustedBackgroundAlpha(numberOfItemsAnimated)));
+    }
+
+    private float getAnimationProgress() {
+        return mAnimatorProgress;
+    }
+
+    private void setAnimationProgress(float expansionProgress) {
+        mAnimatorProgress = expansionProgress;
+        onProgressUpdated(expansionProgress);
+    }
+
+    protected boolean isAppIcon(View item) {
+        return item instanceof BubbleTextView && item.getTag() instanceof ItemInfo
+                && ((ItemInfo) item.getTag()).itemType == ITEM_TYPE_APPLICATION;
+    }
+}
diff --git a/src/com/android/launcher3/allapps/SearchRecyclerView.java b/src/com/android/launcher3/allapps/SearchRecyclerView.java
index 9d1dfc0..68f9f11 100644
--- a/src/com/android/launcher3/allapps/SearchRecyclerView.java
+++ b/src/com/android/launcher3/allapps/SearchRecyclerView.java
@@ -27,8 +27,6 @@
 /** A RecyclerView for AllApps Search results. */
 public class SearchRecyclerView extends AllAppsRecyclerView {
 
-    private Consumer<View> mChildAttachedConsumer;
-
     public SearchRecyclerView(Context context) {
         this(context, null);
     }
@@ -46,11 +44,6 @@
         super(context, attrs, defStyleAttr, defStyleRes);
     }
 
-    /** This will be called just before a new child is attached to the window. */
-    public void setChildAttachedConsumer(Consumer<View> childAttachedConsumer) {
-        mChildAttachedConsumer = childAttachedConsumer;
-    }
-
     @Override
     protected void updatePoolSize() {
         RecycledViewPool pool = getRecycledViewPool();
@@ -67,12 +60,4 @@
     public RecyclerViewFastScroller getScrollbar() {
         return null;
     }
-
-    @Override
-    public void onChildAttachedToWindow(@NonNull View child) {
-        if (mChildAttachedConsumer != null) {
-            mChildAttachedConsumer.accept(child);
-        }
-        super.onChildAttachedToWindow(child);
-    }
 }
diff --git a/src/com/android/launcher3/allapps/SearchTransitionController.java b/src/com/android/launcher3/allapps/SearchTransitionController.java
index eb1bc0a..d5c3b57 100644
--- a/src/com/android/launcher3/allapps/SearchTransitionController.java
+++ b/src/com/android/launcher3/allapps/SearchTransitionController.java
@@ -18,34 +18,21 @@
 
 import static android.view.View.VISIBLE;
 
-import static androidx.recyclerview.widget.RecyclerView.NO_POSITION;
-
 import static com.android.app.animation.Interpolators.DECELERATE_1_7;
 import static com.android.app.animation.Interpolators.INSTANT;
 import static com.android.app.animation.Interpolators.clampToProgress;
-import static com.android.launcher3.LauncherSettings.Favorites.ITEM_TYPE_APPLICATION;
 import static com.android.launcher3.anim.AnimatorListeners.forEndCallback;
 import static com.android.launcher3.anim.AnimatorListeners.forSuccessCallback;
 
 import android.animation.ObjectAnimator;
 import android.animation.TimeInterpolator;
-import android.graphics.drawable.Drawable;
-import android.util.FloatProperty;
-import android.util.Log;
 import android.view.View;
-import android.view.ViewGroup;
 import android.view.animation.Interpolator;
 
-import com.android.launcher3.BubbleTextView;
 import com.android.launcher3.R;
-import com.android.launcher3.Utilities;
-import com.android.launcher3.config.FeatureFlags;
-import com.android.launcher3.model.data.ItemInfo;
 
 /** Coordinates the transition between Search and A-Z in All Apps. */
-public class SearchTransitionController {
-
-    private static final String LOG_TAG = "SearchTransitionCtrl";
+public class SearchTransitionController extends RecyclerViewAnimationController {
 
     // Interpolator when the user taps the QSB while already in All Apps.
     private static final Interpolator INTERPOLATOR_WITHIN_ALL_APPS = DECELERATE_1_7;
@@ -53,42 +40,10 @@
     // happening simultaneously.
     private static final Interpolator INTERPOLATOR_TRANSITIONING_TO_ALL_APPS = INSTANT;
 
-    /**
-     * These values represent points on the [0, 1] animation progress spectrum. They are used to
-     * animate items in the {@link SearchRecyclerView}.
-     */
-    private static final float TOP_CONTENT_FADE_PROGRESS_START = 0.133f;
-    private static final float CONTENT_FADE_PROGRESS_DURATION = 0.083f;
-    private static final float TOP_BACKGROUND_FADE_PROGRESS_START = 0.633f;
-    private static final float BACKGROUND_FADE_PROGRESS_DURATION = 0.15f;
-    private static final float CONTENT_STAGGER = 0.01f;  // Progress before next item starts fading.
-
-    private static final FloatProperty<SearchTransitionController> SEARCH_TO_AZ_PROGRESS =
-            new FloatProperty<SearchTransitionController>("searchToAzProgress") {
-                @Override
-                public Float get(SearchTransitionController controller) {
-                    return controller.getSearchToAzProgress();
-                }
-
-                @Override
-                public void setValue(SearchTransitionController controller, float progress) {
-                    controller.setSearchToAzProgress(progress);
-                }
-            };
-
-    private final ActivityAllAppsContainerView<?> mAllAppsContainerView;
-
-    private ObjectAnimator mSearchToAzAnimator = null;
-    private float mSearchToAzProgress = 1f;
     private boolean mSkipNextAnimationWithinAllApps;
 
     public SearchTransitionController(ActivityAllAppsContainerView<?> allAppsContainerView) {
-        mAllAppsContainerView = allAppsContainerView;
-    }
-
-    /** Returns true if a transition animation is currently in progress. */
-    public boolean isRunning() {
-        return mSearchToAzAnimator != null;
+        super(allAppsContainerView);
     }
 
     /**
@@ -101,51 +56,31 @@
      * @param onEndRunnable will be called when the animation finishes, unless another animation is
      *                      scheduled in the meantime
      */
-    public void animateToSearchState(boolean goingToSearch, long duration, Runnable onEndRunnable) {
-        float targetProgress = goingToSearch ? 0 : 1;
-
-        if (mSearchToAzAnimator != null) {
-            mSearchToAzAnimator.cancel();
-        }
-
-        mSearchToAzAnimator = ObjectAnimator.ofFloat(this, SEARCH_TO_AZ_PROGRESS, targetProgress);
-        boolean inAllApps = mAllAppsContainerView.isInAllApps();
-        TimeInterpolator timeInterpolator =
-                inAllApps ? INTERPOLATOR_WITHIN_ALL_APPS : INTERPOLATOR_TRANSITIONING_TO_ALL_APPS;
-        if (mSkipNextAnimationWithinAllApps) {
-            timeInterpolator = INSTANT;
-            mSkipNextAnimationWithinAllApps = false;
-        }
-        if (timeInterpolator == INSTANT) {
-            duration = 0;  // Don't want to animate when coming from QSB.
-        }
-        mSearchToAzAnimator.setDuration(duration).setInterpolator(timeInterpolator);
-        mSearchToAzAnimator.addListener(forEndCallback(() -> mSearchToAzAnimator = null));
+    @Override
+    protected void animateToState(boolean goingToSearch, long duration, Runnable onEndRunnable) {
+        super.animateToState(goingToSearch, duration, onEndRunnable);
         if (!goingToSearch) {
-            mSearchToAzAnimator.addListener(forSuccessCallback(() -> {
+            mAnimator.addListener(forSuccessCallback(() -> {
                 mAllAppsContainerView.getFloatingHeaderView().setFloatingRowsCollapsed(false);
                 mAllAppsContainerView.getFloatingHeaderView().reset(false /* animate */);
                 mAllAppsContainerView.getAppsRecyclerViewContainer().setTranslationY(0);
             }));
         }
-        mSearchToAzAnimator.addListener(forSuccessCallback(onEndRunnable));
-
         mAllAppsContainerView.getFloatingHeaderView().setFloatingRowsCollapsed(true);
         mAllAppsContainerView.getFloatingHeaderView().setVisibility(VISIBLE);
         mAllAppsContainerView.getFloatingHeaderView().maybeSetTabVisibility(VISIBLE);
         mAllAppsContainerView.getAppsRecyclerViewContainer().setVisibility(VISIBLE);
-        getSearchRecyclerView().setVisibility(VISIBLE);
-        getSearchRecyclerView().setChildAttachedConsumer(this::onSearchChildAttached);
-        mSearchToAzAnimator.start();
+        getRecyclerView().setVisibility(VISIBLE);
     }
 
-    private SearchRecyclerView getSearchRecyclerView() {
+    @Override
+    protected SearchRecyclerView getRecyclerView() {
         return mAllAppsContainerView.getSearchRecyclerView();
     }
 
-    private void setSearchToAzProgress(float searchToAzProgress) {
-        mSearchToAzProgress = searchToAzProgress;
-        int searchHeight = updateSearchRecyclerViewProgress();
+    @Override
+    protected int onProgressUpdated(float searchToAzProgress) {
+        int searchHeight = super.onProgressUpdated(searchToAzProgress);
 
         FloatingHeaderView headerView = mAllAppsContainerView.getFloatingHeaderView();
 
@@ -171,179 +106,27 @@
         appsContainer.setTranslationY(appsTranslationY);
         // Fade apps out with tabs (in 20% of the total animation).
         appsContainer.setAlpha(clampToProgress(searchToAzProgress, 0.8f, 1f));
+        return searchHeight;
     }
 
     /**
-     * Updates the children views of SearchRecyclerView based on the current animation progress.
-     *
-     * @return the total height of animating views (excluding at most one row of app icons).
+     * Should only animate if the view is not an app icon or if the app row is complete.
      */
-    private int updateSearchRecyclerViewProgress() {
-        int numSearchResultsAnimated = 0;
-        int totalHeight = 0;
-        int appRowHeight = 0;
-        boolean appRowComplete = false;
-        Integer top = null;
-        SearchRecyclerView searchRecyclerView = getSearchRecyclerView();
-
-        for (int i = 0; i < searchRecyclerView.getChildCount(); i++) {
-            View searchResultView = searchRecyclerView.getChildAt(i);
-            if (searchResultView == null) {
-                continue;
-            }
-
-            if (top == null) {
-                top = searchResultView.getTop();
-            }
-
-            int adapterPosition = searchRecyclerView.getChildAdapterPosition(searchResultView);
-            int spanIndex = getSpanIndex(searchRecyclerView, adapterPosition);
-            appRowComplete |= appRowHeight > 0 && spanIndex == 0;
-            // We don't animate the first (currently only) app row we see, as that is assumed to be
-            // predicted/prefix-matched apps.
-            boolean shouldAnimate = !isAppIcon(searchResultView) || appRowComplete;
-
-            float contentAlpha = 1f;
-            float backgroundAlpha = 1f;
-            if (shouldAnimate) {
-                if (spanIndex > 0) {
-                    // Animate this item with the previous item on the same row.
-                    numSearchResultsAnimated--;
-                }
-
-                // Adjust content alpha based on start progress and stagger.
-                float startContentFadeProgress = Math.max(0,
-                        TOP_CONTENT_FADE_PROGRESS_START
-                                - CONTENT_STAGGER * numSearchResultsAnimated);
-                float endContentFadeProgress = Math.min(1,
-                        startContentFadeProgress + CONTENT_FADE_PROGRESS_DURATION);
-                contentAlpha = 1 - clampToProgress(mSearchToAzProgress,
-                        startContentFadeProgress, endContentFadeProgress);
-
-                // Adjust background (or decorator) alpha based on start progress and stagger.
-                float startBackgroundFadeProgress = Math.max(0,
-                        TOP_BACKGROUND_FADE_PROGRESS_START
-                                - CONTENT_STAGGER * numSearchResultsAnimated);
-                float endBackgroundFadeProgress = Math.min(1,
-                        startBackgroundFadeProgress + BACKGROUND_FADE_PROGRESS_DURATION);
-                backgroundAlpha = 1 - clampToProgress(mSearchToAzProgress,
-                        startBackgroundFadeProgress, endBackgroundFadeProgress);
-
-                numSearchResultsAnimated++;
-            }
-
-            Drawable background = searchResultView.getBackground();
-            if (background != null
-                    && searchResultView instanceof ViewGroup
-                    && FeatureFlags.ENABLE_SEARCH_RESULT_BACKGROUND_DRAWABLES.get()) {
-                searchResultView.setAlpha(1f);
-
-                // Apply content alpha to each child, since the view needs to be fully opaque for
-                // the background to show properly.
-                ViewGroup searchResultViewGroup = (ViewGroup) searchResultView;
-                for (int j = 0; j < searchResultViewGroup.getChildCount(); j++) {
-                    searchResultViewGroup.getChildAt(j).setAlpha(contentAlpha);
-                }
-
-                // Apply background alpha to the background drawable directly.
-                background.setAlpha((int) (255 * backgroundAlpha));
-            } else {
-                searchResultView.setAlpha(contentAlpha);
-
-                // Apply background alpha to decorator if possible.
-                if (adapterPosition != NO_POSITION) {
-                    searchRecyclerView.getApps().getAdapterItems().get(adapterPosition)
-                            .setDecorationFillAlpha((int) (255 * backgroundAlpha));
-                }
-
-                // Apply background alpha to view's background (e.g. for Search Edu card).
-                if (background != null) {
-                    background.setAlpha((int) (255 * backgroundAlpha));
-                }
-            }
-
-            float scaleY = 1;
-            if (shouldAnimate) {
-                scaleY = 1 - mSearchToAzProgress;
-            }
-            int scaledHeight = (int) (searchResultView.getHeight() * scaleY);
-            searchResultView.setScaleY(scaleY);
-
-            // For rows with multiple elements, only count the height once and translate elements to
-            // the same y position.
-            int y = top + totalHeight;
-            if (spanIndex > 0) {
-                // Continuation of an existing row; move this item into the row.
-                y -= scaledHeight;
-            } else {
-                // Start of a new row contributes to total height.
-                totalHeight += scaledHeight;
-                if (!shouldAnimate) {
-                    appRowHeight = scaledHeight;
-                }
-            }
-            searchResultView.setY(y);
-        }
-
-        return totalHeight - appRowHeight;
+    @Override
+    protected boolean shouldAnimate(View view, boolean hasDecorationInfo, boolean appRowComplete) {
+        return !isAppIcon(view) || appRowComplete;
     }
 
-    /** @return the column that the view at this position is found (0 assumed if indeterminate). */
-    private int getSpanIndex(SearchRecyclerView searchRecyclerView, int adapterPosition) {
-        if (adapterPosition == NO_POSITION) {
-            Log.w(LOG_TAG, "Can't determine span index - child not found in adapter");
-            return 0;
+    @Override
+    protected TimeInterpolator getInterpolator() {
+        TimeInterpolator timeInterpolator =
+                mAllAppsContainerView.isInAllApps()
+                        ? INTERPOLATOR_WITHIN_ALL_APPS : INTERPOLATOR_TRANSITIONING_TO_ALL_APPS;
+        if (mSkipNextAnimationWithinAllApps) {
+            timeInterpolator = INSTANT;
+            mSkipNextAnimationWithinAllApps = false;
         }
-        if (!(searchRecyclerView.getAdapter() instanceof AllAppsGridAdapter<?>)) {
-            Log.e(LOG_TAG, "Search RV doesn't have an AllAppsGridAdapter?");
-            // This case shouldn't happen, but for debug devices we will continue to create a more
-            // visible crash.
-            if (!Utilities.IS_DEBUG_DEVICE) {
-                return 0;
-            }
-        }
-        AllAppsGridAdapter<?> adapter = (AllAppsGridAdapter<?>) searchRecyclerView.getAdapter();
-        return adapter.getSpanIndex(adapterPosition);
-    }
-
-    private boolean isAppIcon(View item) {
-        return item instanceof BubbleTextView && item.getTag() instanceof ItemInfo
-                && ((ItemInfo) item.getTag()).itemType == ITEM_TYPE_APPLICATION;
-    }
-
-    /** Called just before a child is attached to the SearchRecyclerView. */
-    private void onSearchChildAttached(View child) {
-        // Avoid allocating hardware layers for alpha changes.
-        child.forceHasOverlappingRendering(false);
-        child.setPivotY(0);
-        if (mSearchToAzProgress > 0) {
-            // Before the child is rendered, apply the animation including it to avoid flicker.
-            updateSearchRecyclerViewProgress();
-        } else {
-            // Apply default states without processing the full layout.
-            child.setAlpha(1);
-            child.setScaleY(1);
-            child.setTranslationY(0);
-            int adapterPosition = getSearchRecyclerView().getChildAdapterPosition(child);
-            if (adapterPosition != NO_POSITION) {
-                getSearchRecyclerView().getApps().getAdapterItems().get(adapterPosition)
-                        .setDecorationFillAlpha(255);
-            }
-            if (child instanceof ViewGroup
-                    && FeatureFlags.ENABLE_SEARCH_RESULT_BACKGROUND_DRAWABLES.get()) {
-                ViewGroup childGroup = (ViewGroup) child;
-                for (int i = 0; i < childGroup.getChildCount(); i++) {
-                    childGroup.getChildAt(i).setAlpha(1f);
-                }
-            }
-            if (child.getBackground() != null) {
-                child.getBackground().setAlpha(255);
-            }
-        }
-    }
-
-    private float getSearchToAzProgress() {
-        return mSearchToAzProgress;
+        return timeInterpolator;
     }
 
     /**
diff --git a/src/com/android/launcher3/allapps/SectionDecorationHandler.java b/src/com/android/launcher3/allapps/SectionDecorationHandler.java
new file mode 100644
index 0000000..f79b82c
--- /dev/null
+++ b/src/com/android/launcher3/allapps/SectionDecorationHandler.java
@@ -0,0 +1,206 @@
+/*
+ * Copyright (C) 2023 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.launcher3.allapps;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.Path;
+import android.graphics.RectF;
+import android.graphics.drawable.GradientDrawable;
+import android.graphics.drawable.InsetDrawable;
+import android.view.View;
+
+import androidx.annotation.Nullable;
+import androidx.core.content.ContextCompat;
+
+import com.android.launcher3.R;
+import com.android.launcher3.util.Themes;
+
+public class SectionDecorationHandler {
+
+    protected final Path mTmpPath = new Path();
+    protected final RectF mTmpRect = new RectF();
+
+    protected final int mCornerGroupRadius;
+    protected final int mCornerResultRadius;
+    protected final RectF mBounds = new RectF();
+    protected final Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+
+    protected final int mFocusAlpha = 255; // main focused item alpha
+    protected int mFillColor; // grouping color
+    protected int mFocusColor; // main focused item color
+    protected float mFillSpacing;
+    protected int mInlineRadius;
+    protected Context mContext;
+    protected float[] mCorners;
+    protected int mFillAlpha;
+    protected boolean mIsTopLeftRound;
+    protected boolean mIsTopRightRound;
+    protected boolean mIsBottomLeftRound;
+    protected boolean mIsBottomRightRound;
+    protected boolean mIsBottomRound;
+    protected boolean mIsTopRound;
+
+    public SectionDecorationHandler(Context context, int fillAlpha, boolean isTopLeftRound,
+            boolean isTopRightRound, boolean isBottomLeftRound,
+            boolean isBottomRightRound) {
+
+        mContext = context;
+        mFillAlpha = fillAlpha;
+        mFocusColor = ContextCompat.getColor(context,
+                R.color.material_color_surface_bright); // UX recommended
+        mFillColor = ContextCompat.getColor(context,
+                R.color.material_color_surface_container_high); // UX recommended
+
+        mIsTopLeftRound = isTopLeftRound;
+        mIsTopRightRound = isTopRightRound;
+        mIsBottomLeftRound = isBottomLeftRound;
+        mIsBottomRightRound = isBottomRightRound;
+        mIsBottomRound = mIsBottomLeftRound && mIsBottomRightRound;
+        mIsTopRound = mIsTopLeftRound && mIsTopRightRound;
+
+        mCornerGroupRadius = context.getResources().getDimensionPixelSize(
+                R.dimen.all_apps_recycler_view_decorator_group_radius);
+        mCornerResultRadius = context.getResources().getDimensionPixelSize(
+                R.dimen.all_apps_recycler_view_decorator_result_radius);
+
+        mInlineRadius = 0;
+        mFillSpacing = 0;
+        initCorners();
+    }
+
+    protected void initCorners() {
+        mCorners = new float[]{
+                mIsTopLeftRound ? mCornerGroupRadius : 0,
+                mIsTopLeftRound ? mCornerGroupRadius : 0, // Top left radius in px
+                mIsTopRightRound ? mCornerGroupRadius : 0,
+                mIsTopRightRound ? mCornerGroupRadius : 0, // Top right radius in px
+                mIsBottomRightRound ? mCornerGroupRadius : 0,
+                mIsBottomRightRound ? mCornerGroupRadius : 0, // Bottom right
+                mIsBottomLeftRound ? mCornerGroupRadius : 0,
+                mIsBottomLeftRound ? mCornerGroupRadius : 0 // Bottom left
+        };
+    }
+
+    protected void setFillAlpha(int fillAlpha) {
+        mFillAlpha = fillAlpha;
+        mPaint.setAlpha(mFillAlpha);
+    }
+
+    protected void onFocusDraw(Canvas canvas, @Nullable View view) {
+        if (view == null) {
+            return;
+        }
+        mPaint.setColor(mFillColor);
+        mPaint.setAlpha(mFillAlpha);
+        int scaledHeight = (int) (view.getHeight() * view.getScaleY());
+        mBounds.set(view.getLeft(), view.getY(), view.getRight(), view.getY() + scaledHeight);
+        onDraw(canvas);
+    }
+
+    protected void onDraw(Canvas canvas) {
+        mTmpPath.reset();
+        mTmpRect.set(mBounds.left + mFillSpacing,
+                mBounds.top + mFillSpacing,
+                mBounds.right - mFillSpacing,
+                mBounds.bottom - mFillSpacing);
+        mTmpPath.addRoundRect(mTmpRect, mCorners, Path.Direction.CW);
+        canvas.drawPath(mTmpPath, mPaint);
+    }
+
+    /** Sets the right background drawable to the view based on the give decoration info. */
+    public void applyBackground(View view, Context context,
+           @Nullable SectionDecorationInfo decorationInfo, boolean isHighlighted) {
+        int inset = context.getResources().getDimensionPixelSize(
+                R.dimen.all_apps_recycler_view_decorator_padding);
+        float radiusBottom = (decorationInfo == null || decorationInfo.isBottomRound()) ?
+                mCornerGroupRadius : mCornerResultRadius;
+        float radiusTop =
+                (decorationInfo == null || decorationInfo.isTopRound()) ?
+                        mCornerGroupRadius : mCornerResultRadius;
+        int color = isHighlighted ? mFocusColor : mFillColor;
+
+        GradientDrawable shape = new GradientDrawable();
+        shape.setShape(GradientDrawable.RECTANGLE);
+        shape.setCornerRadii(new float[] {
+                radiusTop, radiusTop, // top-left
+                radiusTop, radiusTop, // top-right
+                radiusBottom, radiusBottom, // bottom-right
+                radiusBottom, radiusBottom // bottom-left
+        });
+        shape.setColor(color);
+
+        // Setting the background resets the padding, so we cache it and reset it afterwards.
+        int paddingLeft = view.getPaddingLeft();
+        int paddingTop = view.getPaddingTop();
+        int paddingRight = view.getPaddingRight();
+        int paddingBottom = view.getPaddingBottom();
+
+        view.setBackground(new InsetDrawable(shape, inset));
+
+        view.setPadding(paddingLeft, paddingTop, paddingRight, paddingBottom);
+    }
+
+    /**
+     * Section decorator that combines views and draws a single block decoration
+     */
+    public static class UnionDecorationHandler extends SectionDecorationHandler {
+
+        private final int mPaddingLeft;
+        private final int mPaddingRight;
+
+        public UnionDecorationHandler(
+                SectionDecorationHandler decorationHandler,
+                int paddingLeft, int paddingRight) {
+            super(decorationHandler.mContext, decorationHandler.mFillAlpha,
+                    decorationHandler.mIsTopLeftRound, decorationHandler.mIsTopRightRound,
+                    decorationHandler.mIsBottomLeftRound, decorationHandler.mIsBottomRightRound);
+            mPaddingLeft = paddingLeft;
+            mPaddingRight = paddingRight;
+        }
+
+        /**
+         * Expands decoration bounds to include child {@link PrivateAppsSectionDecorator}
+         */
+        public void addChild(SectionDecorationHandler child, View view, boolean applyBackground) {
+            int scaledHeight = (int) (view.getHeight() * view.getScaleY());
+            mBounds.union(view.getLeft(), view.getY(),
+                    view.getRight(), view.getY() + scaledHeight);
+            if (applyBackground) {
+                applyBackground(view, mContext, null, false);
+            }
+            mIsBottomRound |= child.mIsBottomRound;
+            mIsBottomLeftRound |= child.mIsBottomLeftRound;
+            mIsBottomRightRound |= child.mIsBottomRightRound;
+            mIsTopRound |= child.mIsTopRound;
+            mIsTopLeftRound |= child.mIsTopLeftRound;
+            mIsTopRightRound |= child.mIsTopRightRound;
+        }
+
+        /**
+         * Draws group decoration to canvas
+         */
+        public void onGroupDecorate(Canvas canvas) {
+            initCorners();
+            mBounds.left = mPaddingLeft;
+            mBounds.right = canvas.getWidth() - mPaddingRight;
+            mPaint.setColor(mFillColor);
+            mPaint.setAlpha(mFillAlpha);
+            onDraw(canvas);
+        }
+    }
+}
diff --git a/src/com/android/launcher3/allapps/SectionDecorationInfo.java b/src/com/android/launcher3/allapps/SectionDecorationInfo.java
new file mode 100644
index 0000000..1fed2b6
--- /dev/null
+++ b/src/com/android/launcher3/allapps/SectionDecorationInfo.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright (C) 2023 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.launcher3.allapps;
+
+import android.content.Context;
+import android.os.Bundle;
+
+import androidx.annotation.NonNull;
+
+public class SectionDecorationInfo {
+
+    public static final int ROUND_NOTHING = 1 << 1;
+    public static final int ROUND_TOP_LEFT = 1 << 2;
+    public static final int ROUND_TOP_RIGHT = 1 << 3;
+    public static final int ROUND_BOTTOM_LEFT = 1 << 4;
+    public static final int ROUND_BOTTOM_RIGHT = 1 << 5;
+    public static final int DECORATOR_ALPHA = 255;
+
+    protected boolean mShouldDecorateItemsTogether;
+    private SectionDecorationHandler mDecorationHandler;
+    protected boolean mIsTopRound;
+    protected boolean mIsBottomRound;
+
+    public SectionDecorationInfo(Context context, int roundRegions, boolean decorateTogether) {
+        mDecorationHandler =
+                new SectionDecorationHandler(context, DECORATOR_ALPHA,
+                        isFlagEnabled(roundRegions, ROUND_TOP_LEFT),
+                        isFlagEnabled(roundRegions, ROUND_TOP_RIGHT),
+                        isFlagEnabled(roundRegions, ROUND_BOTTOM_LEFT),
+                        isFlagEnabled(roundRegions, ROUND_BOTTOM_RIGHT));
+        mShouldDecorateItemsTogether = decorateTogether;
+        mIsTopRound = isFlagEnabled(roundRegions, ROUND_TOP_LEFT) &&
+                isFlagEnabled(roundRegions, ROUND_TOP_RIGHT);
+        mIsBottomRound = isFlagEnabled(roundRegions, ROUND_BOTTOM_LEFT) &&
+                isFlagEnabled(roundRegions, ROUND_BOTTOM_RIGHT);
+    }
+
+    public SectionDecorationInfo(Context context, @NonNull Bundle target,
+            String targetLayoutType, @NonNull Bundle prevTarget, @NonNull Bundle nextTarget) {}
+
+    public SectionDecorationHandler getDecorationHandler() {
+        return mDecorationHandler;
+    }
+
+    private boolean isFlagEnabled(int canonicalFlag, int comparison) {
+        return (canonicalFlag & comparison) != 0;
+    }
+
+    /**
+     * Returns whether multiple {@link SectionDecorationInfo}s with the same sectionId should
+     * be grouped together.
+     */
+    public boolean shouldDecorateItemsTogether() {
+        return mShouldDecorateItemsTogether;
+    }
+
+    public boolean isTopRound() {
+        return mIsTopRound;
+    }
+
+    public boolean isBottomRound() {
+        return mIsBottomRound;
+    }
+}