/* * Copyright (C) 2022 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 android.view.View.VISIBLE; import static androidx.recyclerview.widget.RecyclerView.NO_POSITION; 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 static com.android.launcher3.anim.Interpolators.DEACCEL_1_7; import static com.android.launcher3.anim.Interpolators.INSTANT; import static com.android.launcher3.anim.Interpolators.clampToProgress; import android.animation.ObjectAnimator; import android.graphics.drawable.Drawable; import android.util.FloatProperty; import android.util.Log; import android.view.View; import android.view.animation.Interpolator; import com.android.launcher3.BubbleTextView; import com.android.launcher3.Launcher; import com.android.launcher3.LauncherState; import com.android.launcher3.R; import com.android.launcher3.Utilities; 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"; // Interpolator when the user taps the QSB while already in All Apps. private static final Interpolator INTERPOLATOR_WITHIN_ALL_APPS = DEACCEL_1_7; // Interpolator when the user taps the QSB from home screen, so transition to all apps is // 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 SEARCH_TO_AZ_PROGRESS = new FloatProperty("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; public SearchTransitionController(ActivityAllAppsContainerView allAppsContainerView) { mAllAppsContainerView = allAppsContainerView; } /** Returns true if a transition animation is currently in progress. */ public boolean isRunning() { return mSearchToAzAnimator != null; } /** * Starts the transition to or from search state. If a transition is already in progress, the * animation will start from that point with the new duration, and the previous onEndRunnable * will not be called. * * @param goingToSearch true if will be showing search results, otherwise will be showing a-z * @param duration time in ms for the animation to run * @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 = Launcher.getLauncher( mAllAppsContainerView.getContext()).getStateManager().isInStableState( LauncherState.ALL_APPS); if (!inAllApps) { duration = 0; // Don't want to animate when coming from QSB. } mSearchToAzAnimator.setDuration(duration).setInterpolator( inAllApps ? INTERPOLATOR_WITHIN_ALL_APPS : INTERPOLATOR_TRANSITIONING_TO_ALL_APPS); mSearchToAzAnimator.addListener(forEndCallback(() -> mSearchToAzAnimator = null)); if (!goingToSearch) { mSearchToAzAnimator.addListener(forSuccessCallback(() -> { mAllAppsContainerView.getFloatingHeaderView().setFloatingRowsCollapsed(false); mAllAppsContainerView.getFloatingHeaderView().reset(false /* animate */); mAllAppsContainerView.getAppsRecyclerViewContainer().setTranslationY(0); })); } mSearchToAzAnimator.addListener(forSuccessCallback(onEndRunnable)); mAllAppsContainerView.getFloatingHeaderView().setFloatingRowsCollapsed(true); mAllAppsContainerView.getAppsRecyclerViewContainer().setVisibility(VISIBLE); getSearchRecyclerView().setVisibility(VISIBLE); getSearchRecyclerView().setChildAttachedConsumer(this::onSearchChildAttached); mSearchToAzAnimator.start(); } private SearchRecyclerView getSearchRecyclerView() { return mAllAppsContainerView.getSearchRecyclerView(); } private void setSearchToAzProgress(float searchToAzProgress) { mSearchToAzProgress = searchToAzProgress; int searchHeight = updateSearchRecyclerViewProgress(); FloatingHeaderView headerView = mAllAppsContainerView.getFloatingHeaderView(); // Add predictions + app divider height to account for predicted apps which will now be in // the Search RV instead of the floating header view. Note `getFloatingRowsHeight` returns 0 // when predictions are not shown. int appsTranslationY = searchHeight + headerView.getFloatingRowsHeight(); if (headerView.usingTabs()) { // Move tabs below the search results, and fade them out in 20% of the animation. headerView.setTranslationY(searchHeight); headerView.setAlpha(clampToProgress(searchToAzProgress, 0.8f, 1f)); // Account for the additional padding added for the tabs. appsTranslationY += headerView.getTabsAdditionalPaddingBottom() + mAllAppsContainerView.getResources().getDimensionPixelOffset( R.dimen.all_apps_tabs_margin_top) - headerView.getPaddingTop(); } View appsContainer = mAllAppsContainerView.getAppsRecyclerViewContainer(); appsContainer.setTranslationY(appsTranslationY); // Fade apps out with tabs (in 20% of the total animation). appsContainer.setAlpha(clampToProgress(searchToAzProgress, 0.8f, 1f)); } /** * Updates the children views of SearchRecyclerView based on the current animation progress. * * @return the total height of animating views (excluding any app icons). */ private int updateSearchRecyclerViewProgress() { int numSearchResultsAnimated = 0; int totalHeight = 0; int appRowHeight = 0; 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(); } if (searchResultView instanceof BubbleTextView && searchResultView.getTag() instanceof ItemInfo && ((ItemInfo) searchResultView.getTag()).itemType == ITEM_TYPE_APPLICATION) { // The first app icon will set appRowHeight, which will also contribute to // totalHeight. Additional app icons should remove the appRowHeight to remain in // the same row as the first app. searchResultView.setY(top + totalHeight - appRowHeight); if (appRowHeight == 0) { appRowHeight = searchResultView.getHeight(); totalHeight += appRowHeight; } // Don't scale/fade app row. searchResultView.setScaleY(1); searchResultView.setAlpha(1); continue; } // 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); searchResultView.setAlpha(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); float backgroundAlpha = 1 - clampToProgress(mSearchToAzProgress, startBackgroundFadeProgress, endBackgroundFadeProgress); int adapterPosition = searchRecyclerView.getChildAdapterPosition(searchResultView); boolean decoratorFilled = adapterPosition != NO_POSITION && searchRecyclerView.getApps().getAdapterItems().get(adapterPosition) .setDecorationFillAlpha((int) (255 * backgroundAlpha)); if (!decoratorFilled) { // Try to adjust background alpha instead (e.g. for Search Edu card). Drawable background = searchResultView.getBackground(); if (background != null) { background.setAlpha((int) (255 * backgroundAlpha)); } } float 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; int spanIndex = getSpanIndex(searchRecyclerView, adapterPosition); 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 and animation stagger. numSearchResultsAnimated++; totalHeight += scaledHeight; } searchResultView.setY(y); } return totalHeight - appRowHeight; } /** @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; } 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); } /** 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.getBackground() != null) { child.getBackground().setAlpha(255); } } } private float getSearchToAzProgress() { return mSearchToAzProgress; } }