/* * Copyright (C) 2015 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 com.android.launcher3.LauncherAnimUtils.SCALE_PROPERTY; import static com.android.launcher3.LauncherAnimUtils.VIEW_TRANSLATE_Y; import static com.android.launcher3.LauncherState.ALL_APPS; import static com.android.launcher3.LauncherState.ALL_APPS_CONTENT; import static com.android.launcher3.LauncherState.NORMAL; import static com.android.launcher3.anim.Interpolators.DEACCEL_1_7; import static com.android.launcher3.anim.Interpolators.LINEAR; import static com.android.launcher3.anim.PropertySetter.NO_ANIM_PROPERTY_SETTER; import static com.android.launcher3.states.StateAnimationConfig.ANIM_ALL_APPS_FADE; import static com.android.launcher3.states.StateAnimationConfig.ANIM_VERTICAL_PROGRESS; import static com.android.launcher3.util.SystemUiController.FLAG_DARK_NAV; import static com.android.launcher3.util.SystemUiController.FLAG_LIGHT_NAV; import static com.android.launcher3.util.SystemUiController.UI_STATE_ALL_APPS; import android.animation.Animator; import android.animation.Animator.AnimatorListener; import android.animation.ObjectAnimator; import android.animation.ValueAnimator; import android.util.FloatProperty; import android.view.HapticFeedbackConstants; import android.view.View; import android.view.ViewGroup; import android.view.ViewParent; import android.view.animation.Interpolator; import androidx.annotation.FloatRange; import androidx.annotation.Nullable; import com.android.launcher3.DeviceProfile; import com.android.launcher3.DeviceProfile.OnDeviceProfileChangeListener; import com.android.launcher3.Launcher; import com.android.launcher3.LauncherState; import com.android.launcher3.R; import com.android.launcher3.Utilities; import com.android.launcher3.anim.AnimatedFloat; import com.android.launcher3.anim.AnimatorListeners; import com.android.launcher3.anim.Interpolators; import com.android.launcher3.anim.PendingAnimation; import com.android.launcher3.anim.PropertySetter; import com.android.launcher3.config.FeatureFlags; import com.android.launcher3.statemanager.StateManager.StateHandler; import com.android.launcher3.states.StateAnimationConfig; import com.android.launcher3.touch.AllAppsSwipeController; import com.android.launcher3.util.MultiPropertyFactory; import com.android.launcher3.util.MultiPropertyFactory.MultiProperty; import com.android.launcher3.util.MultiValueAlpha; import com.android.launcher3.util.ScrollableLayoutManager; import com.android.launcher3.util.Themes; import com.android.launcher3.util.VibratorWrapper; import com.android.launcher3.views.ScrimView; /** * Handles AllApps view transition. * 1) Slides all apps view using direct manipulation * 2) When finger is released, animate to either top or bottom accordingly. *

* Algorithm: * If release velocity > THRES1, snap according to the direction of movement. * If release velocity < THRES1, snap according to either top or bottom depending on whether it's * closer to top or closer to the page indicator. */ public class AllAppsTransitionController implements StateHandler, OnDeviceProfileChangeListener { // This constant should match the second derivative of the animator interpolator. public static final float INTERP_COEFF = 1.7f; public static final int REVERT_SWIPE_ALL_APPS_TO_HOME_ANIMATION_DURATION_MS = 200; private static final float NAV_BAR_COLOR_FORCE_UPDATE_THRESHOLD = 0.1f; private static final float SWIPE_DRAG_COMMIT_THRESHOLD = 1 - AllAppsSwipeController.ALL_APPS_STATE_TRANSITION_MANUAL; public static final FloatProperty ALL_APPS_PROGRESS = new FloatProperty("allAppsProgress") { @Override public Float get(AllAppsTransitionController controller) { return controller.mProgress; } @Override public void setValue(AllAppsTransitionController controller, float progress) { controller.setProgress(progress); } }; private static final float ALL_APPS_PULL_BACK_TRANSLATION_DEFAULT = 0f; public static final FloatProperty ALL_APPS_PULL_BACK_TRANSLATION = new FloatProperty("allAppsPullBackTranslation") { @Override public Float get(AllAppsTransitionController controller) { if (controller.mIsTablet) { return controller.mAppsView.getActiveRecyclerView().getTranslationY(); } else { return controller.getAppsViewPullbackTranslationY().getValue(); } } @Override public void setValue(AllAppsTransitionController controller, float translation) { if (controller.mIsTablet) { controller.mAppsView.getActiveRecyclerView().setTranslationY(translation); controller.getAppsViewPullbackTranslationY().setValue( ALL_APPS_PULL_BACK_TRANSLATION_DEFAULT); } else { controller.getAppsViewPullbackTranslationY().setValue(translation); controller.mAppsView.getActiveRecyclerView().setTranslationY( ALL_APPS_PULL_BACK_TRANSLATION_DEFAULT); } } }; private static final float ALL_APPS_PULL_BACK_ALPHA_DEFAULT = 1f; public static final FloatProperty ALL_APPS_PULL_BACK_ALPHA = new FloatProperty("allAppsPullBackAlpha") { @Override public Float get(AllAppsTransitionController controller) { if (controller.mIsTablet) { return controller.mAppsView.getActiveRecyclerView().getAlpha(); } else { return controller.getAppsViewPullbackAlpha().getValue(); } } @Override public void setValue(AllAppsTransitionController controller, float alpha) { if (controller.mIsTablet) { controller.mAppsView.getActiveRecyclerView().setAlpha(alpha); controller.getAppsViewPullbackAlpha().setValue( ALL_APPS_PULL_BACK_ALPHA_DEFAULT); } else { controller.getAppsViewPullbackAlpha().setValue(alpha); controller.mAppsView.getActiveRecyclerView().setAlpha( ALL_APPS_PULL_BACK_ALPHA_DEFAULT); } } }; private static final int INDEX_APPS_VIEW_PROGRESS = 0; private static final int INDEX_APPS_VIEW_PULLBACK = 1; private static final int APPS_VIEW_INDEX_COUNT = 2; private ActivityAllAppsContainerView mAppsView; private final Launcher mLauncher; private final AnimatedFloat mAllAppScale = new AnimatedFloat(this::onScaleProgressChanged); private final int mNavScrimFlag; private boolean mIsVerticalLayout; // Whether this class should take care of closing the keyboard. private boolean mShouldControlKeyboard; // Animation in this class is controlled by a single variable {@link mProgress}. // Visually, it represents top y coordinate of the all apps container if multiplied with // {@link mShiftRange}. // When {@link mProgress} is 0, all apps container is pulled up. // When {@link mProgress} is 1, all apps container is pulled down. private float mShiftRange; // changes depending on the orientation private float mProgress; // [0, 1], mShiftRange * mProgress = shiftCurrent private ScrimView mScrimView; private MultiValueAlpha mAppsViewAlpha; private MultiPropertyFactory mAppsViewTranslationY; private boolean mIsTablet; private boolean mHasScaleEffect; private final VibratorWrapper mVibratorWrapper; public AllAppsTransitionController(Launcher l) { mLauncher = l; DeviceProfile dp = mLauncher.getDeviceProfile(); mProgress = 1f; mIsVerticalLayout = dp.isVerticalBarLayout(); mIsTablet = dp.isTablet; mNavScrimFlag = Themes.getAttrBoolean(l, R.attr.isMainColorDark) ? FLAG_DARK_NAV : FLAG_LIGHT_NAV; setShiftRange(dp.allAppsShiftRange); mLauncher.addOnDeviceProfileChangeListener(this); mVibratorWrapper = VibratorWrapper.INSTANCE.get(mLauncher.getApplicationContext()); } public float getShiftRange() { return mShiftRange; } @Override public void onDeviceProfileChanged(DeviceProfile dp) { mIsVerticalLayout = dp.isVerticalBarLayout(); setShiftRange(dp.allAppsShiftRange); if (mIsVerticalLayout) { mLauncher.getHotseat().setTranslationY(0); mLauncher.getWorkspace().getPageIndicator().setTranslationY(0); } mIsTablet = dp.isTablet; } /** * Note this method should not be called outside this class. This is public because it is used * in xml-based animations which also handle updating the appropriate UI. * * @param progress value between 0 and 1, 0 shows all apps and 1 shows workspace * @see #setState(LauncherState) * @see #setStateWithAnimation(LauncherState, StateAnimationConfig, PendingAnimation) */ public void setProgress(float progress) { mProgress = progress; getAppsViewProgressTranslationY().setValue(mProgress * mShiftRange); mLauncher.onAllAppsTransition(1 - progress); boolean hasScrim = progress < NAV_BAR_COLOR_FORCE_UPDATE_THRESHOLD && mLauncher.getAppsView().getNavBarScrimHeight() > 0; mLauncher.getSystemUiController().updateUiState( UI_STATE_ALL_APPS, hasScrim ? mNavScrimFlag : 0); } public float getProgress() { return mProgress; } private MultiProperty getAppsViewProgressTranslationY() { return mAppsViewTranslationY.get(INDEX_APPS_VIEW_PROGRESS); } private MultiProperty getAppsViewPullbackTranslationY() { return mAppsViewTranslationY.get(INDEX_APPS_VIEW_PULLBACK); } private MultiProperty getAppsViewProgressAlpha() { return mAppsViewAlpha.get(INDEX_APPS_VIEW_PROGRESS); } private MultiProperty getAppsViewPullbackAlpha() { return mAppsViewAlpha.get(INDEX_APPS_VIEW_PULLBACK); } /** * Sets the vertical transition progress to {@param state} and updates all the dependent UI * accordingly. */ @Override public void setState(LauncherState state) { setProgress(state.getVerticalProgress(mLauncher)); setAlphas(state, new StateAnimationConfig(), NO_ANIM_PROPERTY_SETTER); onProgressAnimationEnd(); } @Override public void onBackProgressed( LauncherState toState, @FloatRange(from = 0.0, to = 1.0) float backProgress) { if (!mLauncher.isInState(ALL_APPS) || !NORMAL.equals(toState)) { return; } float deceleratedProgress = Interpolators.PREDICTIVE_BACK_DECELERATED_EASE.getInterpolation(backProgress); float scaleProgress = ScrollableLayoutManager.PREDICTIVE_BACK_MIN_SCALE + (1 - ScrollableLayoutManager.PREDICTIVE_BACK_MIN_SCALE) * (1 - deceleratedProgress); mAllAppScale.updateValue(scaleProgress); } private void onScaleProgressChanged() { final float scaleProgress = mAllAppScale.value; SCALE_PROPERTY.set(mLauncher.getAppsView(), scaleProgress); mLauncher.getScrimView().setScrimHeaderScale(scaleProgress); AllAppsRecyclerView rv = mLauncher.getAppsView().getActiveRecyclerView(); if (rv != null && rv.getScrollbar() != null) { rv.getScrollbar().setVisibility(scaleProgress < 1f ? View.INVISIBLE : View.VISIBLE); } // Disable view clipping from all apps' RecyclerView up to all apps view during scale // animation, and vice versa. The goal is to display extra roll(s) app icons (rendered in // {@link AppsGridLayoutManager#calculateExtraLayoutSpace}) during scale animation. boolean hasScaleEffect = scaleProgress < 1f; if (hasScaleEffect != mHasScaleEffect) { mHasScaleEffect = hasScaleEffect; if (mHasScaleEffect) { setClipChildrenOnViewTree(rv, mLauncher.getAppsView(), false); } else { restoreClipChildrenOnViewTree(rv, mLauncher.getAppsView()); } } } /** Animate all apps view to 1f scale. */ public void animateAllAppsToNoScale() { mAllAppScale.animateToValue(1f) .setDuration(REVERT_SWIPE_ALL_APPS_TO_HOME_ANIMATION_DURATION_MS) .start(); } /** * Creates an animation which updates the vertical transition progress and updates all the * dependent UI using various animation events * * This method also dictates where along the progress the haptics should be played. As the user * scrolls up from workspace or down from AllApps, a drag haptic is being played until the * commit point where it plays a commit haptic. Where we play the haptics differs when going * from workspace -> allApps and vice versa. */ @Override public void setStateWithAnimation(LauncherState toState, StateAnimationConfig config, PendingAnimation builder) { if (mLauncher.isInState(ALL_APPS) && !ALL_APPS.equals(toState)) { // For atomic animations, we close the keyboard immediately. if (!config.userControlled && mShouldControlKeyboard) { mLauncher.getAppsView().getSearchUiManager().getEditText().hideKeyboard(); } builder.addEndListener(success -> { // Reset pull back progress and alpha after switching states. ALL_APPS_PULL_BACK_TRANSLATION.set(this, ALL_APPS_PULL_BACK_TRANSLATION_DEFAULT); ALL_APPS_PULL_BACK_ALPHA.set(this, ALL_APPS_PULL_BACK_ALPHA_DEFAULT); // We only want to close the keyboard if the animation has completed successfully. // The reason is that with keyboard sync, if the user swipes down from All Apps with // the keyboard open and then changes their mind and swipes back up, we want the // keyboard to remain open. However an onCancel signal is sent to the listeners // (success = false), so we need to check for that. if (config.userControlled && success && mShouldControlKeyboard) { mLauncher.getAppsView().getSearchUiManager().getEditText().hideKeyboard(); } mAllAppScale.updateValue(1f); }); } if(FeatureFlags.ENABLE_HAPTICS_ALL_APPS.get() && config.userControlled && Utilities.ATLEAST_S) { if (toState == ALL_APPS) { builder.addOnFrameListener( new VibrationAnimatorUpdateListener(this, mVibratorWrapper, SWIPE_DRAG_COMMIT_THRESHOLD, 1)); } else { builder.addOnFrameListener( new VibrationAnimatorUpdateListener(this, mVibratorWrapper, 0, SWIPE_DRAG_COMMIT_THRESHOLD)); } builder.addEndListener(mVibratorWrapper::cancelVibrate); } float targetProgress = toState.getVerticalProgress(mLauncher); if (Float.compare(mProgress, targetProgress) == 0) { setAlphas(toState, config, builder); // Fail fast return; } // need to decide depending on the release velocity Interpolator verticalProgressInterpolator = config.getInterpolator(ANIM_VERTICAL_PROGRESS, config.userControlled ? LINEAR : DEACCEL_1_7); Animator anim = createSpringAnimation(mProgress, targetProgress); anim.setInterpolator(verticalProgressInterpolator); anim.addListener(getProgressAnimatorListener()); builder.add(anim); setAlphas(toState, config, builder); if (ALL_APPS.equals(toState) && mLauncher.isInState(NORMAL) && !(Utilities.ATLEAST_S)) { mLauncher.getAppsView().performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY, HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING); } } public Animator createSpringAnimation(float... progressValues) { return ObjectAnimator.ofFloat(this, ALL_APPS_PROGRESS, progressValues); } /** * Updates the property for the provided state */ public void setAlphas(LauncherState state, StateAnimationConfig config, PropertySetter setter) { int visibleElements = state.getVisibleElements(mLauncher); boolean hasAllAppsContent = (visibleElements & ALL_APPS_CONTENT) != 0; Interpolator allAppsFade = config.getInterpolator(ANIM_ALL_APPS_FADE, LINEAR); setter.setFloat(getAppsViewProgressAlpha(), MultiPropertyFactory.MULTI_PROPERTY_VALUE, hasAllAppsContent ? 1 : 0, allAppsFade); boolean shouldProtectHeader = ALL_APPS == state || mLauncher.getStateManager().getState() == ALL_APPS; mScrimView.setDrawingController(shouldProtectHeader ? mAppsView : null); } public AnimatorListener getProgressAnimatorListener() { return AnimatorListeners.forSuccessCallback(this::onProgressAnimationEnd); } /** * see Launcher#setupViews */ public void setupViews(ScrimView scrimView, ActivityAllAppsContainerView appsView) { mScrimView = scrimView; mAppsView = appsView; mAppsView.setScrimView(scrimView); mAppsViewAlpha = new MultiValueAlpha(mAppsView, APPS_VIEW_INDEX_COUNT); mAppsViewAlpha.setUpdateVisibility(true); mAppsViewTranslationY = new MultiPropertyFactory<>( mAppsView, VIEW_TRANSLATE_Y, APPS_VIEW_INDEX_COUNT, Float::sum); mShouldControlKeyboard = !mLauncher.getSearchConfig().isKeyboardSyncEnabled(); } /** * Recursively call {@link ViewGroup#setClipChildren(boolean)} from {@link View} to ts parent * (direct or indirect) inclusive. This method will also save the old clipChildren value on each * view with {@link View#setTag(int, Object)}, which can be restored in * {@link #restoreClipChildrenOnViewTree(View, ViewParent)}. * * Note that if parent is null or not a parent of the view, this method will be applied all the * way to root view. * * @param v child view * @param parent direct or indirect parent of child view * @param clipChildren whether we should clip children */ private static void setClipChildrenOnViewTree( @Nullable View v, @Nullable ViewParent parent, boolean clipChildren) { if (v == null) { return; } if (v instanceof ViewGroup) { ViewGroup viewGroup = (ViewGroup) v; boolean oldClipChildren = viewGroup.getClipChildren(); if (oldClipChildren != clipChildren) { v.setTag(R.id.saved_clip_children_tag_id, oldClipChildren); viewGroup.setClipChildren(clipChildren); } } if (v == parent) { return; } if (v.getParent() instanceof View) { setClipChildrenOnViewTree((View) v.getParent(), parent, clipChildren); } } /** * Recursively call {@link ViewGroup#setClipChildren(boolean)} to restore clip children value * set in {@link #setClipChildrenOnViewTree(View, ViewParent, boolean)} on view to its parent * (direct or indirect) inclusive. * * Note that if parent is null or not a parent of the view, this method will be applied all the * way to root view. * * @param v child view * @param parent direct or indirect parent of child view */ private static void restoreClipChildrenOnViewTree( @Nullable View v, @Nullable ViewParent parent) { if (v == null) { return; } if (v instanceof ViewGroup) { ViewGroup viewGroup = (ViewGroup) v; Object viewTag = viewGroup.getTag(R.id.saved_clip_children_tag_id); if (viewTag instanceof Boolean) { viewGroup.setClipChildren((boolean) viewTag); viewGroup.setTag(R.id.saved_clip_children_tag_id, null); } } if (v == parent) { return; } if (v.getParent() instanceof View) { restoreClipChildrenOnViewTree((View) v.getParent(), parent); } } /** * Updates the total scroll range but does not update the UI. */ public void setShiftRange(float shiftRange) { mShiftRange = shiftRange; } /** * Set the final view states based on the progress. * TODO: This logic should go in {@link LauncherState} */ private void onProgressAnimationEnd() { if (Float.compare(mProgress, 1f) == 0) { mAppsView.reset(false /* animate */); if (mShouldControlKeyboard) { mLauncher.getAppsView().getSearchUiManager().getEditText().hideKeyboard(); } } } /** * This VibrationAnimatorUpdateListener class takes in four parameters, a controller, start * threshold, end threshold, and a Vibrator wrapper. We use the progress given by the controller * as it gives an accurate progress that dictates where the vibrator should vibrate. * Note: once the user begins a gesture and does the commit haptic, there should not be anymore * haptics played for that gesture. */ private static class VibrationAnimatorUpdateListener implements ValueAnimator.AnimatorUpdateListener { private final VibratorWrapper mVibratorWrapper; private final AllAppsTransitionController mController; private final float mStartThreshold; private final float mEndThreshold; private boolean mHasCommitted; VibrationAnimatorUpdateListener(AllAppsTransitionController controller, VibratorWrapper vibratorWrapper, float startThreshold, float endThreshold) { mController = controller; mVibratorWrapper = vibratorWrapper; mStartThreshold = startThreshold; mEndThreshold = endThreshold; } @Override public void onAnimationUpdate(ValueAnimator animation) { if (mHasCommitted) { return; } float currentProgress = AllAppsTransitionController.ALL_APPS_PROGRESS.get(mController); if (currentProgress > mStartThreshold && currentProgress < mEndThreshold) { mVibratorWrapper.vibrateForDragTexture(); } else if (!(currentProgress == 0 || currentProgress == 1)) { // This check guards against committing at the location of the start of the gesture mVibratorWrapper.vibrateForDragCommit(); mHasCommitted = true; } } } }