Files
Lawnchair/src/com/android/launcher3/allapps/AllAppsTransitionController.java
T
Andy Wickham 830e4b7ce0 Add long swipe from app to overview gesture (with flag).
High level:
 - As you swipe up from an app (OtherActivityInputConsumer),
   a state transition animation to All Apps is created in
   AnimatorControllerWithResistance. The animation is played
   alongside the Recents resistance animation (i.e. past the
   settling point of Overview, which is at mCurrentShift 1).
 - The actual state transition to All Apps only happens if you
   release your finger in the "all apps region." This is set to
   mCurrentShift 2, so double the distance that Overview rests.
 - A haptic plays whenever you enter or exit this region, and
   the all apps animation is set to 0 until the region is
   active. This is so it's clear that something different is
   happening.
 - The panel that was previously used for tablets is now used
   for phones during this transition. It comes in at full
   opacity when you enter the region, and the contents (apps
   and search suggestions) fade in as you continue swiping.
 - The only gesture that is recognized in the all apps region
   is a fling downwards, which will return you to the previous
   app. Otherwise a left/right/up fling or slow release will
   finish the all apps transition.
 - The threshold is ignored if the flag is disabled (default)
   or if FallbackActivityInterface is active.

Flag:
The threshold is ignored if ENABLE_ALL_APPS_FROM_OVERVIEW is
disabled (default).

Bug: 259619990
Bug: 275132633
Test: Manual with and without the flag enabled
Change-Id: Ie311b77252416d97677b2c56fad61dfd392b6fe8
2023-04-26 10:52:04 -07:00

577 lines
24 KiB
Java

/*
* 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.INSTANT;
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_BOTTOM_SHEET_FADE;
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.
* <p/>
* 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<LauncherState>, 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<AllAppsTransitionController> ALL_APPS_PROGRESS =
new FloatProperty<AllAppsTransitionController>("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<AllAppsTransitionController> ALL_APPS_PULL_BACK_TRANSLATION =
new FloatProperty<AllAppsTransitionController>("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<AllAppsTransitionController> ALL_APPS_PULL_BACK_ALPHA =
new FloatProperty<AllAppsTransitionController>("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<Launcher> 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<View> 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);
mAllAppScale.value = 1;
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_PREMIUM_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);
// This controls both haptics for tapping on QSB and going to all apps.
if (ALL_APPS.equals(toState) && mLauncher.isInState(NORMAL) &&
!FeatureFlags.ENABLE_PREMIUM_HAPTICS_ALL_APPS.get()) {
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);
setter.setFloat(getAppsViewPullbackAlpha(), MultiPropertyFactory.MULTI_PROPERTY_VALUE,
hasAllAppsContent ? 1 : 0, allAppsFade);
setter.setFloat(mLauncher.getAppsView(),
ActivityAllAppsContainerView.BOTTOM_SHEET_ALPHA, hasAllAppsContent ? 1 : 0,
config.getInterpolator(ANIM_ALL_APPS_BOTTOM_SHEET_FADE, INSTANT));
boolean shouldProtectHeader = !config.hasAnimationFlag(StateAnimationConfig.SKIP_SCRIM)
&& (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<Launcher> 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) {
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;
}
}
}
}