summaryrefslogtreecommitdiff
path: root/java/com/android/incallui/answer/impl/affordance
diff options
context:
space:
mode:
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>