// Copyright 2020 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.components.messages;
import static org.chromium.components.browser_ui.widget.gesture.SwipeGestureListener.ScrollDirection.DOWN;
import static org.chromium.components.browser_ui.widget.gesture.SwipeGestureListener.ScrollDirection.UP;
import static org.chromium.components.messages.MessageBannerProperties.CONTENT_ALPHA;
import static org.chromium.components.messages.MessageBannerProperties.MARGIN_TOP;
import static org.chromium.components.messages.MessageBannerProperties.TRANSLATION_X;
import static org.chromium.components.messages.MessageBannerProperties.TRANSLATION_Y;
import static org.chromium.components.messages.MessageBannerProperties.VISUAL_HEIGHT;
import android.animation.Animator;
import android.animation.AnimatorSet;
import android.animation.TimeInterpolator;
import android.content.res.Resources;
import android.view.MotionEvent;
import androidx.annotation.IntDef;
import org.chromium.base.MathUtils;
import org.chromium.base.supplier.Supplier;
import org.chromium.components.browser_ui.widget.animation.CancelAwareAnimatorListener;
import org.chromium.components.browser_ui.widget.gesture.SwipeGestureListener.ScrollDirection;
import org.chromium.components.browser_ui.widget.gesture.SwipeGestureListener.SwipeHandler;
import org.chromium.components.messages.MessageStateHandler.Position;
import org.chromium.ui.interpolators.Interpolators;
import org.chromium.ui.modelutil.PropertyModel;
import org.chromium.ui.modelutil.PropertyModel.WritableFloatPropertyKey;
import org.chromium.ui.modelutil.PropertyModelAnimatorFactory;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
import java.util.List;
/** Mediator responsible for the business logic in a message banner. */
class MessageBannerMediator implements SwipeHandler {
// Message banner state
@Retention(RetentionPolicy.SOURCE)
@IntDef({State.HIDDEN, State.ANIMATING, State.IDLE, State.GESTURE})
private @interface State {
// Hidden or never shown
int HIDDEN = 0;
// In motion without user interaction
int ANIMATING = 1;
// Resting state / fully shown
int IDLE = 2;
// User gesture
int GESTURE = 3;
int NUM_ENTRIES = 4;
}
private static final int ENTER_DURATION_MS = 550;
private static final int EXIT_DURATION_MS = 350;
private static final TimeInterpolator TRANSLATION_ENTER_INTERPOLATOR =
Interpolators.EMPHASIZED_DECELERATE;
private static final TimeInterpolator ALPHA_ENTER_INTERPOLATOR =
Interpolators.EMPHASIZED_DECELERATE;
private static final TimeInterpolator EXIT_INTERPOLATOR = Interpolators.EMPHASIZED_DECELERATE;
private final PropertyModel mModel;
private final Supplier<Integer> mMaxTranslationYSupplier;
private final Supplier<Integer> mTopOffsetSupplier;
private final float mVerticalHideThresholdPx;
private final float mHorizontalHideThresholdPx;
private final float mMaxHorizontalTranslationPx;
private final Runnable mMessageDismissed;
private final SwipeAnimationHandler mSwipeAnimationHandler;
private final int mPeekingMarginTop;
private final int mDefaultMarginTop;
private Animator mAnimation;
@State private int mCurrentState = State.HIDDEN;
@ScrollDirection private int mSwipeDirection;
private float mSwipeStartTranslation;
private boolean mDidFling;
/** Constructs the message banner mediator. */
MessageBannerMediator(
PropertyModel model,
Supplier<Integer> topOffsetSupplier,
Supplier<Integer> maxTranslationSupplier,
Resources resources,
Runnable messageDismissed,
SwipeAnimationHandler swipeAnimationHandler) {
mModel = model;
mTopOffsetSupplier = topOffsetSupplier;
mMaxTranslationYSupplier = maxTranslationSupplier;
mVerticalHideThresholdPx =
resources.getDimensionPixelSize(R.dimen.message_vertical_hide_threshold);
mHorizontalHideThresholdPx =
resources.getDimensionPixelSize(R.dimen.message_horizontal_hide_threshold);
mMaxHorizontalTranslationPx = resources.getDisplayMetrics().widthPixels;
mMessageDismissed = messageDismissed;
mSwipeAnimationHandler = swipeAnimationHandler;
mDefaultMarginTop = resources.getDimensionPixelSize(R.dimen.message_shadow_top_margin);
mPeekingMarginTop =
resources.getDimensionPixelSize(R.dimen.message_peeking_layer_height)
+ mDefaultMarginTop;
}
/**
* Shows the message banner with an animation.
*
* @param fromIndex The initial position.
* @param toIndex The target position the message is moving to.
* @param verticalOffset Extra offset the message is supposed to move down besides the default
* marginTop value.
* @param messageShown The {@link Runnable} that will run once the message banner is shown.
* @return The animator to show the message.
*/
Animator show(
@Position int fromIndex,
@Position int toIndex,
int verticalOffset,
Runnable messageShown) {
if (mCurrentState == State.HIDDEN) {
mModel.set(TRANSLATION_Y, fromIndex == Position.FRONT ? 0 : -mTopOffsetSupplier.get());
mModel.set(MARGIN_TOP, mDefaultMarginTop);
mModel.set(CONTENT_ALPHA, 0.f);
mModel.set(VISUAL_HEIGHT, 0.f);
} else if (mCurrentState == State.IDLE && toIndex == Position.FRONT) {
// Animating marginTop is expensive. Use translationY to simulate the effect of
// marginTop.
assert fromIndex == Position.BACK;
mModel.set(TRANSLATION_Y, mModel.get(MARGIN_TOP) - mDefaultMarginTop);
mModel.set(MARGIN_TOP, mDefaultMarginTop);
}
cancelAnyAnimations();
return startAnimation(
true,
true,
0,
false,
toIndex == Position.BACK ? mPeekingMarginTop + verticalOffset : mDefaultMarginTop,
messageShown);
}
/**
* Hides the message banner with an animation.
* @param fromIndex The initial position.
* @param toIndex The target position the message is moving to.
* @param animate Whether to hide with an animation.
* @param messageHidden The {@link Runnable} that will run once the message banner is hidden.
* @return The animator to hide the message.
*/
Animator hide(
@Position int fromIndex,
@Position int toIndex,
boolean animate,
Runnable messageHidden) {
cancelAnyAnimations();
float translateTo = toIndex == Position.FRONT ? 0 : -mTopOffsetSupplier.get();
if (!animate) {
mModel.set(CONTENT_ALPHA, 0.f);
mModel.set(VISUAL_HEIGHT, 0.f);
mModel.set(TRANSLATION_Y, translateTo);
mCurrentState = State.HIDDEN;
}
if (mCurrentState == State.HIDDEN) {
messageHidden.run();
return null;
}
return startAnimation(true, false, translateTo, false, mDefaultMarginTop, messageHidden);
}
void setOnTouchRunnable(Runnable runnable) {
mModel.set(MessageBannerProperties.ON_TOUCH_RUNNABLE, runnable);
}
// region SwipeHandler implementation
// ---------------------------------------------------------------------------------------------
@Override
public void onSwipeStarted(@ScrollDirection int direction, MotionEvent ev) {
mCurrentState = State.GESTURE;
mSwipeDirection = direction;
mSwipeStartTranslation =
isVertical(mSwipeDirection) ? mModel.get(TRANSLATION_Y) : mModel.get(TRANSLATION_X);
mDidFling = false;
mSwipeAnimationHandler.onSwipeStart();
}
@Override
public void onSwipeUpdated(
MotionEvent current, float tx, float ty, float distanceX, float distanceY) {
if (isVertical(mSwipeDirection)) {
final float currentGesturePositionY = mSwipeStartTranslation + ty;
final float currentTranslationY =
MathUtils.clamp(currentGesturePositionY, -mMaxTranslationYSupplier.get(), 0);
mModel.set(TRANSLATION_Y, currentTranslationY);
// At most 50% opacity while swiping.
mModel.set(
CONTENT_ALPHA,
Math.max(.5f, calculateAlphaForTranslation(isVertical(mSwipeDirection))));
} else {
final float currentGesturePositionX = mSwipeStartTranslation + tx;
final float currentTranslationX =
MathUtils.clamp(
currentGesturePositionX,
-mMaxHorizontalTranslationPx,
mMaxHorizontalTranslationPx);
mModel.set(TRANSLATION_X, currentTranslationX);
mModel.set(CONTENT_ALPHA, calculateAlphaForTranslation(isVertical(mSwipeDirection)));
}
}
@Override
public void onSwipeFinished() {
// A fling gesture will already be handled in #onFling.
if (mDidFling) return;
cancelAnyAnimations();
// No need to animate if the message banner is in resting position.
if (isResting()) {
mCurrentState = State.IDLE;
mSwipeAnimationHandler.onSwipeEnd(null);
return;
}
// If the current translation is within the hide threshold, i.e. message shouldn't be
// dismissed, we will run an animation returning the message to the idle position.
// Otherwise, the message will be dismissed with an animation.
final boolean isVertical = isVertical(mSwipeDirection);
float translateTo;
if (isVertical) {
translateTo =
mModel.get(TRANSLATION_Y) > -mVerticalHideThresholdPx
? 0
: -mTopOffsetSupplier.get();
} else {
final float translationX = mModel.get(TRANSLATION_X);
final boolean withinHideThreshold = Math.abs(translationX) < mHorizontalHideThresholdPx;
translateTo =
withinHideThreshold
? 0
: MathUtils.flipSignIf(mMaxHorizontalTranslationPx, translationX < 0);
}
boolean isShow = translateTo == 0;
mSwipeAnimationHandler.onSwipeEnd(
startAnimation(
isVertical,
isShow,
translateTo,
false,
mDefaultMarginTop,
isShow ? () -> {} : mMessageDismissed));
}
@Override
public void onFling(
@ScrollDirection int direction,
MotionEvent current,
float tx,
float ty,
float velocityX,
float velocityY) {
mDidFling = true;
// Flinging toward the idle position from outside the hiding threshold should animate the
// message to the idle position. Otherwise, the message will be dismissed with animation.
final boolean isVertical = isVertical(mSwipeDirection);
final float velocity = isVertical ? velocityY : velocityX;
float translateTo;
if (isVertical) {
final float translationY = mModel.get(TRANSLATION_Y);
translateTo = translationY < 0 ? -mTopOffsetSupplier.get() : 0;
} else {
final float translationX = mModel.get(TRANSLATION_X);
if (Math.abs(translationX) < mHorizontalHideThresholdPx) {
translateTo = 0;
} else {
translateTo = MathUtils.flipSignIf(mMaxHorizontalTranslationPx, translationX < 0);
}
}
// TODO(crbug.com/40736315): See if we can use velocity to change the animation
// speed/duration.
boolean isShow = translateTo == 0;
mSwipeAnimationHandler.onSwipeEnd(
startAnimation(
isVertical(mSwipeDirection),
isShow,
translateTo,
velocity != 0,
mDefaultMarginTop,
isShow ? () -> {} : mMessageDismissed));
}
@Override
public boolean isSwipeEnabled(@ScrollDirection int direction) {
return direction != ScrollDirection.UNKNOWN
&& mCurrentState == State.IDLE
&& mSwipeAnimationHandler.isSwipeEnabled();
}
// ---------------------------------------------------------------------------------------------
// endregion
/**
* Create and start an animation.
*
* @param vertical Whether the message is being animated vertically.
* @param isShow Whether the message is going to be shown.
* @param translateTo Target translation value for the animation.
* @param didFling Whether the animation is the result of a fling gesture.
* @param marginTo The marginTop value the view should move to.
* @param onEndCallback Callback that will be called after the animation.
* @return The animator which can trigger the animation.
*/
private AnimatorSet startAnimation(
boolean vertical,
boolean isShow,
float translateTo,
boolean didFling,
int marginTo,
Runnable onEndCallback) {
final long duration = isShow ? ENTER_DURATION_MS : EXIT_DURATION_MS;
List<Animator> animators = new ArrayList<>();
Animator alphaAnimation = null;
if (vertical) {
final Animator expand =
PropertyModelAnimatorFactory.ofFloat(mModel, VISUAL_HEIGHT, isShow ? 1 : 0);
expand.setInterpolator(ALPHA_ENTER_INTERPOLATOR);
expand.setDuration(duration);
animators.add(expand);
}
final float alphaTo = isShow ? 1.f : 0.f;
alphaAnimation = PropertyModelAnimatorFactory.ofFloat(mModel, CONTENT_ALPHA, alphaTo);
alphaAnimation.setInterpolator(EXIT_INTERPOLATOR);
alphaAnimation.setDuration(duration);
animators.add(alphaAnimation);
final WritableFloatPropertyKey translationProperty =
vertical ? TRANSLATION_Y : TRANSLATION_X;
// Animating marginTop is expensive. Animating translateY here and then set real marginTop
// value and reset translateY in the end of animation.
final Animator translationAnimation =
PropertyModelAnimatorFactory.ofFloat(
mModel, translationProperty, translateTo + marginTo - mDefaultMarginTop);
translationAnimation.setInterpolator(
isShow ? TRANSLATION_ENTER_INTERPOLATOR : EXIT_INTERPOLATOR);
translationAnimation.setDuration(duration);
animators.add(translationAnimation);
final AnimatorSet animatorSet = new AnimatorSet();
animatorSet.playTogether(animators);
animatorSet.addListener(
new CancelAwareAnimatorListener() {
@Override
public void onStart(Animator animator) {
mCurrentState = State.ANIMATING;
}
@Override
public void onEnd(Animator animator) {
if (isShow) {
mModel.set(MARGIN_TOP, marginTo);
mModel.set(TRANSLATION_Y, 0);
}
mCurrentState = isShow ? State.IDLE : State.HIDDEN;
onEndCallback.run();
mAnimation = null;
}
});
mAnimation = animatorSet;
return animatorSet;
}
private void cancelAnyAnimations() {
if (mAnimation != null) mAnimation.cancel();
mAnimation = null;
}
private float calculateAlphaForTranslation(boolean vertical) {
final float displacementRatio =
vertical
? Math.abs(mModel.get(TRANSLATION_Y)) / mTopOffsetSupplier.get()
: Math.abs(mModel.get(TRANSLATION_X)) / mMaxHorizontalTranslationPx;
return 1 - displacementRatio;
}
private boolean isVertical(@ScrollDirection int direction) {
return direction == UP || direction == DOWN;
}
private boolean isResting() {
return mModel.get(TRANSLATION_Y) == 0.f && mModel.get(TRANSLATION_X) == 0.f;
}
}