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