| /* |
| * Copyright (C) 2016 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.incallui.answer.impl.affordance; |
| |
| import android.animation.Animator; |
| import android.animation.AnimatorListenerAdapter; |
| import android.animation.ValueAnimator; |
| import android.content.Context; |
| import android.support.annotation.Nullable; |
| import android.view.MotionEvent; |
| import android.view.VelocityTracker; |
| import android.view.View; |
| import android.view.ViewConfiguration; |
| import com.android.incallui.answer.impl.utils.FlingAnimationUtils; |
| import com.android.incallui.answer.impl.utils.Interpolators; |
| |
| /** A touch handler of the swipe buttons */ |
| public class SwipeButtonHelper { |
| |
| public static final float SWIPE_RESTING_ALPHA_AMOUNT = 0.87f; |
| 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 context; |
| private final Callback callback; |
| |
| private FlingAnimationUtils flingAnimationUtils; |
| private VelocityTracker velocityTracker; |
| private boolean swipingInProgress; |
| private float initialTouchX; |
| private float initialTouchY; |
| private float translation; |
| private float translationOnDown; |
| private int touchSlop; |
| private int minTranslationAmount; |
| private int minFlingVelocity; |
| private int hintGrowAmount; |
| @Nullable private SwipeButtonView leftIcon; |
| @Nullable private SwipeButtonView rightIcon; |
| private Animator swipeAnimator; |
| private int minBackgroundRadius; |
| private boolean motionCancelled; |
| private int touchTargetSize; |
| private View targetedView; |
| private boolean touchSlopExeeded; |
| private AnimatorListenerAdapter flingEndListener = |
| new AnimatorListenerAdapter() { |
| @Override |
| public void onAnimationEnd(Animator animation) { |
| swipeAnimator = null; |
| swipingInProgress = false; |
| targetedView = null; |
| } |
| }; |
| private Runnable animationEndRunnable = |
| new Runnable() { |
| @Override |
| public void run() { |
| callback.onAnimationToSideEnded(); |
| } |
| }; |
| |
| public SwipeButtonHelper(Callback callback, Context context) { |
| this.context = context; |
| this.callback = callback; |
| init(); |
| } |
| |
| public void init() { |
| initIcons(); |
| updateIcon( |
| leftIcon, |
| 0.0f, |
| leftIcon != null ? leftIcon.getRestingAlpha() : 0, |
| false, |
| false, |
| true, |
| false); |
| updateIcon( |
| rightIcon, |
| 0.0f, |
| rightIcon != null ? rightIcon.getRestingAlpha() : 0, |
| false, |
| false, |
| true, |
| false); |
| initDimens(); |
| } |
| |
| private void initDimens() { |
| final ViewConfiguration configuration = ViewConfiguration.get(context); |
| touchSlop = configuration.getScaledPagingTouchSlop(); |
| minFlingVelocity = configuration.getScaledMinimumFlingVelocity(); |
| minTranslationAmount = |
| context.getResources().getDimensionPixelSize(R.dimen.answer_min_swipe_amount); |
| minBackgroundRadius = |
| context |
| .getResources() |
| .getDimensionPixelSize(R.dimen.answer_affordance_min_background_radius); |
| touchTargetSize = |
| context.getResources().getDimensionPixelSize(R.dimen.answer_affordance_touch_target_size); |
| hintGrowAmount = |
| context.getResources().getDimensionPixelSize(R.dimen.hint_grow_amount_sideways); |
| flingAnimationUtils = new FlingAnimationUtils(context, 0.4f); |
| } |
| |
| private void initIcons() { |
| leftIcon = callback.getLeftIcon(); |
| rightIcon = callback.getRightIcon(); |
| updatePreviews(); |
| } |
| |
| public void updatePreviews() { |
| if (leftIcon != null) { |
| leftIcon.setPreviewView(callback.getLeftPreview()); |
| } |
| if (rightIcon != null) { |
| rightIcon.setPreviewView(callback.getRightPreview()); |
| } |
| } |
| |
| public boolean onTouchEvent(MotionEvent event) { |
| int action = event.getActionMasked(); |
| if (motionCancelled && 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 || (targetedView != null && targetedView != targetView)) { |
| motionCancelled = true; |
| return false; |
| } |
| if (targetedView != null) { |
| cancelAnimation(); |
| } else { |
| touchSlopExeeded = false; |
| } |
| startSwiping(targetView); |
| initialTouchX = x; |
| initialTouchY = y; |
| translationOnDown = translation; |
| initVelocityTracker(); |
| trackMovement(event); |
| motionCancelled = false; |
| break; |
| case MotionEvent.ACTION_POINTER_DOWN: |
| motionCancelled = true; |
| endMotion(true /* forceSnapBack */, x, y); |
| break; |
| case MotionEvent.ACTION_MOVE: |
| trackMovement(event); |
| float xDist = x - initialTouchX; |
| float yDist = y - initialTouchY; |
| float distance = (float) Math.hypot(xDist, yDist); |
| if (!touchSlopExeeded && distance > touchSlop) { |
| touchSlopExeeded = true; |
| } |
| if (swipingInProgress) { |
| if (targetedView == rightIcon) { |
| distance = translationOnDown - distance; |
| distance = Math.min(0, distance); |
| } else { |
| distance = translationOnDown + distance; |
| distance = Math.max(0, distance); |
| } |
| setTranslation(distance, false /* isReset */, false /* animateReset */); |
| } |
| break; |
| |
| case MotionEvent.ACTION_UP: |
| isUp = true; |
| //fallthrough_intended |
| case MotionEvent.ACTION_CANCEL: |
| boolean hintOnTheRight = targetedView == rightIcon; |
| trackMovement(event); |
| endMotion(!isUp, x, y); |
| if (!touchSlopExeeded && isUp) { |
| callback.onIconClicked(hintOnTheRight); |
| } |
| break; |
| } |
| return true; |
| } |
| |
| private void startSwiping(View targetView) { |
| callback.onSwipingStarted(targetView == rightIcon); |
| swipingInProgress = true; |
| targetedView = targetView; |
| } |
| |
| private View getIconAtPosition(float x, float y) { |
| if (leftSwipePossible() && isOnIcon(leftIcon, x, y)) { |
| return leftIcon; |
| } |
| if (rightSwipePossible() && isOnIcon(rightIcon, x, y)) { |
| return rightIcon; |
| } |
| return null; |
| } |
| |
| public boolean isOnAffordanceIcon(float x, float y) { |
| return isOnIcon(leftIcon, x, y) || isOnIcon(rightIcon, 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 <= touchTargetSize / 2; |
| } |
| |
| private void endMotion(boolean forceSnapBack, float lastX, float lastY) { |
| if (swipingInProgress) { |
| flingWithCurrentVelocity(forceSnapBack, lastX, lastY); |
| } else { |
| targetedView = null; |
| } |
| if (velocityTracker != null) { |
| velocityTracker.recycle(); |
| velocityTracker = null; |
| } |
| } |
| |
| private boolean rightSwipePossible() { |
| return rightIcon != null && rightIcon.getVisibility() == View.VISIBLE; |
| } |
| |
| private boolean leftSwipePossible() { |
| return leftIcon != null && leftIcon.getVisibility() == View.VISIBLE; |
| } |
| |
| public void startHintAnimation(boolean right, @Nullable Runnable onFinishedListener) { |
| cancelAnimation(); |
| startHintAnimationPhase1(right, onFinishedListener); |
| } |
| |
| private void startHintAnimationPhase1( |
| final boolean right, @Nullable final Runnable onFinishedListener) { |
| final SwipeButtonView targetView = right ? rightIcon : leftIcon; |
| ValueAnimator animator = getAnimatorToRadius(right, hintGrowAmount); |
| if (animator == null) { |
| if (onFinishedListener != null) { |
| onFinishedListener.run(); |
| } |
| return; |
| } |
| animator.addListener( |
| new AnimatorListenerAdapter() { |
| private boolean mCancelled; |
| |
| @Override |
| public void onAnimationCancel(Animator animation) { |
| mCancelled = true; |
| } |
| |
| @Override |
| public void onAnimationEnd(Animator animation) { |
| if (mCancelled) { |
| swipeAnimator = null; |
| targetedView = null; |
| if (onFinishedListener != null) { |
| onFinishedListener.run(); |
| } |
| } else { |
| startUnlockHintAnimationPhase2(right, onFinishedListener); |
| } |
| } |
| }); |
| animator.setInterpolator(Interpolators.LINEAR_OUT_SLOW_IN); |
| animator.setDuration(HINT_PHASE1_DURATION); |
| animator.start(); |
| swipeAnimator = animator; |
| targetedView = targetView; |
| } |
| |
| /** Phase 2: Move back. */ |
| private void startUnlockHintAnimationPhase2( |
| boolean right, @Nullable final Runnable onFinishedListener) { |
| ValueAnimator animator = getAnimatorToRadius(right, 0); |
| if (animator == null) { |
| if (onFinishedListener != null) { |
| onFinishedListener.run(); |
| } |
| return; |
| } |
| animator.addListener( |
| new AnimatorListenerAdapter() { |
| @Override |
| public void onAnimationEnd(Animator animation) { |
| swipeAnimator = null; |
| targetedView = null; |
| if (onFinishedListener != null) { |
| onFinishedListener.run(); |
| } |
| } |
| }); |
| animator.setInterpolator(Interpolators.FAST_OUT_LINEAR_IN); |
| animator.setDuration(HINT_PHASE2_DURATION); |
| animator.setStartDelay(HINT_CIRCLE_OPEN_DURATION); |
| animator.start(); |
| swipeAnimator = animator; |
| } |
| |
| private ValueAnimator getAnimatorToRadius(final boolean right, int radius) { |
| final SwipeButtonView targetView = right ? rightIcon : leftIcon; |
| if (targetView == null) { |
| return null; |
| } |
| 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); |
| SwipeButtonHelper.this.translation = right ? -translation : translation; |
| updateIconsFromTranslation(targetView); |
| } |
| }); |
| return animator; |
| } |
| |
| private void cancelAnimation() { |
| if (swipeAnimator != null) { |
| swipeAnimator.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 = isBelowFalsingThreshold(); |
| |
| // or if the velocity is in the opposite direction. |
| boolean velIsInWrongDirection = vel * translation < 0; |
| snapBack |= Math.abs(vel) > minFlingVelocity && velIsInWrongDirection; |
| vel = snapBack ^ velIsInWrongDirection ? 0 : vel; |
| fling(vel, snapBack || forceSnapBack, translation < 0); |
| } |
| |
| private boolean isBelowFalsingThreshold() { |
| return Math.abs(translation) < Math.abs(translationOnDown) + getMinTranslationAmount(); |
| } |
| |
| private int getMinTranslationAmount() { |
| float factor = callback.getAffordanceFalsingFactor(); |
| return (int) (minTranslationAmount * factor); |
| } |
| |
| private void fling(float vel, final boolean snapBack, boolean right) { |
| float target = |
| right ? -callback.getMaxTranslationDistance() : callback.getMaxTranslationDistance(); |
| target = snapBack ? 0 : target; |
| |
| ValueAnimator animator = ValueAnimator.ofFloat(translation, target); |
| flingAnimationUtils.apply(animator, translation, target, vel); |
| animator.addUpdateListener( |
| new ValueAnimator.AnimatorUpdateListener() { |
| @Override |
| public void onAnimationUpdate(ValueAnimator animation) { |
| translation = (float) animation.getAnimatedValue(); |
| } |
| }); |
| animator.addListener(flingEndListener); |
| if (!snapBack) { |
| startFinishingCircleAnimation(vel * 0.375f, animationEndRunnable, right); |
| callback.onAnimationToSideStarted(right, translation, vel); |
| } else { |
| reset(true); |
| } |
| animator.start(); |
| swipeAnimator = animator; |
| if (snapBack) { |
| callback.onSwipingAborted(); |
| } |
| } |
| |
| private void startFinishingCircleAnimation( |
| float velocity, Runnable mAnimationEndRunnable, boolean right) { |
| SwipeButtonView targetView = right ? rightIcon : leftIcon; |
| if (targetView != null) { |
| targetView.finishAnimation(velocity, mAnimationEndRunnable); |
| } |
| } |
| |
| 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 != this.translation || isReset) { |
| SwipeButtonView targetView = translation > 0 ? leftIcon : rightIcon; |
| SwipeButtonView otherView = translation > 0 ? rightIcon : leftIcon; |
| 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 (targetView != null) { |
| if (!isReset) { |
| updateIcon( |
| targetView, |
| radius, |
| alpha + fadeOutAlpha * targetView.getRestingAlpha(), |
| false, |
| false, |
| false, |
| false); |
| } else { |
| updateIcon( |
| targetView, |
| 0.0f, |
| fadeOutAlpha * targetView.getRestingAlpha(), |
| animateIcons, |
| slowAnimation, |
| false, |
| forceNoCircleAnimation); |
| } |
| } |
| if (otherView != null) { |
| updateIcon( |
| otherView, |
| 0.0f, |
| fadeOutAlpha * otherView.getRestingAlpha(), |
| animateIcons, |
| slowAnimation, |
| false, |
| forceNoCircleAnimation); |
| } |
| |
| this.translation = translation; |
| } |
| } |
| |
| private void updateIconsFromTranslation(SwipeButtonView targetView) { |
| float absTranslation = Math.abs(translation); |
| 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 |
| SwipeButtonView otherView = targetView == rightIcon ? leftIcon : rightIcon; |
| updateIconAlpha(targetView, alpha + fadeOutAlpha * targetView.getRestingAlpha(), false); |
| if (otherView != null) { |
| updateIconAlpha(otherView, fadeOutAlpha * otherView.getRestingAlpha(), false); |
| } |
| } |
| |
| private float getTranslationFromRadius(float circleSize) { |
| float translation = (circleSize - minBackgroundRadius) / BACKGROUND_RADIUS_SCALE_FACTOR; |
| return translation > 0.0f ? translation + touchSlop : 0.0f; |
| } |
| |
| private float getRadiusFromTranslation(float translation) { |
| if (translation <= touchSlop) { |
| return 0.0f; |
| } |
| return (translation - touchSlop) * BACKGROUND_RADIUS_SCALE_FACTOR + minBackgroundRadius; |
| } |
| |
| public void animateHideLeftRightIcon() { |
| cancelAnimation(); |
| updateIcon(rightIcon, 0f, 0f, true, false, false, false); |
| updateIcon(leftIcon, 0f, 0f, true, false, false, false); |
| } |
| |
| private void updateIcon( |
| @Nullable SwipeButtonView view, |
| float circleRadius, |
| float alpha, |
| boolean animate, |
| boolean slowRadiusAnimation, |
| boolean force, |
| boolean forceNoCircleAnimation) { |
| if (view == null) { |
| return; |
| } |
| if (view.getVisibility() != View.VISIBLE && !force) { |
| return; |
| } |
| if (forceNoCircleAnimation) { |
| view.setCircleRadiusWithoutAnimation(circleRadius); |
| } else { |
| view.setCircleRadius(circleRadius, slowRadiusAnimation); |
| } |
| updateIconAlpha(view, alpha, animate); |
| } |
| |
| private void updateIconAlpha(SwipeButtonView 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, SwipeButtonView icon) { |
| float scale = alpha / icon.getRestingAlpha() * 0.2f + SwipeButtonView.MIN_ICON_SCALE_AMOUNT; |
| return Math.min(scale, SwipeButtonView.MAX_ICON_SCALE_AMOUNT); |
| } |
| |
| private void trackMovement(MotionEvent event) { |
| if (velocityTracker != null) { |
| velocityTracker.addMovement(event); |
| } |
| } |
| |
| private void initVelocityTracker() { |
| if (velocityTracker != null) { |
| velocityTracker.recycle(); |
| } |
| velocityTracker = VelocityTracker.obtain(); |
| } |
| |
| private float getCurrentVelocity(float lastX, float lastY) { |
| if (velocityTracker == null) { |
| return 0; |
| } |
| velocityTracker.computeCurrentVelocity(1000); |
| float aX = velocityTracker.getXVelocity(); |
| float aY = velocityTracker.getYVelocity(); |
| float bX = lastX - initialTouchX; |
| float bY = lastY - initialTouchY; |
| 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 (targetedView == rightIcon) { |
| projectedVelocity = -projectedVelocity; |
| } |
| return projectedVelocity; |
| } |
| |
| public void onConfigurationChanged() { |
| initDimens(); |
| initIcons(); |
| } |
| |
| public void onRtlPropertiesChanged() { |
| initIcons(); |
| } |
| |
| public void reset(boolean animate) { |
| cancelAnimation(); |
| setTranslation(0.0f, true, animate); |
| motionCancelled = true; |
| if (swipingInProgress) { |
| callback.onSwipingAborted(); |
| swipingInProgress = false; |
| } |
| } |
| |
| public boolean isSwipingInProgress() { |
| return swipingInProgress; |
| } |
| |
| public void launchAffordance(boolean animate, boolean left) { |
| SwipeButtonView targetView = left ? leftIcon : rightIcon; |
| if (swipingInProgress || targetView == null) { |
| // We don't want to mess with the state if the user is actually swiping already. |
| return; |
| } |
| SwipeButtonView otherView = left ? rightIcon : leftIcon; |
| startSwiping(targetView); |
| if (animate) { |
| fling(0, false, !left); |
| updateIcon(otherView, 0.0f, 0, true, false, true, false); |
| } else { |
| callback.onAnimationToSideStarted(!left, translation, 0); |
| translation = |
| left ? callback.getMaxTranslationDistance() : callback.getMaxTranslationDistance(); |
| updateIcon(otherView, 0.0f, 0.0f, false, false, true, false); |
| targetView.instantFinishAnimation(); |
| flingEndListener.onAnimationEnd(null); |
| animationEndRunnable.run(); |
| } |
| } |
| |
| /** Callback interface for various actions */ |
| 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); |
| |
| @Nullable |
| SwipeButtonView getLeftIcon(); |
| |
| @Nullable |
| SwipeButtonView getRightIcon(); |
| |
| @Nullable |
| View getLeftPreview(); |
| |
| @Nullable |
| View getRightPreview(); |
| |
| /** @return The factor the minimum swipe amount should be multiplied with. */ |
| float getAffordanceFalsingFactor(); |
| } |
| } |