1 /*
2  * Copyright 2022 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 androidx.wear.widget;
18 
19 import static java.lang.Math.max;
20 import static java.lang.Math.min;
21 
22 import android.content.Context;
23 import android.content.res.Resources;
24 import android.graphics.Color;
25 import android.graphics.ColorFilter;
26 import android.graphics.Outline;
27 import android.graphics.Paint;
28 import android.graphics.PorterDuff;
29 import android.graphics.PorterDuffColorFilter;
30 import android.graphics.drawable.Drawable;
31 import android.graphics.drawable.LayerDrawable;
32 import android.graphics.drawable.ShapeDrawable;
33 import android.graphics.drawable.shapes.RectShape;
34 import android.util.SparseArray;
35 import android.view.MotionEvent;
36 import android.view.VelocityTracker;
37 import android.view.View;
38 import android.view.ViewGroup;
39 import android.view.ViewOutlineProvider;
40 
41 import androidx.dynamicanimation.animation.DynamicAnimation;
42 import androidx.dynamicanimation.animation.FloatValueHolder;
43 import androidx.dynamicanimation.animation.SpringAnimation;
44 import androidx.dynamicanimation.animation.SpringForce;
45 
46 import org.jspecify.annotations.NonNull;
47 import org.jspecify.annotations.Nullable;
48 
49 /**
50  * A helper class to handle transition of swiping to dismiss and dismiss animation.
51  */
52 class SwipeDismissTransitionHelper {
53 
54     private static final String TAG = "SwipeDismissTransitionHelper";
55     private static final float SCALE_MIN = 0.7f;
56     private static final float SCALE_MAX = 1.0f;
57     public static final float SCRIM_BACKGROUND_MAX = 0.5f;
58     private static final float DIM_FOREGROUND_PROGRESS_FACTOR = 2.0f;
59     private static final float DIM_FOREGROUND_MIN = 0.3f;
60     private static final int VELOCITY_UNIT = 1000;
61     // Spring properties
62     private static final float SPRING_STIFFNESS = 600f;
63     private static final float SPRING_DAMPING_RATIO = SpringForce.DAMPING_RATIO_NO_BOUNCY;
64     private static final float SPRING_MIN_VISIBLE_CHANGE = 0.5f;
65     private static final int SPRING_ANIMATION_PROGRESS_FINISH_THRESHOLD_PX = 5;
66     private final DismissibleFrameLayout mLayout;
67 
68     private final int mScreenWidth;
69     private final SparseArray<ColorFilter> mDimmingColorFilterCache = new SparseArray<>();
70     private final Drawable mScrimBackground;
71     private final boolean mIsScreenRound;
72     private final Paint mCompositingPaint = new Paint();
73 
74     private VelocityTracker mVelocityTracker;
75     private boolean mStarted;
76     private int mOriginalViewWidth;
77     private float mTranslationX;
78     private float mScale;
79     private float mProgress;
80     private float mDimming;
81     private SpringAnimation mDismissalSpring;
82     private SpringAnimation mRecoverySpring;
83     // Variable to restore the parent's background which is added below mScrimBackground.
84     private Drawable mPrevParentBackground = null;
85 
SwipeDismissTransitionHelper(@onNull Context context, @NonNull DismissibleFrameLayout layout)86     SwipeDismissTransitionHelper(@NonNull Context context,
87             @NonNull DismissibleFrameLayout layout) {
88         mLayout = layout;
89         mIsScreenRound = layout.getResources().getConfiguration().isScreenRound();
90         mScreenWidth = Resources.getSystem().getDisplayMetrics().widthPixels;
91         mScrimBackground = generateScrimBackgroundDrawable(mScreenWidth,
92                 Resources.getSystem().getDisplayMetrics().heightPixels);
93     }
94 
clipOutline(@onNull View view, boolean useRoundShape)95     private static void clipOutline(@NonNull View view, boolean useRoundShape) {
96         view.setOutlineProvider(new ViewOutlineProvider() {
97             @Override
98             public void getOutline(View view, Outline outline) {
99                 if (useRoundShape) {
100                     outline.setOval(0, 0, view.getWidth(), view.getHeight());
101                 } else {
102                     outline.setRect(0, 0, view.getWidth(), view.getHeight());
103                 }
104                 outline.setAlpha(0);
105             }
106         });
107         view.setClipToOutline(true);
108     }
109 
110 
lerp(float min, float max, float value)111     private static float lerp(float min, float max, float value) {
112         return min + (max - min) * value;
113     }
114 
clamp(float min, float max, float value)115     private static float clamp(float min, float max, float value) {
116         return max(min, min(max, value));
117     }
118 
lerpInv(float min, float max, float value)119     private static float lerpInv(float min, float max, float value) {
120         return min != max ? ((value - min) / (max - min)) : 0.0f;
121     }
122 
createDimmingColorFilter(float level)123     private ColorFilter createDimmingColorFilter(float level) {
124         level = clamp(0, 1, level);
125         int alpha = (int) (0xFF * level);
126         int color = Color.argb(alpha, 0, 0, 0);
127         ColorFilter colorFilter = mDimmingColorFilterCache.get(alpha);
128         if (colorFilter != null) {
129             return colorFilter;
130         }
131         colorFilter = new PorterDuffColorFilter(color, PorterDuff.Mode.SRC_ATOP);
132         mDimmingColorFilterCache.put(alpha, colorFilter);
133         return colorFilter;
134     }
135 
createSpringAnimation(float startValue, float finalValue, float startVelocity, DynamicAnimation.OnAnimationUpdateListener onUpdateListener, DynamicAnimation.OnAnimationEndListener onEndListener)136     private SpringAnimation createSpringAnimation(float startValue,
137             float finalValue,
138             float startVelocity,
139             DynamicAnimation.OnAnimationUpdateListener onUpdateListener,
140             DynamicAnimation.OnAnimationEndListener onEndListener) {
141         SpringAnimation animation = new SpringAnimation(new FloatValueHolder());
142         animation.setStartValue(startValue);
143         animation.setMinimumVisibleChange(SPRING_MIN_VISIBLE_CHANGE);
144         SpringForce spring = new SpringForce();
145         spring.setFinalPosition(finalValue);
146         spring.setDampingRatio(SPRING_DAMPING_RATIO);
147         spring.setStiffness(SPRING_STIFFNESS);
148         animation.setMinValue(0.0f);
149         animation.setMaxValue(mScreenWidth);
150         animation.setStartVelocity(startVelocity);
151         animation.setSpring(spring);
152         animation.addUpdateListener(onUpdateListener);
153         animation.addEndListener(onEndListener);
154         animation.start();
155         return animation;
156     }
157 
158     /**
159      * Updates the swipe progress
160      *
161      * @param deltaX The X delta of gesture
162      * @param ev     The motion event
163      */
onSwipeProgressChanged(float deltaX, @NonNull MotionEvent ev)164     void onSwipeProgressChanged(float deltaX, @NonNull MotionEvent ev) {
165         if (!mStarted) {
166             initializeTransition();
167         }
168 
169         mVelocityTracker.addMovement(ev);
170         mOriginalViewWidth = mLayout.getWidth();
171         // For swiping, mProgress is directly manipulated
172         // mProgress = 0 (no swipe) - 0.5 (swiped to mid screen) - 1 (swipe to right of screen)
173         mProgress = deltaX / mOriginalViewWidth;
174         // Solve for other variables
175         // Scale = lerp 100% -> 70% when swiping from left edge to right edge
176         mScale = lerp(SCALE_MAX, SCALE_MIN, mProgress);
177         // Translation: make sure the right edge of mOriginalView touches right edge of screen
178         mTranslationX = max(0f, 1 - mScale) * mLayout.getWidth() / 2.0f;
179         mDimming = Math.min(DIM_FOREGROUND_MIN, mProgress / DIM_FOREGROUND_PROGRESS_FACTOR);
180 
181         updateView();
182     }
183 
onDismissalRecoveryAnimationProgressChanged(float translationX)184     private void onDismissalRecoveryAnimationProgressChanged(float translationX) {
185         mOriginalViewWidth = mLayout.getWidth();
186         mTranslationX = translationX;
187 
188         mScale = 1 - mTranslationX * 2 / mOriginalViewWidth;
189         // Clamp mScale so that we can solve for mProgress
190         mScale = Math.max(SCALE_MIN, Math.min(mScale, SCALE_MAX));
191         float nextProgress = lerpInv(SCALE_MAX, SCALE_MIN, mScale);
192         if (nextProgress > mProgress) {
193             mProgress = nextProgress;
194         }
195         mDimming = Math.min(DIM_FOREGROUND_MIN, mProgress / DIM_FOREGROUND_PROGRESS_FACTOR);
196         updateView();
197     }
198 
updateView()199     private void updateView() {
200         mLayout.setScaleX(mScale);
201         mLayout.setScaleY(mScale);
202         mLayout.setTranslationX(mTranslationX);
203         updateDim();
204         updateScrim();
205     }
206 
updateDim()207     private void updateDim() {
208         mCompositingPaint.setColorFilter(createDimmingColorFilter(mDimming));
209         mLayout.setLayerPaint(mCompositingPaint);
210     }
211 
updateScrim()212     private void updateScrim() {
213         float alpha = SCRIM_BACKGROUND_MAX * (1 - mProgress);
214         // Scaling alpha between 0 to 255, as Drawable.setAlpha expects it in range [0,255].
215         mScrimBackground.setAlpha((int) (alpha * 255));
216     }
217 
initializeTransition()218     private void initializeTransition() {
219         mStarted = true;
220         ViewGroup originalParentView = getOriginalParentView();
221 
222         if (originalParentView == null) {
223             return;
224         }
225 
226         if (mPrevParentBackground == null) {
227             mPrevParentBackground = originalParentView.getBackground();
228         }
229 
230         // Adding scrim over parent background if it exists.
231         Drawable parentBackgroundLayers;
232         if (mPrevParentBackground != null) {
233             parentBackgroundLayers = new LayerDrawable(new Drawable[]{mPrevParentBackground,
234                     mScrimBackground});
235         } else {
236             parentBackgroundLayers = mScrimBackground;
237         }
238         originalParentView.setBackground(parentBackgroundLayers);
239 
240         mCompositingPaint.setColorFilter(null);
241         mLayout.setLayerType(View.LAYER_TYPE_HARDWARE, mCompositingPaint);
242         clipOutline(mLayout, mIsScreenRound);
243     }
244 
resetTranslationAndAlpha()245     private void resetTranslationAndAlpha() {
246         // resetting variables
247         mStarted = false;
248         mTranslationX = 0;
249         mProgress = 0;
250         mScale = 1;
251         // resetting layout params
252         mLayout.setTranslationX(0);
253         mLayout.setScaleX(1);
254         mLayout.setScaleY(1);
255         mLayout.setAlpha(1);
256         mScrimBackground.setAlpha(0);
257 
258         mCompositingPaint.setColorFilter(null);
259         mLayout.setLayerType(View.LAYER_TYPE_NONE, null);
260         mLayout.setClipToOutline(false);
261 
262         // Restoring previous background
263         ViewGroup originalParentView = getOriginalParentView();
264         if (originalParentView != null) {
265             originalParentView.setBackground(mPrevParentBackground);
266         }
267         mPrevParentBackground = null;
268     }
generateScrimBackgroundDrawable(int width, int height)269     private Drawable generateScrimBackgroundDrawable(int width, int height) {
270         ShapeDrawable shape = new ShapeDrawable(new RectShape());
271         shape.setBounds(0, 0, width, height);
272         shape.getPaint().setColor(Color.BLACK);
273         return shape;
274     }
275 
276     /**
277      * @return If dismiss or recovery animation is running.
278      */
isAnimating()279     boolean isAnimating() {
280         return (mDismissalSpring != null && mDismissalSpring.isRunning()) || (
281                 mRecoverySpring != null && mRecoverySpring.isRunning());
282     }
283 
284     /**
285      * Triggers the recovery animation.
286      */
animateRecovery(DismissController.@ullable OnDismissListener dismissListener)287     void animateRecovery(DismissController.@Nullable OnDismissListener dismissListener) {
288         mVelocityTracker.computeCurrentVelocity(VELOCITY_UNIT);
289         mRecoverySpring = createSpringAnimation(mTranslationX, 0, mVelocityTracker.getXVelocity(),
290                 (animation, value, velocity) -> {
291                     float distanceRemaining = Math.max(0, (value - 0));
292                     if (distanceRemaining <= SPRING_ANIMATION_PROGRESS_FINISH_THRESHOLD_PX
293                             && mRecoverySpring != null) {
294                         // Skip last 2% of animation.
295                         mRecoverySpring.skipToEnd();
296                     }
297                     onDismissalRecoveryAnimationProgressChanged(value);
298                 }, (animation, canceled, value, velocity) -> {
299 
300                     resetTranslationAndAlpha();
301                     if (dismissListener != null) {
302                         dismissListener.onDismissCanceled();
303                     }
304                 });
305     }
306 
307     /**
308      * Triggers the dismiss animation.
309      */
animateDismissal(DismissController.@ullable OnDismissListener dismissListener)310     void animateDismissal(DismissController.@Nullable OnDismissListener dismissListener) {
311         if (mVelocityTracker == null) {
312             mVelocityTracker = VelocityTracker.obtain();
313         }
314         mVelocityTracker.computeCurrentVelocity(VELOCITY_UNIT);
315         // Dismissal has started
316         if (dismissListener != null) {
317             dismissListener.onDismissStarted();
318         }
319 
320         mDismissalSpring = createSpringAnimation(mTranslationX, mScreenWidth,
321                 mVelocityTracker.getXVelocity(), (animation, value, velocity) -> {
322                     float distanceRemaining = Math.max(0, (mScreenWidth - value));
323                     if (distanceRemaining <= SPRING_ANIMATION_PROGRESS_FINISH_THRESHOLD_PX
324                             && mDismissalSpring != null) {
325                         // Skip last 2% of animation.
326                         mDismissalSpring.skipToEnd();
327                     }
328                     onDismissalRecoveryAnimationProgressChanged(value);
329                 }, (animation, canceled, value, velocity) -> {
330                     resetTranslationAndAlpha();
331                     if (dismissListener != null) {
332                         dismissListener.onDismissed();
333                     }
334                 });
335     }
336 
getOriginalParentView()337     private @Nullable ViewGroup getOriginalParentView() {
338         if (mLayout.getParent() instanceof ViewGroup) {
339             return (ViewGroup) mLayout.getParent();
340         }
341         return null;
342     }
343 
344     /**
345      * @return The velocity tracker.
346      */
getVelocityTracker()347     @Nullable VelocityTracker getVelocityTracker() {
348         return mVelocityTracker;
349     }
350 
351     /**
352      * Obtain velocity tracker.
353      */
obtainVelocityTracker()354     void obtainVelocityTracker() {
355         mVelocityTracker = VelocityTracker.obtain();
356     }
357 
358     /**
359      * Reset velocity tracker to null.
360      */
resetVelocityTracker()361     void resetVelocityTracker() {
362         mVelocityTracker = null;
363     }
364 }
365