Home | History | Annotate | Download | only in affordance
      1 /*
      2  * Copyright (C) 2016 The Android Open Source Project
      3  *
      4  * Licensed under the Apache License, Version 2.0 (the "License");
      5  * you may not use this file except in compliance with the License.
      6  * You may obtain a copy of the License at
      7  *
      8  *      http://www.apache.org/licenses/LICENSE-2.0
      9  *
     10  * Unless required by applicable law or agreed to in writing, software
     11  * distributed under the License is distributed on an "AS IS" BASIS,
     12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     13  * See the License for the specific language governing permissions and
     14  * limitations under the License
     15  */
     16 
     17 package com.android.incallui.answer.impl.affordance;
     18 
     19 import android.animation.Animator;
     20 import android.animation.AnimatorListenerAdapter;
     21 import android.animation.ValueAnimator;
     22 import android.content.Context;
     23 import android.support.annotation.Nullable;
     24 import android.view.MotionEvent;
     25 import android.view.VelocityTracker;
     26 import android.view.View;
     27 import android.view.ViewConfiguration;
     28 import com.android.incallui.answer.impl.utils.FlingAnimationUtils;
     29 import com.android.incallui.answer.impl.utils.Interpolators;
     30 
     31 /** A touch handler of the swipe buttons */
     32 public class SwipeButtonHelper {
     33 
     34   public static final float SWIPE_RESTING_ALPHA_AMOUNT = 0.87f;
     35   public static final long HINT_PHASE1_DURATION = 200;
     36   private static final long HINT_PHASE2_DURATION = 350;
     37   private static final float BACKGROUND_RADIUS_SCALE_FACTOR = 0.25f;
     38   private static final int HINT_CIRCLE_OPEN_DURATION = 500;
     39 
     40   private final Context context;
     41   private final Callback callback;
     42 
     43   private FlingAnimationUtils flingAnimationUtils;
     44   private VelocityTracker velocityTracker;
     45   private boolean swipingInProgress;
     46   private float initialTouchX;
     47   private float initialTouchY;
     48   private float translation;
     49   private float translationOnDown;
     50   private int touchSlop;
     51   private int minTranslationAmount;
     52   private int minFlingVelocity;
     53   private int hintGrowAmount;
     54   @Nullable private SwipeButtonView leftIcon;
     55   @Nullable private SwipeButtonView rightIcon;
     56   private Animator swipeAnimator;
     57   private int minBackgroundRadius;
     58   private boolean motionCancelled;
     59   private int touchTargetSize;
     60   private View targetedView;
     61   private boolean touchSlopExeeded;
     62   private AnimatorListenerAdapter flingEndListener =
     63       new AnimatorListenerAdapter() {
     64         @Override
     65         public void onAnimationEnd(Animator animation) {
     66           swipeAnimator = null;
     67           swipingInProgress = false;
     68           targetedView = null;
     69         }
     70       };
     71 
     72   private class AnimationEndRunnable implements Runnable {
     73     private final boolean rightPage;
     74 
     75     public AnimationEndRunnable(boolean rightPage) {
     76       this.rightPage = rightPage;
     77     }
     78 
     79     @Override
     80     public void run() {
     81       callback.onAnimationToSideEnded(rightPage);
     82     }
     83   };
     84 
     85   public SwipeButtonHelper(Callback callback, Context context) {
     86     this.context = context;
     87     this.callback = callback;
     88     init();
     89   }
     90 
     91   public void init() {
     92     initIcons();
     93     updateIcon(
     94         leftIcon,
     95         0.0f,
     96         leftIcon != null ? leftIcon.getRestingAlpha() : 0,
     97         false,
     98         false,
     99         true,
    100         false);
    101     updateIcon(
    102         rightIcon,
    103         0.0f,
    104         rightIcon != null ? rightIcon.getRestingAlpha() : 0,
    105         false,
    106         false,
    107         true,
    108         false);
    109     initDimens();
    110   }
    111 
    112   private void initDimens() {
    113     final ViewConfiguration configuration = ViewConfiguration.get(context);
    114     touchSlop = configuration.getScaledPagingTouchSlop();
    115     minFlingVelocity = configuration.getScaledMinimumFlingVelocity();
    116     minTranslationAmount =
    117         context.getResources().getDimensionPixelSize(R.dimen.answer_min_swipe_amount);
    118     minBackgroundRadius =
    119         context
    120             .getResources()
    121             .getDimensionPixelSize(R.dimen.answer_affordance_min_background_radius);
    122     touchTargetSize =
    123         context.getResources().getDimensionPixelSize(R.dimen.answer_affordance_touch_target_size);
    124     hintGrowAmount =
    125         context.getResources().getDimensionPixelSize(R.dimen.hint_grow_amount_sideways);
    126     flingAnimationUtils = new FlingAnimationUtils(context, 0.4f);
    127   }
    128 
    129   private void initIcons() {
    130     leftIcon = callback.getLeftIcon();
    131     rightIcon = callback.getRightIcon();
    132     updatePreviews();
    133   }
    134 
    135   public void updatePreviews() {
    136     if (leftIcon != null) {
    137       leftIcon.setPreviewView(callback.getLeftPreview());
    138     }
    139     if (rightIcon != null) {
    140       rightIcon.setPreviewView(callback.getRightPreview());
    141     }
    142   }
    143 
    144   public boolean onTouchEvent(MotionEvent event) {
    145     int action = event.getActionMasked();
    146     if (motionCancelled && action != MotionEvent.ACTION_DOWN) {
    147       return false;
    148     }
    149     final float y = event.getY();
    150     final float x = event.getX();
    151 
    152     boolean isUp = false;
    153     switch (action) {
    154       case MotionEvent.ACTION_DOWN:
    155         View targetView = getIconAtPosition(x, y);
    156         if (targetView == null || (targetedView != null && targetedView != targetView)) {
    157           motionCancelled = true;
    158           return false;
    159         }
    160         if (targetedView != null) {
    161           cancelAnimation();
    162         } else {
    163           touchSlopExeeded = false;
    164         }
    165         startSwiping(targetView);
    166         initialTouchX = x;
    167         initialTouchY = y;
    168         translationOnDown = translation;
    169         initVelocityTracker();
    170         trackMovement(event);
    171         motionCancelled = false;
    172         break;
    173       case MotionEvent.ACTION_POINTER_DOWN:
    174         motionCancelled = true;
    175         endMotion(true /* forceSnapBack */, x, y);
    176         break;
    177       case MotionEvent.ACTION_MOVE:
    178         trackMovement(event);
    179         float xDist = x - initialTouchX;
    180         float yDist = y - initialTouchY;
    181         float distance = (float) Math.hypot(xDist, yDist);
    182         if (!touchSlopExeeded && distance > touchSlop) {
    183           touchSlopExeeded = true;
    184         }
    185         if (swipingInProgress) {
    186           if (targetedView == rightIcon) {
    187             distance = translationOnDown - distance;
    188             distance = Math.min(0, distance);
    189           } else {
    190             distance = translationOnDown + distance;
    191             distance = Math.max(0, distance);
    192           }
    193           setTranslation(distance, false /* isReset */, false /* animateReset */);
    194         }
    195         break;
    196 
    197       case MotionEvent.ACTION_UP:
    198         isUp = true;
    199         // fall through
    200       case MotionEvent.ACTION_CANCEL:
    201         boolean hintOnTheRight = targetedView == rightIcon;
    202         trackMovement(event);
    203         endMotion(!isUp, x, y);
    204         if (!touchSlopExeeded && isUp) {
    205           callback.onIconClicked(hintOnTheRight);
    206         }
    207         break;
    208     }
    209     return true;
    210   }
    211 
    212   private void startSwiping(View targetView) {
    213     callback.onSwipingStarted(targetView == rightIcon);
    214     swipingInProgress = true;
    215     targetedView = targetView;
    216   }
    217 
    218   private View getIconAtPosition(float x, float y) {
    219     if (leftSwipePossible() && isOnIcon(leftIcon, x, y)) {
    220       return leftIcon;
    221     }
    222     if (rightSwipePossible() && isOnIcon(rightIcon, x, y)) {
    223       return rightIcon;
    224     }
    225     return null;
    226   }
    227 
    228   public boolean isOnAffordanceIcon(float x, float y) {
    229     return isOnIcon(leftIcon, x, y) || isOnIcon(rightIcon, x, y);
    230   }
    231 
    232   private boolean isOnIcon(View icon, float x, float y) {
    233     float iconX = icon.getX() + icon.getWidth() / 2.0f;
    234     float iconY = icon.getY() + icon.getHeight() / 2.0f;
    235     double distance = Math.hypot(x - iconX, y - iconY);
    236     return distance <= touchTargetSize / 2;
    237   }
    238 
    239   private void endMotion(boolean forceSnapBack, float lastX, float lastY) {
    240     if (swipingInProgress) {
    241       flingWithCurrentVelocity(forceSnapBack, lastX, lastY);
    242     } else {
    243       targetedView = null;
    244     }
    245     if (velocityTracker != null) {
    246       velocityTracker.recycle();
    247       velocityTracker = null;
    248     }
    249   }
    250 
    251   private boolean rightSwipePossible() {
    252     return rightIcon != null && rightIcon.getVisibility() == View.VISIBLE;
    253   }
    254 
    255   private boolean leftSwipePossible() {
    256     return leftIcon != null && leftIcon.getVisibility() == View.VISIBLE;
    257   }
    258 
    259   public void startHintAnimation(boolean right, @Nullable Runnable onFinishedListener) {
    260     cancelAnimation();
    261     startHintAnimationPhase1(right, onFinishedListener);
    262   }
    263 
    264   private void startHintAnimationPhase1(
    265       final boolean right, @Nullable final Runnable onFinishedListener) {
    266     final SwipeButtonView targetView = right ? rightIcon : leftIcon;
    267     ValueAnimator animator = getAnimatorToRadius(right, hintGrowAmount);
    268     if (animator == null) {
    269       if (onFinishedListener != null) {
    270         onFinishedListener.run();
    271       }
    272       return;
    273     }
    274     animator.addListener(
    275         new AnimatorListenerAdapter() {
    276           private boolean cancelled;
    277 
    278           @Override
    279           public void onAnimationCancel(Animator animation) {
    280             cancelled = true;
    281           }
    282 
    283           @Override
    284           public void onAnimationEnd(Animator animation) {
    285             if (cancelled) {
    286               swipeAnimator = null;
    287               targetedView = null;
    288               if (onFinishedListener != null) {
    289                 onFinishedListener.run();
    290               }
    291             } else {
    292               startUnlockHintAnimationPhase2(right, onFinishedListener);
    293             }
    294           }
    295         });
    296     animator.setInterpolator(Interpolators.LINEAR_OUT_SLOW_IN);
    297     animator.setDuration(HINT_PHASE1_DURATION);
    298     animator.start();
    299     swipeAnimator = animator;
    300     targetedView = targetView;
    301   }
    302 
    303   /** Phase 2: Move back. */
    304   private void startUnlockHintAnimationPhase2(
    305       boolean right, @Nullable final Runnable onFinishedListener) {
    306     ValueAnimator animator = getAnimatorToRadius(right, 0);
    307     if (animator == null) {
    308       if (onFinishedListener != null) {
    309         onFinishedListener.run();
    310       }
    311       return;
    312     }
    313     animator.addListener(
    314         new AnimatorListenerAdapter() {
    315           @Override
    316           public void onAnimationEnd(Animator animation) {
    317             swipeAnimator = null;
    318             targetedView = null;
    319             if (onFinishedListener != null) {
    320               onFinishedListener.run();
    321             }
    322           }
    323         });
    324     animator.setInterpolator(Interpolators.FAST_OUT_LINEAR_IN);
    325     animator.setDuration(HINT_PHASE2_DURATION);
    326     animator.setStartDelay(HINT_CIRCLE_OPEN_DURATION);
    327     animator.start();
    328     swipeAnimator = animator;
    329   }
    330 
    331   private ValueAnimator getAnimatorToRadius(final boolean right, int radius) {
    332     final SwipeButtonView targetView = right ? rightIcon : leftIcon;
    333     if (targetView == null) {
    334       return null;
    335     }
    336     ValueAnimator animator = ValueAnimator.ofFloat(targetView.getCircleRadius(), radius);
    337     animator.addUpdateListener(
    338         new ValueAnimator.AnimatorUpdateListener() {
    339           @Override
    340           public void onAnimationUpdate(ValueAnimator animation) {
    341             float newRadius = (float) animation.getAnimatedValue();
    342             targetView.setCircleRadiusWithoutAnimation(newRadius);
    343             float translation = getTranslationFromRadius(newRadius);
    344             SwipeButtonHelper.this.translation = right ? -translation : translation;
    345             updateIconsFromTranslation(targetView);
    346           }
    347         });
    348     return animator;
    349   }
    350 
    351   private void cancelAnimation() {
    352     if (swipeAnimator != null) {
    353       swipeAnimator.cancel();
    354     }
    355   }
    356 
    357   private void flingWithCurrentVelocity(boolean forceSnapBack, float lastX, float lastY) {
    358     float vel = getCurrentVelocity(lastX, lastY);
    359 
    360     // We snap back if the current translation is not far enough
    361     boolean snapBack = isBelowFalsingThreshold();
    362 
    363     // or if the velocity is in the opposite direction.
    364     boolean velIsInWrongDirection = vel * translation < 0;
    365     snapBack |= Math.abs(vel) > minFlingVelocity && velIsInWrongDirection;
    366     vel = snapBack ^ velIsInWrongDirection ? 0 : vel;
    367     fling(vel, snapBack || forceSnapBack, translation < 0);
    368   }
    369 
    370   private boolean isBelowFalsingThreshold() {
    371     return Math.abs(translation) < Math.abs(translationOnDown) + getMinTranslationAmount();
    372   }
    373 
    374   private int getMinTranslationAmount() {
    375     float factor = callback.getAffordanceFalsingFactor();
    376     return (int) (minTranslationAmount * factor);
    377   }
    378 
    379   private void fling(float vel, final boolean snapBack, boolean right) {
    380     float target =
    381         right ? -callback.getMaxTranslationDistance() : callback.getMaxTranslationDistance();
    382     target = snapBack ? 0 : target;
    383 
    384     ValueAnimator animator = ValueAnimator.ofFloat(translation, target);
    385     flingAnimationUtils.apply(animator, translation, target, vel);
    386     animator.addUpdateListener(
    387         new ValueAnimator.AnimatorUpdateListener() {
    388           @Override
    389           public void onAnimationUpdate(ValueAnimator animation) {
    390             translation = (float) animation.getAnimatedValue();
    391           }
    392         });
    393     animator.addListener(flingEndListener);
    394     if (!snapBack) {
    395       startFinishingCircleAnimation(vel * 0.375f, new AnimationEndRunnable(right), right);
    396       callback.onAnimationToSideStarted(right, translation, vel);
    397     } else {
    398       reset(true);
    399     }
    400     animator.start();
    401     swipeAnimator = animator;
    402     if (snapBack) {
    403       callback.onSwipingAborted();
    404     }
    405   }
    406 
    407   private void startFinishingCircleAnimation(
    408       float velocity, Runnable mAnimationEndRunnable, boolean right) {
    409     SwipeButtonView targetView = right ? rightIcon : leftIcon;
    410     if (targetView != null) {
    411       targetView.finishAnimation(velocity, mAnimationEndRunnable);
    412     }
    413   }
    414 
    415   private void setTranslation(float translation, boolean isReset, boolean animateReset) {
    416     translation = rightSwipePossible() ? translation : Math.max(0, translation);
    417     translation = leftSwipePossible() ? translation : Math.min(0, translation);
    418     float absTranslation = Math.abs(translation);
    419     if (translation != this.translation || isReset) {
    420       SwipeButtonView targetView = translation > 0 ? leftIcon : rightIcon;
    421       SwipeButtonView otherView = translation > 0 ? rightIcon : leftIcon;
    422       float alpha = absTranslation / getMinTranslationAmount();
    423 
    424       // We interpolate the alpha of the other icons to 0
    425       float fadeOutAlpha = 1.0f - alpha;
    426       fadeOutAlpha = Math.max(fadeOutAlpha, 0.0f);
    427 
    428       boolean animateIcons = isReset && animateReset;
    429       boolean forceNoCircleAnimation = isReset && !animateReset;
    430       float radius = getRadiusFromTranslation(absTranslation);
    431       boolean slowAnimation = isReset && isBelowFalsingThreshold();
    432       if (targetView != null) {
    433         if (!isReset) {
    434           updateIcon(
    435               targetView,
    436               radius,
    437               alpha + fadeOutAlpha * targetView.getRestingAlpha(),
    438               false,
    439               false,
    440               false,
    441               false);
    442         } else {
    443           updateIcon(
    444               targetView,
    445               0.0f,
    446               fadeOutAlpha * targetView.getRestingAlpha(),
    447               animateIcons,
    448               slowAnimation,
    449               false,
    450               forceNoCircleAnimation);
    451         }
    452       }
    453       if (otherView != null) {
    454         updateIcon(
    455             otherView,
    456             0.0f,
    457             fadeOutAlpha * otherView.getRestingAlpha(),
    458             animateIcons,
    459             slowAnimation,
    460             false,
    461             forceNoCircleAnimation);
    462       }
    463 
    464       this.translation = translation;
    465     }
    466   }
    467 
    468   private void updateIconsFromTranslation(SwipeButtonView targetView) {
    469     float absTranslation = Math.abs(translation);
    470     float alpha = absTranslation / getMinTranslationAmount();
    471 
    472     // We interpolate the alpha of the other icons to 0
    473     float fadeOutAlpha = 1.0f - alpha;
    474     fadeOutAlpha = Math.max(0.0f, fadeOutAlpha);
    475 
    476     // We interpolate the alpha of the targetView to 1
    477     SwipeButtonView otherView = targetView == rightIcon ? leftIcon : rightIcon;
    478     updateIconAlpha(targetView, alpha + fadeOutAlpha * targetView.getRestingAlpha(), false);
    479     if (otherView != null) {
    480       updateIconAlpha(otherView, fadeOutAlpha * otherView.getRestingAlpha(), false);
    481     }
    482   }
    483 
    484   private float getTranslationFromRadius(float circleSize) {
    485     float translation = (circleSize - minBackgroundRadius) / BACKGROUND_RADIUS_SCALE_FACTOR;
    486     return translation > 0.0f ? translation + touchSlop : 0.0f;
    487   }
    488 
    489   private float getRadiusFromTranslation(float translation) {
    490     if (translation <= touchSlop) {
    491       return 0.0f;
    492     }
    493     return (translation - touchSlop) * BACKGROUND_RADIUS_SCALE_FACTOR + minBackgroundRadius;
    494   }
    495 
    496   public void animateHideLeftRightIcon() {
    497     cancelAnimation();
    498     updateIcon(rightIcon, 0f, 0f, true, false, false, false);
    499     updateIcon(leftIcon, 0f, 0f, true, false, false, false);
    500   }
    501 
    502   private void updateIcon(
    503       @Nullable SwipeButtonView view,
    504       float circleRadius,
    505       float alpha,
    506       boolean animate,
    507       boolean slowRadiusAnimation,
    508       boolean force,
    509       boolean forceNoCircleAnimation) {
    510     if (view == null) {
    511       return;
    512     }
    513     if (view.getVisibility() != View.VISIBLE && !force) {
    514       return;
    515     }
    516     if (forceNoCircleAnimation) {
    517       view.setCircleRadiusWithoutAnimation(circleRadius);
    518     } else {
    519       view.setCircleRadius(circleRadius, slowRadiusAnimation);
    520     }
    521     updateIconAlpha(view, alpha, animate);
    522   }
    523 
    524   private void updateIconAlpha(SwipeButtonView view, float alpha, boolean animate) {
    525     float scale = getScale(alpha, view);
    526     alpha = Math.min(1.0f, alpha);
    527     view.setImageAlpha(alpha, animate);
    528     view.setImageScale(scale, animate);
    529   }
    530 
    531   private float getScale(float alpha, SwipeButtonView icon) {
    532     float scale = alpha / icon.getRestingAlpha() * 0.2f + SwipeButtonView.MIN_ICON_SCALE_AMOUNT;
    533     return Math.min(scale, SwipeButtonView.MAX_ICON_SCALE_AMOUNT);
    534   }
    535 
    536   private void trackMovement(MotionEvent event) {
    537     if (velocityTracker != null) {
    538       velocityTracker.addMovement(event);
    539     }
    540   }
    541 
    542   private void initVelocityTracker() {
    543     if (velocityTracker != null) {
    544       velocityTracker.recycle();
    545     }
    546     velocityTracker = VelocityTracker.obtain();
    547   }
    548 
    549   private float getCurrentVelocity(float lastX, float lastY) {
    550     if (velocityTracker == null) {
    551       return 0;
    552     }
    553     velocityTracker.computeCurrentVelocity(1000);
    554     float aX = velocityTracker.getXVelocity();
    555     float aY = velocityTracker.getYVelocity();
    556     float bX = lastX - initialTouchX;
    557     float bY = lastY - initialTouchY;
    558     float bLen = (float) Math.hypot(bX, bY);
    559     // Project the velocity onto the distance vector: a * b / |b|
    560     float projectedVelocity = (aX * bX + aY * bY) / bLen;
    561     if (targetedView == rightIcon) {
    562       projectedVelocity = -projectedVelocity;
    563     }
    564     return projectedVelocity;
    565   }
    566 
    567   public void onConfigurationChanged() {
    568     initDimens();
    569     initIcons();
    570   }
    571 
    572   public void onRtlPropertiesChanged() {
    573     initIcons();
    574   }
    575 
    576   public void reset(boolean animate) {
    577     cancelAnimation();
    578     setTranslation(0.0f, true, animate);
    579     motionCancelled = true;
    580     if (swipingInProgress) {
    581       callback.onSwipingAborted();
    582       swipingInProgress = false;
    583     }
    584   }
    585 
    586   public boolean isSwipingInProgress() {
    587     return swipingInProgress;
    588   }
    589 
    590   public void launchAffordance(boolean animate, boolean left) {
    591     SwipeButtonView targetView = left ? leftIcon : rightIcon;
    592     if (swipingInProgress || targetView == null) {
    593       // We don't want to mess with the state if the user is actually swiping already.
    594       return;
    595     }
    596     SwipeButtonView otherView = left ? rightIcon : leftIcon;
    597     startSwiping(targetView);
    598     if (animate) {
    599       fling(0, false, !left);
    600       updateIcon(otherView, 0.0f, 0, true, false, true, false);
    601     } else {
    602       callback.onAnimationToSideStarted(!left, translation, 0);
    603       translation =
    604           left ? callback.getMaxTranslationDistance() : callback.getMaxTranslationDistance();
    605       updateIcon(otherView, 0.0f, 0.0f, false, false, true, false);
    606       targetView.instantFinishAnimation();
    607       flingEndListener.onAnimationEnd(null);
    608       new AnimationEndRunnable(!left).run();
    609     }
    610   }
    611 
    612   /** Callback interface for various actions */
    613   public interface Callback {
    614 
    615     /**
    616      * Notifies the callback when an animation to a side page was started.
    617      *
    618      * @param rightPage Is the page animated to the right page?
    619      */
    620     void onAnimationToSideStarted(boolean rightPage, float translation, float vel);
    621 
    622     /** Notifies the callback the animation to a side page has ended. */
    623     void onAnimationToSideEnded(boolean rightPage);
    624 
    625     float getMaxTranslationDistance();
    626 
    627     void onSwipingStarted(boolean rightIcon);
    628 
    629     void onSwipingAborted();
    630 
    631     void onIconClicked(boolean rightIcon);
    632 
    633     @Nullable
    634     SwipeButtonView getLeftIcon();
    635 
    636     @Nullable
    637     SwipeButtonView getRightIcon();
    638 
    639     @Nullable
    640     View getLeftPreview();
    641 
    642     @Nullable
    643     View getRightPreview();
    644 
    645     /** @return The factor the minimum swipe amount should be multiplied with. */
    646     float getAffordanceFalsingFactor();
    647   }
    648 }
    649