| /* |
| * Copyright (C) 2014 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.systemui.statusbar.phone; |
| |
| import android.animation.Animator; |
| import android.animation.AnimatorListenerAdapter; |
| import android.animation.ValueAnimator; |
| import android.content.Context; |
| import android.view.MotionEvent; |
| import android.view.VelocityTracker; |
| import android.view.View; |
| import android.view.ViewConfiguration; |
| |
| import com.android.systemui.Interpolators; |
| import com.android.systemui.R; |
| import com.android.systemui.classifier.Classifier; |
| import com.android.systemui.plugins.FalsingManager; |
| import com.android.systemui.statusbar.FlingAnimationUtils; |
| import com.android.systemui.statusbar.KeyguardAffordanceView; |
| |
| /** |
| * A touch handler of the keyguard which is responsible for launching phone and camera affordances. |
| */ |
| public class KeyguardAffordanceHelper { |
| |
| public static final long HINT_PHASE1_DURATION = 200; |
| private static final long HINT_PHASE2_DURATION = 350; |
| private static final float BACKGROUND_RADIUS_SCALE_FACTOR = 0.25f; |
| private static final int HINT_CIRCLE_OPEN_DURATION = 500; |
| |
| private final Context mContext; |
| private final Callback mCallback; |
| |
| private FlingAnimationUtils mFlingAnimationUtils; |
| private VelocityTracker mVelocityTracker; |
| private boolean mSwipingInProgress; |
| private float mInitialTouchX; |
| private float mInitialTouchY; |
| private float mTranslation; |
| private float mTranslationOnDown; |
| private int mTouchSlop; |
| private int mMinTranslationAmount; |
| private int mMinFlingVelocity; |
| private int mHintGrowAmount; |
| private KeyguardAffordanceView mLeftIcon; |
| private KeyguardAffordanceView mRightIcon; |
| private Animator mSwipeAnimator; |
| private final FalsingManager mFalsingManager; |
| private int mMinBackgroundRadius; |
| private boolean mMotionCancelled; |
| private int mTouchTargetSize; |
| private View mTargetedView; |
| private boolean mTouchSlopExeeded; |
| private AnimatorListenerAdapter mFlingEndListener = new AnimatorListenerAdapter() { |
| @Override |
| public void onAnimationEnd(Animator animation) { |
| mSwipeAnimator = null; |
| mSwipingInProgress = false; |
| mTargetedView = null; |
| } |
| }; |
| private Runnable mAnimationEndRunnable = new Runnable() { |
| @Override |
| public void run() { |
| mCallback.onAnimationToSideEnded(); |
| } |
| }; |
| |
| KeyguardAffordanceHelper(Callback callback, Context context, FalsingManager falsingManager) { |
| mContext = context; |
| mCallback = callback; |
| initIcons(); |
| updateIcon(mLeftIcon, 0.0f, mLeftIcon.getRestingAlpha(), false, false, true, false); |
| updateIcon(mRightIcon, 0.0f, mRightIcon.getRestingAlpha(), false, false, true, false); |
| mFalsingManager = falsingManager; |
| initDimens(); |
| } |
| |
| private void initDimens() { |
| final ViewConfiguration configuration = ViewConfiguration.get(mContext); |
| mTouchSlop = configuration.getScaledPagingTouchSlop(); |
| mMinFlingVelocity = configuration.getScaledMinimumFlingVelocity(); |
| mMinTranslationAmount = mContext.getResources().getDimensionPixelSize( |
| R.dimen.keyguard_min_swipe_amount); |
| mMinBackgroundRadius = mContext.getResources().getDimensionPixelSize( |
| R.dimen.keyguard_affordance_min_background_radius); |
| mTouchTargetSize = mContext.getResources().getDimensionPixelSize( |
| R.dimen.keyguard_affordance_touch_target_size); |
| mHintGrowAmount = |
| mContext.getResources().getDimensionPixelSize(R.dimen.hint_grow_amount_sideways); |
| mFlingAnimationUtils = new FlingAnimationUtils(mContext.getResources().getDisplayMetrics(), |
| 0.4f); |
| } |
| |
| private void initIcons() { |
| mLeftIcon = mCallback.getLeftIcon(); |
| mRightIcon = mCallback.getRightIcon(); |
| updatePreviews(); |
| } |
| |
| public void updatePreviews() { |
| mLeftIcon.setPreviewView(mCallback.getLeftPreview()); |
| mRightIcon.setPreviewView(mCallback.getRightPreview()); |
| } |
| |
| public boolean onTouchEvent(MotionEvent event) { |
| int action = event.getActionMasked(); |
| if (mMotionCancelled && action != MotionEvent.ACTION_DOWN) { |
| return false; |
| } |
| final float y = event.getY(); |
| final float x = event.getX(); |
| |
| boolean isUp = false; |
| switch (action) { |
| case MotionEvent.ACTION_DOWN: |
| View targetView = getIconAtPosition(x, y); |
| if (targetView == null || (mTargetedView != null && mTargetedView != targetView)) { |
| mMotionCancelled = true; |
| return false; |
| } |
| if (mTargetedView != null) { |
| cancelAnimation(); |
| } else { |
| mTouchSlopExeeded = false; |
| } |
| startSwiping(targetView); |
| mInitialTouchX = x; |
| mInitialTouchY = y; |
| mTranslationOnDown = mTranslation; |
| initVelocityTracker(); |
| trackMovement(event); |
| mMotionCancelled = false; |
| break; |
| case MotionEvent.ACTION_POINTER_DOWN: |
| mMotionCancelled = true; |
| endMotion(true /* forceSnapBack */, x, y); |
| break; |
| case MotionEvent.ACTION_MOVE: |
| trackMovement(event); |
| float xDist = x - mInitialTouchX; |
| float yDist = y - mInitialTouchY; |
| float distance = (float) Math.hypot(xDist, yDist); |
| if (!mTouchSlopExeeded && distance > mTouchSlop) { |
| mTouchSlopExeeded = true; |
| } |
| if (mSwipingInProgress) { |
| if (mTargetedView == mRightIcon) { |
| distance = mTranslationOnDown - distance; |
| distance = Math.min(0, distance); |
| } else { |
| distance = mTranslationOnDown + distance; |
| distance = Math.max(0, distance); |
| } |
| setTranslation(distance, false /* isReset */, false /* animateReset */); |
| } |
| break; |
| |
| case MotionEvent.ACTION_UP: |
| isUp = true; |
| case MotionEvent.ACTION_CANCEL: |
| boolean hintOnTheRight = mTargetedView == mRightIcon; |
| trackMovement(event); |
| endMotion(!isUp, x, y); |
| if (!mTouchSlopExeeded && isUp) { |
| mCallback.onIconClicked(hintOnTheRight); |
| } |
| break; |
| } |
| return true; |
| } |
| |
| private void startSwiping(View targetView) { |
| mCallback.onSwipingStarted(targetView == mRightIcon); |
| mSwipingInProgress = true; |
| mTargetedView = targetView; |
| } |
| |
| private View getIconAtPosition(float x, float y) { |
| if (leftSwipePossible() && isOnIcon(mLeftIcon, x, y)) { |
| return mLeftIcon; |
| } |
| if (rightSwipePossible() && isOnIcon(mRightIcon, x, y)) { |
| return mRightIcon; |
| } |
| return null; |
| } |
| |
| public boolean isOnAffordanceIcon(float x, float y) { |
| return isOnIcon(mLeftIcon, x, y) || isOnIcon(mRightIcon, x, y); |
| } |
| |
| private boolean isOnIcon(View icon, float x, float y) { |
| float iconX = icon.getX() + icon.getWidth() / 2.0f; |
| float iconY = icon.getY() + icon.getHeight() / 2.0f; |
| double distance = Math.hypot(x - iconX, y - iconY); |
| return distance <= mTouchTargetSize / 2; |
| } |
| |
| private void endMotion(boolean forceSnapBack, float lastX, float lastY) { |
| if (mSwipingInProgress) { |
| flingWithCurrentVelocity(forceSnapBack, lastX, lastY); |
| } else { |
| mTargetedView = null; |
| } |
| if (mVelocityTracker != null) { |
| mVelocityTracker.recycle(); |
| mVelocityTracker = null; |
| } |
| } |
| |
| private boolean rightSwipePossible() { |
| return mRightIcon.getVisibility() == View.VISIBLE; |
| } |
| |
| private boolean leftSwipePossible() { |
| return mLeftIcon.getVisibility() == View.VISIBLE; |
| } |
| |
| public boolean onInterceptTouchEvent(MotionEvent ev) { |
| return false; |
| } |
| |
| public void startHintAnimation(boolean right, |
| Runnable onFinishedListener) { |
| cancelAnimation(); |
| startHintAnimationPhase1(right, onFinishedListener); |
| } |
| |
| private void startHintAnimationPhase1(final boolean right, final Runnable onFinishedListener) { |
| final KeyguardAffordanceView targetView = right ? mRightIcon : mLeftIcon; |
| ValueAnimator animator = getAnimatorToRadius(right, mHintGrowAmount); |
| animator.addListener(new AnimatorListenerAdapter() { |
| private boolean mCancelled; |
| |
| @Override |
| public void onAnimationCancel(Animator animation) { |
| mCancelled = true; |
| } |
| |
| @Override |
| public void onAnimationEnd(Animator animation) { |
| if (mCancelled) { |
| mSwipeAnimator = null; |
| mTargetedView = null; |
| onFinishedListener.run(); |
| } else { |
| startUnlockHintAnimationPhase2(right, onFinishedListener); |
| } |
| } |
| }); |
| animator.setInterpolator(Interpolators.LINEAR_OUT_SLOW_IN); |
| animator.setDuration(HINT_PHASE1_DURATION); |
| animator.start(); |
| mSwipeAnimator = animator; |
| mTargetedView = targetView; |
| } |
| |
| /** |
| * Phase 2: Move back. |
| */ |
| private void startUnlockHintAnimationPhase2(boolean right, final Runnable onFinishedListener) { |
| ValueAnimator animator = getAnimatorToRadius(right, 0); |
| animator.addListener(new AnimatorListenerAdapter() { |
| @Override |
| public void onAnimationEnd(Animator animation) { |
| mSwipeAnimator = null; |
| mTargetedView = null; |
| onFinishedListener.run(); |
| } |
| }); |
| animator.setInterpolator(Interpolators.FAST_OUT_LINEAR_IN); |
| animator.setDuration(HINT_PHASE2_DURATION); |
| animator.setStartDelay(HINT_CIRCLE_OPEN_DURATION); |
| animator.start(); |
| mSwipeAnimator = animator; |
| } |
| |
| private ValueAnimator getAnimatorToRadius(final boolean right, int radius) { |
| final KeyguardAffordanceView targetView = right ? mRightIcon : mLeftIcon; |
| ValueAnimator animator = ValueAnimator.ofFloat(targetView.getCircleRadius(), radius); |
| animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { |
| @Override |
| public void onAnimationUpdate(ValueAnimator animation) { |
| float newRadius = (float) animation.getAnimatedValue(); |
| targetView.setCircleRadiusWithoutAnimation(newRadius); |
| float translation = getTranslationFromRadius(newRadius); |
| mTranslation = right ? -translation : translation; |
| updateIconsFromTranslation(targetView); |
| } |
| }); |
| return animator; |
| } |
| |
| private void cancelAnimation() { |
| if (mSwipeAnimator != null) { |
| mSwipeAnimator.cancel(); |
| } |
| } |
| |
| private void flingWithCurrentVelocity(boolean forceSnapBack, float lastX, float lastY) { |
| float vel = getCurrentVelocity(lastX, lastY); |
| |
| // We snap back if the current translation is not far enough |
| boolean snapBack = false; |
| if (mCallback.needsAntiFalsing()) { |
| snapBack = snapBack || mFalsingManager.isFalseTouch( |
| mTargetedView == mRightIcon |
| ? Classifier.RIGHT_AFFORDANCE : Classifier.LEFT_AFFORDANCE); |
| } |
| snapBack = snapBack || isBelowFalsingThreshold(); |
| |
| // or if the velocity is in the opposite direction. |
| boolean velIsInWrongDirection = vel * mTranslation < 0; |
| snapBack |= Math.abs(vel) > mMinFlingVelocity && velIsInWrongDirection; |
| vel = snapBack ^ velIsInWrongDirection ? 0 : vel; |
| fling(vel, snapBack || forceSnapBack, mTranslation < 0); |
| } |
| |
| private boolean isBelowFalsingThreshold() { |
| return Math.abs(mTranslation) < Math.abs(mTranslationOnDown) + getMinTranslationAmount(); |
| } |
| |
| private int getMinTranslationAmount() { |
| float factor = mCallback.getAffordanceFalsingFactor(); |
| return (int) (mMinTranslationAmount * factor); |
| } |
| |
| private void fling(float vel, final boolean snapBack, boolean right) { |
| float target = right ? -mCallback.getMaxTranslationDistance() |
| : mCallback.getMaxTranslationDistance(); |
| target = snapBack ? 0 : target; |
| |
| ValueAnimator animator = ValueAnimator.ofFloat(mTranslation, target); |
| mFlingAnimationUtils.apply(animator, mTranslation, target, vel); |
| animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { |
| @Override |
| public void onAnimationUpdate(ValueAnimator animation) { |
| mTranslation = (float) animation.getAnimatedValue(); |
| } |
| }); |
| animator.addListener(mFlingEndListener); |
| if (!snapBack) { |
| startFinishingCircleAnimation(vel * 0.375f, mAnimationEndRunnable, right); |
| mCallback.onAnimationToSideStarted(right, mTranslation, vel); |
| } else { |
| reset(true); |
| } |
| animator.start(); |
| mSwipeAnimator = animator; |
| if (snapBack) { |
| mCallback.onSwipingAborted(); |
| } |
| } |
| |
| private void startFinishingCircleAnimation(float velocity, Runnable animationEndRunnable, |
| boolean right) { |
| KeyguardAffordanceView targetView = right ? mRightIcon : mLeftIcon; |
| targetView.finishAnimation(velocity, animationEndRunnable); |
| } |
| |
| private void setTranslation(float translation, boolean isReset, boolean animateReset) { |
| translation = rightSwipePossible() ? translation : Math.max(0, translation); |
| translation = leftSwipePossible() ? translation : Math.min(0, translation); |
| float absTranslation = Math.abs(translation); |
| if (translation != mTranslation || isReset) { |
| KeyguardAffordanceView targetView = translation > 0 ? mLeftIcon : mRightIcon; |
| KeyguardAffordanceView otherView = translation > 0 ? mRightIcon : mLeftIcon; |
| float alpha = absTranslation / getMinTranslationAmount(); |
| |
| // We interpolate the alpha of the other icons to 0 |
| float fadeOutAlpha = 1.0f - alpha; |
| fadeOutAlpha = Math.max(fadeOutAlpha, 0.0f); |
| |
| boolean animateIcons = isReset && animateReset; |
| boolean forceNoCircleAnimation = isReset && !animateReset; |
| float radius = getRadiusFromTranslation(absTranslation); |
| boolean slowAnimation = isReset && isBelowFalsingThreshold(); |
| if (!isReset) { |
| updateIcon(targetView, radius, alpha + fadeOutAlpha * targetView.getRestingAlpha(), |
| false, false, false, false); |
| } else { |
| updateIcon(targetView, 0.0f, fadeOutAlpha * targetView.getRestingAlpha(), |
| animateIcons, slowAnimation, true /* isReset */, forceNoCircleAnimation); |
| } |
| updateIcon(otherView, 0.0f, fadeOutAlpha * otherView.getRestingAlpha(), |
| animateIcons, slowAnimation, isReset, forceNoCircleAnimation); |
| |
| mTranslation = translation; |
| } |
| } |
| |
| private void updateIconsFromTranslation(KeyguardAffordanceView targetView) { |
| float absTranslation = Math.abs(mTranslation); |
| float alpha = absTranslation / getMinTranslationAmount(); |
| |
| // We interpolate the alpha of the other icons to 0 |
| float fadeOutAlpha = 1.0f - alpha; |
| fadeOutAlpha = Math.max(0.0f, fadeOutAlpha); |
| |
| // We interpolate the alpha of the targetView to 1 |
| KeyguardAffordanceView otherView = targetView == mRightIcon ? mLeftIcon : mRightIcon; |
| updateIconAlpha(targetView, alpha + fadeOutAlpha * targetView.getRestingAlpha(), false); |
| updateIconAlpha(otherView, fadeOutAlpha * otherView.getRestingAlpha(), false); |
| } |
| |
| private float getTranslationFromRadius(float circleSize) { |
| float translation = (circleSize - mMinBackgroundRadius) |
| / BACKGROUND_RADIUS_SCALE_FACTOR; |
| return translation > 0.0f ? translation + mTouchSlop : 0.0f; |
| } |
| |
| private float getRadiusFromTranslation(float translation) { |
| if (translation <= mTouchSlop) { |
| return 0.0f; |
| } |
| return (translation - mTouchSlop) * BACKGROUND_RADIUS_SCALE_FACTOR + mMinBackgroundRadius; |
| } |
| |
| public void animateHideLeftRightIcon() { |
| cancelAnimation(); |
| updateIcon(mRightIcon, 0f, 0f, true, false, false, false); |
| updateIcon(mLeftIcon, 0f, 0f, true, false, false, false); |
| } |
| |
| private void updateIcon(KeyguardAffordanceView view, float circleRadius, float alpha, |
| boolean animate, boolean slowRadiusAnimation, boolean force, |
| boolean forceNoCircleAnimation) { |
| if (view.getVisibility() != View.VISIBLE && !force) { |
| return; |
| } |
| if (forceNoCircleAnimation) { |
| view.setCircleRadiusWithoutAnimation(circleRadius); |
| } else { |
| view.setCircleRadius(circleRadius, slowRadiusAnimation); |
| } |
| updateIconAlpha(view, alpha, animate); |
| } |
| |
| private void updateIconAlpha(KeyguardAffordanceView view, float alpha, boolean animate) { |
| float scale = getScale(alpha, view); |
| alpha = Math.min(1.0f, alpha); |
| view.setImageAlpha(alpha, animate); |
| view.setImageScale(scale, animate); |
| } |
| |
| private float getScale(float alpha, KeyguardAffordanceView icon) { |
| float scale = alpha / icon.getRestingAlpha() * 0.2f + |
| KeyguardAffordanceView.MIN_ICON_SCALE_AMOUNT; |
| return Math.min(scale, KeyguardAffordanceView.MAX_ICON_SCALE_AMOUNT); |
| } |
| |
| private void trackMovement(MotionEvent event) { |
| if (mVelocityTracker != null) { |
| mVelocityTracker.addMovement(event); |
| } |
| } |
| |
| private void initVelocityTracker() { |
| if (mVelocityTracker != null) { |
| mVelocityTracker.recycle(); |
| } |
| mVelocityTracker = VelocityTracker.obtain(); |
| } |
| |
| private float getCurrentVelocity(float lastX, float lastY) { |
| if (mVelocityTracker == null) { |
| return 0; |
| } |
| mVelocityTracker.computeCurrentVelocity(1000); |
| float aX = mVelocityTracker.getXVelocity(); |
| float aY = mVelocityTracker.getYVelocity(); |
| float bX = lastX - mInitialTouchX; |
| float bY = lastY - mInitialTouchY; |
| float bLen = (float) Math.hypot(bX, bY); |
| // Project the velocity onto the distance vector: a * b / |b| |
| float projectedVelocity = (aX * bX + aY * bY) / bLen; |
| if (mTargetedView == mRightIcon) { |
| projectedVelocity = -projectedVelocity; |
| } |
| return projectedVelocity; |
| } |
| |
| public void onConfigurationChanged() { |
| initDimens(); |
| initIcons(); |
| } |
| |
| public void onRtlPropertiesChanged() { |
| initIcons(); |
| } |
| |
| public void reset(boolean animate) { |
| cancelAnimation(); |
| setTranslation(0.0f, true /* isReset */, animate); |
| mMotionCancelled = true; |
| if (mSwipingInProgress) { |
| mCallback.onSwipingAborted(); |
| mSwipingInProgress = false; |
| } |
| } |
| |
| public boolean isSwipingInProgress() { |
| return mSwipingInProgress; |
| } |
| |
| public void launchAffordance(boolean animate, boolean left) { |
| if (mSwipingInProgress) { |
| // We don't want to mess with the state if the user is actually swiping already. |
| return; |
| } |
| KeyguardAffordanceView targetView = left ? mLeftIcon : mRightIcon; |
| KeyguardAffordanceView otherView = left ? mRightIcon : mLeftIcon; |
| startSwiping(targetView); |
| |
| // Do not animate the circle expanding if the affordance isn't visible, |
| // otherwise the circle will be meaningless. |
| if (targetView.getVisibility() != View.VISIBLE) { |
| animate = false; |
| } |
| |
| if (animate) { |
| fling(0, false, !left); |
| updateIcon(otherView, 0.0f, 0, true, false, true, false); |
| } else { |
| mCallback.onAnimationToSideStarted(!left, mTranslation, 0); |
| mTranslation = left ? mCallback.getMaxTranslationDistance() |
| : mCallback.getMaxTranslationDistance(); |
| updateIcon(otherView, 0.0f, 0.0f, false, false, true, false); |
| targetView.instantFinishAnimation(); |
| mFlingEndListener.onAnimationEnd(null); |
| mAnimationEndRunnable.run(); |
| } |
| } |
| |
| public interface Callback { |
| |
| /** |
| * Notifies the callback when an animation to a side page was started. |
| * |
| * @param rightPage Is the page animated to the right page? |
| */ |
| void onAnimationToSideStarted(boolean rightPage, float translation, float vel); |
| |
| /** |
| * Notifies the callback the animation to a side page has ended. |
| */ |
| void onAnimationToSideEnded(); |
| |
| float getMaxTranslationDistance(); |
| |
| void onSwipingStarted(boolean rightIcon); |
| |
| void onSwipingAborted(); |
| |
| void onIconClicked(boolean rightIcon); |
| |
| KeyguardAffordanceView getLeftIcon(); |
| |
| KeyguardAffordanceView getRightIcon(); |
| |
| View getLeftPreview(); |
| |
| View getRightPreview(); |
| |
| /** |
| * @return The factor the minimum swipe amount should be multiplied with. |
| */ |
| float getAffordanceFalsingFactor(); |
| |
| boolean needsAntiFalsing(); |
| } |
| } |