1 /* 2 * Copyright 2018 Google Inc. 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.car.notification; 18 19 import android.animation.Animator; 20 import android.animation.AnimatorListenerAdapter; 21 import android.content.res.Resources; 22 import android.view.MotionEvent; 23 import android.view.VelocityTracker; 24 import android.view.View; 25 import android.view.ViewConfiguration; 26 import android.view.ViewPropertyAnimator; 27 import android.view.ViewTreeObserver; 28 29 import com.android.internal.annotations.VisibleForTesting; 30 31 import java.util.concurrent.TimeUnit; 32 33 /** 34 * OnTouchListener that enables swipe-to-dismiss gesture on heads-up notifications. 35 */ 36 class HeadsUpNotificationOnTouchListener implements View.OnTouchListener { 37 // todo(b/301474982): converge common logic in this and CarNotificationItemTouchListener class. 38 private static final int INITIAL_TRANSLATION_X = 0; 39 private static final int INITIAL_TRANSLATION_Y = 0; 40 private static final float MAXIMUM_ALPHA = 1f; 41 private static final float MINIMUM_ALPHA = 0f; 42 /** 43 * Factor by which view's alpha decreases based on the translation in the direction of dismiss. 44 * Example: If set to 1f, the view will be invisible when it has translated the maximum possible 45 * translation, similarly for 2f, view will be invisible halfway. 46 */ 47 private static final float ALPHA_FADE_FACTOR_MULTIPLIER = 2f; 48 49 /** 50 * The unit of velocity in milliseconds. A value of 1 means "pixels per millisecond", 51 * 1000 means "pixels per 1000 milliseconds (1 second)". 52 */ 53 private static final int PIXELS_PER_SECOND = (int) TimeUnit.SECONDS.toMillis(1); 54 private final View mView; 55 private final DismissCallbacks mCallbacks; 56 private final Axis mDismissAxis; 57 /** 58 * Distance a touch can wander before we think the user is scrolling in pixels. 59 */ 60 private final int mTouchSlop; 61 private final boolean mDismissOnSwipe; 62 /** 63 * The proportion which view has to be swiped before it dismisses. 64 */ 65 private final float mPercentageOfMaxTransaltionToDismiss; 66 /** 67 * The minimum velocity in pixel per second the swipe gesture to initiate a dismiss action. 68 */ 69 private final int mMinimumFlingVelocity; 70 /** 71 * The cap on velocity in pixel per second a swipe gesture is calculated to have. 72 */ 73 private final int mMaximumFlingVelocity; 74 /** 75 * The transaltion that a view can have. To set change value of 76 * {@code R.dimen.max_translation_headsup} to a non zero value. If set to zero, the view's 77 * dimensions(height/width) will be used instead. 78 */ 79 private float mMaxTranslation; 80 /** 81 * Distance by which a view should be translated by to be considered dismissed. Can be 82 * configured by setting {@code R.dimen.percentage_of_max_translation_to_dismiss} 83 */ 84 private float mDismissDelta; 85 private VelocityTracker mVelocityTracker; 86 private float mDownX; 87 private float mDownY; 88 private boolean mSwiping; 89 private int mSwipingSlop; 90 private float mTranslation; 91 92 /** 93 * The callback indicating the supplied view has been dismissed. 94 */ 95 interface DismissCallbacks { onDismiss()96 void onDismiss(); 97 } 98 99 private enum Axis { 100 HORIZONTAL, VERTICAL; 101 getOppositeAxis()102 public Axis getOppositeAxis() { 103 switch (this) { 104 case VERTICAL: 105 return HORIZONTAL; 106 default: 107 return VERTICAL; 108 } 109 } 110 } 111 HeadsUpNotificationOnTouchListener(View view, boolean dismissOnSwipe, DismissCallbacks callbacks)112 HeadsUpNotificationOnTouchListener(View view, boolean dismissOnSwipe, 113 DismissCallbacks callbacks) { 114 mView = view; 115 mCallbacks = callbacks; 116 mDismissOnSwipe = dismissOnSwipe; 117 Resources res = view.getContext().getResources(); 118 mDismissAxis = res.getBoolean(R.bool.config_isHeadsUpNotificationDismissibleVertically) 119 ? Axis.VERTICAL : Axis.HORIZONTAL; 120 mTouchSlop = res.getDimensionPixelSize(R.dimen.touch_slop); 121 mPercentageOfMaxTransaltionToDismiss = 122 res.getFloat(R.dimen.percentage_of_max_translation_to_dismiss); 123 mMaxTranslation = res.getDimension(R.dimen.max_translation_headsup); 124 if (mMaxTranslation != 0) { 125 mDismissDelta = mMaxTranslation * mPercentageOfMaxTransaltionToDismiss; 126 } else { 127 mView.getViewTreeObserver().addOnGlobalLayoutListener( 128 new ViewTreeObserver.OnGlobalLayoutListener() { 129 @Override 130 public void onGlobalLayout() { 131 mView.getViewTreeObserver().removeOnGlobalLayoutListener(this); 132 if (mDismissAxis == Axis.VERTICAL) { 133 mMaxTranslation = view.getHeight(); 134 } else { 135 mMaxTranslation = view.getWidth(); 136 } 137 mDismissDelta = mMaxTranslation * mPercentageOfMaxTransaltionToDismiss; 138 } 139 }); 140 } 141 ViewConfiguration viewConfiguration = ViewConfiguration.get(view.getContext()); 142 mMaximumFlingVelocity = viewConfiguration.getScaledMaximumFlingVelocity(); 143 mMinimumFlingVelocity = viewConfiguration.getScaledMinimumFlingVelocity(); 144 } 145 146 @Override onTouch(View view, MotionEvent motionEvent)147 public boolean onTouch(View view, MotionEvent motionEvent) { 148 if (mDismissAxis == Axis.VERTICAL) { 149 motionEvent.offsetLocation(INITIAL_TRANSLATION_X, /* deltaY= */ mTranslation); 150 } else { 151 motionEvent.offsetLocation(/* deltaX= */ mTranslation, INITIAL_TRANSLATION_Y); 152 } 153 154 switch (motionEvent.getActionMasked()) { 155 case MotionEvent.ACTION_DOWN: { 156 mDownX = motionEvent.getRawX(); 157 mDownY = motionEvent.getRawY(); 158 mVelocityTracker = obtainVelocityTracker(); 159 mVelocityTracker.addMovement(motionEvent); 160 break; 161 } 162 163 case MotionEvent.ACTION_UP: { 164 if (mVelocityTracker == null) { 165 return false; 166 } 167 168 mVelocityTracker.addMovement(motionEvent); 169 mVelocityTracker.computeCurrentVelocity(PIXELS_PER_SECOND, mMaximumFlingVelocity); 170 float deltaInDismissAxis = 171 getDeltaInAxis(mDownX, mDownY, motionEvent, mDismissAxis); 172 boolean shouldBeDismissed = false; 173 boolean dismissInPositiveDirection = false; 174 if (Math.abs(deltaInDismissAxis) > mDismissDelta) { 175 // dismiss when the movement is more than the defined threshold. 176 shouldBeDismissed = true; 177 dismissInPositiveDirection = deltaInDismissAxis > 0; 178 } else if (mSwiping && isFlingEnoughForDismiss(mVelocityTracker, mDismissAxis) 179 && isFlingInSameDirectionAsDelta( 180 deltaInDismissAxis, mVelocityTracker, mDismissAxis)) { 181 // dismiss when the velocity is more than the defined threshold. 182 // dismiss only if flinging in the same direction as dragging. 183 shouldBeDismissed = true; 184 dismissInPositiveDirection = 185 getVelocityInAxis(mVelocityTracker, mDismissAxis) > 0; 186 } 187 188 if (shouldBeDismissed && mDismissOnSwipe) { 189 mCallbacks.onDismiss(); 190 animateDismissInAxis(mView, mDismissAxis, dismissInPositiveDirection); 191 } else if (mSwiping) { 192 animateToCenter(); 193 } 194 reset(); 195 break; 196 } 197 198 case MotionEvent.ACTION_CANCEL: { 199 if (mVelocityTracker == null) { 200 return false; 201 } 202 animateToCenter(); 203 reset(); 204 return false; 205 } 206 207 case MotionEvent.ACTION_MOVE: { 208 if (mVelocityTracker == null) { 209 return false; 210 } 211 212 mVelocityTracker.addMovement(motionEvent); 213 float deltaInDismissAxis = 214 getDeltaInAxis(mDownX, mDownY, motionEvent, mDismissAxis); 215 if (Math.abs(deltaInDismissAxis) > mTouchSlop) { 216 mSwiping = true; 217 mSwipingSlop = (deltaInDismissAxis > 0 ? mTouchSlop : -mTouchSlop); 218 disallowAndCancelTouchEvents(mView, motionEvent); 219 } 220 221 if (mSwiping) { 222 mTranslation = deltaInDismissAxis; 223 moveView(mView, 224 /* translation= */ deltaInDismissAxis - mSwipingSlop, mDismissAxis); 225 if (mDismissOnSwipe) { 226 mView.setAlpha(getAlphaForDismissingView(mTranslation, mMaxTranslation)); 227 } 228 return true; 229 } 230 } 231 } 232 return false; 233 } 234 animateToCenter()235 private void animateToCenter() { 236 mView.animate() 237 .translationX(INITIAL_TRANSLATION_X) 238 .translationY(INITIAL_TRANSLATION_Y) 239 .alpha(MAXIMUM_ALPHA) 240 .setListener(null); 241 } 242 reset()243 private void reset() { 244 if (mVelocityTracker != null) { 245 mVelocityTracker.recycle(); 246 } 247 mVelocityTracker = null; 248 mTranslation = 0; 249 mDownX = 0; 250 mDownY = 0; 251 mSwiping = false; 252 } 253 resetView(View view)254 private void resetView(View view) { 255 view.setTranslationX(INITIAL_TRANSLATION_X); 256 view.setTranslationY(INITIAL_TRANSLATION_Y); 257 view.setAlpha(MAXIMUM_ALPHA); 258 } 259 getDeltaInAxis( float downX, float downY, MotionEvent motionEvent, Axis dismissAxis)260 private float getDeltaInAxis( 261 float downX, float downY, MotionEvent motionEvent, Axis dismissAxis) { 262 switch (dismissAxis) { 263 case VERTICAL: 264 return motionEvent.getRawY() - downY; 265 default: 266 return motionEvent.getRawX() - downX; 267 } 268 } 269 disallowAndCancelTouchEvents(View view, MotionEvent motionEvent)270 private void disallowAndCancelTouchEvents(View view, MotionEvent motionEvent) { 271 view.getParent().requestDisallowInterceptTouchEvent(true); 272 273 // prevent onClickListener being triggered when moving. 274 MotionEvent cancelEvent = obtainMotionEvent(motionEvent); 275 cancelEvent.setAction(MotionEvent.ACTION_CANCEL 276 | (motionEvent.getActionIndex() 277 << MotionEvent.ACTION_POINTER_INDEX_SHIFT)); 278 view.onTouchEvent(cancelEvent); 279 cancelEvent.recycle(); 280 } 281 moveView(View view, float translation, Axis dismissAxis)282 private void moveView(View view, float translation, Axis dismissAxis) { 283 if (dismissAxis == Axis.VERTICAL) { 284 view.setTranslationY(translation); 285 } else { 286 view.setTranslationX(translation); 287 } 288 } 289 getAlphaForDismissingView(float translation, float maxTranslation)290 private float getAlphaForDismissingView(float translation, float maxTranslation) { 291 float fractionMoved = Math.abs(translation) / Math.abs(maxTranslation); 292 // min is required to avoid value greater than MAXIMUM_ALPHA 293 float alphaBasedOnTranslation = Math.min(MAXIMUM_ALPHA, 294 MAXIMUM_ALPHA - (ALPHA_FADE_FACTOR_MULTIPLIER * fractionMoved)); 295 // max is required to avoid alpha values less than min 296 return Math.max(MINIMUM_ALPHA, alphaBasedOnTranslation); 297 } 298 isFlingEnoughForDismiss(VelocityTracker velocityTracker, Axis axis)299 private boolean isFlingEnoughForDismiss(VelocityTracker velocityTracker, Axis axis) { 300 float velocityInDismissingDirection = getVelocityInAxis(velocityTracker, axis); 301 float velocityInOppositeDirection = 302 getVelocityInAxis(velocityTracker, axis.getOppositeAxis()); 303 boolean isMoreFlingInDismissAxis = 304 Math.abs(velocityInDismissingDirection) > Math.abs(velocityInOppositeDirection); 305 return mMinimumFlingVelocity <= Math.abs(velocityInDismissingDirection) 306 && isMoreFlingInDismissAxis; 307 } 308 getVelocityInAxis(VelocityTracker velocityTracker, Axis axis)309 private float getVelocityInAxis(VelocityTracker velocityTracker, Axis axis) { 310 switch (axis) { 311 case VERTICAL: 312 return velocityTracker.getYVelocity(); 313 default: 314 return velocityTracker.getXVelocity(); 315 } 316 } 317 isFlingInSameDirectionAsDelta(float delta, VelocityTracker velocityTracker, Axis axis)318 private boolean isFlingInSameDirectionAsDelta(float delta, VelocityTracker velocityTracker, 319 Axis axis) { 320 float velocityInDismissingDirection = getVelocityInAxis(velocityTracker, axis); 321 boolean isVelocityInPositiveDirection = velocityInDismissingDirection > 0; 322 boolean isDeltaInPositiveDirection = delta > 0; 323 return isVelocityInPositiveDirection == isDeltaInPositiveDirection; 324 } 325 animateDismissInAxis(View view, Axis axis, boolean dismissInPositiveDirection)326 private void animateDismissInAxis(View view, Axis axis, boolean dismissInPositiveDirection) { 327 float dismissTranslation = dismissInPositiveDirection ? mMaxTranslation : -mMaxTranslation; 328 ViewPropertyAnimator animator = view.animate(); 329 if (axis == Axis.VERTICAL) { 330 animator.translationY(dismissTranslation); 331 } else { 332 animator.translationX(dismissTranslation); 333 } 334 animator.alpha(MINIMUM_ALPHA).setListener(new AnimatorListenerAdapter() { 335 @Override 336 public void onAnimationEnd(Animator animation) { 337 resetView(mView); 338 } 339 }).start(); 340 } 341 342 /** 343 * Should be overridden in test to not access static obtain method. 344 */ 345 @VisibleForTesting obtainMotionEvent(MotionEvent motionEvent)346 MotionEvent obtainMotionEvent(MotionEvent motionEvent) { 347 return MotionEvent.obtain(motionEvent); 348 } 349 350 /** 351 * Should be overridden in test to not access static obtain method. 352 */ 353 @VisibleForTesting obtainVelocityTracker()354 VelocityTracker obtainVelocityTracker() { 355 return VelocityTracker.obtain(); 356 } 357 358 @VisibleForTesting getMinimumFlingVelocity()359 int getMinimumFlingVelocity() { 360 return mMinimumFlingVelocity; 361 } 362 363 @VisibleForTesting getTouchSlop()364 int getTouchSlop() { 365 return mTouchSlop; 366 } 367 368 @VisibleForTesting getPercentageOfMaxTransaltionToDismiss()369 float getPercentageOfMaxTransaltionToDismiss() { 370 return mPercentageOfMaxTransaltionToDismiss; 371 } 372 } 373