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