• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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