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