diff options
| author | Eric Erfanian <erfanian@google.com> | 2017-02-22 16:32:36 -0800 |
|---|---|---|
| committer | Eric Erfanian <erfanian@google.com> | 2017-03-01 09:56:52 -0800 |
| commit | ccca31529c07970e89419fb85a9e8153a5396838 (patch) | |
| tree | a7034c0a01672b97728c13282a2672771cd28baa /java/com/android/incallui/answer/impl/answermethod/FlingUpDownMethod.java | |
| parent | e7ae4624ba6f25cb8e648db74e0d64c0113a16ba (diff) | |
Update dialer sources.
Test: Built package and system image.
This change clobbers the old source, and is an export
from an internal Google repository.
The internal repository was forked form Android in March,
and this change includes modifications since then, to
near the v8 release.
Since the fork, we've moved code from monolithic to independent modules. In addition,
we've switched to Blaze/Bazel as the build sysetm. This export, however, still uses make.
New dependencies have been added:
- Dagger
- Auto-Value
- Glide
- Libshortcutbadger
Going forward, development will still be in Google3, and the Gerrit release
will become an automated export, with the next drop happening in ~ two weeks.
Android.mk includes local modifications from ToT.
Abridged changelog:
Bug fixes
● Not able to mute, add a call when using Phone app in multiwindow mode
● Double tap on keypad triggering multiple key and tones
● Reported spam numbers not showing as spam in the call log
● Crash when user tries to block number while Phone app is not set as default
● Crash when user picks a number from search auto-complete list
Visual Voicemail (VVM) improvements
● Share Voicemail audio via standard exporting mechanisms that support file attachment
(email, MMS, etc.)
● Make phone number, email and web sites in VVM transcript clickable
● Set PIN before declining VVM Terms of Service {Carrier}
● Set client type for outbound visual voicemail SMS {Carrier}
New incoming call and incall UI on older devices
(Android M)
● Updated Phone app icon
● New incall UI (large buttons, button labels)
● New and animated Answer/Reject gestures
Accessibility
● Add custom answer/decline call buttons on answer screen for touch exploration
accessibility services
● Increase size of touch target
● Add verbal feedback when a Voicemail fails to load
● Fix pressing of Phone buttons while in a phone call using Switch Access
● Fix selecting and opening contacts in talkback mode
● Split focus for ‘Learn More’ link in caller id & spam to help distinguish similar text
Other
● Backup & Restore for App Preferences
● Prompt user to enable Wi-Fi calling if the call ends due to out of service and Wi-Fi is
connected
● Rename “Dialpad” to “Keypad”
● Show "Private number" for restricted calls
● Delete unused items (vcard, add contact, call history) from Phone menu
Change-Id: I2a7e53532a24c21bf308bf0a6d178d7ddbca4958
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)); + } + } +} |
