blob: 0bc65818c03980e3eda9895a5e5e6a4c3840372f [file] [log] [blame]
/*
* 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));
}
}
}