| /* |
| * 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.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"; |
| |
| // 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<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; |
| |
| 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 = mAllAppsContainerView.isInAllApps(); |
| 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.getFloatingHeaderView().setVisibility(VISIBLE); |
| 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 at most one row of app icons). |
| */ |
| 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; |
| } |
| |
| /** @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); |
| } |
| |
| 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; |
| } |
| } |