FlingUpDownMethod.java revision 2df4538eb90b896be15eebc1d9adf1206131c8a3
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
17package com.android.incallui.answer.impl.answermethod;
18
19import android.animation.Animator;
20import android.animation.AnimatorListenerAdapter;
21import android.animation.AnimatorSet;
22import android.animation.ObjectAnimator;
23import android.animation.PropertyValuesHolder;
24import android.animation.ValueAnimator;
25import android.annotation.SuppressLint;
26import android.content.Context;
27import android.content.res.ColorStateList;
28import android.graphics.PorterDuff.Mode;
29import android.graphics.drawable.Drawable;
30import android.os.Bundle;
31import android.support.annotation.ColorInt;
32import android.support.annotation.FloatRange;
33import android.support.annotation.IntDef;
34import android.support.annotation.NonNull;
35import android.support.annotation.Nullable;
36import android.support.annotation.VisibleForTesting;
37import android.support.v4.graphics.ColorUtils;
38import android.support.v4.view.animation.FastOutLinearInInterpolator;
39import android.support.v4.view.animation.FastOutSlowInInterpolator;
40import android.support.v4.view.animation.LinearOutSlowInInterpolator;
41import android.support.v4.view.animation.PathInterpolatorCompat;
42import android.view.LayoutInflater;
43import android.view.MotionEvent;
44import android.view.View;
45import android.view.View.AccessibilityDelegate;
46import android.view.ViewGroup;
47import android.view.accessibility.AccessibilityNodeInfo;
48import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction;
49import android.view.animation.BounceInterpolator;
50import android.view.animation.DecelerateInterpolator;
51import android.view.animation.Interpolator;
52import android.widget.ImageView;
53import android.widget.TextView;
54import com.android.dialer.common.DpUtil;
55import com.android.dialer.common.LogUtil;
56import com.android.dialer.common.MathUtil;
57import com.android.dialer.util.DrawableConverter;
58import com.android.dialer.util.ViewUtil;
59import com.android.incallui.answer.impl.answermethod.FlingUpDownTouchHandler.OnProgressChangedListener;
60import com.android.incallui.answer.impl.classifier.FalsingManager;
61import com.android.incallui.answer.impl.hint.AnswerHint;
62import com.android.incallui.answer.impl.hint.AnswerHintFactory;
63import com.android.incallui.answer.impl.hint.PawImageLoaderImpl;
64import java.lang.annotation.Retention;
65import java.lang.annotation.RetentionPolicy;
66
67/** Answer method that swipes up to answer or down to reject. */
68@SuppressLint("ClickableViewAccessibility")
69public class FlingUpDownMethod extends AnswerMethod implements OnProgressChangedListener {
70
71  private static final float SWIPE_LERP_PROGRESS_FACTOR = 0.5f;
72  private static final long ANIMATE_DURATION_SHORT_MILLIS = 667;
73  private static final long ANIMATE_DURATION_NORMAL_MILLIS = 1_333;
74  private static final long ANIMATE_DURATION_LONG_MILLIS = 1_500;
75  private static final long BOUNCE_ANIMATION_DELAY = 167;
76  private static final long VIBRATION_TIME_MILLIS = 1_833;
77  private static final long SETTLE_ANIMATION_DURATION_MILLIS = 100;
78  private static final int HINT_JUMP_DP = 60;
79  private static final int HINT_DIP_DP = 8;
80  private static final float HINT_SCALE_RATIO = 1.15f;
81  private static final long SWIPE_TO_DECLINE_FADE_IN_DELAY_MILLIS = 333;
82  private static final int HINT_REJECT_SHOW_DURATION_MILLIS = 2000;
83  private static final int ICON_END_CALL_ROTATION_DEGREES = 135;
84  private static final int HINT_REJECT_FADE_TRANSLATION_Y_DP = -8;
85  private static final float SWIPE_TO_ANSWER_MAX_TRANSLATION_Y_DP = 150;
86  private static final int SWIPE_TO_REJECT_MAX_TRANSLATION_Y_DP = 24;
87
88  @Retention(RetentionPolicy.SOURCE)
89  @IntDef(
90    value = {
91      AnimationState.NONE,
92      AnimationState.ENTRY,
93      AnimationState.BOUNCE,
94      AnimationState.SWIPE,
95      AnimationState.SETTLE,
96      AnimationState.HINT,
97      AnimationState.COMPLETED
98    }
99  )
100  @VisibleForTesting
101  @interface AnimationState {
102
103    int NONE = 0;
104    int ENTRY = 1; // Entry animation for incoming call
105    int BOUNCE = 2; // An idle state in which text and icon slightly bounces off its base repeatedly
106    int SWIPE = 3; // A special state in which text and icon follows the finger movement
107    int SETTLE = 4; // A short animation to reset from swipe and prepare for hint or bounce
108    int HINT = 5; // Jump animation to suggest what to do
109    int COMPLETED = 6; // Animation loop completed. Occurs after user swipes beyond threshold
110  }
111
112  private static void moveTowardY(View view, float newY) {
113    view.setTranslationY(MathUtil.lerp(view.getTranslationY(), newY, SWIPE_LERP_PROGRESS_FACTOR));
114  }
115
116  private static void moveTowardX(View view, float newX) {
117    view.setTranslationX(MathUtil.lerp(view.getTranslationX(), newX, SWIPE_LERP_PROGRESS_FACTOR));
118  }
119
120  private static void fadeToward(View view, float newAlpha) {
121    view.setAlpha(MathUtil.lerp(view.getAlpha(), newAlpha, SWIPE_LERP_PROGRESS_FACTOR));
122  }
123
124  private static void rotateToward(View view, float newRotation) {
125    view.setRotation(MathUtil.lerp(view.getRotation(), newRotation, SWIPE_LERP_PROGRESS_FACTOR));
126  }
127
128  private TextView swipeToAnswerText;
129  private TextView swipeToRejectText;
130  private View contactPuckContainer;
131  private ImageView contactPuckBackground;
132  private ImageView contactPuckIcon;
133  private View incomingDisconnectText;
134  private Animator lockBounceAnim;
135  private AnimatorSet lockEntryAnim;
136  private AnimatorSet lockHintAnim;
137  private AnimatorSet lockSettleAnim;
138  @AnimationState private int animationState = AnimationState.NONE;
139  @AnimationState private int afterSettleAnimationState = AnimationState.NONE;
140  // a value for finger swipe progress. -1 or less for "reject"; 1 or more for "accept".
141  private float swipeProgress;
142  private Animator rejectHintHide;
143  private Animator vibrationAnimator;
144  private Drawable contactPhoto;
145  private boolean incomingWillDisconnect;
146  private FlingUpDownTouchHandler touchHandler;
147  private FalsingManager falsingManager;
148
149  private AnswerHint answerHint;
150
151  @Override
152  public void onCreate(@Nullable Bundle bundle) {
153    super.onCreate(bundle);
154    falsingManager = new FalsingManager(getContext());
155  }
156
157  @Override
158  public void onStart() {
159    super.onStart();
160    falsingManager.onScreenOn();
161    if (getView() != null) {
162      if (animationState == AnimationState.SWIPE || animationState == AnimationState.HINT) {
163        swipeProgress = 0;
164        updateContactPuck();
165        onMoveReset(false);
166      } else if (animationState == AnimationState.ENTRY) {
167        // When starting from the lock screen, the activity may be stopped and started briefly.
168        // Don't let that interrupt the entry animation
169        startSwipeToAnswerEntryAnimation();
170      }
171    }
172  }
173
174  @Override
175  public void onStop() {
176    endAnimation();
177    falsingManager.onScreenOff();
178    if (getActivity().isFinishing()) {
179      setAnimationState(AnimationState.COMPLETED);
180    }
181    super.onStop();
182  }
183
184  @Nullable
185  @Override
186  public View onCreateView(
187      LayoutInflater layoutInflater, @Nullable ViewGroup viewGroup, @Nullable Bundle bundle) {
188    View view = layoutInflater.inflate(R.layout.swipe_up_down_method, viewGroup, false);
189
190    contactPuckContainer = view.findViewById(R.id.incoming_call_puck_container);
191    contactPuckBackground = (ImageView) view.findViewById(R.id.incoming_call_puck_bg);
192    contactPuckIcon = (ImageView) view.findViewById(R.id.incoming_call_puck_icon);
193    swipeToAnswerText = (TextView) view.findViewById(R.id.incoming_swipe_to_answer_text);
194    swipeToRejectText = (TextView) view.findViewById(R.id.incoming_swipe_to_reject_text);
195    incomingDisconnectText = view.findViewById(R.id.incoming_will_disconnect_text);
196    incomingDisconnectText.setAlpha(incomingWillDisconnect ? 1 : 0);
197
198    view.setAccessibilityDelegate(
199        new AccessibilityDelegate() {
200          @Override
201          public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) {
202            super.onInitializeAccessibilityNodeInfo(host, info);
203            info.addAction(
204                new AccessibilityAction(
205                    R.id.accessibility_action_answer, getString(R.string.call_incoming_answer)));
206            info.addAction(
207                new AccessibilityAction(
208                    R.id.accessibility_action_decline, getString(R.string.call_incoming_decline)));
209          }
210
211          @Override
212          public boolean performAccessibilityAction(View host, int action, Bundle args) {
213            if (action == R.id.accessibility_action_answer) {
214              performAccept();
215              return true;
216            } else if (action == R.id.accessibility_action_decline) {
217              performReject();
218              return true;
219            }
220            return super.performAccessibilityAction(host, action, args);
221          }
222        });
223
224    swipeProgress = 0;
225
226    updateContactPuck();
227
228    touchHandler = FlingUpDownTouchHandler.attach(view, this, falsingManager);
229
230    answerHint =
231        new AnswerHintFactory(new PawImageLoaderImpl())
232            .create(getContext(), ANIMATE_DURATION_LONG_MILLIS, BOUNCE_ANIMATION_DELAY);
233    answerHint.onCreateView(
234        layoutInflater,
235        (ViewGroup) view.findViewById(R.id.hint_container),
236        contactPuckContainer,
237        swipeToAnswerText);
238    return view;
239  }
240
241  @Override
242  public void onViewCreated(View view, @Nullable Bundle bundle) {
243    super.onViewCreated(view, bundle);
244    setAnimationState(AnimationState.ENTRY);
245  }
246
247  @Override
248  public void onDestroyView() {
249    super.onDestroyView();
250    if (touchHandler != null) {
251      touchHandler.detach();
252      touchHandler = null;
253    }
254  }
255
256  @Override
257  public void onProgressChanged(@FloatRange(from = -1f, to = 1f) float progress) {
258    swipeProgress = progress;
259    if (animationState == AnimationState.SWIPE && getContext() != null && isVisible()) {
260      updateSwipeTextAndPuckForTouch();
261    }
262  }
263
264  @Override
265  public void onTrackingStart() {
266    setAnimationState(AnimationState.SWIPE);
267  }
268
269  @Override
270  public void onTrackingStopped() {}
271
272  @Override
273  public void onMoveReset(boolean showHint) {
274    if (showHint) {
275      showSwipeHint();
276    } else {
277      setAnimationState(AnimationState.BOUNCE);
278    }
279    resetTouchState();
280    getParent().resetAnswerProgress();
281  }
282
283  @Override
284  public void onMoveFinish(boolean accept) {
285    touchHandler.setTouchEnabled(false);
286    answerHint.onAnswered();
287    if (accept) {
288      performAccept();
289    } else {
290      performReject();
291    }
292  }
293
294  @Override
295  public boolean shouldUseFalsing(@NonNull MotionEvent downEvent) {
296    if (contactPuckContainer == null) {
297      return false;
298    }
299
300    float puckCenterX = contactPuckContainer.getX() + (contactPuckContainer.getWidth() / 2);
301    float puckCenterY = contactPuckContainer.getY() + (contactPuckContainer.getHeight() / 2);
302    double radius = contactPuckContainer.getHeight() / 2;
303
304    // Squaring a number is more performant than taking a sqrt, so we compare the square of the
305    // distance with the square of the radius.
306    double distSq =
307        Math.pow(downEvent.getX() - puckCenterX, 2) + Math.pow(downEvent.getY() - puckCenterY, 2);
308    return distSq >= Math.pow(radius, 2);
309  }
310
311  @Override
312  public void setContactPhoto(Drawable contactPhoto) {
313    this.contactPhoto = contactPhoto;
314
315    updateContactPuck();
316  }
317
318  private void updateContactPuck() {
319    if (contactPuckIcon == null) {
320      return;
321    }
322    if (getParent().isVideoCall() || getParent().isVideoUpgradeRequest()) {
323      contactPuckIcon.setImageResource(R.drawable.quantum_ic_videocam_white_24);
324    } else {
325      contactPuckIcon.setImageResource(R.drawable.quantum_ic_call_white_24);
326    }
327
328    int size =
329        contactPuckBackground
330            .getResources()
331            .getDimensionPixelSize(
332                shouldShowPhotoInPuck()
333                    ? R.dimen.answer_contact_puck_size_photo
334                    : R.dimen.answer_contact_puck_size_no_photo);
335    contactPuckBackground.setImageDrawable(
336        shouldShowPhotoInPuck()
337            ? makeRoundedDrawable(contactPuckBackground.getContext(), contactPhoto, size)
338            : null);
339    ViewGroup.LayoutParams contactPuckParams = contactPuckBackground.getLayoutParams();
340    contactPuckParams.height = size;
341    contactPuckParams.width = size;
342    contactPuckBackground.setLayoutParams(contactPuckParams);
343    contactPuckIcon.setAlpha(shouldShowPhotoInPuck() ? 0f : 1f);
344  }
345
346  private Drawable makeRoundedDrawable(Context context, Drawable contactPhoto, int size) {
347    return DrawableConverter.getRoundedDrawable(context, contactPhoto, size, size);
348  }
349
350  private boolean shouldShowPhotoInPuck() {
351    return (getParent().isVideoCall() || getParent().isVideoUpgradeRequest())
352        && contactPhoto != null;
353  }
354
355  @Override
356  public void setHintText(@Nullable CharSequence hintText) {
357    if (hintText == null) {
358      swipeToAnswerText.setText(R.string.call_incoming_swipe_to_answer);
359      swipeToRejectText.setText(R.string.call_incoming_swipe_to_reject);
360    } else {
361      swipeToAnswerText.setText(hintText);
362      swipeToRejectText.setText(null);
363    }
364  }
365
366  @Override
367  public void setShowIncomingWillDisconnect(boolean incomingWillDisconnect) {
368    this.incomingWillDisconnect = incomingWillDisconnect;
369    if (incomingDisconnectText != null) {
370      incomingDisconnectText.animate().alpha(incomingWillDisconnect ? 1 : 0);
371    }
372  }
373
374  private void showSwipeHint() {
375    setAnimationState(AnimationState.HINT);
376  }
377
378  private void updateSwipeTextAndPuckForTouch() {
379    // Clamp progress value between -1 and 1.
380    final float clampedProgress = MathUtil.clamp(swipeProgress, -1 /* min */, 1 /* max */);
381    final float positiveAdjustedProgress = Math.abs(clampedProgress);
382    final boolean isAcceptingFlow = clampedProgress >= 0;
383
384    // Cancel view property animators on views we're about to mutate
385    swipeToAnswerText.animate().cancel();
386    contactPuckIcon.animate().cancel();
387
388    // Since the animation progression is controlled by user gesture instead of real timeline, the
389    // spec timeline can be divided into 9 slots. Each slot is equivalent to 83ms in the spec.
390    // Therefore, we use 9 slots of 83ms to map user gesture into the spec timeline.
391    //
392    // See specs -
393    // Accept: https://direct.googleplex.com/#/spec/8510001
394    // Decline: https://direct.googleplex.com/#/spec/3850001
395    final float progressSlots = 9;
396
397    // Fade out the "swipe up to answer". It only takes 1 slot to complete the fade.
398    float swipeTextAlpha = Math.max(0, 1 - Math.abs(clampedProgress) * progressSlots);
399    fadeToward(swipeToAnswerText, swipeTextAlpha);
400    // Fade out the "swipe down to dismiss" at the same time. Don't ever increase its alpha
401    fadeToward(swipeToRejectText, Math.min(swipeTextAlpha, swipeToRejectText.getAlpha()));
402    // Fade out the "incoming will disconnect" text
403    fadeToward(incomingDisconnectText, incomingWillDisconnect ? swipeTextAlpha : 0);
404
405    // Move swipe text back to zero.
406    moveTowardX(swipeToAnswerText, 0 /* newX */);
407    moveTowardY(swipeToAnswerText, 0 /* newY */);
408
409    // Animate puck color
410    @ColorInt
411    int destPuckColor =
412        getContext()
413            .getColor(
414                isAcceptingFlow ? R.color.call_accept_background : R.color.call_hangup_background);
415    destPuckColor =
416        ColorUtils.setAlphaComponent(destPuckColor, (int) (0xFF * positiveAdjustedProgress));
417    contactPuckBackground.setBackgroundTintList(ColorStateList.valueOf(destPuckColor));
418    contactPuckBackground.setBackgroundTintMode(Mode.SRC_ATOP);
419    contactPuckBackground.setColorFilter(destPuckColor);
420
421    // Animate decline icon
422    if (isAcceptingFlow || getParent().isVideoCall() || getParent().isVideoUpgradeRequest()) {
423      rotateToward(contactPuckIcon, 0f);
424    } else {
425      rotateToward(contactPuckIcon, positiveAdjustedProgress * ICON_END_CALL_ROTATION_DEGREES);
426    }
427
428    // Fade in icon
429    if (shouldShowPhotoInPuck()) {
430      fadeToward(contactPuckIcon, positiveAdjustedProgress);
431    }
432    float iconProgress = Math.min(1f, positiveAdjustedProgress * 4);
433    @ColorInt
434    int iconColor =
435        ColorUtils.setAlphaComponent(
436            contactPuckIcon.getContext().getColor(R.color.incoming_answer_icon),
437            (int) (0xFF * (1 - iconProgress)));
438    contactPuckIcon.setImageTintList(ColorStateList.valueOf(iconColor));
439
440    // Move puck.
441    if (isAcceptingFlow) {
442      moveTowardY(
443          contactPuckContainer,
444          -clampedProgress * DpUtil.dpToPx(getContext(), SWIPE_TO_ANSWER_MAX_TRANSLATION_Y_DP));
445    } else {
446      moveTowardY(
447          contactPuckContainer,
448          -clampedProgress * DpUtil.dpToPx(getContext(), SWIPE_TO_REJECT_MAX_TRANSLATION_Y_DP));
449    }
450
451    getParent().onAnswerProgressUpdate(clampedProgress);
452  }
453
454  private void startSwipeToAnswerSwipeAnimation() {
455    LogUtil.i("FlingUpDownMethod.startSwipeToAnswerSwipeAnimation", "Start swipe animation.");
456    resetTouchState();
457    endAnimation();
458  }
459
460  private void setPuckTouchState() {
461    contactPuckBackground.setActivated(touchHandler.isTracking());
462  }
463
464  private void resetTouchState() {
465    if (getContext() == null) {
466      // State will be reset in onStart(), so just abort.
467      return;
468    }
469    contactPuckContainer.animate().scaleX(1 /* scaleX */);
470    contactPuckContainer.animate().scaleY(1 /* scaleY */);
471    contactPuckBackground.animate().scaleX(1 /* scaleX */);
472    contactPuckBackground.animate().scaleY(1 /* scaleY */);
473    contactPuckBackground.setBackgroundTintList(null);
474    contactPuckBackground.setColorFilter(null);
475    contactPuckIcon.setImageTintList(
476        ColorStateList.valueOf(getContext().getColor(R.color.incoming_answer_icon)));
477    contactPuckIcon.animate().rotation(0);
478
479    getParent().resetAnswerProgress();
480    setPuckTouchState();
481
482    final float alpha = 1;
483    swipeToAnswerText.animate().alpha(alpha);
484    contactPuckContainer.animate().alpha(alpha);
485    contactPuckBackground.animate().alpha(alpha);
486    contactPuckIcon.animate().alpha(shouldShowPhotoInPuck() ? 0 : alpha);
487  }
488
489  @VisibleForTesting
490  void setAnimationState(@AnimationState int state) {
491    if (state != AnimationState.HINT && animationState == state) {
492      return;
493    }
494
495    if (animationState == AnimationState.COMPLETED) {
496      LogUtil.e(
497          "FlingUpDownMethod.setAnimationState",
498          "Animation loop has completed. Cannot switch to new state: " + state);
499      return;
500    }
501
502    if (state == AnimationState.HINT || state == AnimationState.BOUNCE) {
503      if (animationState == AnimationState.SWIPE) {
504        afterSettleAnimationState = state;
505        state = AnimationState.SETTLE;
506      }
507    }
508
509    LogUtil.i("FlingUpDownMethod.setAnimationState", "animation state: " + state);
510    animationState = state;
511
512    // Start animation after the current one is finished completely.
513    View view = getView();
514    if (view != null) {
515      // As long as the fragment is added, we can start update the animation state.
516      if (isAdded() && (animationState == state)) {
517        updateAnimationState();
518      } else {
519        endAnimation();
520      }
521    }
522  }
523
524  @AnimationState
525  @VisibleForTesting
526  int getAnimationState() {
527    return animationState;
528  }
529
530  private void updateAnimationState() {
531    switch (animationState) {
532      case AnimationState.ENTRY:
533        startSwipeToAnswerEntryAnimation();
534        break;
535      case AnimationState.BOUNCE:
536        startSwipeToAnswerBounceAnimation();
537        break;
538      case AnimationState.SWIPE:
539        startSwipeToAnswerSwipeAnimation();
540        break;
541      case AnimationState.SETTLE:
542        startSwipeToAnswerSettleAnimation();
543        break;
544      case AnimationState.COMPLETED:
545        clearSwipeToAnswerUi();
546        break;
547      case AnimationState.HINT:
548        startSwipeToAnswerHintAnimation();
549        break;
550      case AnimationState.NONE:
551      default:
552        LogUtil.e(
553            "FlingUpDownMethod.updateAnimationState",
554            "Unexpected animation state: " + animationState);
555        break;
556    }
557  }
558
559  private void startSwipeToAnswerEntryAnimation() {
560    LogUtil.i("FlingUpDownMethod.startSwipeToAnswerEntryAnimation", "Swipe entry animation.");
561    endAnimation();
562
563    lockEntryAnim = new AnimatorSet();
564    Animator textUp =
565        ObjectAnimator.ofFloat(
566            swipeToAnswerText,
567            View.TRANSLATION_Y,
568            DpUtil.dpToPx(getContext(), 192 /* dp */),
569            DpUtil.dpToPx(getContext(), -20 /* dp */));
570    textUp.setDuration(ANIMATE_DURATION_NORMAL_MILLIS);
571    textUp.setInterpolator(new LinearOutSlowInInterpolator());
572
573    Animator textDown =
574        ObjectAnimator.ofFloat(
575            swipeToAnswerText,
576            View.TRANSLATION_Y,
577            DpUtil.dpToPx(getContext(), -20) /* dp */,
578            0 /* end pos */);
579    textDown.setDuration(ANIMATE_DURATION_NORMAL_MILLIS);
580    textUp.setInterpolator(new FastOutSlowInInterpolator());
581
582    // "Swipe down to reject" text fades in with a slight translation
583    swipeToRejectText.setAlpha(0f);
584    Animator rejectTextShow =
585        ObjectAnimator.ofPropertyValuesHolder(
586            swipeToRejectText,
587            PropertyValuesHolder.ofFloat(View.ALPHA, 1f),
588            PropertyValuesHolder.ofFloat(
589                View.TRANSLATION_Y,
590                DpUtil.dpToPx(getContext(), HINT_REJECT_FADE_TRANSLATION_Y_DP),
591                0f));
592    rejectTextShow.setInterpolator(new FastOutLinearInInterpolator());
593    rejectTextShow.setDuration(ANIMATE_DURATION_SHORT_MILLIS);
594    rejectTextShow.setStartDelay(SWIPE_TO_DECLINE_FADE_IN_DELAY_MILLIS);
595
596    Animator puckUp =
597        ObjectAnimator.ofFloat(
598            contactPuckContainer,
599            View.TRANSLATION_Y,
600            DpUtil.dpToPx(getContext(), 400 /* dp */),
601            DpUtil.dpToPx(getContext(), -12 /* dp */));
602    puckUp.setDuration(ANIMATE_DURATION_LONG_MILLIS);
603    puckUp.setInterpolator(
604        PathInterpolatorCompat.create(
605            0 /* controlX1 */, 0 /* controlY1 */, 0 /* controlX2 */, 1 /* controlY2 */));
606
607    Animator puckDown =
608        ObjectAnimator.ofFloat(
609            contactPuckContainer,
610            View.TRANSLATION_Y,
611            DpUtil.dpToPx(getContext(), -12 /* dp */),
612            0 /* end pos */);
613    puckDown.setDuration(ANIMATE_DURATION_NORMAL_MILLIS);
614    puckDown.setInterpolator(new FastOutSlowInInterpolator());
615
616    Animator puckScaleUp =
617        createUniformScaleAnimators(
618            contactPuckBackground,
619            0.33f /* beginScale */,
620            1.1f /* endScale */,
621            ANIMATE_DURATION_NORMAL_MILLIS,
622            PathInterpolatorCompat.create(
623                0.4f /* controlX1 */, 0 /* controlY1 */, 0 /* controlX2 */, 1 /* controlY2 */));
624    Animator puckScaleDown =
625        createUniformScaleAnimators(
626            contactPuckBackground,
627            1.1f /* beginScale */,
628            1 /* endScale */,
629            ANIMATE_DURATION_NORMAL_MILLIS,
630            new FastOutSlowInInterpolator());
631
632    // Upward animation chain.
633    lockEntryAnim.play(textUp).with(puckScaleUp).with(puckUp);
634
635    // Downward animation chain.
636    lockEntryAnim.play(textDown).with(puckDown).with(puckScaleDown).after(puckUp);
637
638    lockEntryAnim.play(rejectTextShow).after(puckUp);
639
640    // Add vibration animation.
641    addVibrationAnimator(lockEntryAnim);
642
643    lockEntryAnim.addListener(
644        new AnimatorListenerAdapter() {
645
646          public boolean canceled;
647
648          @Override
649          public void onAnimationCancel(Animator animation) {
650            super.onAnimationCancel(animation);
651            canceled = true;
652          }
653
654          @Override
655          public void onAnimationEnd(Animator animation) {
656            super.onAnimationEnd(animation);
657            if (!canceled) {
658              onEntryAnimationDone();
659            }
660          }
661        });
662    lockEntryAnim.start();
663  }
664
665  @VisibleForTesting
666  void onEntryAnimationDone() {
667    LogUtil.i("FlingUpDownMethod.onEntryAnimationDone", "Swipe entry anim ends.");
668    if (animationState == AnimationState.ENTRY) {
669      setAnimationState(AnimationState.BOUNCE);
670    }
671  }
672
673  private void startSwipeToAnswerBounceAnimation() {
674    LogUtil.i("FlingUpDownMethod.startSwipeToAnswerBounceAnimation", "Swipe bounce animation.");
675    endAnimation();
676
677    if (ViewUtil.areAnimationsDisabled(getContext())) {
678      swipeToAnswerText.setTranslationY(0);
679      contactPuckContainer.setTranslationY(0);
680      contactPuckBackground.setScaleY(1f);
681      contactPuckBackground.setScaleX(1f);
682      swipeToRejectText.setAlpha(1f);
683      swipeToRejectText.setTranslationY(0);
684      return;
685    }
686
687    lockBounceAnim = createBreatheAnimation();
688
689    answerHint.onBounceStart();
690    lockBounceAnim.addListener(
691        new AnimatorListenerAdapter() {
692          boolean firstPass = true;
693
694          @Override
695          public void onAnimationEnd(Animator animation) {
696            super.onAnimationEnd(animation);
697            if (getContext() != null
698                && lockBounceAnim != null
699                && animationState == AnimationState.BOUNCE) {
700              // AnimatorSet doesn't have repeat settings. Instead, we start a new one after the
701              // previous set is completed, until endAnimation is called.
702              LogUtil.v("FlingUpDownMethod.onAnimationEnd", "Bounce again.");
703
704              // If this is the first time repeating the animation, we should recreate it so its
705              // starting values will be correct
706              if (firstPass) {
707                lockBounceAnim = createBreatheAnimation();
708                lockBounceAnim.addListener(this);
709              }
710              firstPass = false;
711              answerHint.onBounceStart();
712              lockBounceAnim.start();
713            }
714          }
715        });
716    lockBounceAnim.start();
717  }
718
719  private Animator createBreatheAnimation() {
720    AnimatorSet breatheAnimation = new AnimatorSet();
721    float textOffset = DpUtil.dpToPx(getContext(), 42 /* dp */);
722    Animator textUp =
723        ObjectAnimator.ofFloat(
724            swipeToAnswerText, View.TRANSLATION_Y, 0 /* begin pos */, -textOffset);
725    textUp.setInterpolator(new FastOutSlowInInterpolator());
726    textUp.setDuration(ANIMATE_DURATION_NORMAL_MILLIS);
727
728    Animator textDown =
729        ObjectAnimator.ofFloat(swipeToAnswerText, View.TRANSLATION_Y, -textOffset, 0 /* end pos */);
730    textDown.setInterpolator(new FastOutSlowInInterpolator());
731    textDown.setDuration(ANIMATE_DURATION_NORMAL_MILLIS);
732
733    // "Swipe down to reject" text fade in
734    Animator rejectTextShow = ObjectAnimator.ofFloat(swipeToRejectText, View.ALPHA, 1f);
735    rejectTextShow.setInterpolator(new LinearOutSlowInInterpolator());
736    rejectTextShow.setDuration(ANIMATE_DURATION_SHORT_MILLIS);
737    rejectTextShow.setStartDelay(SWIPE_TO_DECLINE_FADE_IN_DELAY_MILLIS);
738
739    // reject hint text translate in
740    Animator rejectTextTranslate =
741        ObjectAnimator.ofFloat(
742            swipeToRejectText,
743            View.TRANSLATION_Y,
744            DpUtil.dpToPx(getContext(), HINT_REJECT_FADE_TRANSLATION_Y_DP),
745            0f);
746    rejectTextTranslate.setInterpolator(new FastOutSlowInInterpolator());
747    rejectTextTranslate.setDuration(ANIMATE_DURATION_NORMAL_MILLIS);
748
749    // reject hint text fade out
750    Animator rejectTextHide = ObjectAnimator.ofFloat(swipeToRejectText, View.ALPHA, 0f);
751    rejectTextHide.setInterpolator(new FastOutLinearInInterpolator());
752    rejectTextHide.setDuration(ANIMATE_DURATION_SHORT_MILLIS);
753
754    Interpolator curve =
755        PathInterpolatorCompat.create(
756            0.4f /* controlX1 */, 0 /* controlY1 */, 0 /* controlX2 */, 1 /* controlY2 */);
757    float puckOffset = DpUtil.dpToPx(getContext(), 42 /* dp */);
758    Animator puckUp = ObjectAnimator.ofFloat(contactPuckContainer, View.TRANSLATION_Y, -puckOffset);
759    puckUp.setInterpolator(curve);
760    puckUp.setDuration(ANIMATE_DURATION_LONG_MILLIS);
761
762    final float scale = 1.0625f;
763    Animator puckScaleUp =
764        createUniformScaleAnimators(
765            contactPuckBackground,
766            1 /* beginScale */,
767            scale,
768            ANIMATE_DURATION_NORMAL_MILLIS,
769            curve);
770
771    Animator puckDown =
772        ObjectAnimator.ofFloat(contactPuckContainer, View.TRANSLATION_Y, 0 /* end pos */);
773    puckDown.setInterpolator(new FastOutSlowInInterpolator());
774    puckDown.setDuration(ANIMATE_DURATION_NORMAL_MILLIS);
775
776    Animator puckScaleDown =
777        createUniformScaleAnimators(
778            contactPuckBackground,
779            scale,
780            1 /* endScale */,
781            ANIMATE_DURATION_NORMAL_MILLIS,
782            new FastOutSlowInInterpolator());
783
784    // Bounce upward animation chain.
785    breatheAnimation
786        .play(textUp)
787        .with(rejectTextHide)
788        .with(puckUp)
789        .with(puckScaleUp)
790        .after(167 /* delay */);
791
792    // Bounce downward animation chain.
793    breatheAnimation
794        .play(puckDown)
795        .with(textDown)
796        .with(puckScaleDown)
797        .with(rejectTextShow)
798        .with(rejectTextTranslate)
799        .after(puckUp);
800
801    // Add vibration animation to the animator set.
802    addVibrationAnimator(breatheAnimation);
803
804    return breatheAnimation;
805  }
806
807  private void startSwipeToAnswerSettleAnimation() {
808    endAnimation();
809
810    ObjectAnimator puckScale =
811        ObjectAnimator.ofPropertyValuesHolder(
812            contactPuckBackground,
813            PropertyValuesHolder.ofFloat(View.SCALE_X, 1),
814            PropertyValuesHolder.ofFloat(View.SCALE_Y, 1));
815    puckScale.setDuration(SETTLE_ANIMATION_DURATION_MILLIS);
816
817    ObjectAnimator iconRotation = ObjectAnimator.ofFloat(contactPuckIcon, View.ROTATION, 0);
818    iconRotation.setDuration(SETTLE_ANIMATION_DURATION_MILLIS);
819
820    ObjectAnimator swipeToAnswerTextFade =
821        createFadeAnimation(swipeToAnswerText, 1, SETTLE_ANIMATION_DURATION_MILLIS);
822
823    ObjectAnimator contactPuckContainerFade =
824        createFadeAnimation(contactPuckContainer, 1, SETTLE_ANIMATION_DURATION_MILLIS);
825
826    ObjectAnimator contactPuckBackgroundFade =
827        createFadeAnimation(contactPuckBackground, 1, SETTLE_ANIMATION_DURATION_MILLIS);
828
829    ObjectAnimator contactPuckIconFade =
830        createFadeAnimation(
831            contactPuckIcon, shouldShowPhotoInPuck() ? 0 : 1, SETTLE_ANIMATION_DURATION_MILLIS);
832
833    ObjectAnimator contactPuckTranslation =
834        ObjectAnimator.ofPropertyValuesHolder(
835            contactPuckContainer,
836            PropertyValuesHolder.ofFloat(View.TRANSLATION_X, 0),
837            PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, 0));
838    contactPuckTranslation.setDuration(SETTLE_ANIMATION_DURATION_MILLIS);
839
840    lockSettleAnim = new AnimatorSet();
841    lockSettleAnim
842        .play(puckScale)
843        .with(iconRotation)
844        .with(swipeToAnswerTextFade)
845        .with(contactPuckContainerFade)
846        .with(contactPuckBackgroundFade)
847        .with(contactPuckIconFade)
848        .with(contactPuckTranslation);
849
850    lockSettleAnim.addListener(
851        new AnimatorListenerAdapter() {
852          @Override
853          public void onAnimationCancel(Animator animation) {
854            afterSettleAnimationState = AnimationState.NONE;
855          }
856
857          @Override
858          public void onAnimationEnd(Animator animation) {
859            onSettleAnimationDone();
860          }
861        });
862
863    lockSettleAnim.start();
864  }
865
866  @VisibleForTesting
867  void onSettleAnimationDone() {
868    if (afterSettleAnimationState != AnimationState.NONE) {
869      int nextState = afterSettleAnimationState;
870      afterSettleAnimationState = AnimationState.NONE;
871      lockSettleAnim = null;
872
873      setAnimationState(nextState);
874    }
875  }
876
877  private ObjectAnimator createFadeAnimation(View target, float targetAlpha, long duration) {
878    ObjectAnimator objectAnimator = ObjectAnimator.ofFloat(target, View.ALPHA, targetAlpha);
879    objectAnimator.setDuration(duration);
880    return objectAnimator;
881  }
882
883  private void startSwipeToAnswerHintAnimation() {
884    if (rejectHintHide != null) {
885      rejectHintHide.cancel();
886    }
887
888    endAnimation();
889    resetTouchState();
890
891    if (ViewUtil.areAnimationsDisabled(getContext())) {
892      onHintAnimationDone(false);
893      return;
894    }
895
896    lockHintAnim = new AnimatorSet();
897    float jumpOffset = DpUtil.dpToPx(getContext(), HINT_JUMP_DP);
898    float dipOffset = DpUtil.dpToPx(getContext(), HINT_DIP_DP);
899    float scaleSize = HINT_SCALE_RATIO;
900    float textOffset = jumpOffset + (scaleSize - 1) * contactPuckBackground.getHeight();
901    int shortAnimTime =
902        getContext().getResources().getInteger(android.R.integer.config_shortAnimTime);
903    int mediumAnimTime =
904        getContext().getResources().getInteger(android.R.integer.config_mediumAnimTime);
905
906    // Puck squashes to anticipate jump
907    ObjectAnimator puckAnticipate =
908        ObjectAnimator.ofPropertyValuesHolder(
909            contactPuckContainer,
910            PropertyValuesHolder.ofFloat(View.SCALE_Y, .95f),
911            PropertyValuesHolder.ofFloat(View.SCALE_X, 1.05f));
912    puckAnticipate.setRepeatCount(1);
913    puckAnticipate.setRepeatMode(ValueAnimator.REVERSE);
914    puckAnticipate.setDuration(shortAnimTime / 2);
915    puckAnticipate.setInterpolator(new DecelerateInterpolator());
916    puckAnticipate.addListener(
917        new AnimatorListenerAdapter() {
918          @Override
919          public void onAnimationStart(Animator animation) {
920            super.onAnimationStart(animation);
921            contactPuckContainer.setPivotY(contactPuckContainer.getHeight());
922          }
923
924          @Override
925          public void onAnimationEnd(Animator animation) {
926            super.onAnimationEnd(animation);
927            contactPuckContainer.setPivotY(contactPuckContainer.getHeight() / 2);
928          }
929        });
930
931    // Ensure puck is at the right starting point for the jump
932    ObjectAnimator puckResetTranslation =
933        ObjectAnimator.ofPropertyValuesHolder(
934            contactPuckContainer,
935            PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, 0),
936            PropertyValuesHolder.ofFloat(View.TRANSLATION_X, 0));
937    puckResetTranslation.setDuration(shortAnimTime / 2);
938    puckAnticipate.setInterpolator(new DecelerateInterpolator());
939
940    Animator textUp = ObjectAnimator.ofFloat(swipeToAnswerText, View.TRANSLATION_Y, -textOffset);
941    textUp.setInterpolator(new LinearOutSlowInInterpolator());
942    textUp.setDuration(shortAnimTime);
943
944    Animator puckUp = ObjectAnimator.ofFloat(contactPuckContainer, View.TRANSLATION_Y, -jumpOffset);
945    puckUp.setInterpolator(new LinearOutSlowInInterpolator());
946    puckUp.setDuration(shortAnimTime);
947
948    Animator puckScaleUp =
949        createUniformScaleAnimators(
950            contactPuckBackground, 1f, scaleSize, shortAnimTime, new LinearOutSlowInInterpolator());
951
952    Animator rejectHintShow =
953        ObjectAnimator.ofPropertyValuesHolder(
954            swipeToRejectText,
955            PropertyValuesHolder.ofFloat(View.ALPHA, 1f),
956            PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, 0f));
957    rejectHintShow.setDuration(shortAnimTime);
958
959    Animator rejectHintDip =
960        ObjectAnimator.ofFloat(swipeToRejectText, View.TRANSLATION_Y, dipOffset);
961    rejectHintDip.setInterpolator(new LinearOutSlowInInterpolator());
962    rejectHintDip.setDuration(shortAnimTime);
963
964    Animator textDown = ObjectAnimator.ofFloat(swipeToAnswerText, View.TRANSLATION_Y, 0);
965    textDown.setInterpolator(new LinearOutSlowInInterpolator());
966    textDown.setDuration(mediumAnimTime);
967
968    Animator puckDown = ObjectAnimator.ofFloat(contactPuckContainer, View.TRANSLATION_Y, 0);
969    BounceInterpolator bounce = new BounceInterpolator();
970    puckDown.setInterpolator(bounce);
971    puckDown.setDuration(mediumAnimTime);
972
973    Animator puckScaleDown =
974        createUniformScaleAnimators(
975            contactPuckBackground, scaleSize, 1f, shortAnimTime, new LinearOutSlowInInterpolator());
976
977    Animator rejectHintUp = ObjectAnimator.ofFloat(swipeToRejectText, View.TRANSLATION_Y, 0);
978    rejectHintUp.setInterpolator(new LinearOutSlowInInterpolator());
979    rejectHintUp.setDuration(mediumAnimTime);
980
981    lockHintAnim.play(puckAnticipate).with(puckResetTranslation).before(puckUp);
982    lockHintAnim
983        .play(textUp)
984        .with(puckUp)
985        .with(puckScaleUp)
986        .with(rejectHintDip)
987        .with(rejectHintShow);
988    lockHintAnim.play(textDown).with(puckDown).with(puckScaleDown).with(rejectHintUp).after(puckUp);
989    lockHintAnim.start();
990
991    rejectHintHide = ObjectAnimator.ofFloat(swipeToRejectText, View.ALPHA, 0);
992    rejectHintHide.setStartDelay(HINT_REJECT_SHOW_DURATION_MILLIS);
993    rejectHintHide.addListener(
994        new AnimatorListenerAdapter() {
995
996          private boolean canceled;
997
998          @Override
999          public void onAnimationCancel(Animator animation) {
1000            super.onAnimationCancel(animation);
1001            canceled = true;
1002            rejectHintHide = null;
1003          }
1004
1005          @Override
1006          public void onAnimationEnd(Animator animation) {
1007            super.onAnimationEnd(animation);
1008            onHintAnimationDone(canceled);
1009          }
1010        });
1011    rejectHintHide.start();
1012  }
1013
1014  @VisibleForTesting
1015  void onHintAnimationDone(boolean canceled) {
1016    if (!canceled && animationState == AnimationState.HINT) {
1017      setAnimationState(AnimationState.BOUNCE);
1018    }
1019    rejectHintHide = null;
1020  }
1021
1022  private void clearSwipeToAnswerUi() {
1023    LogUtil.i("FlingUpDownMethod.clearSwipeToAnswerUi", "Clear swipe animation.");
1024    endAnimation();
1025    swipeToAnswerText.setVisibility(View.GONE);
1026    contactPuckContainer.setVisibility(View.GONE);
1027  }
1028
1029  private void endAnimation() {
1030    LogUtil.i("FlingUpDownMethod.endAnimation", "End animations.");
1031    if (lockSettleAnim != null) {
1032      lockSettleAnim.cancel();
1033      lockSettleAnim = null;
1034    }
1035    if (lockBounceAnim != null) {
1036      lockBounceAnim.cancel();
1037      lockBounceAnim = null;
1038    }
1039    if (lockEntryAnim != null) {
1040      lockEntryAnim.cancel();
1041      lockEntryAnim = null;
1042    }
1043    if (lockHintAnim != null) {
1044      lockHintAnim.cancel();
1045      lockHintAnim = null;
1046    }
1047    if (rejectHintHide != null) {
1048      rejectHintHide.cancel();
1049      rejectHintHide = null;
1050    }
1051    if (vibrationAnimator != null) {
1052      vibrationAnimator.end();
1053      vibrationAnimator = null;
1054    }
1055    answerHint.onBounceEnd();
1056  }
1057
1058  // Create an animator to scale on X/Y directions uniformly.
1059  private Animator createUniformScaleAnimators(
1060      View target, float begin, float end, long duration, Interpolator interpolator) {
1061    ObjectAnimator animator =
1062        ObjectAnimator.ofPropertyValuesHolder(
1063            target,
1064            PropertyValuesHolder.ofFloat(View.SCALE_X, begin, end),
1065            PropertyValuesHolder.ofFloat(View.SCALE_Y, begin, end));
1066    animator.setDuration(duration);
1067    animator.setInterpolator(interpolator);
1068    return animator;
1069  }
1070
1071  private void addVibrationAnimator(AnimatorSet animatorSet) {
1072    if (vibrationAnimator != null) {
1073      vibrationAnimator.end();
1074    }
1075
1076    // Note that we animate the value between 0 and 1, but internally VibrateInterpolator will
1077    // translate it into actually X translation value.
1078    vibrationAnimator =
1079        ObjectAnimator.ofFloat(
1080            contactPuckContainer, View.TRANSLATION_X, 0 /* begin value */, 1 /* end value */);
1081    vibrationAnimator.setDuration(VIBRATION_TIME_MILLIS);
1082    vibrationAnimator.setInterpolator(new VibrateInterpolator(getContext()));
1083
1084    animatorSet.play(vibrationAnimator).after(0 /* delay */);
1085  }
1086
1087  private void performAccept() {
1088    LogUtil.i("FlingUpDownMethod.performAccept", null);
1089    swipeToAnswerText.setVisibility(View.GONE);
1090    contactPuckContainer.setVisibility(View.GONE);
1091
1092    // Complete the animation loop.
1093    setAnimationState(AnimationState.COMPLETED);
1094    getParent().answerFromMethod();
1095  }
1096
1097  private void performReject() {
1098    LogUtil.i("FlingUpDownMethod.performReject", null);
1099    swipeToAnswerText.setVisibility(View.GONE);
1100    contactPuckContainer.setVisibility(View.GONE);
1101
1102    // Complete the animation loop.
1103    setAnimationState(AnimationState.COMPLETED);
1104    getParent().rejectFromMethod();
1105  }
1106
1107  /** Custom interpolator class for puck vibration. */
1108  private static class VibrateInterpolator implements Interpolator {
1109
1110    private static final long RAMP_UP_BEGIN_MS = 583;
1111    private static final long RAMP_UP_DURATION_MS = 167;
1112    private static final long RAMP_UP_END_MS = RAMP_UP_BEGIN_MS + RAMP_UP_DURATION_MS;
1113    private static final long RAMP_DOWN_BEGIN_MS = 1_583;
1114    private static final long RAMP_DOWN_DURATION_MS = 250;
1115    private static final long RAMP_DOWN_END_MS = RAMP_DOWN_BEGIN_MS + RAMP_DOWN_DURATION_MS;
1116    private static final long RAMP_TOTAL_TIME_MS = RAMP_DOWN_END_MS;
1117    private final float ampMax;
1118    private final float freqMax = 80;
1119    private Interpolator sliderInterpolator = new FastOutSlowInInterpolator();
1120
1121    VibrateInterpolator(Context context) {
1122      ampMax = DpUtil.dpToPx(context, 1 /* dp */);
1123    }
1124
1125    @Override
1126    public float getInterpolation(float t) {
1127      float slider = 0;
1128      float time = t * RAMP_TOTAL_TIME_MS;
1129
1130      // Calculate the slider value based on RAMP_UP and RAMP_DOWN times. Between RAMP_UP and
1131      // RAMP_DOWN, the slider remains the maximum value of 1.
1132      if (time > RAMP_UP_BEGIN_MS && time < RAMP_UP_END_MS) {
1133        // Ramp up.
1134        slider =
1135            sliderInterpolator.getInterpolation(
1136                (time - RAMP_UP_BEGIN_MS) / (float) RAMP_UP_DURATION_MS);
1137      } else if ((time >= RAMP_UP_END_MS) && time <= RAMP_DOWN_BEGIN_MS) {
1138        // Vibrate at maximum
1139        slider = 1;
1140      } else if (time > RAMP_DOWN_BEGIN_MS && time < RAMP_DOWN_END_MS) {
1141        // Ramp down.
1142        slider =
1143            1
1144                - sliderInterpolator.getInterpolation(
1145                    (time - RAMP_DOWN_BEGIN_MS) / (float) RAMP_DOWN_DURATION_MS);
1146      }
1147
1148      float ampNormalized = ampMax * slider;
1149      float freqNormalized = freqMax * slider;
1150
1151      return (float) (ampNormalized * Math.sin(time * freqNormalized));
1152    }
1153  }
1154}
1155