diff options
Diffstat (limited to 'java/com/android/incallui/answer/impl/affordance')
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> |