| /* |
| * Copyright (C) 2016 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License |
| */ |
| |
| package com.android.incallui.answer.impl.answermethod; |
| |
| import android.animation.Animator; |
| import android.animation.AnimatorListenerAdapter; |
| import android.animation.ValueAnimator; |
| import android.animation.ValueAnimator.AnimatorUpdateListener; |
| import android.annotation.SuppressLint; |
| import android.content.Context; |
| import android.support.annotation.FloatRange; |
| import android.support.annotation.IntDef; |
| import android.support.annotation.NonNull; |
| import android.support.annotation.Nullable; |
| import android.view.MotionEvent; |
| import android.view.VelocityTracker; |
| import android.view.View; |
| import android.view.View.OnTouchListener; |
| import android.view.ViewConfiguration; |
| import com.android.dialer.common.DpUtil; |
| import com.android.dialer.common.LogUtil; |
| import com.android.dialer.common.MathUtil; |
| import com.android.incallui.answer.impl.classifier.FalsingManager; |
| import com.android.incallui.answer.impl.utils.FlingAnimationUtils; |
| import java.lang.annotation.Retention; |
| import java.lang.annotation.RetentionPolicy; |
| |
| /** Touch handler that keeps track of flings for {@link FlingUpDownMethod}. */ |
| @SuppressLint("ClickableViewAccessibility") |
| class FlingUpDownTouchHandler implements OnTouchListener { |
| |
| /** Callback interface for significant events with this touch handler */ |
| interface OnProgressChangedListener { |
| |
| /** |
| * Called when the visible answer progress has changed. Implementations should use this for |
| * animation, but should not perform accepts or rejects until {@link #onMoveFinish(boolean)} is |
| * called. |
| * |
| * @param progress float representation of the progress with +1f fully accepted, -1f fully |
| * rejected, and 0 neutral. |
| */ |
| void onProgressChanged(@FloatRange(from = -1f, to = 1f) float progress); |
| |
| /** Called when a touch event has started being tracked. */ |
| void onTrackingStart(); |
| |
| /** Called when touch events stop being tracked. */ |
| void onTrackingStopped(); |
| |
| /** |
| * Called when the progress has fully animated back to neutral. Normal resting animation should |
| * resume, possibly with a hint animation first. |
| * |
| * @param showHint {@code true} iff the hint animation should be run before resuming normal |
| * animation. |
| */ |
| void onMoveReset(boolean showHint); |
| |
| /** |
| * Called when the progress has animated fully to accept or reject. |
| * |
| * @param accept {@code true} if the call has been accepted, {@code false} if it has been |
| * rejected. |
| */ |
| void onMoveFinish(boolean accept); |
| |
| /** |
| * Determine whether this gesture should use the {@link FalsingManager} to reject accidental |
| * touches |
| * |
| * @param downEvent the MotionEvent corresponding to the start of the gesture |
| * @return {@code true} if the {@link FalsingManager} should be used to reject accidental |
| * touches for this gesture |
| */ |
| boolean shouldUseFalsing(@NonNull MotionEvent downEvent); |
| } |
| |
| // Progress that must be moved through to not show the hint animation after gesture completes |
| private static final float HINT_MOVE_THRESHOLD_RATIO = .1f; |
| // Dp touch needs to move upward to be considered fully accepted |
| private static final int ACCEPT_THRESHOLD_DP = 150; |
| // Dp touch needs to move downward to be considered fully rejected |
| private static final int REJECT_THRESHOLD_DP = 150; |
| // Dp touch needs to move for it to not be considered a false touch (if FalsingManager is not |
| // enabled) |
| private static final int FALSING_THRESHOLD_DP = 40; |
| |
| // Progress at which a fling in the opposite direction will recenter instead of |
| // accepting/rejecting |
| private static final float PROGRESS_FLING_RECENTER = .1f; |
| |
| // Progress at which a slow swipe would continue toward accept/reject after the |
| // touch has been let go, otherwise will recenter |
| private static final float PROGRESS_SWIPE_RECENTER = .8f; |
| |
| private static final float REJECT_FLING_THRESHOLD_MODIFIER = 2f; |
| |
| @Retention(RetentionPolicy.SOURCE) |
| @IntDef({FlingTarget.CENTER, FlingTarget.ACCEPT, FlingTarget.REJECT}) |
| private @interface FlingTarget { |
| int CENTER = 0; |
| int ACCEPT = 1; |
| int REJECT = -1; |
| } |
| |
| /** |
| * Create a new FlingUpDownTouchHandler and attach it to the target. Will call {@link |
| * View#setOnTouchListener(OnTouchListener)} before returning. |
| * |
| * @param target View whose touches are to be listened to |
| * @param listener Callback to listen to major events |
| * @param falsingManager FalsingManager to identify false touches |
| * @return the instance of FlingUpDownTouchHandler that has been added as a touch listener |
| */ |
| public static FlingUpDownTouchHandler attach( |
| @NonNull View target, |
| @NonNull OnProgressChangedListener listener, |
| @Nullable FalsingManager falsingManager) { |
| FlingUpDownTouchHandler handler = new FlingUpDownTouchHandler(target, listener, falsingManager); |
| target.setOnTouchListener(handler); |
| return handler; |
| } |
| |
| @NonNull private final View target; |
| @NonNull private final OnProgressChangedListener listener; |
| |
| private VelocityTracker velocityTracker; |
| private FlingAnimationUtils flingAnimationUtils; |
| |
| private boolean touchEnabled = true; |
| private boolean flingEnabled = true; |
| private float currentProgress; |
| private boolean tracking; |
| |
| private boolean motionAborted; |
| private boolean touchSlopExceeded; |
| private boolean hintDistanceExceeded; |
| private int trackingPointer; |
| private Animator progressAnimator; |
| |
| private float touchSlop; |
| private float initialTouchY; |
| private float acceptThresholdY; |
| private float rejectThresholdY; |
| private float zeroY; |
| |
| private boolean touchAboveFalsingThreshold; |
| private float falsingThresholdPx; |
| private boolean touchUsesFalsing; |
| |
| private final float acceptThresholdPx; |
| private final float rejectThresholdPx; |
| private final float deadZoneTopPx; |
| |
| @Nullable private final FalsingManager falsingManager; |
| |
| private FlingUpDownTouchHandler( |
| @NonNull View target, |
| @NonNull OnProgressChangedListener listener, |
| @Nullable FalsingManager falsingManager) { |
| this.target = target; |
| this.listener = listener; |
| Context context = target.getContext(); |
| touchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); |
| flingAnimationUtils = new FlingAnimationUtils(context, .6f); |
| falsingThresholdPx = DpUtil.dpToPx(context, FALSING_THRESHOLD_DP); |
| acceptThresholdPx = DpUtil.dpToPx(context, ACCEPT_THRESHOLD_DP); |
| rejectThresholdPx = DpUtil.dpToPx(context, REJECT_THRESHOLD_DP); |
| |
| deadZoneTopPx = |
| Math.max( |
| context.getResources().getDimension(R.dimen.answer_swipe_dead_zone_top), |
| acceptThresholdPx); |
| this.falsingManager = falsingManager; |
| } |
| |
| /** Returns {@code true} iff a touch is being tracked */ |
| public boolean isTracking() { |
| return tracking; |
| } |
| |
| /** |
| * Sets whether touch events will continue to be listened to |
| * |
| * @param touchEnabled whether future touch events will be listened to |
| */ |
| public void setTouchEnabled(boolean touchEnabled) { |
| this.touchEnabled = touchEnabled; |
| } |
| |
| /** |
| * Sets whether fling velocity is used to affect accept/reject behavior |
| * |
| * @param flingEnabled whether fling velocity will be used when determining whether to |
| * accept/reject or recenter |
| */ |
| public void setFlingEnabled(boolean flingEnabled) { |
| this.flingEnabled = flingEnabled; |
| } |
| |
| public void detach() { |
| cancelProgressAnimator(); |
| setTouchEnabled(false); |
| } |
| |
| @Override |
| public boolean onTouch(View v, MotionEvent event) { |
| if (falsingManager != null) { |
| falsingManager.onTouchEvent(event); |
| } |
| if (!touchEnabled) { |
| return false; |
| } |
| if (motionAborted && (event.getActionMasked() != MotionEvent.ACTION_DOWN)) { |
| return false; |
| } |
| |
| int pointerIndex = event.findPointerIndex(trackingPointer); |
| if (pointerIndex < 0) { |
| pointerIndex = 0; |
| trackingPointer = event.getPointerId(pointerIndex); |
| } |
| final float pointerY = event.getY(pointerIndex); |
| |
| switch (event.getActionMasked()) { |
| case MotionEvent.ACTION_DOWN: |
| if (pointerY < deadZoneTopPx) { |
| return false; |
| } |
| motionAborted = false; |
| startMotion(pointerY, false, currentProgress); |
| touchAboveFalsingThreshold = false; |
| touchUsesFalsing = listener.shouldUseFalsing(event); |
| if (velocityTracker == null) { |
| initVelocityTracker(); |
| } |
| trackMovement(event); |
| cancelProgressAnimator(); |
| touchSlopExceeded = progressAnimator != null; |
| onTrackingStarted(); |
| break; |
| case MotionEvent.ACTION_POINTER_UP: |
| final int upPointer = event.getPointerId(event.getActionIndex()); |
| if (trackingPointer == upPointer) { |
| // gesture is ongoing, find a new pointer to track |
| int newIndex = event.getPointerId(0) != upPointer ? 0 : 1; |
| float newY = event.getY(newIndex); |
| trackingPointer = event.getPointerId(newIndex); |
| startMotion(newY, true, currentProgress); |
| } |
| break; |
| case MotionEvent.ACTION_POINTER_DOWN: |
| motionAborted = true; |
| endMotionEvent(event, pointerY, true); |
| return false; |
| case MotionEvent.ACTION_MOVE: |
| float deltaY = pointerY - initialTouchY; |
| |
| if (Math.abs(deltaY) > touchSlop) { |
| touchSlopExceeded = true; |
| } |
| if (Math.abs(deltaY) >= falsingThresholdPx) { |
| touchAboveFalsingThreshold = true; |
| } |
| setCurrentProgress(pointerYToProgress(pointerY)); |
| trackMovement(event); |
| break; |
| |
| case MotionEvent.ACTION_UP: |
| case MotionEvent.ACTION_CANCEL: |
| trackMovement(event); |
| endMotionEvent(event, pointerY, false); |
| } |
| return true; |
| } |
| |
| private void endMotionEvent(MotionEvent event, float pointerY, boolean forceCancel) { |
| trackingPointer = -1; |
| if ((tracking && touchSlopExceeded) |
| || Math.abs(pointerY - initialTouchY) > touchSlop |
| || event.getActionMasked() == MotionEvent.ACTION_CANCEL |
| || forceCancel) { |
| float vel = 0f; |
| float vectorVel = 0f; |
| if (velocityTracker != null) { |
| velocityTracker.computeCurrentVelocity(1000); |
| vel = velocityTracker.getYVelocity(); |
| vectorVel = |
| Math.copySign( |
| (float) Math.hypot(velocityTracker.getXVelocity(), velocityTracker.getYVelocity()), |
| vel); |
| } |
| |
| boolean falseTouch = isFalseTouch(); |
| boolean forceRecenter = |
| falseTouch |
| || !touchSlopExceeded |
| || forceCancel |
| || event.getActionMasked() == MotionEvent.ACTION_CANCEL; |
| |
| @FlingTarget |
| int target = forceRecenter ? FlingTarget.CENTER : getFlingTarget(pointerY, vectorVel); |
| |
| fling(vel, target, falseTouch); |
| onTrackingStopped(); |
| } else { |
| onTrackingStopped(); |
| setCurrentProgress(0); |
| onMoveEnded(); |
| } |
| |
| if (velocityTracker != null) { |
| velocityTracker.recycle(); |
| velocityTracker = null; |
| } |
| } |
| |
| @FlingTarget |
| private int getFlingTarget(float pointerY, float vectorVel) { |
| float progress = pointerYToProgress(pointerY); |
| |
| float minVelocityPxPerSecond = flingAnimationUtils.getMinVelocityPxPerSecond(); |
| if (vectorVel > 0) { |
| minVelocityPxPerSecond *= REJECT_FLING_THRESHOLD_MODIFIER; |
| } |
| if (!flingEnabled || Math.abs(vectorVel) < minVelocityPxPerSecond) { |
| // Not a fling |
| if (Math.abs(progress) > PROGRESS_SWIPE_RECENTER) { |
| // Progress near one of the edges |
| return progress > 0 ? FlingTarget.ACCEPT : FlingTarget.REJECT; |
| } else { |
| return FlingTarget.CENTER; |
| } |
| } |
| |
| boolean sameDirection = vectorVel < 0 == progress > 0; |
| if (!sameDirection && Math.abs(progress) >= PROGRESS_FLING_RECENTER) { |
| // Being flung back toward center |
| return FlingTarget.CENTER; |
| } |
| // Flung toward an edge |
| return vectorVel < 0 ? FlingTarget.ACCEPT : FlingTarget.REJECT; |
| } |
| |
| @FloatRange(from = -1f, to = 1f) |
| private float pointerYToProgress(float pointerY) { |
| boolean pointerAboveZero = pointerY > zeroY; |
| float nearestThreshold = pointerAboveZero ? rejectThresholdY : acceptThresholdY; |
| |
| float absoluteProgress = (pointerY - zeroY) / (nearestThreshold - zeroY); |
| return MathUtil.clamp(absoluteProgress * (pointerAboveZero ? -1 : 1), -1f, 1f); |
| } |
| |
| private boolean isFalseTouch() { |
| if (falsingManager != null && falsingManager.isEnabled()) { |
| if (falsingManager.isFalseTouch()) { |
| if (touchUsesFalsing) { |
| LogUtil.i("FlingUpDownTouchHandler.isFalseTouch", "rejecting false touch"); |
| return true; |
| } else { |
| LogUtil.i( |
| "FlingUpDownTouchHandler.isFalseTouch", |
| "Suspected false touch, but not using false touch rejection for this gesture"); |
| return false; |
| } |
| } else { |
| return false; |
| } |
| } |
| return !touchAboveFalsingThreshold; |
| } |
| |
| private void trackMovement(MotionEvent event) { |
| if (velocityTracker != null) { |
| velocityTracker.addMovement(event); |
| } |
| } |
| |
| private void fling(float velocity, @FlingTarget int target, boolean centerBecauseOfFalsing) { |
| ValueAnimator animator = createProgressAnimator(target); |
| if (target == FlingTarget.CENTER) { |
| flingAnimationUtils.apply(animator, currentProgress, target, velocity); |
| } else { |
| flingAnimationUtils.applyDismissing(animator, currentProgress, target, velocity, 1); |
| } |
| if (target == FlingTarget.CENTER && centerBecauseOfFalsing) { |
| velocity = 0; |
| } |
| if (velocity == 0) { |
| animator.setDuration(350); |
| } |
| |
| animator.addListener( |
| new AnimatorListenerAdapter() { |
| boolean canceled; |
| |
| @Override |
| public void onAnimationCancel(Animator animation) { |
| canceled = true; |
| } |
| |
| @Override |
| public void onAnimationEnd(Animator animation) { |
| progressAnimator = null; |
| if (!canceled) { |
| onMoveEnded(); |
| } |
| } |
| }); |
| progressAnimator = animator; |
| animator.start(); |
| } |
| |
| private void onMoveEnded() { |
| if (currentProgress == 0) { |
| listener.onMoveReset(!hintDistanceExceeded); |
| } else { |
| listener.onMoveFinish(currentProgress > 0); |
| } |
| } |
| |
| private ValueAnimator createProgressAnimator(float targetProgress) { |
| ValueAnimator animator = ValueAnimator.ofFloat(currentProgress, targetProgress); |
| animator.addUpdateListener( |
| new AnimatorUpdateListener() { |
| @Override |
| public void onAnimationUpdate(ValueAnimator animation) { |
| setCurrentProgress((Float) animation.getAnimatedValue()); |
| } |
| }); |
| return animator; |
| } |
| |
| private void initVelocityTracker() { |
| if (velocityTracker != null) { |
| velocityTracker.recycle(); |
| } |
| velocityTracker = VelocityTracker.obtain(); |
| } |
| |
| private void startMotion(float newY, boolean startTracking, float startProgress) { |
| initialTouchY = newY; |
| hintDistanceExceeded = false; |
| |
| if (startProgress <= .25) { |
| acceptThresholdY = Math.max(0, initialTouchY - acceptThresholdPx); |
| rejectThresholdY = Math.min(target.getHeight(), initialTouchY + rejectThresholdPx); |
| zeroY = initialTouchY; |
| } |
| |
| if (startTracking) { |
| touchSlopExceeded = true; |
| onTrackingStarted(); |
| setCurrentProgress(startProgress); |
| } |
| } |
| |
| private void onTrackingStarted() { |
| tracking = true; |
| listener.onTrackingStart(); |
| } |
| |
| private void onTrackingStopped() { |
| tracking = false; |
| listener.onTrackingStopped(); |
| } |
| |
| private void cancelProgressAnimator() { |
| if (progressAnimator != null) { |
| progressAnimator.cancel(); |
| } |
| } |
| |
| private void setCurrentProgress(float progress) { |
| if (Math.abs(progress) > HINT_MOVE_THRESHOLD_RATIO) { |
| hintDistanceExceeded = true; |
| } |
| currentProgress = progress; |
| listener.onProgressChanged(progress); |
| } |
| } |