Initial interaction for two state swipe to overview
> Currently swipe only works from NORMAL and ALL_APPS state
> All interpolation is spread linearly
On pausing the drag for some time, the workspace moves to overview state,
and all other transitions interpolate linearly from there over the
remaining swipe range
Change-Id: Ic79f9d0f446c9bfff11e4af4d31ddc1c86c45ab2
diff --git a/quickstep/src/com/android/launcher3/uioverrides/DragPauseDetector.java b/quickstep/src/com/android/launcher3/uioverrides/DragPauseDetector.java
new file mode 100644
index 0000000..1977e93
--- /dev/null
+++ b/quickstep/src/com/android/launcher3/uioverrides/DragPauseDetector.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright (C) 2017 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.uioverrides;
+
+import com.android.launcher3.Alarm;
+import com.android.launcher3.OnAlarmListener;
+
+/**
+ * Utility class to detect a pause during a drag.
+ */
+public class DragPauseDetector implements OnAlarmListener {
+
+ private static final float MAX_VELOCITY_TO_PAUSE = 0.2f;
+ private static final long PAUSE_DURATION = 100;
+
+ private final Alarm mAlarm;
+ private final Runnable mOnPauseCallback;
+
+ private boolean mEnabled = true;
+ private boolean mTriggered = false;
+
+ public DragPauseDetector(Runnable onPauseCallback) {
+ mOnPauseCallback = onPauseCallback;
+
+ mAlarm = new Alarm();
+ mAlarm.setOnAlarmListener(this);
+ mAlarm.setAlarm(PAUSE_DURATION);
+ }
+
+ public void onDrag(float displacement, float velocity) {
+ if (mTriggered || !mEnabled) {
+ return;
+ }
+
+ if (Math.abs(velocity) > MAX_VELOCITY_TO_PAUSE) {
+ // Cancel any previous alarm and set a new alarm
+ mAlarm.setAlarm(PAUSE_DURATION);
+ }
+ }
+
+ @Override
+ public void onAlarm(Alarm alarm) {
+ if (!mTriggered && mEnabled) {
+ mTriggered = true;
+ mOnPauseCallback.run();
+ }
+ }
+
+ public boolean isTriggered () {
+ return mTriggered;
+ }
+
+ public boolean isEnabled() {
+ return mEnabled;
+ }
+
+ public void setEnabled(boolean isEnabled) {
+ if (mEnabled != isEnabled) {
+ mEnabled = isEnabled;
+ if (isEnabled && !mTriggered) {
+ mAlarm.setAlarm(PAUSE_DURATION);
+ } else if (!isEnabled) {
+ mAlarm.cancelAlarm();
+ }
+ }
+ }
+}
diff --git a/quickstep/src/com/android/launcher3/uioverrides/TaggedAnimatorSetBuilder.java b/quickstep/src/com/android/launcher3/uioverrides/TaggedAnimatorSetBuilder.java
new file mode 100644
index 0000000..651a753
--- /dev/null
+++ b/quickstep/src/com/android/launcher3/uioverrides/TaggedAnimatorSetBuilder.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright (C) 2017 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.uioverrides;
+
+import android.animation.Animator;
+import android.util.SparseArray;
+
+import com.android.launcher3.anim.AnimatorSetBuilder;
+
+import java.util.Collections;
+import java.util.List;
+
+public class TaggedAnimatorSetBuilder extends AnimatorSetBuilder {
+
+ /**
+ * Map of the index in {@link #mAnims} to tag. All the animations in {@link #mAnims} starting
+ * from this index correspond to the tag (until a new tag is specified for an index)
+ */
+ private final SparseArray<Object> mTags = new SparseArray<>();
+
+ @Override
+ public void startTag(Object obj) {
+ mTags.put(mAnims.size(), obj);
+ }
+
+ public List<Animator> getAnimationsForTag(Object tag) {
+ int startIndex = mTags.indexOfValue(tag);
+ if (startIndex < 0) {
+ return Collections.emptyList();
+ }
+ int startPos = mTags.keyAt(startIndex);
+
+ int endIndex = startIndex + 1;
+ int endPos = endIndex >= mTags.size() ? mAnims.size() : mTags.keyAt(endIndex);
+
+ return mAnims.subList(startPos, endPos);
+ }
+}
diff --git a/quickstep/src/com/android/launcher3/uioverrides/TwoStepSwipeController.java b/quickstep/src/com/android/launcher3/uioverrides/TwoStepSwipeController.java
index 9081865..299db47 100644
--- a/quickstep/src/com/android/launcher3/uioverrides/TwoStepSwipeController.java
+++ b/quickstep/src/com/android/launcher3/uioverrides/TwoStepSwipeController.java
@@ -17,22 +17,32 @@
import static com.android.launcher3.LauncherState.ALL_APPS;
import static com.android.launcher3.LauncherState.NORMAL;
+import static com.android.launcher3.LauncherState.OVERVIEW;
import static com.android.launcher3.anim.Interpolators.scrollInterpolatorForVelocity;
import static com.android.launcher3.anim.SpringAnimationHandler.Y_DIRECTION;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
+import android.animation.AnimatorSet;
import android.animation.ValueAnimator;
+import android.animation.ValueAnimator.AnimatorUpdateListener;
import android.support.animation.SpringAnimation;
import android.util.Log;
import android.view.MotionEvent;
+import android.view.animation.Interpolator;
import com.android.launcher3.AbstractFloatingView;
import com.android.launcher3.Launcher;
import com.android.launcher3.LauncherState;
+import com.android.launcher3.LauncherStateManager;
+import com.android.launcher3.LauncherStateManager.AnimationConfig;
+import com.android.launcher3.LauncherStateManager.StateHandler;
import com.android.launcher3.Utilities;
import com.android.launcher3.allapps.AllAppsContainerView;
+import com.android.launcher3.anim.AnimationSuccessListener;
import com.android.launcher3.anim.AnimatorPlaybackController;
+import com.android.launcher3.anim.AnimatorSetBuilder;
+import com.android.launcher3.anim.Interpolators;
import com.android.launcher3.anim.SpringAnimationHandler;
import com.android.launcher3.touch.SwipeDetector;
import com.android.launcher3.userevent.nano.LauncherLogProto.Action.Direction;
@@ -52,16 +62,34 @@
private static final float RECATCH_REJECTION_FRACTION = .0875f;
private static final int SINGLE_FRAME_MS = 16;
+ private static final long QUICK_SNAP_TO_OVERVIEW_DURATION = 250;
// Progress after which the transition is assumed to be a success in case user does not fling
private static final float SUCCESS_TRANSITION_PROGRESS = 0.5f;
+ /**
+ * Index of the vertical swipe handles in {@link LauncherStateManager#getStateHandlers()}.
+ */
+ private static final int SWIPE_HANDLER_INDEX = 0;
+
+ /**
+ * Index of various UI handlers in {@link LauncherStateManager#getStateHandlers()} not related
+ * to vertical swipe.
+ */
+ private static final int OTHER_HANDLERS_START_INDEX = SWIPE_HANDLER_INDEX + 1;
+
private final Launcher mLauncher;
private final SwipeDetector mDetector;
private boolean mNoIntercept;
private int mStartContainerType;
+ private DragPauseDetector mDragPauseDetector;
+ private TaggedAnimatorSetBuilder mTaggedAnimatorSetBuilder;
+ private AnimatorSet mQuickOverviewAnimation;
+ private boolean mAnimatingToOverview;
+ private TwoStateAnimationController mTwoStateAnimationController;
+
private AnimatorPlaybackController mCurrentAnimation;
private LauncherState mToState;
@@ -81,6 +109,9 @@
// Don't listen for the swipe gesture if we are already in some other state.
return false;
}
+ if (mAnimatingToOverview) {
+ return false;
+ }
if (mCurrentAnimation != null) {
// If we are already animating from a previous state, we can intercept.
return true;
@@ -97,10 +128,9 @@
@Override
public void onAnimationCancel(Animator animation) {
- if (mCurrentAnimation != null && animation == mCurrentAnimation.getTarget()) {
+ if (mCurrentAnimation != null && animation == mCurrentAnimation.getOriginalTarget()) {
Log.e(TAG, "Who dare cancel the animation when I am in control", new Exception());
- mDetector.finishedScrolling();
- mCurrentAnimation = null;
+ clearState();
}
}
@@ -190,10 +220,14 @@
float range = getShiftRange();
long maxAccuracy = (long) (2 * range);
+ mDragPauseDetector = new DragPauseDetector(this::onDragPauseDetected);
+ mTaggedAnimatorSetBuilder = new TaggedAnimatorSetBuilder();
+
// Build current animation
mToState = mLauncher.isInState(ALL_APPS) ? NORMAL : ALL_APPS;
- mCurrentAnimation = mLauncher.getStateManager()
- .createAnimationToNewWorkspace(mToState, maxAccuracy);
+ mCurrentAnimation = mLauncher.getStateManager().createAnimationToNewWorkspace(
+ mToState, mTaggedAnimatorSetBuilder, maxAccuracy);
+
mCurrentAnimation.getTarget().addListener(this);
mStartProgress = 0;
mProgressMultiplier = (mLauncher.isInState(ALL_APPS) ? 1 : -1) / range;
@@ -214,6 +248,8 @@
@Override
public boolean onDrag(float displacement, float velocity) {
+ mDragPauseDetector.onDrag(displacement, velocity);
+
float deltaProgress = mProgressMultiplier * displacement;
mCurrentAnimation.setPlayFraction(deltaProgress + mStartProgress);
return true;
@@ -221,6 +257,11 @@
@Override
public void onDragEnd(float velocity, boolean fling) {
+ if (!fling && mDragPauseDetector.isEnabled() && mDragPauseDetector.isTriggered()) {
+ snapToOverview(velocity);
+ return;
+ }
+
final long animationDuration;
final int logAction;
final LauncherState targetState;
@@ -255,23 +296,7 @@
h.animateToFinalPosition(0 /* pos */, 1 /* startValue */);
}
}
-
- mCurrentAnimation.setEndAction(new Runnable() {
- @Override
- public void run() {
- if (targetState == mToState) {
- // Transition complete. log the action
- mLauncher.getUserEventDispatcher().logActionOnContainer(logAction,
- mToState == ALL_APPS ? Direction.UP : Direction.DOWN,
- mStartContainerType, mLauncher.getWorkspace().getCurrentPage());
- } else {
- mLauncher.getStateManager().goToState(
- mToState == ALL_APPS ? NORMAL : ALL_APPS, false);
- }
- mDetector.finishedScrolling();
- mCurrentAnimation = null;
- }
- });
+ mCurrentAnimation.setEndAction(() -> onSwipeInteractionCompleted(targetState, logAction));
float nextFrameProgress = Utilities.boundToRange(
progress + velocity * SINGLE_FRAME_MS / getShiftRange(), 0f, 1f);
@@ -281,5 +306,185 @@
anim.setDuration(animationDuration);
anim.setInterpolator(scrollInterpolatorForVelocity(velocity));
anim.start();
+
+ // TODO: Re-enable later
+ mDragPauseDetector.setEnabled(false);
+ }
+
+ private void onSwipeInteractionCompleted(LauncherState targetState, int logAction) {
+ if (targetState == mToState) {
+ // Transition complete. log the action
+ mLauncher.getUserEventDispatcher().logActionOnContainer(logAction,
+ mToState == ALL_APPS ? Direction.UP : Direction.DOWN,
+ mStartContainerType, mLauncher.getWorkspace().getCurrentPage());
+ }
+ clearState();
+
+ // TODO: mQuickOverviewAnimation might still be running in which changing a state instantly
+ // may cause a jump. Animate the state change with a short duration in this case?
+ mLauncher.getStateManager().goToState(targetState, false /* animated */);
+ }
+
+ private void snapToOverview(float velocity) {
+ mAnimatingToOverview = true;
+
+ final float progress = mCurrentAnimation.getProgressFraction();
+ float endProgress = mToState == NORMAL ? 1f : 0f;
+ long animationDuration = SwipeDetector.calculateDuration(
+ velocity, Math.abs(endProgress - progress));
+ float nextFrameProgress = Utilities.boundToRange(
+ progress + velocity * SINGLE_FRAME_MS / getShiftRange(), 0f, 1f);
+
+ mCurrentAnimation.setEndAction(() -> {
+ // TODO: Add logging
+ clearState();
+ mLauncher.getStateManager().goToState(OVERVIEW, false /* animated */);
+ });
+
+ if (mTwoStateAnimationController != null) {
+ mTwoStateAnimationController.goBackToStart(endProgress);
+ }
+
+ ValueAnimator anim = mCurrentAnimation.getAnimationPlayer();
+ anim.setFloatValues(nextFrameProgress, endProgress);
+ anim.setDuration(animationDuration);
+ anim.setInterpolator(scrollInterpolatorForVelocity(velocity));
+ anim.start();
+ }
+
+ private void onDragPauseDetected() {
+ final ValueAnimator twoStepAnimator = ValueAnimator.ofFloat(0, 1);
+ twoStepAnimator.setDuration(mCurrentAnimation.getDuration());
+ StateHandler[] handlers = mLauncher.getStateManager().getStateHandlers();
+
+ // Change the current animation to only play the vertical handle
+ AnimatorSet anim = new AnimatorSet();
+ anim.playTogether(mTaggedAnimatorSetBuilder.getAnimationsForTag(
+ handlers[SWIPE_HANDLER_INDEX]));
+ anim.play(twoStepAnimator);
+ mCurrentAnimation = mCurrentAnimation.cloneFor(anim);
+
+ AnimatorSetBuilder builder = new AnimatorSetBuilder();
+ AnimationConfig config = new AnimationConfig();
+ config.duration = QUICK_SNAP_TO_OVERVIEW_DURATION;
+ for (int i = OTHER_HANDLERS_START_INDEX; i < handlers.length; i++) {
+ handlers[i].setStateWithAnimation(OVERVIEW, builder, config);
+ }
+ mQuickOverviewAnimation = builder.build();
+ mQuickOverviewAnimation.addListener(new AnimationSuccessListener() {
+ @Override
+ public void onAnimationSuccess(Animator animator) {
+ onQuickOverviewAnimationComplete(twoStepAnimator);
+ }
+ });
+ mQuickOverviewAnimation.start();
+ }
+
+ private void onQuickOverviewAnimationComplete(ValueAnimator twoStepAnimator) {
+ if (mAnimatingToOverview) {
+ return;
+ }
+
+ // The remaining state handlers are on the OVERVIEW state. Create two animations, one
+ // towards the NORMAL state and one towards ALL_APPS state and control them based on the
+ // swipe progress.
+ AnimationConfig config = new AnimationConfig();
+ config.duration = (long) (2 * getShiftRange());
+ config.userControlled = true;
+
+ LauncherState fromState = mToState == ALL_APPS ? NORMAL : ALL_APPS;
+ AnimatorSetBuilder builderToTargetState = new AnimatorSetBuilder();
+ AnimatorSetBuilder builderToSourceState = new AnimatorSetBuilder();
+
+ StateHandler[] handlers = mLauncher.getStateManager().getStateHandlers();
+ for (int i = OTHER_HANDLERS_START_INDEX; i < handlers.length; i++) {
+ handlers[i].setStateWithAnimation(mToState, builderToTargetState, config);
+ handlers[i].setStateWithAnimation(fromState, builderToSourceState, config);
+ }
+
+ mTwoStateAnimationController = new TwoStateAnimationController(
+ AnimatorPlaybackController.wrap(builderToSourceState.build(), config.duration),
+ AnimatorPlaybackController.wrap(builderToTargetState.build(), config.duration),
+ twoStepAnimator.getAnimatedFraction());
+ twoStepAnimator.addUpdateListener(mTwoStateAnimationController);
+ }
+
+ private void clearState() {
+ mCurrentAnimation = null;
+ mTaggedAnimatorSetBuilder = null;
+ if (mDragPauseDetector != null) {
+ mDragPauseDetector.setEnabled(false);
+ }
+ mDragPauseDetector = null;
+
+ if (mQuickOverviewAnimation != null) {
+ mQuickOverviewAnimation.cancel();
+ mQuickOverviewAnimation = null;
+ }
+ mTwoStateAnimationController = null;
+ mAnimatingToOverview = false;
+
+ mDetector.finishedScrolling();
+ }
+
+ /**
+ * {@link AnimatorUpdateListener} which interpolates two animations based the progress
+ */
+ private static class TwoStateAnimationController implements AnimatorUpdateListener {
+
+ private final AnimatorPlaybackController mControllerTowardsStart;
+ private final AnimatorPlaybackController mControllerTowardsEnd;
+
+ private Interpolator mInterpolator = Interpolators.LINEAR;
+ private float mStartFraction;
+ private float mLastFraction;
+
+ TwoStateAnimationController(AnimatorPlaybackController controllerTowardsStart,
+ AnimatorPlaybackController controllerTowardsEnd, float startFraction) {
+ mControllerTowardsStart = controllerTowardsStart;
+ mControllerTowardsEnd = controllerTowardsEnd;
+ mLastFraction = mStartFraction = startFraction;
+ }
+
+ @Override
+ public void onAnimationUpdate(ValueAnimator valueAnimator) {
+ mLastFraction = mInterpolator.getInterpolation(valueAnimator.getAnimatedFraction());
+ if (mLastFraction > mStartFraction) {
+ if (mStartFraction >= 1) {
+ mControllerTowardsEnd.setPlayFraction(0);
+ } else {
+ mControllerTowardsEnd.setPlayFraction(
+ (mLastFraction - mStartFraction) / (1 - mStartFraction));
+ }
+ } else {
+ if (mStartFraction <= 0) {
+ mControllerTowardsStart.setPlayFraction(0);
+ } else {
+ mControllerTowardsStart.setPlayFraction(
+ (mStartFraction - mLastFraction) / mStartFraction);
+ }
+ }
+ }
+
+ /**
+ * Changes the interpolator such that from this point ({@link #mLastFraction}), the
+ * animation run towards {@link #mStartFraction}. This allows us to animate the UI back
+ * to the original point.
+ * @param endFraction expected end point for this animation. Should either be 0 or 1.
+ */
+ public void goBackToStart(float endFraction) {
+ if (mLastFraction == mStartFraction || mLastFraction == endFraction) {
+ mInterpolator = (v) -> mStartFraction;
+ } else if (mLastFraction > mStartFraction && endFraction < mStartFraction) {
+ mInterpolator = (v) -> Math.max(v, mStartFraction);
+ } else if (mLastFraction < mStartFraction && endFraction > mStartFraction) {
+ mInterpolator = (v) -> Math.min(mStartFraction, v);
+ } else {
+ final float start = mLastFraction;
+ final float range = endFraction - mLastFraction;
+ mInterpolator = (v) ->
+ SwipeDetector.interpolate(start, mStartFraction, (v - start) / range);
+ }
+ }
}
}
diff --git a/src/com/android/launcher3/LauncherStateManager.java b/src/com/android/launcher3/LauncherStateManager.java
index e3768e2..0137b26 100644
--- a/src/com/android/launcher3/LauncherStateManager.java
+++ b/src/com/android/launcher3/LauncherStateManager.java
@@ -93,7 +93,7 @@
return mState;
}
- private StateHandler[] getStateHandlers() {
+ public StateHandler[] getStateHandlers() {
if (mStateHandlers == null) {
mStateHandlers = UiFactory.getStateHandler(mLauncher);
}
diff --git a/src/com/android/launcher3/anim/AnimatorPlaybackController.java b/src/com/android/launcher3/anim/AnimatorPlaybackController.java
index 826a20e..6819386 100644
--- a/src/com/android/launcher3/anim/AnimatorPlaybackController.java
+++ b/src/com/android/launcher3/anim/AnimatorPlaybackController.java
@@ -45,12 +45,14 @@
private final long mDuration;
protected final AnimatorSet mAnim;
+ private AnimatorSet mOriginalTarget;
protected float mCurrentFraction;
private Runnable mEndAction;
protected AnimatorPlaybackController(AnimatorSet anim, long duration) {
mAnim = anim;
+ mOriginalTarget = mAnim;
mDuration = duration;
mAnimationPlayer = ValueAnimator.ofFloat(0, 1);
@@ -63,6 +65,25 @@
return mAnim;
}
+ public void setOriginalTarget(AnimatorSet anim) {
+ mOriginalTarget = anim;
+ }
+
+ public AnimatorSet getOriginalTarget() {
+ return mOriginalTarget;
+ }
+
+ public long getDuration() {
+ return mDuration;
+ }
+
+ public AnimatorPlaybackController cloneFor(AnimatorSet anim) {
+ AnimatorPlaybackController controller = AnimatorPlaybackController.wrap(anim, mDuration);
+ controller.setOriginalTarget(mOriginalTarget);
+ controller.setPlayFraction(mCurrentFraction);
+ return controller;
+ }
+
/**
* Starts playing the animation forward from current position.
*/
@@ -211,6 +232,6 @@
}
private static <T> List<T> nonNullList(ArrayList<T> list) {
- return list == null ? Collections.<T>emptyList() : list;
+ return list == null ? Collections.emptyList() : list;
}
}
diff --git a/src/com/android/launcher3/touch/SwipeDetector.java b/src/com/android/launcher3/touch/SwipeDetector.java
index 351f88d..ff5f64c 100644
--- a/src/com/android/launcher3/touch/SwipeDetector.java
+++ b/src/com/android/launcher3/touch/SwipeDetector.java
@@ -336,7 +336,7 @@
/**
* Returns the linear interpolation between two values
*/
- private static float interpolate(float from, float to, float alpha) {
+ public static float interpolate(float from, float to, float alpha) {
return (1.0f - alpha) * from + alpha * to;
}