1 /* 2 * Copyright (C) 2016 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License 15 */ 16 17 package com.android.incallui.answer.impl.answermethod; 18 19 import android.animation.Animator; 20 import android.animation.AnimatorListenerAdapter; 21 import android.animation.ValueAnimator; 22 import android.animation.ValueAnimator.AnimatorUpdateListener; 23 import android.annotation.SuppressLint; 24 import android.content.Context; 25 import android.support.annotation.FloatRange; 26 import android.support.annotation.IntDef; 27 import android.support.annotation.NonNull; 28 import android.support.annotation.Nullable; 29 import android.view.MotionEvent; 30 import android.view.VelocityTracker; 31 import android.view.View; 32 import android.view.View.OnTouchListener; 33 import android.view.ViewConfiguration; 34 import com.android.dialer.common.DpUtil; 35 import com.android.dialer.common.LogUtil; 36 import com.android.dialer.common.MathUtil; 37 import com.android.incallui.answer.impl.classifier.FalsingManager; 38 import com.android.incallui.answer.impl.utils.FlingAnimationUtils; 39 import java.lang.annotation.Retention; 40 import java.lang.annotation.RetentionPolicy; 41 42 /** Touch handler that keeps track of flings for {@link FlingUpDownMethod}. */ 43 @SuppressLint("ClickableViewAccessibility") 44 class FlingUpDownTouchHandler implements OnTouchListener { 45 46 /** Callback interface for significant events with this touch handler */ 47 interface OnProgressChangedListener { 48 49 /** 50 * Called when the visible answer progress has changed. Implementations should use this for 51 * animation, but should not perform accepts or rejects until {@link #onMoveFinish(boolean)} is 52 * called. 53 * 54 * @param progress float representation of the progress with +1f fully accepted, -1f fully 55 * rejected, and 0 neutral. 56 */ onProgressChanged(@loatRangefrom = -1f, to = 1f) float progress)57 void onProgressChanged(@FloatRange(from = -1f, to = 1f) float progress); 58 59 /** Called when a touch event has started being tracked. */ onTrackingStart()60 void onTrackingStart(); 61 62 /** Called when touch events stop being tracked. */ onTrackingStopped()63 void onTrackingStopped(); 64 65 /** 66 * Called when the progress has fully animated back to neutral. Normal resting animation should 67 * resume, possibly with a hint animation first. 68 * 69 * @param showHint {@code true} iff the hint animation should be run before resuming normal 70 * animation. 71 */ onMoveReset(boolean showHint)72 void onMoveReset(boolean showHint); 73 74 /** 75 * Called when the progress has animated fully to accept or reject. 76 * 77 * @param accept {@code true} if the call has been accepted, {@code false} if it has been 78 * rejected. 79 */ onMoveFinish(boolean accept)80 void onMoveFinish(boolean accept); 81 82 /** 83 * Determine whether this gesture should use the {@link FalsingManager} to reject accidental 84 * touches 85 * 86 * @param downEvent the MotionEvent corresponding to the start of the gesture 87 * @return {@code true} if the {@link FalsingManager} should be used to reject accidental 88 * touches for this gesture 89 */ shouldUseFalsing(@onNull MotionEvent downEvent)90 boolean shouldUseFalsing(@NonNull MotionEvent downEvent); 91 } 92 93 // Progress that must be moved through to not show the hint animation after gesture completes 94 private static final float HINT_MOVE_THRESHOLD_RATIO = .1f; 95 // Dp touch needs to move upward to be considered fully accepted 96 private static final int ACCEPT_THRESHOLD_DP = 150; 97 // Dp touch needs to move downward to be considered fully rejected 98 private static final int REJECT_THRESHOLD_DP = 150; 99 // Dp touch needs to move for it to not be considered a false touch (if FalsingManager is not 100 // enabled) 101 private static final int FALSING_THRESHOLD_DP = 40; 102 103 // Progress at which a fling in the opposite direction will recenter instead of 104 // accepting/rejecting 105 private static final float PROGRESS_FLING_RECENTER = .1f; 106 107 // Progress at which a slow swipe would continue toward accept/reject after the 108 // touch has been let go, otherwise will recenter 109 private static final float PROGRESS_SWIPE_RECENTER = .8f; 110 111 private static final float REJECT_FLING_THRESHOLD_MODIFIER = 2f; 112 113 @Retention(RetentionPolicy.SOURCE) 114 @IntDef({FlingTarget.CENTER, FlingTarget.ACCEPT, FlingTarget.REJECT}) 115 private @interface FlingTarget { 116 int CENTER = 0; 117 int ACCEPT = 1; 118 int REJECT = -1; 119 } 120 121 /** 122 * Create a new FlingUpDownTouchHandler and attach it to the target. Will call {@link 123 * View#setOnTouchListener(OnTouchListener)} before returning. 124 * 125 * @param target View whose touches are to be listened to 126 * @param listener Callback to listen to major events 127 * @param falsingManager FalsingManager to identify false touches 128 * @return the instance of FlingUpDownTouchHandler that has been added as a touch listener 129 */ attach( @onNull View target, @NonNull OnProgressChangedListener listener, @Nullable FalsingManager falsingManager)130 public static FlingUpDownTouchHandler attach( 131 @NonNull View target, 132 @NonNull OnProgressChangedListener listener, 133 @Nullable FalsingManager falsingManager) { 134 FlingUpDownTouchHandler handler = new FlingUpDownTouchHandler(target, listener, falsingManager); 135 target.setOnTouchListener(handler); 136 return handler; 137 } 138 139 @NonNull private final View target; 140 @NonNull private final OnProgressChangedListener listener; 141 142 private VelocityTracker velocityTracker; 143 private FlingAnimationUtils flingAnimationUtils; 144 145 private boolean touchEnabled = true; 146 private boolean flingEnabled = true; 147 private float currentProgress; 148 private boolean tracking; 149 150 private boolean motionAborted; 151 private boolean touchSlopExceeded; 152 private boolean hintDistanceExceeded; 153 private int trackingPointer; 154 private Animator progressAnimator; 155 156 private float touchSlop; 157 private float initialTouchY; 158 private float acceptThresholdY; 159 private float rejectThresholdY; 160 private float zeroY; 161 162 private boolean touchAboveFalsingThreshold; 163 private float falsingThresholdPx; 164 private boolean touchUsesFalsing; 165 166 private final float acceptThresholdPx; 167 private final float rejectThresholdPx; 168 private final float deadZoneTopPx; 169 170 @Nullable private final FalsingManager falsingManager; 171 FlingUpDownTouchHandler( @onNull View target, @NonNull OnProgressChangedListener listener, @Nullable FalsingManager falsingManager)172 private FlingUpDownTouchHandler( 173 @NonNull View target, 174 @NonNull OnProgressChangedListener listener, 175 @Nullable FalsingManager falsingManager) { 176 this.target = target; 177 this.listener = listener; 178 Context context = target.getContext(); 179 touchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); 180 flingAnimationUtils = new FlingAnimationUtils(context, .6f); 181 falsingThresholdPx = DpUtil.dpToPx(context, FALSING_THRESHOLD_DP); 182 acceptThresholdPx = DpUtil.dpToPx(context, ACCEPT_THRESHOLD_DP); 183 rejectThresholdPx = DpUtil.dpToPx(context, REJECT_THRESHOLD_DP); 184 185 deadZoneTopPx = 186 Math.max( 187 context.getResources().getDimension(R.dimen.answer_swipe_dead_zone_top), 188 acceptThresholdPx); 189 this.falsingManager = falsingManager; 190 } 191 192 /** Returns {@code true} iff a touch is being tracked */ isTracking()193 public boolean isTracking() { 194 return tracking; 195 } 196 197 /** 198 * Sets whether touch events will continue to be listened to 199 * 200 * @param touchEnabled whether future touch events will be listened to 201 */ setTouchEnabled(boolean touchEnabled)202 public void setTouchEnabled(boolean touchEnabled) { 203 this.touchEnabled = touchEnabled; 204 } 205 206 /** 207 * Sets whether fling velocity is used to affect accept/reject behavior 208 * 209 * @param flingEnabled whether fling velocity will be used when determining whether to 210 * accept/reject or recenter 211 */ setFlingEnabled(boolean flingEnabled)212 public void setFlingEnabled(boolean flingEnabled) { 213 this.flingEnabled = flingEnabled; 214 } 215 detach()216 public void detach() { 217 cancelProgressAnimator(); 218 setTouchEnabled(false); 219 } 220 221 @Override onTouch(View v, MotionEvent event)222 public boolean onTouch(View v, MotionEvent event) { 223 if (falsingManager != null) { 224 falsingManager.onTouchEvent(event); 225 } 226 if (!touchEnabled) { 227 return false; 228 } 229 if (motionAborted && (event.getActionMasked() != MotionEvent.ACTION_DOWN)) { 230 return false; 231 } 232 233 int pointerIndex = event.findPointerIndex(trackingPointer); 234 if (pointerIndex < 0) { 235 pointerIndex = 0; 236 trackingPointer = event.getPointerId(pointerIndex); 237 } 238 final float pointerY = event.getY(pointerIndex); 239 240 switch (event.getActionMasked()) { 241 case MotionEvent.ACTION_DOWN: 242 if (pointerY < deadZoneTopPx) { 243 return false; 244 } 245 motionAborted = false; 246 startMotion(pointerY, false, currentProgress); 247 touchAboveFalsingThreshold = false; 248 touchUsesFalsing = listener.shouldUseFalsing(event); 249 if (velocityTracker == null) { 250 initVelocityTracker(); 251 } 252 trackMovement(event); 253 cancelProgressAnimator(); 254 touchSlopExceeded = progressAnimator != null; 255 onTrackingStarted(); 256 break; 257 case MotionEvent.ACTION_POINTER_UP: 258 final int upPointer = event.getPointerId(event.getActionIndex()); 259 if (trackingPointer == upPointer) { 260 // gesture is ongoing, find a new pointer to track 261 int newIndex = event.getPointerId(0) != upPointer ? 0 : 1; 262 float newY = event.getY(newIndex); 263 trackingPointer = event.getPointerId(newIndex); 264 startMotion(newY, true, currentProgress); 265 } 266 break; 267 case MotionEvent.ACTION_POINTER_DOWN: 268 motionAborted = true; 269 endMotionEvent(event, pointerY, true); 270 return false; 271 case MotionEvent.ACTION_MOVE: 272 float deltaY = pointerY - initialTouchY; 273 274 if (Math.abs(deltaY) > touchSlop) { 275 touchSlopExceeded = true; 276 } 277 if (Math.abs(deltaY) >= falsingThresholdPx) { 278 touchAboveFalsingThreshold = true; 279 } 280 setCurrentProgress(pointerYToProgress(pointerY)); 281 trackMovement(event); 282 break; 283 284 case MotionEvent.ACTION_UP: 285 case MotionEvent.ACTION_CANCEL: 286 trackMovement(event); 287 endMotionEvent(event, pointerY, false); 288 } 289 return true; 290 } 291 endMotionEvent(MotionEvent event, float pointerY, boolean forceCancel)292 private void endMotionEvent(MotionEvent event, float pointerY, boolean forceCancel) { 293 trackingPointer = -1; 294 if ((tracking && touchSlopExceeded) 295 || Math.abs(pointerY - initialTouchY) > touchSlop 296 || event.getActionMasked() == MotionEvent.ACTION_CANCEL 297 || forceCancel) { 298 float vel = 0f; 299 float vectorVel = 0f; 300 if (velocityTracker != null) { 301 velocityTracker.computeCurrentVelocity(1000); 302 vel = velocityTracker.getYVelocity(); 303 vectorVel = 304 Math.copySign( 305 (float) Math.hypot(velocityTracker.getXVelocity(), velocityTracker.getYVelocity()), 306 vel); 307 } 308 309 boolean falseTouch = isFalseTouch(); 310 boolean forceRecenter = 311 falseTouch 312 || !touchSlopExceeded 313 || forceCancel 314 || event.getActionMasked() == MotionEvent.ACTION_CANCEL; 315 316 @FlingTarget 317 int target = forceRecenter ? FlingTarget.CENTER : getFlingTarget(pointerY, vectorVel); 318 319 fling(vel, target, falseTouch); 320 onTrackingStopped(); 321 } else { 322 onTrackingStopped(); 323 setCurrentProgress(0); 324 onMoveEnded(); 325 } 326 327 if (velocityTracker != null) { 328 velocityTracker.recycle(); 329 velocityTracker = null; 330 } 331 } 332 333 @FlingTarget getFlingTarget(float pointerY, float vectorVel)334 private int getFlingTarget(float pointerY, float vectorVel) { 335 float progress = pointerYToProgress(pointerY); 336 337 float minVelocityPxPerSecond = flingAnimationUtils.getMinVelocityPxPerSecond(); 338 if (vectorVel > 0) { 339 minVelocityPxPerSecond *= REJECT_FLING_THRESHOLD_MODIFIER; 340 } 341 if (!flingEnabled || Math.abs(vectorVel) < minVelocityPxPerSecond) { 342 // Not a fling 343 if (Math.abs(progress) > PROGRESS_SWIPE_RECENTER) { 344 // Progress near one of the edges 345 return progress > 0 ? FlingTarget.ACCEPT : FlingTarget.REJECT; 346 } else { 347 return FlingTarget.CENTER; 348 } 349 } 350 351 boolean sameDirection = vectorVel < 0 == progress > 0; 352 if (!sameDirection && Math.abs(progress) >= PROGRESS_FLING_RECENTER) { 353 // Being flung back toward center 354 return FlingTarget.CENTER; 355 } 356 // Flung toward an edge 357 return vectorVel < 0 ? FlingTarget.ACCEPT : FlingTarget.REJECT; 358 } 359 360 @FloatRange(from = -1f, to = 1f) pointerYToProgress(float pointerY)361 private float pointerYToProgress(float pointerY) { 362 boolean pointerAboveZero = pointerY > zeroY; 363 float nearestThreshold = pointerAboveZero ? rejectThresholdY : acceptThresholdY; 364 365 float absoluteProgress = (pointerY - zeroY) / (nearestThreshold - zeroY); 366 return MathUtil.clamp(absoluteProgress * (pointerAboveZero ? -1 : 1), -1f, 1f); 367 } 368 isFalseTouch()369 private boolean isFalseTouch() { 370 if (falsingManager != null && falsingManager.isEnabled()) { 371 if (falsingManager.isFalseTouch()) { 372 if (touchUsesFalsing) { 373 LogUtil.i("FlingUpDownTouchHandler.isFalseTouch", "rejecting false touch"); 374 return true; 375 } else { 376 LogUtil.i( 377 "FlingUpDownTouchHandler.isFalseTouch", 378 "Suspected false touch, but not using false touch rejection for this gesture"); 379 return false; 380 } 381 } else { 382 return false; 383 } 384 } 385 return !touchAboveFalsingThreshold; 386 } 387 trackMovement(MotionEvent event)388 private void trackMovement(MotionEvent event) { 389 if (velocityTracker != null) { 390 velocityTracker.addMovement(event); 391 } 392 } 393 fling(float velocity, @FlingTarget int target, boolean centerBecauseOfFalsing)394 private void fling(float velocity, @FlingTarget int target, boolean centerBecauseOfFalsing) { 395 ValueAnimator animator = createProgressAnimator(target); 396 if (target == FlingTarget.CENTER) { 397 flingAnimationUtils.apply(animator, currentProgress, target, velocity); 398 } else { 399 flingAnimationUtils.applyDismissing(animator, currentProgress, target, velocity, 1); 400 } 401 if (target == FlingTarget.CENTER && centerBecauseOfFalsing) { 402 velocity = 0; 403 } 404 if (velocity == 0) { 405 animator.setDuration(350); 406 } 407 408 animator.addListener( 409 new AnimatorListenerAdapter() { 410 boolean canceled; 411 412 @Override 413 public void onAnimationCancel(Animator animation) { 414 canceled = true; 415 } 416 417 @Override 418 public void onAnimationEnd(Animator animation) { 419 progressAnimator = null; 420 if (!canceled) { 421 onMoveEnded(); 422 } 423 } 424 }); 425 progressAnimator = animator; 426 animator.start(); 427 } 428 onMoveEnded()429 private void onMoveEnded() { 430 if (currentProgress == 0) { 431 listener.onMoveReset(!hintDistanceExceeded); 432 } else { 433 listener.onMoveFinish(currentProgress > 0); 434 } 435 } 436 createProgressAnimator(float targetProgress)437 private ValueAnimator createProgressAnimator(float targetProgress) { 438 ValueAnimator animator = ValueAnimator.ofFloat(currentProgress, targetProgress); 439 animator.addUpdateListener( 440 new AnimatorUpdateListener() { 441 @Override 442 public void onAnimationUpdate(ValueAnimator animation) { 443 setCurrentProgress((Float) animation.getAnimatedValue()); 444 } 445 }); 446 return animator; 447 } 448 initVelocityTracker()449 private void initVelocityTracker() { 450 if (velocityTracker != null) { 451 velocityTracker.recycle(); 452 } 453 velocityTracker = VelocityTracker.obtain(); 454 } 455 startMotion(float newY, boolean startTracking, float startProgress)456 private void startMotion(float newY, boolean startTracking, float startProgress) { 457 initialTouchY = newY; 458 hintDistanceExceeded = false; 459 460 if (startProgress <= .25) { 461 acceptThresholdY = Math.max(0, initialTouchY - acceptThresholdPx); 462 rejectThresholdY = Math.min(target.getHeight(), initialTouchY + rejectThresholdPx); 463 zeroY = initialTouchY; 464 } 465 466 if (startTracking) { 467 touchSlopExceeded = true; 468 onTrackingStarted(); 469 setCurrentProgress(startProgress); 470 } 471 } 472 onTrackingStarted()473 private void onTrackingStarted() { 474 tracking = true; 475 listener.onTrackingStart(); 476 } 477 onTrackingStopped()478 private void onTrackingStopped() { 479 tracking = false; 480 listener.onTrackingStopped(); 481 } 482 cancelProgressAnimator()483 private void cancelProgressAnimator() { 484 if (progressAnimator != null) { 485 progressAnimator.cancel(); 486 } 487 } 488 setCurrentProgress(float progress)489 private void setCurrentProgress(float progress) { 490 if (Math.abs(progress) > HINT_MOVE_THRESHOLD_RATIO) { 491 hintDistanceExceeded = true; 492 } 493 currentProgress = progress; 494 listener.onProgressChanged(progress); 495 } 496 } 497