• 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.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