summaryrefslogtreecommitdiff
path: root/java/com/android/incallui/answer/impl/affordance
diff options
context:
space:
mode:
authorEric Erfanian <erfanian@google.com>2017-02-22 16:32:36 -0800
committerEric Erfanian <erfanian@google.com>2017-03-01 09:56:52 -0800
commitccca31529c07970e89419fb85a9e8153a5396838 (patch)
treea7034c0a01672b97728c13282a2672771cd28baa /java/com/android/incallui/answer/impl/affordance
parente7ae4624ba6f25cb8e648db74e0d64c0113a16ba (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/affordance')
-rw-r--r--java/com/android/incallui/answer/impl/affordance/AndroidManifest.xml3
-rw-r--r--java/com/android/incallui/answer/impl/affordance/SwipeButtonHelper.java642
-rw-r--r--java/com/android/incallui/answer/impl/affordance/SwipeButtonView.java505
-rw-r--r--java/com/android/incallui/answer/impl/affordance/res/values/dimens.xml23
4 files changed, 1173 insertions, 0 deletions
diff --git a/java/com/android/incallui/answer/impl/affordance/AndroidManifest.xml b/java/com/android/incallui/answer/impl/affordance/AndroidManifest.xml
new file mode 100644
index 000000000..960fd71c1
--- /dev/null
+++ b/java/com/android/incallui/answer/impl/affordance/AndroidManifest.xml
@@ -0,0 +1,3 @@
+<manifest
+ package="com.android.incallui.answer.impl.affordance">
+</manifest>
diff --git a/java/com/android/incallui/answer/impl/affordance/SwipeButtonHelper.java b/java/com/android/incallui/answer/impl/affordance/SwipeButtonHelper.java
new file mode 100644
index 000000000..62845b748
--- /dev/null
+++ b/java/com/android/incallui/answer/impl/affordance/SwipeButtonHelper.java
@@ -0,0 +1,642 @@
+/*
+ * 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.affordance;
+
+import android.animation.Animator;
+import android.animation.AnimatorListenerAdapter;
+import android.animation.ValueAnimator;
+import android.content.Context;
+import android.support.annotation.Nullable;
+import android.view.MotionEvent;
+import android.view.VelocityTracker;
+import android.view.View;
+import android.view.ViewConfiguration;
+import com.android.incallui.answer.impl.utils.FlingAnimationUtils;
+import com.android.incallui.answer.impl.utils.Interpolators;
+
+/** A touch handler of the swipe buttons */
+public class SwipeButtonHelper {
+
+ public static final float SWIPE_RESTING_ALPHA_AMOUNT = 0.87f;
+ public static final long HINT_PHASE1_DURATION = 200;
+ private static final long HINT_PHASE2_DURATION = 350;
+ private static final float BACKGROUND_RADIUS_SCALE_FACTOR = 0.25f;
+ private static final int HINT_CIRCLE_OPEN_DURATION = 500;
+
+ private final Context context;
+ private final Callback callback;
+
+ private FlingAnimationUtils flingAnimationUtils;
+ private VelocityTracker velocityTracker;
+ private boolean swipingInProgress;
+ private float initialTouchX;
+ private float initialTouchY;
+ private float translation;
+ private float translationOnDown;
+ private int touchSlop;
+ private int minTranslationAmount;
+ private int minFlingVelocity;
+ private int hintGrowAmount;
+ @Nullable private SwipeButtonView leftIcon;
+ @Nullable private SwipeButtonView rightIcon;
+ private Animator swipeAnimator;
+ private int minBackgroundRadius;
+ private boolean motionCancelled;
+ private int touchTargetSize;
+ private View targetedView;
+ private boolean touchSlopExeeded;
+ private AnimatorListenerAdapter flingEndListener =
+ new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ swipeAnimator = null;
+ swipingInProgress = false;
+ targetedView = null;
+ }
+ };
+ private Runnable animationEndRunnable =
+ new Runnable() {
+ @Override
+ public void run() {
+ callback.onAnimationToSideEnded();
+ }
+ };
+
+ public SwipeButtonHelper(Callback callback, Context context) {
+ this.context = context;
+ this.callback = callback;
+ init();
+ }
+
+ public void init() {
+ initIcons();
+ updateIcon(
+ leftIcon,
+ 0.0f,
+ leftIcon != null ? leftIcon.getRestingAlpha() : 0,
+ false,
+ false,
+ true,
+ false);
+ updateIcon(
+ rightIcon,
+ 0.0f,
+ rightIcon != null ? rightIcon.getRestingAlpha() : 0,
+ false,
+ false,
+ true,
+ false);
+ initDimens();
+ }
+
+ private void initDimens() {
+ final ViewConfiguration configuration = ViewConfiguration.get(context);
+ touchSlop = configuration.getScaledPagingTouchSlop();
+ minFlingVelocity = configuration.getScaledMinimumFlingVelocity();
+ minTranslationAmount =
+ context.getResources().getDimensionPixelSize(R.dimen.answer_min_swipe_amount);
+ minBackgroundRadius =
+ context
+ .getResources()
+ .getDimensionPixelSize(R.dimen.answer_affordance_min_background_radius);
+ touchTargetSize =
+ context.getResources().getDimensionPixelSize(R.dimen.answer_affordance_touch_target_size);
+ hintGrowAmount =
+ context.getResources().getDimensionPixelSize(R.dimen.hint_grow_amount_sideways);
+ flingAnimationUtils = new FlingAnimationUtils(context, 0.4f);
+ }
+
+ private void initIcons() {
+ leftIcon = callback.getLeftIcon();
+ rightIcon = callback.getRightIcon();
+ updatePreviews();
+ }
+
+ public void updatePreviews() {
+ if (leftIcon != null) {
+ leftIcon.setPreviewView(callback.getLeftPreview());
+ }
+ if (rightIcon != null) {
+ rightIcon.setPreviewView(callback.getRightPreview());
+ }
+ }
+
+ public boolean onTouchEvent(MotionEvent event) {
+ int action = event.getActionMasked();
+ if (motionCancelled && action != MotionEvent.ACTION_DOWN) {
+ return false;
+ }
+ final float y = event.getY();
+ final float x = event.getX();
+
+ boolean isUp = false;
+ switch (action) {
+ case MotionEvent.ACTION_DOWN:
+ View targetView = getIconAtPosition(x, y);
+ if (targetView == null || (targetedView != null && targetedView != targetView)) {
+ motionCancelled = true;
+ return false;
+ }
+ if (targetedView != null) {
+ cancelAnimation();
+ } else {
+ touchSlopExeeded = false;
+ }
+ startSwiping(targetView);
+ initialTouchX = x;
+ initialTouchY = y;
+ translationOnDown = translation;
+ initVelocityTracker();
+ trackMovement(event);
+ motionCancelled = false;
+ break;
+ case MotionEvent.ACTION_POINTER_DOWN:
+ motionCancelled = true;
+ endMotion(true /* forceSnapBack */, x, y);
+ break;
+ case MotionEvent.ACTION_MOVE:
+ trackMovement(event);
+ float xDist = x - initialTouchX;
+ float yDist = y - initialTouchY;
+ float distance = (float) Math.hypot(xDist, yDist);
+ if (!touchSlopExeeded && distance > touchSlop) {
+ touchSlopExeeded = true;
+ }
+ if (swipingInProgress) {
+ if (targetedView == rightIcon) {
+ distance = translationOnDown - distance;
+ distance = Math.min(0, distance);
+ } else {
+ distance = translationOnDown + distance;
+ distance = Math.max(0, distance);
+ }
+ setTranslation(distance, false /* isReset */, false /* animateReset */);
+ }
+ break;
+
+ case MotionEvent.ACTION_UP:
+ isUp = true;
+ //fallthrough_intended
+ case MotionEvent.ACTION_CANCEL:
+ boolean hintOnTheRight = targetedView == rightIcon;
+ trackMovement(event);
+ endMotion(!isUp, x, y);
+ if (!touchSlopExeeded && isUp) {
+ callback.onIconClicked(hintOnTheRight);
+ }
+ break;
+ }
+ return true;
+ }
+
+ private void startSwiping(View targetView) {
+ callback.onSwipingStarted(targetView == rightIcon);
+ swipingInProgress = true;
+ targetedView = targetView;
+ }
+
+ private View getIconAtPosition(float x, float y) {
+ if (leftSwipePossible() && isOnIcon(leftIcon, x, y)) {
+ return leftIcon;
+ }
+ if (rightSwipePossible() && isOnIcon(rightIcon, x, y)) {
+ return rightIcon;
+ }
+ return null;
+ }
+
+ public boolean isOnAffordanceIcon(float x, float y) {
+ return isOnIcon(leftIcon, x, y) || isOnIcon(rightIcon, x, y);
+ }
+
+ private boolean isOnIcon(View icon, float x, float y) {
+ float iconX = icon.getX() + icon.getWidth() / 2.0f;
+ float iconY = icon.getY() + icon.getHeight() / 2.0f;
+ double distance = Math.hypot(x - iconX, y - iconY);
+ return distance <= touchTargetSize / 2;
+ }
+
+ private void endMotion(boolean forceSnapBack, float lastX, float lastY) {
+ if (swipingInProgress) {
+ flingWithCurrentVelocity(forceSnapBack, lastX, lastY);
+ } else {
+ targetedView = null;
+ }
+ if (velocityTracker != null) {
+ velocityTracker.recycle();
+ velocityTracker = null;
+ }
+ }
+
+ private boolean rightSwipePossible() {
+ return rightIcon != null && rightIcon.getVisibility() == View.VISIBLE;
+ }
+
+ private boolean leftSwipePossible() {
+ return leftIcon != null && leftIcon.getVisibility() == View.VISIBLE;
+ }
+
+ public void startHintAnimation(boolean right, @Nullable Runnable onFinishedListener) {
+ cancelAnimation();
+ startHintAnimationPhase1(right, onFinishedListener);
+ }
+
+ private void startHintAnimationPhase1(
+ final boolean right, @Nullable final Runnable onFinishedListener) {
+ final SwipeButtonView targetView = right ? rightIcon : leftIcon;
+ ValueAnimator animator = getAnimatorToRadius(right, hintGrowAmount);
+ if (animator == null) {
+ if (onFinishedListener != null) {
+ onFinishedListener.run();
+ }
+ return;
+ }
+ animator.addListener(
+ new AnimatorListenerAdapter() {
+ private boolean mCancelled;
+
+ @Override
+ public void onAnimationCancel(Animator animation) {
+ mCancelled = true;
+ }
+
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ if (mCancelled) {
+ swipeAnimator = null;
+ targetedView = null;
+ if (onFinishedListener != null) {
+ onFinishedListener.run();
+ }
+ } else {
+ startUnlockHintAnimationPhase2(right, onFinishedListener);
+ }
+ }
+ });
+ animator.setInterpolator(Interpolators.LINEAR_OUT_SLOW_IN);
+ animator.setDuration(HINT_PHASE1_DURATION);
+ animator.start();
+ swipeAnimator = animator;
+ targetedView = targetView;
+ }
+
+ /** Phase 2: Move back. */
+ private void startUnlockHintAnimationPhase2(
+ boolean right, @Nullable final Runnable onFinishedListener) {
+ ValueAnimator animator = getAnimatorToRadius(right, 0);
+ if (animator == null) {
+ if (onFinishedListener != null) {
+ onFinishedListener.run();
+ }
+ return;
+ }
+ animator.addListener(
+ new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ swipeAnimator = null;
+ targetedView = null;
+ if (onFinishedListener != null) {
+ onFinishedListener.run();
+ }
+ }
+ });
+ animator.setInterpolator(Interpolators.FAST_OUT_LINEAR_IN);
+ animator.setDuration(HINT_PHASE2_DURATION);
+ animator.setStartDelay(HINT_CIRCLE_OPEN_DURATION);
+ animator.start();
+ swipeAnimator = animator;
+ }
+
+ private ValueAnimator getAnimatorToRadius(final boolean right, int radius) {
+ final SwipeButtonView targetView = right ? rightIcon : leftIcon;
+ if (targetView == null) {
+ return null;
+ }
+ ValueAnimator animator = ValueAnimator.ofFloat(targetView.getCircleRadius(), radius);
+ animator.addUpdateListener(
+ new ValueAnimator.AnimatorUpdateListener() {
+ @Override
+ public void onAnimationUpdate(ValueAnimator animation) {
+ float newRadius = (float) animation.getAnimatedValue();
+ targetView.setCircleRadiusWithoutAnimation(newRadius);
+ float translation = getTranslationFromRadius(newRadius);
+ SwipeButtonHelper.this.translation = right ? -translation : translation;
+ updateIconsFromTranslation(targetView);
+ }
+ });
+ return animator;
+ }
+
+ private void cancelAnimation() {
+ if (swipeAnimator != null) {
+ swipeAnimator.cancel();
+ }
+ }
+
+ private void flingWithCurrentVelocity(boolean forceSnapBack, float lastX, float lastY) {
+ float vel = getCurrentVelocity(lastX, lastY);
+
+ // We snap back if the current translation is not far enough
+ boolean snapBack = isBelowFalsingThreshold();
+
+ // or if the velocity is in the opposite direction.
+ boolean velIsInWrongDirection = vel * translation < 0;
+ snapBack |= Math.abs(vel) > minFlingVelocity && velIsInWrongDirection;
+ vel = snapBack ^ velIsInWrongDirection ? 0 : vel;
+ fling(vel, snapBack || forceSnapBack, translation < 0);
+ }
+
+ private boolean isBelowFalsingThreshold() {
+ return Math.abs(translation) < Math.abs(translationOnDown) + getMinTranslationAmount();
+ }
+
+ private int getMinTranslationAmount() {
+ float factor = callback.getAffordanceFalsingFactor();
+ return (int) (minTranslationAmount * factor);
+ }
+
+ private void fling(float vel, final boolean snapBack, boolean right) {
+ float target =
+ right ? -callback.getMaxTranslationDistance() : callback.getMaxTranslationDistance();
+ target = snapBack ? 0 : target;
+
+ ValueAnimator animator = ValueAnimator.ofFloat(translation, target);
+ flingAnimationUtils.apply(animator, translation, target, vel);
+ animator.addUpdateListener(
+ new ValueAnimator.AnimatorUpdateListener() {
+ @Override
+ public void onAnimationUpdate(ValueAnimator animation) {
+ translation = (float) animation.getAnimatedValue();
+ }
+ });
+ animator.addListener(flingEndListener);
+ if (!snapBack) {
+ startFinishingCircleAnimation(vel * 0.375f, animationEndRunnable, right);
+ callback.onAnimationToSideStarted(right, translation, vel);
+ } else {
+ reset(true);
+ }
+ animator.start();
+ swipeAnimator = animator;
+ if (snapBack) {
+ callback.onSwipingAborted();
+ }
+ }
+
+ private void startFinishingCircleAnimation(
+ float velocity, Runnable mAnimationEndRunnable, boolean right) {
+ SwipeButtonView targetView = right ? rightIcon : leftIcon;
+ if (targetView != null) {
+ targetView.finishAnimation(velocity, mAnimationEndRunnable);
+ }
+ }
+
+ private void setTranslation(float translation, boolean isReset, boolean animateReset) {
+ translation = rightSwipePossible() ? translation : Math.max(0, translation);
+ translation = leftSwipePossible() ? translation : Math.min(0, translation);
+ float absTranslation = Math.abs(translation);
+ if (translation != this.translation || isReset) {
+ SwipeButtonView targetView = translation > 0 ? leftIcon : rightIcon;
+ SwipeButtonView otherView = translation > 0 ? rightIcon : leftIcon;
+ float alpha = absTranslation / getMinTranslationAmount();
+
+ // We interpolate the alpha of the other icons to 0
+ float fadeOutAlpha = 1.0f - alpha;
+ fadeOutAlpha = Math.max(fadeOutAlpha, 0.0f);
+
+ boolean animateIcons = isReset && animateReset;
+ boolean forceNoCircleAnimation = isReset && !animateReset;
+ float radius = getRadiusFromTranslation(absTranslation);
+ boolean slowAnimation = isReset && isBelowFalsingThreshold();
+ if (targetView != null) {
+ if (!isReset) {
+ updateIcon(
+ targetView,
+ radius,
+ alpha + fadeOutAlpha * targetView.getRestingAlpha(),
+ false,
+ false,
+ false,
+ false);
+ } else {
+ updateIcon(
+ targetView,
+ 0.0f,
+ fadeOutAlpha * targetView.getRestingAlpha(),
+ animateIcons,
+ slowAnimation,
+ false,
+ forceNoCircleAnimation);
+ }
+ }
+ if (otherView != null) {
+ updateIcon(
+ otherView,
+ 0.0f,
+ fadeOutAlpha * otherView.getRestingAlpha(),
+ animateIcons,
+ slowAnimation,
+ false,
+ forceNoCircleAnimation);
+ }
+
+ this.translation = translation;
+ }
+ }
+
+ private void updateIconsFromTranslation(SwipeButtonView targetView) {
+ float absTranslation = Math.abs(translation);
+ float alpha = absTranslation / getMinTranslationAmount();
+
+ // We interpolate the alpha of the other icons to 0
+ float fadeOutAlpha = 1.0f - alpha;
+ fadeOutAlpha = Math.max(0.0f, fadeOutAlpha);
+
+ // We interpolate the alpha of the targetView to 1
+ SwipeButtonView otherView = targetView == rightIcon ? leftIcon : rightIcon;
+ updateIconAlpha(targetView, alpha + fadeOutAlpha * targetView.getRestingAlpha(), false);
+ if (otherView != null) {
+ updateIconAlpha(otherView, fadeOutAlpha * otherView.getRestingAlpha(), false);
+ }
+ }
+
+ private float getTranslationFromRadius(float circleSize) {
+ float translation = (circleSize - minBackgroundRadius) / BACKGROUND_RADIUS_SCALE_FACTOR;
+ return translation > 0.0f ? translation + touchSlop : 0.0f;
+ }
+
+ private float getRadiusFromTranslation(float translation) {
+ if (translation <= touchSlop) {
+ return 0.0f;
+ }
+ return (translation - touchSlop) * BACKGROUND_RADIUS_SCALE_FACTOR + minBackgroundRadius;
+ }
+
+ public void animateHideLeftRightIcon() {
+ cancelAnimation();
+ updateIcon(rightIcon, 0f, 0f, true, false, false, false);
+ updateIcon(leftIcon, 0f, 0f, true, false, false, false);
+ }
+
+ private void updateIcon(
+ @Nullable SwipeButtonView view,
+ float circleRadius,
+ float alpha,
+ boolean animate,
+ boolean slowRadiusAnimation,
+ boolean force,
+ boolean forceNoCircleAnimation) {
+ if (view == null) {
+ return;
+ }
+ if (view.getVisibility() != View.VISIBLE && !force) {
+ return;
+ }
+ if (forceNoCircleAnimation) {
+ view.setCircleRadiusWithoutAnimation(circleRadius);
+ } else {
+ view.setCircleRadius(circleRadius, slowRadiusAnimation);
+ }
+ updateIconAlpha(view, alpha, animate);
+ }
+
+ private void updateIconAlpha(SwipeButtonView view, float alpha, boolean animate) {
+ float scale = getScale(alpha, view);
+ alpha = Math.min(1.0f, alpha);
+ view.setImageAlpha(alpha, animate);
+ view.setImageScale(scale, animate);
+ }
+
+ private float getScale(float alpha, SwipeButtonView icon) {
+ float scale = alpha / icon.getRestingAlpha() * 0.2f + SwipeButtonView.MIN_ICON_SCALE_AMOUNT;
+ return Math.min(scale, SwipeButtonView.MAX_ICON_SCALE_AMOUNT);
+ }
+
+ private void trackMovement(MotionEvent event) {
+ if (velocityTracker != null) {
+ velocityTracker.addMovement(event);
+ }
+ }
+
+ private void initVelocityTracker() {
+ if (velocityTracker != null) {
+ velocityTracker.recycle();
+ }
+ velocityTracker = VelocityTracker.obtain();
+ }
+
+ private float getCurrentVelocity(float lastX, float lastY) {
+ if (velocityTracker == null) {
+ return 0;
+ }
+ velocityTracker.computeCurrentVelocity(1000);
+ float aX = velocityTracker.getXVelocity();
+ float aY = velocityTracker.getYVelocity();
+ float bX = lastX - initialTouchX;
+ float bY = lastY - initialTouchY;
+ float bLen = (float) Math.hypot(bX, bY);
+ // Project the velocity onto the distance vector: a * b / |b|
+ float projectedVelocity = (aX * bX + aY * bY) / bLen;
+ if (targetedView == rightIcon) {
+ projectedVelocity = -projectedVelocity;
+ }
+ return projectedVelocity;
+ }
+
+ public void onConfigurationChanged() {
+ initDimens();
+ initIcons();
+ }
+
+ public void onRtlPropertiesChanged() {
+ initIcons();
+ }
+
+ public void reset(boolean animate) {
+ cancelAnimation();
+ setTranslation(0.0f, true, animate);
+ motionCancelled = true;
+ if (swipingInProgress) {
+ callback.onSwipingAborted();
+ swipingInProgress = false;
+ }
+ }
+
+ public boolean isSwipingInProgress() {
+ return swipingInProgress;
+ }
+
+ public void launchAffordance(boolean animate, boolean left) {
+ SwipeButtonView targetView = left ? leftIcon : rightIcon;
+ if (swipingInProgress || targetView == null) {
+ // We don't want to mess with the state if the user is actually swiping already.
+ return;
+ }
+ SwipeButtonView otherView = left ? rightIcon : leftIcon;
+ startSwiping(targetView);
+ if (animate) {
+ fling(0, false, !left);
+ updateIcon(otherView, 0.0f, 0, true, false, true, false);
+ } else {
+ callback.onAnimationToSideStarted(!left, translation, 0);
+ translation =
+ left ? callback.getMaxTranslationDistance() : callback.getMaxTranslationDistance();
+ updateIcon(otherView, 0.0f, 0.0f, false, false, true, false);
+ targetView.instantFinishAnimation();
+ flingEndListener.onAnimationEnd(null);
+ animationEndRunnable.run();
+ }
+ }
+
+ /** Callback interface for various actions */
+ public interface Callback {
+
+ /**
+ * Notifies the callback when an animation to a side page was started.
+ *
+ * @param rightPage Is the page animated to the right page?
+ */
+ void onAnimationToSideStarted(boolean rightPage, float translation, float vel);
+
+ /** Notifies the callback the animation to a side page has ended. */
+ void onAnimationToSideEnded();
+
+ float getMaxTranslationDistance();
+
+ void onSwipingStarted(boolean rightIcon);
+
+ void onSwipingAborted();
+
+ void onIconClicked(boolean rightIcon);
+
+ @Nullable
+ SwipeButtonView getLeftIcon();
+
+ @Nullable
+ SwipeButtonView getRightIcon();
+
+ @Nullable
+ View getLeftPreview();
+
+ @Nullable
+ View getRightPreview();
+
+ /** @return The factor the minimum swipe amount should be multiplied with. */
+ float getAffordanceFalsingFactor();
+ }
+}
diff --git a/java/com/android/incallui/answer/impl/affordance/SwipeButtonView.java b/java/com/android/incallui/answer/impl/affordance/SwipeButtonView.java
new file mode 100644
index 000000000..46879ea3f
--- /dev/null
+++ b/java/com/android/incallui/answer/impl/affordance/SwipeButtonView.java
@@ -0,0 +1,505 @@
+/*
+ * 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.affordance;
+
+import android.animation.Animator;
+import android.animation.AnimatorListenerAdapter;
+import android.animation.ArgbEvaluator;
+import android.animation.PropertyValuesHolder;
+import android.animation.ValueAnimator;
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.PorterDuff;
+import android.graphics.drawable.Drawable;
+import android.support.annotation.Nullable;
+import android.util.AttributeSet;
+import android.view.View;
+import android.view.ViewAnimationUtils;
+import android.view.animation.Interpolator;
+import android.widget.ImageView;
+import com.android.incallui.answer.impl.utils.FlingAnimationUtils;
+import com.android.incallui.answer.impl.utils.Interpolators;
+
+/** Button that allows swiping to trigger */
+public class SwipeButtonView extends ImageView {
+
+ private static final long CIRCLE_APPEAR_DURATION = 80;
+ private static final long CIRCLE_DISAPPEAR_MAX_DURATION = 200;
+ private static final long NORMAL_ANIMATION_DURATION = 200;
+ public static final float MAX_ICON_SCALE_AMOUNT = 1.5f;
+ public static final float MIN_ICON_SCALE_AMOUNT = 0.8f;
+
+ private final int minBackgroundRadius;
+ private final Paint circlePaint;
+ private final int inverseColor;
+ private final int normalColor;
+ private final ArgbEvaluator colorInterpolator;
+ private final FlingAnimationUtils flingAnimationUtils;
+ private float circleRadius;
+ private int centerX;
+ private int centerY;
+ private ValueAnimator circleAnimator;
+ private ValueAnimator alphaAnimator;
+ private ValueAnimator scaleAnimator;
+ private float circleStartValue;
+ private boolean circleWillBeHidden;
+ private int[] tempPoint = new int[2];
+ private float tmageScale = 1f;
+ private int circleColor;
+ private View previewView;
+ private float circleStartRadius;
+ private float maxCircleSize;
+ private Animator previewClipper;
+ private float restingAlpha = SwipeButtonHelper.SWIPE_RESTING_ALPHA_AMOUNT;
+ private boolean finishing;
+ private boolean launchingAffordance;
+
+ private AnimatorListenerAdapter clipEndListener =
+ new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ previewClipper = null;
+ }
+ };
+ private AnimatorListenerAdapter circleEndListener =
+ new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ circleAnimator = null;
+ }
+ };
+ private AnimatorListenerAdapter scaleEndListener =
+ new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ scaleAnimator = null;
+ }
+ };
+ private AnimatorListenerAdapter alphaEndListener =
+ new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ alphaAnimator = null;
+ }
+ };
+
+ public SwipeButtonView(Context context) {
+ this(context, null);
+ }
+
+ public SwipeButtonView(Context context, AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public SwipeButtonView(Context context, AttributeSet attrs, int defStyleAttr) {
+ this(context, attrs, defStyleAttr, 0);
+ }
+
+ public SwipeButtonView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ circlePaint = new Paint();
+ circlePaint.setAntiAlias(true);
+ circleColor = 0xffffffff;
+ circlePaint.setColor(circleColor);
+
+ normalColor = 0xffffffff;
+ inverseColor = 0xff000000;
+ minBackgroundRadius =
+ context
+ .getResources()
+ .getDimensionPixelSize(R.dimen.answer_affordance_min_background_radius);
+ colorInterpolator = new ArgbEvaluator();
+ flingAnimationUtils = new FlingAnimationUtils(context, 0.3f);
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
+ super.onLayout(changed, left, top, right, bottom);
+ centerX = getWidth() / 2;
+ centerY = getHeight() / 2;
+ maxCircleSize = getMaxCircleSize();
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ drawBackgroundCircle(canvas);
+ canvas.save();
+ canvas.scale(tmageScale, tmageScale, getWidth() / 2, getHeight() / 2);
+ super.onDraw(canvas);
+ canvas.restore();
+ }
+
+ public void setPreviewView(@Nullable View v) {
+ View oldPreviewView = previewView;
+ previewView = v;
+ if (previewView != null) {
+ previewView.setVisibility(launchingAffordance ? oldPreviewView.getVisibility() : INVISIBLE);
+ }
+ }
+
+ private void updateIconColor() {
+ Drawable drawable = getDrawable().mutate();
+ float alpha = circleRadius / minBackgroundRadius;
+ alpha = Math.min(1.0f, alpha);
+ int color = (int) colorInterpolator.evaluate(alpha, normalColor, inverseColor);
+ drawable.setColorFilter(color, PorterDuff.Mode.SRC_ATOP);
+ }
+
+ private void drawBackgroundCircle(Canvas canvas) {
+ if (circleRadius > 0 || finishing) {
+ updateCircleColor();
+ canvas.drawCircle(centerX, centerY, circleRadius, circlePaint);
+ }
+ }
+
+ private void updateCircleColor() {
+ float fraction =
+ 0.5f
+ + 0.5f
+ * Math.max(
+ 0.0f,
+ Math.min(
+ 1.0f, (circleRadius - minBackgroundRadius) / (0.5f * minBackgroundRadius)));
+ if (previewView != null && previewView.getVisibility() == VISIBLE) {
+ float finishingFraction =
+ 1 - Math.max(0, circleRadius - circleStartRadius) / (maxCircleSize - circleStartRadius);
+ fraction *= finishingFraction;
+ }
+ int color =
+ Color.argb(
+ (int) (Color.alpha(circleColor) * fraction),
+ Color.red(circleColor),
+ Color.green(circleColor),
+ Color.blue(circleColor));
+ circlePaint.setColor(color);
+ }
+
+ public void finishAnimation(float velocity, @Nullable final Runnable mAnimationEndRunnable) {
+ cancelAnimator(circleAnimator);
+ cancelAnimator(previewClipper);
+ finishing = true;
+ circleStartRadius = circleRadius;
+ final float maxCircleSize = getMaxCircleSize();
+ Animator animatorToRadius;
+ animatorToRadius = getAnimatorToRadius(maxCircleSize);
+ flingAnimationUtils.applyDismissing(
+ animatorToRadius, circleRadius, maxCircleSize, velocity, maxCircleSize);
+ animatorToRadius.addListener(
+ new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ if (mAnimationEndRunnable != null) {
+ mAnimationEndRunnable.run();
+ }
+ finishing = false;
+ circleRadius = maxCircleSize;
+ invalidate();
+ }
+ });
+ animatorToRadius.start();
+ setImageAlpha(0, true);
+ if (previewView != null) {
+ previewView.setVisibility(View.VISIBLE);
+ previewClipper =
+ ViewAnimationUtils.createCircularReveal(
+ previewView, getLeft() + centerX, getTop() + centerY, circleRadius, maxCircleSize);
+ flingAnimationUtils.applyDismissing(
+ previewClipper, circleRadius, maxCircleSize, velocity, maxCircleSize);
+ previewClipper.addListener(clipEndListener);
+ previewClipper.start();
+ }
+ }
+
+ public void instantFinishAnimation() {
+ cancelAnimator(previewClipper);
+ if (previewView != null) {
+ previewView.setClipBounds(null);
+ previewView.setVisibility(View.VISIBLE);
+ }
+ circleRadius = getMaxCircleSize();
+ setImageAlpha(0, false);
+ invalidate();
+ }
+
+ private float getMaxCircleSize() {
+ getLocationInWindow(tempPoint);
+ float rootWidth = getRootView().getWidth();
+ float width = tempPoint[0] + centerX;
+ width = Math.max(rootWidth - width, width);
+ float height = tempPoint[1] + centerY;
+ return (float) Math.hypot(width, height);
+ }
+
+ public void setCircleRadius(float circleRadius) {
+ setCircleRadius(circleRadius, false, false);
+ }
+
+ public void setCircleRadius(float circleRadius, boolean slowAnimation) {
+ setCircleRadius(circleRadius, slowAnimation, false);
+ }
+
+ public void setCircleRadiusWithoutAnimation(float circleRadius) {
+ cancelAnimator(circleAnimator);
+ setCircleRadius(circleRadius, false, true);
+ }
+
+ private void setCircleRadius(float circleRadius, boolean slowAnimation, boolean noAnimation) {
+
+ // Check if we need a new animation
+ boolean radiusHidden =
+ (circleAnimator != null && circleWillBeHidden)
+ || (circleAnimator == null && this.circleRadius == 0.0f);
+ boolean nowHidden = circleRadius == 0.0f;
+ boolean radiusNeedsAnimation = (radiusHidden != nowHidden) && !noAnimation;
+ if (!radiusNeedsAnimation) {
+ if (circleAnimator == null) {
+ this.circleRadius = circleRadius;
+ updateIconColor();
+ invalidate();
+ if (nowHidden) {
+ if (previewView != null) {
+ previewView.setVisibility(View.INVISIBLE);
+ }
+ }
+ } else if (!circleWillBeHidden) {
+
+ // We just update the end value
+ float diff = circleRadius - minBackgroundRadius;
+ PropertyValuesHolder[] values = circleAnimator.getValues();
+ values[0].setFloatValues(circleStartValue + diff, circleRadius);
+ circleAnimator.setCurrentPlayTime(circleAnimator.getCurrentPlayTime());
+ }
+ } else {
+ cancelAnimator(circleAnimator);
+ cancelAnimator(previewClipper);
+ ValueAnimator animator = getAnimatorToRadius(circleRadius);
+ Interpolator interpolator =
+ circleRadius == 0.0f
+ ? Interpolators.FAST_OUT_LINEAR_IN
+ : Interpolators.LINEAR_OUT_SLOW_IN;
+ animator.setInterpolator(interpolator);
+ long duration = 250;
+ if (!slowAnimation) {
+ float durationFactor =
+ Math.abs(this.circleRadius - circleRadius) / (float) minBackgroundRadius;
+ duration = (long) (CIRCLE_APPEAR_DURATION * durationFactor);
+ duration = Math.min(duration, CIRCLE_DISAPPEAR_MAX_DURATION);
+ }
+ animator.setDuration(duration);
+ animator.start();
+ if (previewView != null && previewView.getVisibility() == View.VISIBLE) {
+ previewView.setVisibility(View.VISIBLE);
+ previewClipper =
+ ViewAnimationUtils.createCircularReveal(
+ previewView,
+ getLeft() + centerX,
+ getTop() + centerY,
+ this.circleRadius,
+ circleRadius);
+ previewClipper.setInterpolator(interpolator);
+ previewClipper.setDuration(duration);
+ previewClipper.addListener(clipEndListener);
+ previewClipper.addListener(
+ new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ previewView.setVisibility(View.INVISIBLE);
+ }
+ });
+ previewClipper.start();
+ }
+ }
+ }
+
+ private ValueAnimator getAnimatorToRadius(float circleRadius) {
+ ValueAnimator animator = ValueAnimator.ofFloat(this.circleRadius, circleRadius);
+ circleAnimator = animator;
+ circleStartValue = this.circleRadius;
+ circleWillBeHidden = circleRadius == 0.0f;
+ animator.addUpdateListener(
+ new ValueAnimator.AnimatorUpdateListener() {
+ @Override
+ public void onAnimationUpdate(ValueAnimator animation) {
+ SwipeButtonView.this.circleRadius = (float) animation.getAnimatedValue();
+ updateIconColor();
+ invalidate();
+ }
+ });
+ animator.addListener(circleEndListener);
+ return animator;
+ }
+
+ private void cancelAnimator(Animator animator) {
+ if (animator != null) {
+ animator.cancel();
+ }
+ }
+
+ public void setImageScale(float imageScale, boolean animate) {
+ setImageScale(imageScale, animate, -1, null);
+ }
+
+ /**
+ * Sets the scale of the containing image
+ *
+ * @param imageScale The new Scale.
+ * @param animate Should an animation be performed
+ * @param duration If animate, whats the duration? When -1 we take the default duration
+ * @param interpolator If animate, whats the interpolator? When null we take the default
+ * interpolator.
+ */
+ public void setImageScale(
+ float imageScale, boolean animate, long duration, @Nullable Interpolator interpolator) {
+ cancelAnimator(scaleAnimator);
+ if (!animate) {
+ tmageScale = imageScale;
+ invalidate();
+ } else {
+ ValueAnimator animator = ValueAnimator.ofFloat(tmageScale, imageScale);
+ scaleAnimator = animator;
+ animator.addUpdateListener(
+ new ValueAnimator.AnimatorUpdateListener() {
+ @Override
+ public void onAnimationUpdate(ValueAnimator animation) {
+ tmageScale = (float) animation.getAnimatedValue();
+ invalidate();
+ }
+ });
+ animator.addListener(scaleEndListener);
+ if (interpolator == null) {
+ interpolator =
+ imageScale == 0.0f
+ ? Interpolators.FAST_OUT_LINEAR_IN
+ : Interpolators.LINEAR_OUT_SLOW_IN;
+ }
+ animator.setInterpolator(interpolator);
+ if (duration == -1) {
+ float durationFactor = Math.abs(tmageScale - imageScale) / (1.0f - MIN_ICON_SCALE_AMOUNT);
+ durationFactor = Math.min(1.0f, durationFactor);
+ duration = (long) (NORMAL_ANIMATION_DURATION * durationFactor);
+ }
+ animator.setDuration(duration);
+ animator.start();
+ }
+ }
+
+ public void setRestingAlpha(float alpha) {
+ restingAlpha = alpha;
+
+ // TODO: Handle the case an animation is playing.
+ setImageAlpha(alpha, false);
+ }
+
+ public float getRestingAlpha() {
+ return restingAlpha;
+ }
+
+ public void setImageAlpha(float alpha, boolean animate) {
+ setImageAlpha(alpha, animate, -1, null, null);
+ }
+
+ /**
+ * Sets the alpha of the containing image
+ *
+ * @param alpha The new alpha.
+ * @param animate Should an animation be performed
+ * @param duration If animate, whats the duration? When -1 we take the default duration
+ * @param interpolator If animate, whats the interpolator? When null we take the default
+ * interpolator.
+ */
+ public void setImageAlpha(
+ float alpha,
+ boolean animate,
+ long duration,
+ @Nullable Interpolator interpolator,
+ @Nullable Runnable runnable) {
+ cancelAnimator(alphaAnimator);
+ alpha = launchingAffordance ? 0 : alpha;
+ int endAlpha = (int) (alpha * 255);
+ final Drawable background = getBackground();
+ if (!animate) {
+ if (background != null) {
+ background.mutate().setAlpha(endAlpha);
+ }
+ setImageAlpha(endAlpha);
+ } else {
+ int currentAlpha = getImageAlpha();
+ ValueAnimator animator = ValueAnimator.ofInt(currentAlpha, endAlpha);
+ alphaAnimator = animator;
+ animator.addUpdateListener(
+ new ValueAnimator.AnimatorUpdateListener() {
+ @Override
+ public void onAnimationUpdate(ValueAnimator animation) {
+ int alpha = (int) animation.getAnimatedValue();
+ if (background != null) {
+ background.mutate().setAlpha(alpha);
+ }
+ setImageAlpha(alpha);
+ }
+ });
+ animator.addListener(alphaEndListener);
+ if (interpolator == null) {
+ interpolator =
+ alpha == 0.0f ? Interpolators.FAST_OUT_LINEAR_IN : Interpolators.LINEAR_OUT_SLOW_IN;
+ }
+ animator.setInterpolator(interpolator);
+ if (duration == -1) {
+ float durationFactor = Math.abs(currentAlpha - endAlpha) / 255f;
+ durationFactor = Math.min(1.0f, durationFactor);
+ duration = (long) (NORMAL_ANIMATION_DURATION * durationFactor);
+ }
+ animator.setDuration(duration);
+ if (runnable != null) {
+ animator.addListener(getEndListener(runnable));
+ }
+ animator.start();
+ }
+ }
+
+ private Animator.AnimatorListener getEndListener(final Runnable runnable) {
+ return new AnimatorListenerAdapter() {
+ boolean mCancelled;
+
+ @Override
+ public void onAnimationCancel(Animator animation) {
+ mCancelled = true;
+ }
+
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ if (!mCancelled) {
+ runnable.run();
+ }
+ }
+ };
+ }
+
+ public float getCircleRadius() {
+ return circleRadius;
+ }
+
+ @Override
+ public boolean performClick() {
+ return isClickable() && super.performClick();
+ }
+
+ public void setLaunchingAffordance(boolean launchingAffordance) {
+ this.launchingAffordance = launchingAffordance;
+ }
+}
diff --git a/java/com/android/incallui/answer/impl/affordance/res/values/dimens.xml b/java/com/android/incallui/answer/impl/affordance/res/values/dimens.xml
new file mode 100644
index 000000000..71d014dd9
--- /dev/null
+++ b/java/com/android/incallui/answer/impl/affordance/res/values/dimens.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ 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
+ -->
+
+<resources>
+ <dimen name="answer_min_swipe_amount">110dp</dimen>
+ <dimen name="answer_affordance_min_background_radius">30dp</dimen>
+ <dimen name="answer_affordance_touch_target_size">120dp</dimen>
+ <dimen name="hint_grow_amount_sideways">60dp</dimen>
+</resources>