/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.wear.widget;
import static java.lang.Math.max;
import static java.lang.Math.min;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Color;
import android.graphics.ColorFilter;
import android.graphics.Outline;
import android.graphics.Paint;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffColorFilter;
import android.util.SparseArray;
import android.view.MotionEvent;
import android.view.VelocityTracker;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewOutlineProvider;
import android.view.ViewParent;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.dynamicanimation.animation.DynamicAnimation;
import androidx.dynamicanimation.animation.FloatValueHolder;
import androidx.dynamicanimation.animation.SpringAnimation;
import androidx.dynamicanimation.animation.SpringForce;
/**
* A helper class to handle transition of swiping to dismiss and dismiss animation.
*/
class SwipeDismissTransitionHelper {
private static final String TAG = "SwipeDismissTransitionHelper";
private static final float SCALE_MIN = 0.7f;
private static final float SCALE_MAX = 1.0f;
public static final float SCRIM_BACKGROUND_MAX = 0.5f;
private static final float DIM_FOREGROUND_PROGRESS_FACTOR = 2.0f;
private static final float DIM_FOREGROUND_MIN = 0.3f;
private static final int VELOCITY_UNIT = 1000;
// Spring properties
private static final float SPRING_STIFFNESS = 600f;
private static final float SPRING_DAMPING_RATIO = SpringForce.DAMPING_RATIO_NO_BOUNCY;
private static final float SPRING_MIN_VISIBLE_CHANGE = 0.5f;
private static final int SPRING_ANIMATION_PROGRESS_FINISH_THRESHOLD_PX = 5;
private final DismissibleFrameLayout mLayout;
private final int mScreenWidth;
private final SparseArray<ColorFilter> mDimmingColorFilterCache = new SparseArray<>();
private final View mScrimBackground;
private final boolean mIsScreenRound;
private final Paint mCompositingPaint = new Paint();
private VelocityTracker mVelocityTracker;
private boolean mStarted;
private int mOriginalViewWidth;
private float mTranslationX;
private float mScale;
private float mProgress;
private float mDimming;
private SpringAnimation mDismissalSpring;
private SpringAnimation mRecoverySpring;
SwipeDismissTransitionHelper(@NonNull Context context,
@NonNull DismissibleFrameLayout layout) {
mLayout = layout;
mIsScreenRound = layout.getResources().getConfiguration().isScreenRound();
mScreenWidth = Resources.getSystem().getDisplayMetrics().widthPixels;
mScrimBackground = new View(context);
clipOutline(mScrimBackground, mIsScreenRound);
mScrimBackground.setLayoutParams(
new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT));
mScrimBackground.setBackgroundColor(Color.BLACK);
}
private static void clipOutline(@NonNull View view, boolean useRoundShape) {
view.setOutlineProvider(new ViewOutlineProvider() {
@Override
public void getOutline(View view, Outline outline) {
if (useRoundShape) {
outline.setOval(0, 0, view.getWidth(), view.getHeight());
} else {
outline.setRect(0, 0, view.getWidth(), view.getHeight());
}
outline.setAlpha(0);
}
});
view.setClipToOutline(true);
}
private static float lerp(float min, float max, float value) {
return min + (max - min) * value;
}
private static float clamp(float min, float max, float value) {
return max(min, min(max, value));
}
private static float lerpInv(float min, float max, float value) {
return min != max ? ((value - min) / (max - min)) : 0.0f;
}
private ColorFilter createDimmingColorFilter(float level) {
level = clamp(0, 1, level);
int alpha = (int) (0xFF * level);
int color = Color.argb(alpha, 0, 0, 0);
ColorFilter colorFilter = mDimmingColorFilterCache.get(alpha);
if (colorFilter != null) {
return colorFilter;
}
colorFilter = new PorterDuffColorFilter(color, PorterDuff.Mode.SRC_ATOP);
mDimmingColorFilterCache.put(alpha, colorFilter);
return colorFilter;
}
private SpringAnimation createSpringAnimation(float startValue,
float finalValue,
float startVelocity,
DynamicAnimation.OnAnimationUpdateListener onUpdateListener,
DynamicAnimation.OnAnimationEndListener onEndListener) {
SpringAnimation animation = new SpringAnimation(new FloatValueHolder());
animation.setStartValue(startValue);
animation.setMinimumVisibleChange(SPRING_MIN_VISIBLE_CHANGE);
SpringForce spring = new SpringForce();
spring.setFinalPosition(finalValue);
spring.setDampingRatio(SPRING_DAMPING_RATIO);
spring.setStiffness(SPRING_STIFFNESS);
animation.setMinValue(0.0f);
animation.setMaxValue(mScreenWidth);
animation.setStartVelocity(startVelocity);
animation.setSpring(spring);
animation.addUpdateListener(onUpdateListener);
animation.addEndListener(onEndListener);
animation.start();
return animation;
}
/**
* Updates the swipe progress
*
* @param deltaX The X delta of gesture
* @param ev The motion event
*/
void onSwipeProgressChanged(float deltaX, @NonNull MotionEvent ev) {
if (!mStarted) {
initializeTransition();
}
mVelocityTracker.addMovement(ev);
mOriginalViewWidth = mLayout.getWidth();
// For swiping, mProgress is directly manipulated
// mProgress = 0 (no swipe) - 0.5 (swiped to mid screen) - 1 (swipe to right of screen)
mProgress = deltaX / mOriginalViewWidth;
// Solve for other variables
// Scale = lerp 100% -> 70% when swiping from left edge to right edge
mScale = lerp(SCALE_MAX, SCALE_MIN, mProgress);
// Translation: make sure the right edge of mOriginalView touches right edge of screen
mTranslationX = max(0f, 1 - mScale) * mLayout.getWidth() / 2.0f;
mDimming = Math.min(DIM_FOREGROUND_MIN, mProgress / DIM_FOREGROUND_PROGRESS_FACTOR);
updateView();
}
private void onDismissalRecoveryAnimationProgressChanged(float translationX) {
mOriginalViewWidth = mLayout.getWidth();
mTranslationX = translationX;
mScale = 1 - mTranslationX * 2 / mOriginalViewWidth;
// Clamp mScale so that we can solve for mProgress
mScale = Math.max(SCALE_MIN, Math.min(mScale, SCALE_MAX));
float nextProgress = lerpInv(SCALE_MAX, SCALE_MIN, mScale);
if (nextProgress > mProgress) {
mProgress = nextProgress;
}
mDimming = Math.min(DIM_FOREGROUND_MIN, mProgress / DIM_FOREGROUND_PROGRESS_FACTOR);
updateView();
}
private void updateView() {
mLayout.setScaleX(mScale);
mLayout.setScaleY(mScale);
mLayout.setTranslationX(mTranslationX);
updateDim();
updateScrim();
}
private void updateDim() {
mCompositingPaint.setColorFilter(createDimmingColorFilter(mDimming));
mLayout.setLayerPaint(mCompositingPaint);
}
private void updateScrim() {
float alpha = SCRIM_BACKGROUND_MAX * (1 - mProgress);
mScrimBackground.setAlpha(alpha);
}
private void initializeTransition() {
mStarted = true;
ViewGroup originalParentView = getOriginalParentView();
ViewParent scrimBackgroundParent = mScrimBackground.getParent();
if (originalParentView == null) return;
// Check if scrim background is already attached to the parent view.
if (scrimBackgroundParent != originalParentView) {
originalParentView.addView(mScrimBackground);
mLayout.bringToFront();
}
mCompositingPaint.setColorFilter(null);
mLayout.setLayerType(View.LAYER_TYPE_HARDWARE, mCompositingPaint);
clipOutline(mLayout, mIsScreenRound);
}
private void resetTranslationAndAlpha() {
// resetting variables
mStarted = false;
mTranslationX = 0;
mProgress = 0;
mScale = 1;
// resetting layout params
mLayout.setTranslationX(0);
mLayout.setScaleX(1);
mLayout.setScaleY(1);
mLayout.setAlpha(1);
mScrimBackground.setAlpha(0);
mCompositingPaint.setColorFilter(null);
mLayout.setLayerType(View.LAYER_TYPE_NONE, null);
mLayout.setClipToOutline(false);
}
/**
* @return If dismiss or recovery animation is running.
*/
boolean isAnimating() {
return (mDismissalSpring != null && mDismissalSpring.isRunning()) || (
mRecoverySpring != null && mRecoverySpring.isRunning());
}
/**
* Triggers the recovery animation.
*/
void animateRecovery(@Nullable DismissController.OnDismissListener dismissListener) {
mVelocityTracker.computeCurrentVelocity(VELOCITY_UNIT);
mRecoverySpring = createSpringAnimation(mTranslationX, 0, mVelocityTracker.getXVelocity(),
(animation, value, velocity) -> {
float distanceRemaining = Math.max(0, (value - 0));
if (distanceRemaining <= SPRING_ANIMATION_PROGRESS_FINISH_THRESHOLD_PX
&& mRecoverySpring != null) {
// Skip last 2% of animation.
mRecoverySpring.skipToEnd();
}
onDismissalRecoveryAnimationProgressChanged(value);
}, (animation, canceled, value, velocity) -> {
resetTranslationAndAlpha();
if (dismissListener != null) {
dismissListener.onDismissCanceled();
}
});
}
/**
* Triggers the dismiss animation.
*/
void animateDismissal(@Nullable DismissController.OnDismissListener dismissListener) {
if (mVelocityTracker == null) {
mVelocityTracker = VelocityTracker.obtain();
}
mVelocityTracker.computeCurrentVelocity(VELOCITY_UNIT);
// Dismissal has started
if (dismissListener != null) {
dismissListener.onDismissStarted();
}
mDismissalSpring = createSpringAnimation(mTranslationX, mScreenWidth,
mVelocityTracker.getXVelocity(), (animation, value, velocity) -> {
float distanceRemaining = Math.max(0, (mScreenWidth - value));
if (distanceRemaining <= SPRING_ANIMATION_PROGRESS_FINISH_THRESHOLD_PX
&& mDismissalSpring != null) {
// Skip last 2% of animation.
mDismissalSpring.skipToEnd();
}
onDismissalRecoveryAnimationProgressChanged(value);
}, (animation, canceled, value, velocity) -> {
resetTranslationAndAlpha();
if (dismissListener != null) {
dismissListener.onDismissed();
}
});
}
private @Nullable ViewGroup getOriginalParentView() {
if (mLayout.getParent() instanceof ViewGroup) {
return (ViewGroup) mLayout.getParent();
}
return null;
}
/**
* @return The velocity tracker.
*/
@Nullable
VelocityTracker getVelocityTracker() {
return mVelocityTracker;
}
/**
* Obtain velocity tracker.
*/
void obtainVelocityTracker() {
mVelocityTracker = VelocityTracker.obtain();
}
/**
* Reset velocity tracker to null.
*/
void resetVelocityTracker() {
mVelocityTracker = null;
}
}