diff options
Diffstat (limited to 'java/com/android/incallui/answer/impl/answermethod/FlingUpDownMethod.java')
-rw-r--r-- | java/com/android/incallui/answer/impl/answermethod/FlingUpDownMethod.java | 1149 |
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)); + } + } +} |