summaryrefslogtreecommitdiff
path: root/java/com/android/incallui/answer/impl/answermethod/FlingUpDownMethod.java
diff options
context:
space:
mode:
Diffstat (limited to 'java/com/android/incallui/answer/impl/answermethod/FlingUpDownMethod.java')
-rw-r--r--java/com/android/incallui/answer/impl/answermethod/FlingUpDownMethod.java1149
1 files changed, 1149 insertions, 0 deletions
diff --git a/java/com/android/incallui/answer/impl/answermethod/FlingUpDownMethod.java b/java/com/android/incallui/answer/impl/answermethod/FlingUpDownMethod.java
new file mode 100644
index 000000000..0bc65818c
--- /dev/null
+++ b/java/com/android/incallui/answer/impl/answermethod/FlingUpDownMethod.java
@@ -0,0 +1,1149 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.incallui.answer.impl.answermethod;
+
+import android.animation.Animator;
+import android.animation.AnimatorListenerAdapter;
+import android.animation.AnimatorSet;
+import android.animation.ObjectAnimator;
+import android.animation.PropertyValuesHolder;
+import android.animation.ValueAnimator;
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.content.res.ColorStateList;
+import android.graphics.PorterDuff.Mode;
+import android.graphics.drawable.Drawable;
+import android.os.Bundle;
+import android.support.annotation.ColorInt;
+import android.support.annotation.FloatRange;
+import android.support.annotation.IntDef;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.support.annotation.VisibleForTesting;
+import android.support.v4.graphics.ColorUtils;
+import android.support.v4.view.animation.FastOutLinearInInterpolator;
+import android.support.v4.view.animation.FastOutSlowInInterpolator;
+import android.support.v4.view.animation.LinearOutSlowInInterpolator;
+import android.support.v4.view.animation.PathInterpolatorCompat;
+import android.view.LayoutInflater;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.View.AccessibilityDelegate;
+import android.view.ViewGroup;
+import android.view.accessibility.AccessibilityNodeInfo;
+import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction;
+import android.view.animation.BounceInterpolator;
+import android.view.animation.DecelerateInterpolator;
+import android.view.animation.Interpolator;
+import android.widget.ImageView;
+import android.widget.TextView;
+import com.android.dialer.common.DpUtil;
+import com.android.dialer.common.LogUtil;
+import com.android.dialer.common.MathUtil;
+import com.android.dialer.util.DrawableConverter;
+import com.android.dialer.util.ViewUtil;
+import com.android.incallui.answer.impl.answermethod.FlingUpDownTouchHandler.OnProgressChangedListener;
+import com.android.incallui.answer.impl.classifier.FalsingManager;
+import com.android.incallui.answer.impl.hint.AnswerHint;
+import com.android.incallui.answer.impl.hint.AnswerHintFactory;
+import com.android.incallui.answer.impl.hint.EventPayloadLoaderImpl;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/** Answer method that swipes up to answer or down to reject. */
+@SuppressLint("ClickableViewAccessibility")
+public class FlingUpDownMethod extends AnswerMethod implements OnProgressChangedListener {
+
+ private static final float SWIPE_LERP_PROGRESS_FACTOR = 0.5f;
+ private static final long ANIMATE_DURATION_SHORT_MILLIS = 667;
+ private static final long ANIMATE_DURATION_NORMAL_MILLIS = 1_333;
+ private static final long ANIMATE_DURATION_LONG_MILLIS = 1_500;
+ private static final long BOUNCE_ANIMATION_DELAY = 167;
+ private static final long VIBRATION_TIME_MILLIS = 1_833;
+ private static final long SETTLE_ANIMATION_DURATION_MILLIS = 100;
+ private static final int HINT_JUMP_DP = 60;
+ private static final int HINT_DIP_DP = 8;
+ private static final float HINT_SCALE_RATIO = 1.15f;
+ private static final long SWIPE_TO_DECLINE_FADE_IN_DELAY_MILLIS = 333;
+ private static final int HINT_REJECT_SHOW_DURATION_MILLIS = 2000;
+ private static final int ICON_END_CALL_ROTATION_DEGREES = 135;
+ private static final int HINT_REJECT_FADE_TRANSLATION_Y_DP = -8;
+ private static final float SWIPE_TO_ANSWER_MAX_TRANSLATION_Y_DP = 150;
+ private static final int SWIPE_TO_REJECT_MAX_TRANSLATION_Y_DP = 24;
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ value = {
+ AnimationState.NONE,
+ AnimationState.ENTRY,
+ AnimationState.BOUNCE,
+ AnimationState.SWIPE,
+ AnimationState.SETTLE,
+ AnimationState.HINT,
+ AnimationState.COMPLETED
+ }
+ )
+ @VisibleForTesting
+ @interface AnimationState {
+
+ int NONE = 0;
+ int ENTRY = 1; // Entry animation for incoming call
+ int BOUNCE = 2; // An idle state in which text and icon slightly bounces off its base repeatedly
+ int SWIPE = 3; // A special state in which text and icon follows the finger movement
+ int SETTLE = 4; // A short animation to reset from swipe and prepare for hint or bounce
+ int HINT = 5; // Jump animation to suggest what to do
+ int COMPLETED = 6; // Animation loop completed. Occurs after user swipes beyond threshold
+ }
+
+ private static void moveTowardY(View view, float newY) {
+ view.setTranslationY(MathUtil.lerp(view.getTranslationY(), newY, SWIPE_LERP_PROGRESS_FACTOR));
+ }
+
+ private static void moveTowardX(View view, float newX) {
+ view.setTranslationX(MathUtil.lerp(view.getTranslationX(), newX, SWIPE_LERP_PROGRESS_FACTOR));
+ }
+
+ private static void fadeToward(View view, float newAlpha) {
+ view.setAlpha(MathUtil.lerp(view.getAlpha(), newAlpha, SWIPE_LERP_PROGRESS_FACTOR));
+ }
+
+ private static void rotateToward(View view, float newRotation) {
+ view.setRotation(MathUtil.lerp(view.getRotation(), newRotation, SWIPE_LERP_PROGRESS_FACTOR));
+ }
+
+ private TextView swipeToAnswerText;
+ private TextView swipeToRejectText;
+ private View contactPuckContainer;
+ private ImageView contactPuckBackground;
+ private ImageView contactPuckIcon;
+ private View incomingDisconnectText;
+ private Animator lockBounceAnim;
+ private AnimatorSet lockEntryAnim;
+ private AnimatorSet lockHintAnim;
+ private AnimatorSet lockSettleAnim;
+ @AnimationState private int animationState = AnimationState.NONE;
+ @AnimationState private int afterSettleAnimationState = AnimationState.NONE;
+ // a value for finger swipe progress. -1 or less for "reject"; 1 or more for "accept".
+ private float swipeProgress;
+ private Animator rejectHintHide;
+ private Animator vibrationAnimator;
+ private Drawable contactPhoto;
+ private boolean incomingWillDisconnect;
+ private FlingUpDownTouchHandler touchHandler;
+ private FalsingManager falsingManager;
+
+ private AnswerHint answerHint;
+
+ @Override
+ public void onCreate(@Nullable Bundle bundle) {
+ super.onCreate(bundle);
+ falsingManager = new FalsingManager(getContext());
+ }
+
+ @Override
+ public void onStart() {
+ super.onStart();
+ falsingManager.onScreenOn();
+ if (getView() != null) {
+ if (animationState == AnimationState.SWIPE || animationState == AnimationState.HINT) {
+ swipeProgress = 0;
+ updateContactPuck();
+ onMoveReset(false);
+ } else if (animationState == AnimationState.ENTRY) {
+ // When starting from the lock screen, the activity may be stopped and started briefly.
+ // Don't let that interrupt the entry animation
+ startSwipeToAnswerEntryAnimation();
+ }
+ }
+ }
+
+ @Override
+ public void onStop() {
+ endAnimation();
+ falsingManager.onScreenOff();
+ if (getActivity().isFinishing()) {
+ setAnimationState(AnimationState.COMPLETED);
+ }
+ super.onStop();
+ }
+
+ @Nullable
+ @Override
+ public View onCreateView(
+ LayoutInflater layoutInflater, @Nullable ViewGroup viewGroup, @Nullable Bundle bundle) {
+ View view = layoutInflater.inflate(R.layout.swipe_up_down_method, viewGroup, false);
+
+ contactPuckContainer = view.findViewById(R.id.incoming_call_puck_container);
+ contactPuckBackground = (ImageView) view.findViewById(R.id.incoming_call_puck_bg);
+ contactPuckIcon = (ImageView) view.findViewById(R.id.incoming_call_puck_icon);
+ swipeToAnswerText = (TextView) view.findViewById(R.id.incoming_swipe_to_answer_text);
+ swipeToRejectText = (TextView) view.findViewById(R.id.incoming_swipe_to_reject_text);
+ incomingDisconnectText = view.findViewById(R.id.incoming_will_disconnect_text);
+ incomingDisconnectText.setAlpha(incomingWillDisconnect ? 1 : 0);
+
+ view.setAccessibilityDelegate(
+ new AccessibilityDelegate() {
+ @Override
+ public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) {
+ super.onInitializeAccessibilityNodeInfo(host, info);
+ info.addAction(
+ new AccessibilityAction(
+ R.id.accessibility_action_answer, getString(R.string.call_incoming_answer)));
+ info.addAction(
+ new AccessibilityAction(
+ R.id.accessibility_action_decline, getString(R.string.call_incoming_decline)));
+ }
+
+ @Override
+ public boolean performAccessibilityAction(View host, int action, Bundle args) {
+ if (action == R.id.accessibility_action_answer) {
+ performAccept();
+ return true;
+ } else if (action == R.id.accessibility_action_decline) {
+ performReject();
+ return true;
+ }
+ return super.performAccessibilityAction(host, action, args);
+ }
+ });
+
+ swipeProgress = 0;
+
+ updateContactPuck();
+
+ touchHandler = FlingUpDownTouchHandler.attach(view, this, falsingManager);
+
+ answerHint =
+ new AnswerHintFactory(new EventPayloadLoaderImpl())
+ .create(getContext(), ANIMATE_DURATION_LONG_MILLIS, BOUNCE_ANIMATION_DELAY);
+ answerHint.onCreateView(
+ layoutInflater,
+ (ViewGroup) view.findViewById(R.id.hint_container),
+ contactPuckContainer,
+ swipeToAnswerText);
+ return view;
+ }
+
+ @Override
+ public void onViewCreated(View view, @Nullable Bundle bundle) {
+ super.onViewCreated(view, bundle);
+ setAnimationState(AnimationState.ENTRY);
+ }
+
+ @Override
+ public void onDestroyView() {
+ super.onDestroyView();
+ if (touchHandler != null) {
+ touchHandler.detach();
+ touchHandler = null;
+ }
+ }
+
+ @Override
+ public void onProgressChanged(@FloatRange(from = -1f, to = 1f) float progress) {
+ swipeProgress = progress;
+ if (animationState == AnimationState.SWIPE && getContext() != null && isVisible()) {
+ updateSwipeTextAndPuckForTouch();
+ }
+ }
+
+ @Override
+ public void onTrackingStart() {
+ setAnimationState(AnimationState.SWIPE);
+ }
+
+ @Override
+ public void onTrackingStopped() {}
+
+ @Override
+ public void onMoveReset(boolean showHint) {
+ if (showHint) {
+ showSwipeHint();
+ } else {
+ setAnimationState(AnimationState.BOUNCE);
+ }
+ resetTouchState();
+ getParent().resetAnswerProgress();
+ }
+
+ @Override
+ public void onMoveFinish(boolean accept) {
+ touchHandler.setTouchEnabled(false);
+ answerHint.onAnswered();
+ if (accept) {
+ performAccept();
+ } else {
+ performReject();
+ }
+ }
+
+ @Override
+ public boolean shouldUseFalsing(@NonNull MotionEvent downEvent) {
+ if (contactPuckContainer == null) {
+ return false;
+ }
+
+ float puckCenterX = contactPuckContainer.getX() + (contactPuckContainer.getWidth() / 2);
+ float puckCenterY = contactPuckContainer.getY() + (contactPuckContainer.getHeight() / 2);
+ double radius = contactPuckContainer.getHeight() / 2;
+
+ // Squaring a number is more performant than taking a sqrt, so we compare the square of the
+ // distance with the square of the radius.
+ double distSq =
+ Math.pow(downEvent.getX() - puckCenterX, 2) + Math.pow(downEvent.getY() - puckCenterY, 2);
+ return distSq >= Math.pow(radius, 2);
+ }
+
+ @Override
+ public void setContactPhoto(Drawable contactPhoto) {
+ this.contactPhoto = contactPhoto;
+
+ updateContactPuck();
+ }
+
+ private void updateContactPuck() {
+ if (contactPuckIcon == null) {
+ return;
+ }
+ if (getParent().isVideoCall()) {
+ contactPuckIcon.setImageResource(R.drawable.quantum_ic_videocam_white_24);
+ } else {
+ contactPuckIcon.setImageResource(R.drawable.quantum_ic_call_white_24);
+ }
+
+ int size =
+ contactPuckBackground
+ .getResources()
+ .getDimensionPixelSize(
+ shouldShowPhotoInPuck()
+ ? R.dimen.answer_contact_puck_size_photo
+ : R.dimen.answer_contact_puck_size_no_photo);
+ contactPuckBackground.setImageDrawable(
+ shouldShowPhotoInPuck()
+ ? makeRoundedDrawable(contactPuckBackground.getContext(), contactPhoto, size)
+ : null);
+ ViewGroup.LayoutParams contactPuckParams = contactPuckBackground.getLayoutParams();
+ contactPuckParams.height = size;
+ contactPuckParams.width = size;
+ contactPuckBackground.setLayoutParams(contactPuckParams);
+ contactPuckIcon.setAlpha(shouldShowPhotoInPuck() ? 0f : 1f);
+ }
+
+ private Drawable makeRoundedDrawable(Context context, Drawable contactPhoto, int size) {
+ return DrawableConverter.getRoundedDrawable(context, contactPhoto, size, size);
+ }
+
+ private boolean shouldShowPhotoInPuck() {
+ return getParent().isVideoCall() && contactPhoto != null;
+ }
+
+ @Override
+ public void setHintText(@Nullable CharSequence hintText) {
+ if (hintText == null) {
+ swipeToAnswerText.setText(R.string.call_incoming_swipe_to_answer);
+ swipeToRejectText.setText(R.string.call_incoming_swipe_to_reject);
+ } else {
+ swipeToAnswerText.setText(hintText);
+ swipeToRejectText.setText(null);
+ }
+ }
+
+ @Override
+ public void setShowIncomingWillDisconnect(boolean incomingWillDisconnect) {
+ this.incomingWillDisconnect = incomingWillDisconnect;
+ if (incomingDisconnectText != null) {
+ incomingDisconnectText.animate().alpha(incomingWillDisconnect ? 1 : 0);
+ }
+ }
+
+ private void showSwipeHint() {
+ setAnimationState(AnimationState.HINT);
+ }
+
+ private void updateSwipeTextAndPuckForTouch() {
+ // Clamp progress value between -1 and 1.
+ final float clampedProgress = MathUtil.clamp(swipeProgress, -1 /* min */, 1 /* max */);
+ final float positiveAdjustedProgress = Math.abs(clampedProgress);
+ final boolean isAcceptingFlow = clampedProgress >= 0;
+
+ // Cancel view property animators on views we're about to mutate
+ swipeToAnswerText.animate().cancel();
+ contactPuckIcon.animate().cancel();
+
+ // Since the animation progression is controlled by user gesture instead of real timeline, the
+ // spec timeline can be divided into 9 slots. Each slot is equivalent to 83ms in the spec.
+ // Therefore, we use 9 slots of 83ms to map user gesture into the spec timeline.
+ final float progressSlots = 9;
+
+ // Fade out the "swipe up to answer". It only takes 1 slot to complete the fade.
+ float swipeTextAlpha = Math.max(0, 1 - Math.abs(clampedProgress) * progressSlots);
+ fadeToward(swipeToAnswerText, swipeTextAlpha);
+ // Fade out the "swipe down to dismiss" at the same time. Don't ever increase its alpha
+ fadeToward(swipeToRejectText, Math.min(swipeTextAlpha, swipeToRejectText.getAlpha()));
+ // Fade out the "incoming will disconnect" text
+ fadeToward(incomingDisconnectText, incomingWillDisconnect ? swipeTextAlpha : 0);
+
+ // Move swipe text back to zero.
+ moveTowardX(swipeToAnswerText, 0 /* newX */);
+ moveTowardY(swipeToAnswerText, 0 /* newY */);
+
+ // Animate puck color
+ @ColorInt
+ int destPuckColor =
+ getContext()
+ .getColor(
+ isAcceptingFlow ? R.color.call_accept_background : R.color.call_hangup_background);
+ destPuckColor =
+ ColorUtils.setAlphaComponent(destPuckColor, (int) (0xFF * positiveAdjustedProgress));
+ contactPuckBackground.setBackgroundTintList(ColorStateList.valueOf(destPuckColor));
+ contactPuckBackground.setBackgroundTintMode(Mode.SRC_ATOP);
+ contactPuckBackground.setColorFilter(destPuckColor);
+
+ // Animate decline icon
+ if (isAcceptingFlow || getParent().isVideoCall()) {
+ rotateToward(contactPuckIcon, 0f);
+ } else {
+ rotateToward(contactPuckIcon, positiveAdjustedProgress * ICON_END_CALL_ROTATION_DEGREES);
+ }
+
+ // Fade in icon
+ if (shouldShowPhotoInPuck()) {
+ fadeToward(contactPuckIcon, positiveAdjustedProgress);
+ }
+ float iconProgress = Math.min(1f, positiveAdjustedProgress * 4);
+ @ColorInt
+ int iconColor =
+ ColorUtils.setAlphaComponent(
+ contactPuckIcon.getContext().getColor(R.color.incoming_answer_icon),
+ (int) (0xFF * (1 - iconProgress)));
+ contactPuckIcon.setImageTintList(ColorStateList.valueOf(iconColor));
+
+ // Move puck.
+ if (isAcceptingFlow) {
+ moveTowardY(
+ contactPuckContainer,
+ -clampedProgress * DpUtil.dpToPx(getContext(), SWIPE_TO_ANSWER_MAX_TRANSLATION_Y_DP));
+ } else {
+ moveTowardY(
+ contactPuckContainer,
+ -clampedProgress * DpUtil.dpToPx(getContext(), SWIPE_TO_REJECT_MAX_TRANSLATION_Y_DP));
+ }
+
+ getParent().onAnswerProgressUpdate(clampedProgress);
+ }
+
+ private void startSwipeToAnswerSwipeAnimation() {
+ LogUtil.i("FlingUpDownMethod.startSwipeToAnswerSwipeAnimation", "Start swipe animation.");
+ resetTouchState();
+ endAnimation();
+ }
+
+ private void setPuckTouchState() {
+ contactPuckBackground.setActivated(touchHandler.isTracking());
+ }
+
+ private void resetTouchState() {
+ if (getContext() == null) {
+ // State will be reset in onStart(), so just abort.
+ return;
+ }
+ contactPuckContainer.animate().scaleX(1 /* scaleX */);
+ contactPuckContainer.animate().scaleY(1 /* scaleY */);
+ contactPuckBackground.animate().scaleX(1 /* scaleX */);
+ contactPuckBackground.animate().scaleY(1 /* scaleY */);
+ contactPuckBackground.setBackgroundTintList(null);
+ contactPuckBackground.setColorFilter(null);
+ contactPuckIcon.setImageTintList(
+ ColorStateList.valueOf(getContext().getColor(R.color.incoming_answer_icon)));
+ contactPuckIcon.animate().rotation(0);
+
+ getParent().resetAnswerProgress();
+ setPuckTouchState();
+
+ final float alpha = 1;
+ swipeToAnswerText.animate().alpha(alpha);
+ contactPuckContainer.animate().alpha(alpha);
+ contactPuckBackground.animate().alpha(alpha);
+ contactPuckIcon.animate().alpha(shouldShowPhotoInPuck() ? 0 : alpha);
+ }
+
+ @VisibleForTesting
+ void setAnimationState(@AnimationState int state) {
+ if (state != AnimationState.HINT && animationState == state) {
+ return;
+ }
+
+ if (animationState == AnimationState.COMPLETED) {
+ LogUtil.e(
+ "FlingUpDownMethod.setAnimationState",
+ "Animation loop has completed. Cannot switch to new state: " + state);
+ return;
+ }
+
+ if (state == AnimationState.HINT || state == AnimationState.BOUNCE) {
+ if (animationState == AnimationState.SWIPE) {
+ afterSettleAnimationState = state;
+ state = AnimationState.SETTLE;
+ }
+ }
+
+ LogUtil.i("FlingUpDownMethod.setAnimationState", "animation state: " + state);
+ animationState = state;
+
+ // Start animation after the current one is finished completely.
+ View view = getView();
+ if (view != null) {
+ // As long as the fragment is added, we can start update the animation state.
+ if (isAdded() && (animationState == state)) {
+ updateAnimationState();
+ } else {
+ endAnimation();
+ }
+ }
+ }
+
+ @AnimationState
+ @VisibleForTesting
+ int getAnimationState() {
+ return animationState;
+ }
+
+ private void updateAnimationState() {
+ switch (animationState) {
+ case AnimationState.ENTRY:
+ startSwipeToAnswerEntryAnimation();
+ break;
+ case AnimationState.BOUNCE:
+ startSwipeToAnswerBounceAnimation();
+ break;
+ case AnimationState.SWIPE:
+ startSwipeToAnswerSwipeAnimation();
+ break;
+ case AnimationState.SETTLE:
+ startSwipeToAnswerSettleAnimation();
+ break;
+ case AnimationState.COMPLETED:
+ clearSwipeToAnswerUi();
+ break;
+ case AnimationState.HINT:
+ startSwipeToAnswerHintAnimation();
+ break;
+ case AnimationState.NONE:
+ default:
+ LogUtil.e(
+ "FlingUpDownMethod.updateAnimationState",
+ "Unexpected animation state: " + animationState);
+ break;
+ }
+ }
+
+ private void startSwipeToAnswerEntryAnimation() {
+ LogUtil.i("FlingUpDownMethod.startSwipeToAnswerEntryAnimation", "Swipe entry animation.");
+ endAnimation();
+
+ lockEntryAnim = new AnimatorSet();
+ Animator textUp =
+ ObjectAnimator.ofFloat(
+ swipeToAnswerText,
+ View.TRANSLATION_Y,
+ DpUtil.dpToPx(getContext(), 192 /* dp */),
+ DpUtil.dpToPx(getContext(), -20 /* dp */));
+ textUp.setDuration(ANIMATE_DURATION_NORMAL_MILLIS);
+ textUp.setInterpolator(new LinearOutSlowInInterpolator());
+
+ Animator textDown =
+ ObjectAnimator.ofFloat(
+ swipeToAnswerText,
+ View.TRANSLATION_Y,
+ DpUtil.dpToPx(getContext(), -20) /* dp */,
+ 0 /* end pos */);
+ textDown.setDuration(ANIMATE_DURATION_NORMAL_MILLIS);
+ textUp.setInterpolator(new FastOutSlowInInterpolator());
+
+ // "Swipe down to reject" text fades in with a slight translation
+ swipeToRejectText.setAlpha(0f);
+ Animator rejectTextShow =
+ ObjectAnimator.ofPropertyValuesHolder(
+ swipeToRejectText,
+ PropertyValuesHolder.ofFloat(View.ALPHA, 1f),
+ PropertyValuesHolder.ofFloat(
+ View.TRANSLATION_Y,
+ DpUtil.dpToPx(getContext(), HINT_REJECT_FADE_TRANSLATION_Y_DP),
+ 0f));
+ rejectTextShow.setInterpolator(new FastOutLinearInInterpolator());
+ rejectTextShow.setDuration(ANIMATE_DURATION_SHORT_MILLIS);
+ rejectTextShow.setStartDelay(SWIPE_TO_DECLINE_FADE_IN_DELAY_MILLIS);
+
+ Animator puckUp =
+ ObjectAnimator.ofFloat(
+ contactPuckContainer,
+ View.TRANSLATION_Y,
+ DpUtil.dpToPx(getContext(), 400 /* dp */),
+ DpUtil.dpToPx(getContext(), -12 /* dp */));
+ puckUp.setDuration(ANIMATE_DURATION_LONG_MILLIS);
+ puckUp.setInterpolator(
+ PathInterpolatorCompat.create(
+ 0 /* controlX1 */, 0 /* controlY1 */, 0 /* controlX2 */, 1 /* controlY2 */));
+
+ Animator puckDown =
+ ObjectAnimator.ofFloat(
+ contactPuckContainer,
+ View.TRANSLATION_Y,
+ DpUtil.dpToPx(getContext(), -12 /* dp */),
+ 0 /* end pos */);
+ puckDown.setDuration(ANIMATE_DURATION_NORMAL_MILLIS);
+ puckDown.setInterpolator(new FastOutSlowInInterpolator());
+
+ Animator puckScaleUp =
+ createUniformScaleAnimators(
+ contactPuckBackground,
+ 0.33f /* beginScale */,
+ 1.1f /* endScale */,
+ ANIMATE_DURATION_NORMAL_MILLIS,
+ PathInterpolatorCompat.create(
+ 0.4f /* controlX1 */, 0 /* controlY1 */, 0 /* controlX2 */, 1 /* controlY2 */));
+ Animator puckScaleDown =
+ createUniformScaleAnimators(
+ contactPuckBackground,
+ 1.1f /* beginScale */,
+ 1 /* endScale */,
+ ANIMATE_DURATION_NORMAL_MILLIS,
+ new FastOutSlowInInterpolator());
+
+ // Upward animation chain.
+ lockEntryAnim.play(textUp).with(puckScaleUp).with(puckUp);
+
+ // Downward animation chain.
+ lockEntryAnim.play(textDown).with(puckDown).with(puckScaleDown).after(puckUp);
+
+ lockEntryAnim.play(rejectTextShow).after(puckUp);
+
+ // Add vibration animation.
+ addVibrationAnimator(lockEntryAnim);
+
+ lockEntryAnim.addListener(
+ new AnimatorListenerAdapter() {
+
+ public boolean canceled;
+
+ @Override
+ public void onAnimationCancel(Animator animation) {
+ super.onAnimationCancel(animation);
+ canceled = true;
+ }
+
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ super.onAnimationEnd(animation);
+ if (!canceled) {
+ onEntryAnimationDone();
+ }
+ }
+ });
+ lockEntryAnim.start();
+ }
+
+ @VisibleForTesting
+ void onEntryAnimationDone() {
+ LogUtil.i("FlingUpDownMethod.onEntryAnimationDone", "Swipe entry anim ends.");
+ if (animationState == AnimationState.ENTRY) {
+ setAnimationState(AnimationState.BOUNCE);
+ }
+ }
+
+ private void startSwipeToAnswerBounceAnimation() {
+ LogUtil.i("FlingUpDownMethod.startSwipeToAnswerBounceAnimation", "Swipe bounce animation.");
+ endAnimation();
+
+ if (ViewUtil.areAnimationsDisabled(getContext())) {
+ swipeToAnswerText.setTranslationY(0);
+ contactPuckContainer.setTranslationY(0);
+ contactPuckBackground.setScaleY(1f);
+ contactPuckBackground.setScaleX(1f);
+ swipeToRejectText.setAlpha(1f);
+ swipeToRejectText.setTranslationY(0);
+ return;
+ }
+
+ lockBounceAnim = createBreatheAnimation();
+
+ answerHint.onBounceStart();
+ lockBounceAnim.addListener(
+ new AnimatorListenerAdapter() {
+ boolean firstPass = true;
+
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ super.onAnimationEnd(animation);
+ if (getContext() != null
+ && lockBounceAnim != null
+ && animationState == AnimationState.BOUNCE) {
+ // AnimatorSet doesn't have repeat settings. Instead, we start a new one after the
+ // previous set is completed, until endAnimation is called.
+ LogUtil.v("FlingUpDownMethod.onAnimationEnd", "Bounce again.");
+
+ // If this is the first time repeating the animation, we should recreate it so its
+ // starting values will be correct
+ if (firstPass) {
+ lockBounceAnim = createBreatheAnimation();
+ lockBounceAnim.addListener(this);
+ }
+ firstPass = false;
+ answerHint.onBounceStart();
+ lockBounceAnim.start();
+ }
+ }
+ });
+ lockBounceAnim.start();
+ }
+
+ private Animator createBreatheAnimation() {
+ AnimatorSet breatheAnimation = new AnimatorSet();
+ float textOffset = DpUtil.dpToPx(getContext(), 42 /* dp */);
+ Animator textUp =
+ ObjectAnimator.ofFloat(
+ swipeToAnswerText, View.TRANSLATION_Y, 0 /* begin pos */, -textOffset);
+ textUp.setInterpolator(new FastOutSlowInInterpolator());
+ textUp.setDuration(ANIMATE_DURATION_NORMAL_MILLIS);
+
+ Animator textDown =
+ ObjectAnimator.ofFloat(swipeToAnswerText, View.TRANSLATION_Y, -textOffset, 0 /* end pos */);
+ textDown.setInterpolator(new FastOutSlowInInterpolator());
+ textDown.setDuration(ANIMATE_DURATION_NORMAL_MILLIS);
+
+ // "Swipe down to reject" text fade in
+ Animator rejectTextShow = ObjectAnimator.ofFloat(swipeToRejectText, View.ALPHA, 1f);
+ rejectTextShow.setInterpolator(new LinearOutSlowInInterpolator());
+ rejectTextShow.setDuration(ANIMATE_DURATION_SHORT_MILLIS);
+ rejectTextShow.setStartDelay(SWIPE_TO_DECLINE_FADE_IN_DELAY_MILLIS);
+
+ // reject hint text translate in
+ Animator rejectTextTranslate =
+ ObjectAnimator.ofFloat(
+ swipeToRejectText,
+ View.TRANSLATION_Y,
+ DpUtil.dpToPx(getContext(), HINT_REJECT_FADE_TRANSLATION_Y_DP),
+ 0f);
+ rejectTextTranslate.setInterpolator(new FastOutSlowInInterpolator());
+ rejectTextTranslate.setDuration(ANIMATE_DURATION_NORMAL_MILLIS);
+
+ // reject hint text fade out
+ Animator rejectTextHide = ObjectAnimator.ofFloat(swipeToRejectText, View.ALPHA, 0f);
+ rejectTextHide.setInterpolator(new FastOutLinearInInterpolator());
+ rejectTextHide.setDuration(ANIMATE_DURATION_SHORT_MILLIS);
+
+ Interpolator curve =
+ PathInterpolatorCompat.create(
+ 0.4f /* controlX1 */, 0 /* controlY1 */, 0 /* controlX2 */, 1 /* controlY2 */);
+ float puckOffset = DpUtil.dpToPx(getContext(), 42 /* dp */);
+ Animator puckUp = ObjectAnimator.ofFloat(contactPuckContainer, View.TRANSLATION_Y, -puckOffset);
+ puckUp.setInterpolator(curve);
+ puckUp.setDuration(ANIMATE_DURATION_LONG_MILLIS);
+
+ final float scale = 1.0625f;
+ Animator puckScaleUp =
+ createUniformScaleAnimators(
+ contactPuckBackground,
+ 1 /* beginScale */,
+ scale,
+ ANIMATE_DURATION_NORMAL_MILLIS,
+ curve);
+
+ Animator puckDown =
+ ObjectAnimator.ofFloat(contactPuckContainer, View.TRANSLATION_Y, 0 /* end pos */);
+ puckDown.setInterpolator(new FastOutSlowInInterpolator());
+ puckDown.setDuration(ANIMATE_DURATION_NORMAL_MILLIS);
+
+ Animator puckScaleDown =
+ createUniformScaleAnimators(
+ contactPuckBackground,
+ scale,
+ 1 /* endScale */,
+ ANIMATE_DURATION_NORMAL_MILLIS,
+ new FastOutSlowInInterpolator());
+
+ // Bounce upward animation chain.
+ breatheAnimation
+ .play(textUp)
+ .with(rejectTextHide)
+ .with(puckUp)
+ .with(puckScaleUp)
+ .after(167 /* delay */);
+
+ // Bounce downward animation chain.
+ breatheAnimation
+ .play(puckDown)
+ .with(textDown)
+ .with(puckScaleDown)
+ .with(rejectTextShow)
+ .with(rejectTextTranslate)
+ .after(puckUp);
+
+ // Add vibration animation to the animator set.
+ addVibrationAnimator(breatheAnimation);
+
+ return breatheAnimation;
+ }
+
+ private void startSwipeToAnswerSettleAnimation() {
+ endAnimation();
+
+ ObjectAnimator puckScale =
+ ObjectAnimator.ofPropertyValuesHolder(
+ contactPuckBackground,
+ PropertyValuesHolder.ofFloat(View.SCALE_X, 1),
+ PropertyValuesHolder.ofFloat(View.SCALE_Y, 1));
+ puckScale.setDuration(SETTLE_ANIMATION_DURATION_MILLIS);
+
+ ObjectAnimator iconRotation = ObjectAnimator.ofFloat(contactPuckIcon, View.ROTATION, 0);
+ iconRotation.setDuration(SETTLE_ANIMATION_DURATION_MILLIS);
+
+ ObjectAnimator swipeToAnswerTextFade =
+ createFadeAnimation(swipeToAnswerText, 1, SETTLE_ANIMATION_DURATION_MILLIS);
+
+ ObjectAnimator contactPuckContainerFade =
+ createFadeAnimation(contactPuckContainer, 1, SETTLE_ANIMATION_DURATION_MILLIS);
+
+ ObjectAnimator contactPuckBackgroundFade =
+ createFadeAnimation(contactPuckBackground, 1, SETTLE_ANIMATION_DURATION_MILLIS);
+
+ ObjectAnimator contactPuckIconFade =
+ createFadeAnimation(
+ contactPuckIcon, shouldShowPhotoInPuck() ? 0 : 1, SETTLE_ANIMATION_DURATION_MILLIS);
+
+ ObjectAnimator contactPuckTranslation =
+ ObjectAnimator.ofPropertyValuesHolder(
+ contactPuckContainer,
+ PropertyValuesHolder.ofFloat(View.TRANSLATION_X, 0),
+ PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, 0));
+ contactPuckTranslation.setDuration(SETTLE_ANIMATION_DURATION_MILLIS);
+
+ lockSettleAnim = new AnimatorSet();
+ lockSettleAnim
+ .play(puckScale)
+ .with(iconRotation)
+ .with(swipeToAnswerTextFade)
+ .with(contactPuckContainerFade)
+ .with(contactPuckBackgroundFade)
+ .with(contactPuckIconFade)
+ .with(contactPuckTranslation);
+
+ lockSettleAnim.addListener(
+ new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationCancel(Animator animation) {
+ afterSettleAnimationState = AnimationState.NONE;
+ }
+
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ onSettleAnimationDone();
+ }
+ });
+
+ lockSettleAnim.start();
+ }
+
+ @VisibleForTesting
+ void onSettleAnimationDone() {
+ if (afterSettleAnimationState != AnimationState.NONE) {
+ int nextState = afterSettleAnimationState;
+ afterSettleAnimationState = AnimationState.NONE;
+ lockSettleAnim = null;
+
+ setAnimationState(nextState);
+ }
+ }
+
+ private ObjectAnimator createFadeAnimation(View target, float targetAlpha, long duration) {
+ ObjectAnimator objectAnimator = ObjectAnimator.ofFloat(target, View.ALPHA, targetAlpha);
+ objectAnimator.setDuration(duration);
+ return objectAnimator;
+ }
+
+ private void startSwipeToAnswerHintAnimation() {
+ if (rejectHintHide != null) {
+ rejectHintHide.cancel();
+ }
+
+ endAnimation();
+ resetTouchState();
+
+ if (ViewUtil.areAnimationsDisabled(getContext())) {
+ onHintAnimationDone(false);
+ return;
+ }
+
+ lockHintAnim = new AnimatorSet();
+ float jumpOffset = DpUtil.dpToPx(getContext(), HINT_JUMP_DP);
+ float dipOffset = DpUtil.dpToPx(getContext(), HINT_DIP_DP);
+ float scaleSize = HINT_SCALE_RATIO;
+ float textOffset = jumpOffset + (scaleSize - 1) * contactPuckBackground.getHeight();
+ int shortAnimTime =
+ getContext().getResources().getInteger(android.R.integer.config_shortAnimTime);
+ int mediumAnimTime =
+ getContext().getResources().getInteger(android.R.integer.config_mediumAnimTime);
+
+ // Puck squashes to anticipate jump
+ ObjectAnimator puckAnticipate =
+ ObjectAnimator.ofPropertyValuesHolder(
+ contactPuckContainer,
+ PropertyValuesHolder.ofFloat(View.SCALE_Y, .95f),
+ PropertyValuesHolder.ofFloat(View.SCALE_X, 1.05f));
+ puckAnticipate.setRepeatCount(1);
+ puckAnticipate.setRepeatMode(ValueAnimator.REVERSE);
+ puckAnticipate.setDuration(shortAnimTime / 2);
+ puckAnticipate.setInterpolator(new DecelerateInterpolator());
+ puckAnticipate.addListener(
+ new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationStart(Animator animation) {
+ super.onAnimationStart(animation);
+ contactPuckContainer.setPivotY(contactPuckContainer.getHeight());
+ }
+
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ super.onAnimationEnd(animation);
+ contactPuckContainer.setPivotY(contactPuckContainer.getHeight() / 2);
+ }
+ });
+
+ // Ensure puck is at the right starting point for the jump
+ ObjectAnimator puckResetTranslation =
+ ObjectAnimator.ofPropertyValuesHolder(
+ contactPuckContainer,
+ PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, 0),
+ PropertyValuesHolder.ofFloat(View.TRANSLATION_X, 0));
+ puckResetTranslation.setDuration(shortAnimTime / 2);
+ puckAnticipate.setInterpolator(new DecelerateInterpolator());
+
+ Animator textUp = ObjectAnimator.ofFloat(swipeToAnswerText, View.TRANSLATION_Y, -textOffset);
+ textUp.setInterpolator(new LinearOutSlowInInterpolator());
+ textUp.setDuration(shortAnimTime);
+
+ Animator puckUp = ObjectAnimator.ofFloat(contactPuckContainer, View.TRANSLATION_Y, -jumpOffset);
+ puckUp.setInterpolator(new LinearOutSlowInInterpolator());
+ puckUp.setDuration(shortAnimTime);
+
+ Animator puckScaleUp =
+ createUniformScaleAnimators(
+ contactPuckBackground, 1f, scaleSize, shortAnimTime, new LinearOutSlowInInterpolator());
+
+ Animator rejectHintShow =
+ ObjectAnimator.ofPropertyValuesHolder(
+ swipeToRejectText,
+ PropertyValuesHolder.ofFloat(View.ALPHA, 1f),
+ PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, 0f));
+ rejectHintShow.setDuration(shortAnimTime);
+
+ Animator rejectHintDip =
+ ObjectAnimator.ofFloat(swipeToRejectText, View.TRANSLATION_Y, dipOffset);
+ rejectHintDip.setInterpolator(new LinearOutSlowInInterpolator());
+ rejectHintDip.setDuration(shortAnimTime);
+
+ Animator textDown = ObjectAnimator.ofFloat(swipeToAnswerText, View.TRANSLATION_Y, 0);
+ textDown.setInterpolator(new LinearOutSlowInInterpolator());
+ textDown.setDuration(mediumAnimTime);
+
+ Animator puckDown = ObjectAnimator.ofFloat(contactPuckContainer, View.TRANSLATION_Y, 0);
+ BounceInterpolator bounce = new BounceInterpolator();
+ puckDown.setInterpolator(bounce);
+ puckDown.setDuration(mediumAnimTime);
+
+ Animator puckScaleDown =
+ createUniformScaleAnimators(
+ contactPuckBackground, scaleSize, 1f, shortAnimTime, new LinearOutSlowInInterpolator());
+
+ Animator rejectHintUp = ObjectAnimator.ofFloat(swipeToRejectText, View.TRANSLATION_Y, 0);
+ rejectHintUp.setInterpolator(new LinearOutSlowInInterpolator());
+ rejectHintUp.setDuration(mediumAnimTime);
+
+ lockHintAnim.play(puckAnticipate).with(puckResetTranslation).before(puckUp);
+ lockHintAnim
+ .play(textUp)
+ .with(puckUp)
+ .with(puckScaleUp)
+ .with(rejectHintDip)
+ .with(rejectHintShow);
+ lockHintAnim.play(textDown).with(puckDown).with(puckScaleDown).with(rejectHintUp).after(puckUp);
+ lockHintAnim.start();
+
+ rejectHintHide = ObjectAnimator.ofFloat(swipeToRejectText, View.ALPHA, 0);
+ rejectHintHide.setStartDelay(HINT_REJECT_SHOW_DURATION_MILLIS);
+ rejectHintHide.addListener(
+ new AnimatorListenerAdapter() {
+
+ private boolean canceled;
+
+ @Override
+ public void onAnimationCancel(Animator animation) {
+ super.onAnimationCancel(animation);
+ canceled = true;
+ rejectHintHide = null;
+ }
+
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ super.onAnimationEnd(animation);
+ onHintAnimationDone(canceled);
+ }
+ });
+ rejectHintHide.start();
+ }
+
+ @VisibleForTesting
+ void onHintAnimationDone(boolean canceled) {
+ if (!canceled && animationState == AnimationState.HINT) {
+ setAnimationState(AnimationState.BOUNCE);
+ }
+ rejectHintHide = null;
+ }
+
+ private void clearSwipeToAnswerUi() {
+ LogUtil.i("FlingUpDownMethod.clearSwipeToAnswerUi", "Clear swipe animation.");
+ endAnimation();
+ swipeToAnswerText.setVisibility(View.GONE);
+ contactPuckContainer.setVisibility(View.GONE);
+ }
+
+ private void endAnimation() {
+ LogUtil.i("FlingUpDownMethod.endAnimation", "End animations.");
+ if (lockSettleAnim != null) {
+ lockSettleAnim.cancel();
+ lockSettleAnim = null;
+ }
+ if (lockBounceAnim != null) {
+ lockBounceAnim.cancel();
+ lockBounceAnim = null;
+ }
+ if (lockEntryAnim != null) {
+ lockEntryAnim.cancel();
+ lockEntryAnim = null;
+ }
+ if (lockHintAnim != null) {
+ lockHintAnim.cancel();
+ lockHintAnim = null;
+ }
+ if (rejectHintHide != null) {
+ rejectHintHide.cancel();
+ rejectHintHide = null;
+ }
+ if (vibrationAnimator != null) {
+ vibrationAnimator.end();
+ vibrationAnimator = null;
+ }
+ answerHint.onBounceEnd();
+ }
+
+ // Create an animator to scale on X/Y directions uniformly.
+ private Animator createUniformScaleAnimators(
+ View target, float begin, float end, long duration, Interpolator interpolator) {
+ ObjectAnimator animator =
+ ObjectAnimator.ofPropertyValuesHolder(
+ target,
+ PropertyValuesHolder.ofFloat(View.SCALE_X, begin, end),
+ PropertyValuesHolder.ofFloat(View.SCALE_Y, begin, end));
+ animator.setDuration(duration);
+ animator.setInterpolator(interpolator);
+ return animator;
+ }
+
+ private void addVibrationAnimator(AnimatorSet animatorSet) {
+ if (vibrationAnimator != null) {
+ vibrationAnimator.end();
+ }
+
+ // Note that we animate the value between 0 and 1, but internally VibrateInterpolator will
+ // translate it into actually X translation value.
+ vibrationAnimator =
+ ObjectAnimator.ofFloat(
+ contactPuckContainer, View.TRANSLATION_X, 0 /* begin value */, 1 /* end value */);
+ vibrationAnimator.setDuration(VIBRATION_TIME_MILLIS);
+ vibrationAnimator.setInterpolator(new VibrateInterpolator(getContext()));
+
+ animatorSet.play(vibrationAnimator).after(0 /* delay */);
+ }
+
+ private void performAccept() {
+ LogUtil.i("FlingUpDownMethod.performAccept", null);
+ swipeToAnswerText.setVisibility(View.GONE);
+ contactPuckContainer.setVisibility(View.GONE);
+
+ // Complete the animation loop.
+ setAnimationState(AnimationState.COMPLETED);
+ getParent().answerFromMethod();
+ }
+
+ private void performReject() {
+ LogUtil.i("FlingUpDownMethod.performReject", null);
+ swipeToAnswerText.setVisibility(View.GONE);
+ contactPuckContainer.setVisibility(View.GONE);
+
+ // Complete the animation loop.
+ setAnimationState(AnimationState.COMPLETED);
+ getParent().rejectFromMethod();
+ }
+
+ /** Custom interpolator class for puck vibration. */
+ private static class VibrateInterpolator implements Interpolator {
+
+ private static final long RAMP_UP_BEGIN_MS = 583;
+ private static final long RAMP_UP_DURATION_MS = 167;
+ private static final long RAMP_UP_END_MS = RAMP_UP_BEGIN_MS + RAMP_UP_DURATION_MS;
+ private static final long RAMP_DOWN_BEGIN_MS = 1_583;
+ private static final long RAMP_DOWN_DURATION_MS = 250;
+ private static final long RAMP_DOWN_END_MS = RAMP_DOWN_BEGIN_MS + RAMP_DOWN_DURATION_MS;
+ private static final long RAMP_TOTAL_TIME_MS = RAMP_DOWN_END_MS;
+ private final float ampMax;
+ private final float freqMax = 80;
+ private Interpolator sliderInterpolator = new FastOutSlowInInterpolator();
+
+ VibrateInterpolator(Context context) {
+ ampMax = DpUtil.dpToPx(context, 1 /* dp */);
+ }
+
+ @Override
+ public float getInterpolation(float t) {
+ float slider = 0;
+ float time = t * RAMP_TOTAL_TIME_MS;
+
+ // Calculate the slider value based on RAMP_UP and RAMP_DOWN times. Between RAMP_UP and
+ // RAMP_DOWN, the slider remains the maximum value of 1.
+ if (time > RAMP_UP_BEGIN_MS && time < RAMP_UP_END_MS) {
+ // Ramp up.
+ slider =
+ sliderInterpolator.getInterpolation(
+ (time - RAMP_UP_BEGIN_MS) / (float) RAMP_UP_DURATION_MS);
+ } else if ((time >= RAMP_UP_END_MS) && time <= RAMP_DOWN_BEGIN_MS) {
+ // Vibrate at maximum
+ slider = 1;
+ } else if (time > RAMP_DOWN_BEGIN_MS && time < RAMP_DOWN_END_MS) {
+ // Ramp down.
+ slider =
+ 1
+ - sliderInterpolator.getInterpolation(
+ (time - RAMP_DOWN_BEGIN_MS) / (float) RAMP_DOWN_DURATION_MS);
+ }
+
+ float ampNormalized = ampMax * slider;
+ float freqNormalized = freqMax * slider;
+
+ return (float) (ampNormalized * Math.sin(time * freqNormalized));
+ }
+ }
+}