1 /* 2 * Copyright (C) 2021 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.systemui.screenshot; 18 19 import static com.android.systemui.screenshot.LogConfig.DEBUG_ANIM; 20 import static com.android.systemui.screenshot.LogConfig.DEBUG_DISMISS; 21 22 import android.animation.Animator; 23 import android.animation.AnimatorListenerAdapter; 24 import android.animation.ValueAnimator; 25 import android.content.Context; 26 import android.graphics.Rect; 27 import android.graphics.Region; 28 import android.util.AttributeSet; 29 import android.util.DisplayMetrics; 30 import android.util.Log; 31 import android.util.MathUtils; 32 import android.view.GestureDetector; 33 import android.view.MotionEvent; 34 import android.view.View; 35 import android.view.ViewTreeObserver; 36 37 import androidx.constraintlayout.widget.ConstraintLayout; 38 39 import com.android.systemui.R; 40 41 /** 42 * ConstraintLayout that is draggable when touched in a specific region 43 */ 44 public class DraggableConstraintLayout extends ConstraintLayout 45 implements ViewTreeObserver.OnComputeInternalInsetsListener { 46 47 private static final float VELOCITY_DP_PER_MS = 1; 48 private static final int MAXIMUM_DISMISS_DISTANCE_DP = 400; 49 50 private final SwipeDismissHandler mSwipeDismissHandler; 51 private final GestureDetector mSwipeDetector; 52 private View mActionsContainer; 53 private SwipeDismissCallbacks mCallbacks; 54 private final DisplayMetrics mDisplayMetrics; 55 56 /** 57 * Stores the callbacks when the view is interacted with or dismissed. 58 */ 59 public interface SwipeDismissCallbacks { 60 /** 61 * Run when the view is interacted with (touched) 62 */ onInteraction()63 default void onInteraction() { 64 65 } 66 67 /** 68 * Run when the view is dismissed (the distance threshold is met), pre-dismissal animation 69 */ onSwipeDismissInitiated(Animator animator)70 default void onSwipeDismissInitiated(Animator animator) { 71 72 } 73 74 /** 75 * Run when the view is dismissed (the distance threshold is met), post-dismissal animation 76 */ onDismissComplete()77 default void onDismissComplete() { 78 79 } 80 } 81 DraggableConstraintLayout(Context context)82 public DraggableConstraintLayout(Context context) { 83 this(context, null); 84 } 85 DraggableConstraintLayout(Context context, AttributeSet attrs)86 public DraggableConstraintLayout(Context context, AttributeSet attrs) { 87 this(context, attrs, 0); 88 } 89 DraggableConstraintLayout(Context context, AttributeSet attrs, int defStyleAttr)90 public DraggableConstraintLayout(Context context, AttributeSet attrs, int defStyleAttr) { 91 super(context, attrs, defStyleAttr); 92 93 mDisplayMetrics = new DisplayMetrics(); 94 mContext.getDisplay().getRealMetrics(mDisplayMetrics); 95 96 mSwipeDismissHandler = new SwipeDismissHandler(mContext, this); 97 setOnTouchListener(mSwipeDismissHandler); 98 99 mSwipeDetector = new GestureDetector(mContext, 100 new GestureDetector.SimpleOnGestureListener() { 101 final Rect mActionsRect = new Rect(); 102 103 @Override 104 public boolean onScroll( 105 MotionEvent ev1, MotionEvent ev2, float distanceX, float distanceY) { 106 mActionsContainer.getBoundsOnScreen(mActionsRect); 107 // return true if we aren't in the actions bar, or if we are but it isn't 108 // scrollable in the direction of movement 109 return !mActionsRect.contains((int) ev2.getRawX(), (int) ev2.getRawY()) 110 || !mActionsContainer.canScrollHorizontally((int) distanceX); 111 } 112 }); 113 mSwipeDetector.setIsLongpressEnabled(false); 114 115 mCallbacks = new SwipeDismissCallbacks() { 116 }; // default to unimplemented callbacks 117 } 118 setCallbacks(SwipeDismissCallbacks callbacks)119 public void setCallbacks(SwipeDismissCallbacks callbacks) { 120 mCallbacks = callbacks; 121 } 122 123 @Override onInterceptHoverEvent(MotionEvent event)124 public boolean onInterceptHoverEvent(MotionEvent event) { 125 mCallbacks.onInteraction(); 126 return super.onInterceptHoverEvent(event); 127 } 128 129 @Override // View onFinishInflate()130 protected void onFinishInflate() { 131 mActionsContainer = findViewById(R.id.actions_container); 132 } 133 134 @Override onInterceptTouchEvent(MotionEvent ev)135 public boolean onInterceptTouchEvent(MotionEvent ev) { 136 if (ev.getActionMasked() == MotionEvent.ACTION_DOWN) { 137 mSwipeDismissHandler.onTouch(this, ev); 138 } 139 return mSwipeDetector.onTouchEvent(ev); 140 } 141 142 /** 143 * Cancel current dismissal animation, if any 144 */ cancelDismissal()145 public void cancelDismissal() { 146 mSwipeDismissHandler.cancel(); 147 } 148 149 /** 150 * Return whether the view is currently dismissing 151 */ isDismissing()152 public boolean isDismissing() { 153 return mSwipeDismissHandler.isDismissing(); 154 } 155 156 /** 157 * Dismiss the view, with animation controlled by SwipeDismissHandler 158 */ dismiss()159 public void dismiss() { 160 mSwipeDismissHandler.dismiss(); 161 } 162 163 164 @Override onAttachedToWindow()165 protected void onAttachedToWindow() { 166 super.onAttachedToWindow(); 167 getViewTreeObserver().addOnComputeInternalInsetsListener(this); 168 } 169 170 @Override onDetachedFromWindow()171 protected void onDetachedFromWindow() { 172 super.onDetachedFromWindow(); 173 getViewTreeObserver().removeOnComputeInternalInsetsListener(this); 174 } 175 176 @Override onComputeInternalInsets(ViewTreeObserver.InternalInsetsInfo inoutInfo)177 public void onComputeInternalInsets(ViewTreeObserver.InternalInsetsInfo inoutInfo) { 178 // Only child views are touchable. 179 Region r = new Region(); 180 Rect rect = new Rect(); 181 for (int i = 0; i < getChildCount(); i++) { 182 getChildAt(i).getGlobalVisibleRect(rect); 183 r.op(rect, Region.Op.UNION); 184 } 185 inoutInfo.setTouchableInsets(ViewTreeObserver.InternalInsetsInfo.TOUCHABLE_INSETS_REGION); 186 inoutInfo.touchableRegion.set(r); 187 } 188 getBackgroundRight()189 private int getBackgroundRight() { 190 // background expected to be null in testing. 191 // animation may have unexpected behavior if view is not present 192 View background = findViewById(R.id.actions_container_background); 193 return background == null ? 0 : background.getRight(); 194 } 195 196 /** 197 * Allows a view to be swipe-dismissed, or returned to its location if distance threshold is not 198 * met 199 */ 200 private class SwipeDismissHandler implements OnTouchListener { 201 private static final String TAG = "SwipeDismissHandler"; 202 203 // distance needed to register a dismissal 204 private static final float DISMISS_DISTANCE_THRESHOLD_DP = 20; 205 206 private final DraggableConstraintLayout mView; 207 private final GestureDetector mGestureDetector; 208 private final DisplayMetrics mDisplayMetrics; 209 private ValueAnimator mDismissAnimation; 210 211 private float mStartX; 212 // Keeps track of the most recent direction (between the last two move events). 213 // -1 for left; +1 for right. 214 private int mDirectionX; 215 private float mPreviousX; 216 SwipeDismissHandler(Context context, DraggableConstraintLayout view)217 SwipeDismissHandler(Context context, DraggableConstraintLayout view) { 218 mView = view; 219 GestureDetector.OnGestureListener gestureListener = new SwipeDismissGestureListener(); 220 mGestureDetector = new GestureDetector(context, gestureListener); 221 mDisplayMetrics = new DisplayMetrics(); 222 context.getDisplay().getRealMetrics(mDisplayMetrics); 223 } 224 225 @Override onTouch(View view, MotionEvent event)226 public boolean onTouch(View view, MotionEvent event) { 227 boolean gestureResult = mGestureDetector.onTouchEvent(event); 228 mCallbacks.onInteraction(); 229 if (event.getActionMasked() == MotionEvent.ACTION_DOWN) { 230 mStartX = event.getRawX(); 231 mPreviousX = mStartX; 232 return true; 233 } else if (event.getActionMasked() == MotionEvent.ACTION_UP) { 234 if (mDismissAnimation != null && mDismissAnimation.isRunning()) { 235 return true; 236 } 237 if (isPastDismissThreshold()) { 238 ValueAnimator anim = createSwipeDismissAnimation(); 239 mCallbacks.onSwipeDismissInitiated(anim); 240 dismiss(anim); 241 } else { 242 // if we've moved, but not past the threshold, start the return animation 243 if (DEBUG_DISMISS) { 244 Log.d(TAG, "swipe gesture abandoned"); 245 } 246 createSwipeReturnAnimation().start(); 247 } 248 return true; 249 } 250 return gestureResult; 251 } 252 253 class SwipeDismissGestureListener extends GestureDetector.SimpleOnGestureListener { 254 @Override onScroll( MotionEvent ev1, MotionEvent ev2, float distanceX, float distanceY)255 public boolean onScroll( 256 MotionEvent ev1, MotionEvent ev2, float distanceX, float distanceY) { 257 mView.setTranslationX(ev2.getRawX() - mStartX); 258 mDirectionX = (ev2.getRawX() < mPreviousX) ? -1 : 1; 259 mPreviousX = ev2.getRawX(); 260 return true; 261 } 262 263 @Override onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY)264 public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, 265 float velocityY) { 266 if (mView.getTranslationX() * velocityX > 0 267 && (mDismissAnimation == null || !mDismissAnimation.isRunning())) { 268 ValueAnimator dismissAnimator = 269 createSwipeDismissAnimation(velocityX / (float) 1000); 270 mCallbacks.onSwipeDismissInitiated(dismissAnimator); 271 dismiss(dismissAnimator); 272 return true; 273 } 274 return false; 275 } 276 } 277 isPastDismissThreshold()278 private boolean isPastDismissThreshold() { 279 float translationX = mView.getTranslationX(); 280 // Determines whether the absolute translation from the start is in the same direction 281 // as the current movement. For example, if the user moves most of the way to the right, 282 // but then starts dragging back left, we do not dismiss even though the absolute 283 // distance is greater than the threshold. 284 if (translationX * mDirectionX > 0) { 285 return Math.abs(translationX) >= FloatingWindowUtil.dpToPx(mDisplayMetrics, 286 DISMISS_DISTANCE_THRESHOLD_DP); 287 } 288 return false; 289 } 290 isDismissing()291 boolean isDismissing() { 292 return (mDismissAnimation != null && mDismissAnimation.isRunning()); 293 } 294 cancel()295 void cancel() { 296 if (isDismissing()) { 297 if (DEBUG_ANIM) { 298 Log.d(TAG, "cancelling dismiss animation"); 299 } 300 mDismissAnimation.cancel(); 301 } 302 } 303 dismiss()304 void dismiss() { 305 dismiss(createSwipeDismissAnimation()); 306 } 307 dismiss(ValueAnimator animator)308 private void dismiss(ValueAnimator animator) { 309 mDismissAnimation = animator; 310 mDismissAnimation.addListener(new AnimatorListenerAdapter() { 311 private boolean mCancelled; 312 313 @Override 314 public void onAnimationCancel(Animator animation) { 315 super.onAnimationCancel(animation); 316 mCancelled = true; 317 } 318 319 @Override 320 public void onAnimationEnd(Animator animation) { 321 super.onAnimationEnd(animation); 322 if (!mCancelled) { 323 mCallbacks.onDismissComplete(); 324 } 325 } 326 }); 327 mDismissAnimation.start(); 328 } 329 createSwipeDismissAnimation()330 private ValueAnimator createSwipeDismissAnimation() { 331 float velocityPxPerMs = FloatingWindowUtil.dpToPx(mDisplayMetrics, VELOCITY_DP_PER_MS); 332 return createSwipeDismissAnimation(velocityPxPerMs); 333 } 334 createSwipeDismissAnimation(float velocity)335 private ValueAnimator createSwipeDismissAnimation(float velocity) { 336 // velocity is measured in pixels per millisecond 337 velocity = Math.min(3, Math.max(1, velocity)); 338 ValueAnimator anim = ValueAnimator.ofFloat(0, 1); 339 float startX = mView.getTranslationX(); 340 // make sure the UI gets all the way off the screen in the direction of movement 341 // (the actions container background is guaranteed to be both the leftmost and 342 // rightmost UI element in LTR and RTL) 343 float finalX; 344 int layoutDir = 345 mView.getContext().getResources().getConfiguration().getLayoutDirection(); 346 if (startX > 0 || (startX == 0 && layoutDir == LAYOUT_DIRECTION_RTL)) { 347 finalX = mDisplayMetrics.widthPixels; 348 } else { 349 finalX = -1 * getBackgroundRight(); 350 } 351 float distance = Math.min(Math.abs(finalX - startX), 352 FloatingWindowUtil.dpToPx(mDisplayMetrics, MAXIMUM_DISMISS_DISTANCE_DP)); 353 // ensure that view dismisses in the right direction (right in LTR, left in RTL) 354 float distanceVector = Math.copySign(distance, finalX - startX); 355 356 anim.addUpdateListener(animation -> { 357 float translation = MathUtils.lerp( 358 startX, startX + distanceVector, animation.getAnimatedFraction()); 359 mView.setTranslationX(translation); 360 mView.setAlpha(1 - animation.getAnimatedFraction()); 361 }); 362 anim.setDuration((long) (Math.abs(distance / velocity))); 363 return anim; 364 } 365 createSwipeReturnAnimation()366 private ValueAnimator createSwipeReturnAnimation() { 367 ValueAnimator anim = ValueAnimator.ofFloat(0, 1); 368 float startX = mView.getTranslationX(); 369 float finalX = 0; 370 371 anim.addUpdateListener(animation -> { 372 float translation = MathUtils.lerp( 373 startX, finalX, animation.getAnimatedFraction()); 374 mView.setTranslationX(translation); 375 }); 376 377 return anim; 378 } 379 } 380 } 381