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 
AnimationEndRunnable(boolean rightPage)75     public AnimationEndRunnable(boolean rightPage) {
76       this.rightPage = rightPage;
77     }
78 
79     @Override
run()80     public void run() {
81       callback.onAnimationToSideEnded(rightPage);
82     }
83   };
84 
SwipeButtonHelper(Callback callback, Context context)85   public SwipeButtonHelper(Callback callback, Context context) {
86     this.context = context;
87     this.callback = callback;
88     init();
89   }
90 
init()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 
initDimens()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 
initIcons()129   private void initIcons() {
130     leftIcon = callback.getLeftIcon();
131     rightIcon = callback.getRightIcon();
132     updatePreviews();
133   }
134 
updatePreviews()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 
onTouchEvent(MotionEvent event)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 
startSwiping(View targetView)212   private void startSwiping(View targetView) {
213     callback.onSwipingStarted(targetView == rightIcon);
214     swipingInProgress = true;
215     targetedView = targetView;
216   }
217 
getIconAtPosition(float x, float y)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 
isOnAffordanceIcon(float x, float y)228   public boolean isOnAffordanceIcon(float x, float y) {
229     return isOnIcon(leftIcon, x, y) || isOnIcon(rightIcon, x, y);
230   }
231 
isOnIcon(View icon, float x, float y)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 
endMotion(boolean forceSnapBack, float lastX, float lastY)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 
rightSwipePossible()251   private boolean rightSwipePossible() {
252     return rightIcon != null && rightIcon.getVisibility() == View.VISIBLE;
253   }
254 
leftSwipePossible()255   private boolean leftSwipePossible() {
256     return leftIcon != null && leftIcon.getVisibility() == View.VISIBLE;
257   }
258 
startHintAnimation(boolean right, @Nullable Runnable onFinishedListener)259   public void startHintAnimation(boolean right, @Nullable Runnable onFinishedListener) {
260     cancelAnimation();
261     startHintAnimationPhase1(right, onFinishedListener);
262   }
263 
startHintAnimationPhase1( final boolean right, @Nullable final Runnable onFinishedListener)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. */
startUnlockHintAnimationPhase2( boolean right, @Nullable final Runnable onFinishedListener)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 
getAnimatorToRadius(final boolean right, int radius)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 
cancelAnimation()351   private void cancelAnimation() {
352     if (swipeAnimator != null) {
353       swipeAnimator.cancel();
354     }
355   }
356 
flingWithCurrentVelocity(boolean forceSnapBack, float lastX, float lastY)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 
isBelowFalsingThreshold()370   private boolean isBelowFalsingThreshold() {
371     return Math.abs(translation) < Math.abs(translationOnDown) + getMinTranslationAmount();
372   }
373 
getMinTranslationAmount()374   private int getMinTranslationAmount() {
375     float factor = callback.getAffordanceFalsingFactor();
376     return (int) (minTranslationAmount * factor);
377   }
378 
fling(float vel, final boolean snapBack, boolean right)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 
startFinishingCircleAnimation( float velocity, Runnable mAnimationEndRunnable, boolean right)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 
setTranslation(float translation, boolean isReset, boolean animateReset)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 
updateIconsFromTranslation(SwipeButtonView targetView)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 
getTranslationFromRadius(float circleSize)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 
getRadiusFromTranslation(float translation)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 
animateHideLeftRightIcon()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 
updateIcon( @ullable SwipeButtonView view, float circleRadius, float alpha, boolean animate, boolean slowRadiusAnimation, boolean force, boolean forceNoCircleAnimation)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 
updateIconAlpha(SwipeButtonView view, float alpha, boolean animate)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 
getScale(float alpha, SwipeButtonView icon)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 
trackMovement(MotionEvent event)536   private void trackMovement(MotionEvent event) {
537     if (velocityTracker != null) {
538       velocityTracker.addMovement(event);
539     }
540   }
541 
initVelocityTracker()542   private void initVelocityTracker() {
543     if (velocityTracker != null) {
544       velocityTracker.recycle();
545     }
546     velocityTracker = VelocityTracker.obtain();
547   }
548 
getCurrentVelocity(float lastX, float lastY)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 
onConfigurationChanged()567   public void onConfigurationChanged() {
568     initDimens();
569     initIcons();
570   }
571 
onRtlPropertiesChanged()572   public void onRtlPropertiesChanged() {
573     initIcons();
574   }
575 
reset(boolean animate)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 
isSwipingInProgress()586   public boolean isSwipingInProgress() {
587     return swipingInProgress;
588   }
589 
launchAffordance(boolean animate, boolean left)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      */
onAnimationToSideStarted(boolean rightPage, float translation, float vel)620     void onAnimationToSideStarted(boolean rightPage, float translation, float vel);
621 
622     /** Notifies the callback the animation to a side page has ended. */
onAnimationToSideEnded(boolean rightPage)623     void onAnimationToSideEnded(boolean rightPage);
624 
getMaxTranslationDistance()625     float getMaxTranslationDistance();
626 
onSwipingStarted(boolean rightIcon)627     void onSwipingStarted(boolean rightIcon);
628 
onSwipingAborted()629     void onSwipingAborted();
630 
onIconClicked(boolean rightIcon)631     void onIconClicked(boolean rightIcon);
632 
633     @Nullable
getLeftIcon()634     SwipeButtonView getLeftIcon();
635 
636     @Nullable
getRightIcon()637     SwipeButtonView getRightIcon();
638 
639     @Nullable
getLeftPreview()640     View getLeftPreview();
641 
642     @Nullable
getRightPreview()643     View getRightPreview();
644 
645     /** @return The factor the minimum swipe amount should be multiplied with. */
getAffordanceFalsingFactor()646     float getAffordanceFalsingFactor();
647   }
648 }
649