Commit 6d9a9e8e authored by Sinan Sahin's avatar Sinan Sahin Committed by Chromium LUCI CQ

[Messages] Add horizontal swipe-to-dismiss gesture

This CL adds support for horizontal swipe-to-dismiss gestures, adds
fling support and updates the existing motion specs. It also adds unit
tests for the mediator.

Flings currently don't affect the speed of the resulting animations as
it turned out to be complicated to implement.

There are several cases that are handled in this CL:
- No fling:
  - If the message is within the dismiss threshold, vertical or
    horizontal, the message is animated back to the idle position, i.e.
    center.
  - If it's outside the threshold, the message is dismissed by being
    animated to out of screen.
- Fling:
  - If the message is flung toward the center from outside the dismiss
    threshold, it is animated to the idle position.
  - If it's flung from within the threshold to the outside, the message
    is dismissed with animation.
  - If it's flung toward out of screen from outside the dismiss
    threshold, the message is dismissed with animation.

Bug: 1157213
Change-Id: I82cfac7111e5ada8687be74a90162784a1e7c992
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2602706
Commit-Queue: Sinan Sahin <sinansahin@google.com>
Reviewed-by: default avatarPavel Yatsuk <pavely@chromium.org>
Reviewed-by: default avatarTheresa  <twellington@chromium.org>
Cr-Commit-Position: refs/heads/master@{#843290}
parent 3fce8349
......@@ -54,20 +54,20 @@ public class MessageContainerCoordinator implements BrowserControlsStateProvider
}
/**
* If there are no browser controls visible, the {@link MessageContainer} view should be laid
* out for this method to return a meaningful value.
* The {@link MessageContainer} view should be laid out for this method to return a meaningful
* value.
*
* @return The maximum translation Y value the message banner can have as a result of the
* animations or the gestures. Positive values mean the message banner can be translated
* upward from the top of the MessagesContainer.
*/
public int getMessageMaxTranslation() {
final int containerTopOffset = getContainerTopOffset();
if (containerTopOffset == 0) {
return mContainer.getHeight();
}
return containerTopOffset;
// The max translation is message height + message shadow + controls height (adjusted for
// Message container offsets)
final int messageHeightWithShadow = mContainer.findViewById(R.id.message_banner).getHeight()
+ mContainer.getResources().getDimensionPixelOffset(
R.dimen.message_shadow_top_margin);
return messageHeightWithShadow + getContainerTopOffset();
}
@Override
......
......@@ -7,6 +7,7 @@ package org.chromium.components.browser_ui.widget.animation;
import android.view.animation.AccelerateInterpolator;
import android.view.animation.DecelerateInterpolator;
import android.view.animation.LinearInterpolator;
import android.view.animation.OvershootInterpolator;
import androidx.interpolator.view.animation.FastOutLinearInInterpolator;
import androidx.interpolator.view.animation.FastOutSlowInInterpolator;
......@@ -25,4 +26,5 @@ public class Interpolators {
public static final LinearInterpolator LINEAR_INTERPOLATOR = new LinearInterpolator();
public static final LinearOutSlowInInterpolator LINEAR_OUT_SLOW_IN_INTERPOLATOR =
new LinearOutSlowInInterpolator();
public static final OvershootInterpolator OVERSHOOT_INTERPOLATOR = new OvershootInterpolator();
}
......@@ -139,14 +139,19 @@ java_library("junit") {
testonly = true
sources = [
"java/src/org/chromium/components/messages/MessageAutoDismissTimerTest.java",
"java/src/org/chromium/components/messages/MessageBannerMediatorUnitTest.java",
"java/src/org/chromium/components/messages/MessageWrapperTest.java",
]
deps = [
":java",
"//base:base_java",
"//base:base_java_test_support",
"//base:base_junit_test_support",
"//components/browser_ui/widget/android:java",
"//third_party/android_deps:androidx_test_runner_java",
"//third_party/android_deps:robolectric_all_java",
"//third_party/hamcrest:hamcrest_core_java",
"//third_party/hamcrest:hamcrest_library_java",
"//third_party/junit",
"//third_party/mockito:mockito_java",
"//ui/android:ui_java",
......
......@@ -6,6 +6,7 @@
<org.chromium.components.messages.MessageBannerView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/message_banner"
android:layout_height="wrap_content"
android:layout_width="match_parent"
android:minHeight="@dimen/message_banner_height"
......
......@@ -17,7 +17,9 @@
<dimen name="message_shadow_lateral_margin">12dp</dimen>
<dimen name="message_shadow_bottom_margin">16dp</dimen>
<dimen name="message_bubble_inset">8dp</dimen>
<dimen name="message_hide_threshold">16dp</dimen>
<dimen name="message_vertical_hide_threshold">16dp</dimen>
<dimen name="message_horizontal_hide_threshold">24dp</dimen>
<dimen name="message_max_horizontal_translation">360dp</dimen>
<dimen name="message_max_width">380dp</dimen>
</resources>
\ No newline at end of file
......@@ -4,7 +4,10 @@
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.ALPHA;
import static org.chromium.components.messages.MessageBannerProperties.TRANSLATION_X;
import static org.chromium.components.messages.MessageBannerProperties.TRANSLATION_Y;
import android.animation.Animator;
......@@ -13,6 +16,9 @@ import android.animation.TimeInterpolator;
import android.content.res.Resources;
import android.view.MotionEvent;
import androidx.annotation.IntDef;
import androidx.annotation.VisibleForTesting;
import org.chromium.base.MathUtils;
import org.chromium.base.supplier.Supplier;
import org.chromium.components.browser_ui.widget.animation.CancelAwareAnimatorListener;
......@@ -20,29 +26,57 @@ import org.chromium.components.browser_ui.widget.animation.Interpolators;
import org.chromium.components.browser_ui.widget.gesture.SwipeGestureListener.ScrollDirection;
import org.chromium.components.browser_ui.widget.gesture.SwipeGestureListener.SwipeHandler;
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;
/**
* Mediator responsible for the business logic in a message banner.
*/
class MessageBannerMediator implements SwipeHandler {
private static final int SHOW_DURATION_MS = 400;
private static final int HIDE_DURATION_MS = 300;
// 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 = 600;
private static final int EXIT_DURATION_MS = 300;
private static final int ANIMATION_DELAY_MS = 100;
private static final TimeInterpolator TRANSLATION_SHOW_INTERPOLATOR =
Interpolators.LINEAR_OUT_SLOW_IN_INTERPOLATOR;
private static final TimeInterpolator TRANSLATION_HIDE_INTERPOLATOR =
private static final TimeInterpolator TRANSLATION_ENTER_INTERPOLATOR =
Interpolators.OVERSHOOT_INTERPOLATOR;
private static final TimeInterpolator ALPHA_ENTER_INTERPOLATOR =
Interpolators.LINEAR_INTERPOLATOR;
private static final TimeInterpolator EXIT_INTERPOLATOR =
Interpolators.FAST_OUT_LINEAR_IN_INTERPOLATOR;
private static final TimeInterpolator ALPHA_INTERPOLATOR = Interpolators.LINEAR_INTERPOLATOR;
private PropertyModel mModel;
private AnimatorSet mAnimatorSet;
private Supplier<Integer> mMaxTranslationSupplier;
private final PropertyModel mModel;
private final Supplier<Integer> mMaxTranslationYSupplier;
private final float mHideThresholdPx;
private final float mVerticalHideThresholdPx;
private final float mHorizontalHideThresholdPx;
private final Supplier<Float> mMaxHorizontalTranslationPx;
private final Runnable mMessageDismissed;
private float mSwipeStartTranslationY;
private Animator mAnimation;
@State
private int mCurrentState = State.HIDDEN;
@ScrollDirection
private int mSwipeDirection;
private float mSwipeStartTranslation;
private boolean mDidFling;
/**
* Constructs the message banner mediator.
......@@ -50,8 +84,17 @@ class MessageBannerMediator implements SwipeHandler {
MessageBannerMediator(PropertyModel model, Supplier<Integer> maxTranslationSupplier,
Resources resources, Runnable messageDismissed) {
mModel = model;
mMaxTranslationSupplier = maxTranslationSupplier;
mHideThresholdPx = resources.getDimensionPixelSize(R.dimen.message_hide_threshold);
mMaxTranslationYSupplier = maxTranslationSupplier;
mVerticalHideThresholdPx =
resources.getDimensionPixelSize(R.dimen.message_vertical_hide_threshold);
mHorizontalHideThresholdPx =
resources.getDimensionPixelSize(R.dimen.message_horizontal_hide_threshold);
mMaxHorizontalTranslationPx = () -> {
final float screenWidth = resources.getDisplayMetrics().widthPixels;
return Math.min(
resources.getDimensionPixelSize(R.dimen.message_max_horizontal_translation),
screenWidth / 2);
};
mMessageDismissed = messageDismissed;
}
......@@ -60,19 +103,12 @@ class MessageBannerMediator implements SwipeHandler {
* @param messageShown The {@link Runnable} that will run once the message banner is shown.
*/
void show(Runnable messageShown) {
if (mAnimatorSet == null) {
mModel.set(TRANSLATION_Y, -mMaxTranslationSupplier.get());
if (mCurrentState == State.HIDDEN) {
mModel.set(TRANSLATION_Y, -mMaxTranslationYSupplier.get());
}
cancelAnyAnimations();
mAnimatorSet = createAnimatorSet(true);
mAnimatorSet.addListener(new CancelAwareAnimatorListener() {
@Override
public void onEnd(Animator animator) {
messageShown.run();
mAnimatorSet = null;
}
});
mAnimatorSet.start();
mAnimation = createAnimation(true, 0, false, messageShown);
mAnimation.start();
}
/**
......@@ -80,16 +116,14 @@ class MessageBannerMediator implements SwipeHandler {
* @param messageHidden The {@link Runnable} that will run once the message banner is hidden.
*/
void hide(Runnable messageHidden) {
if (mCurrentState == State.HIDDEN) {
messageHidden.run();
return;
}
cancelAnyAnimations();
mAnimatorSet = createAnimatorSet(false);
mAnimatorSet.addListener(new CancelAwareAnimatorListener() {
@Override
public void onEnd(Animator animator) {
messageHidden.run();
mAnimatorSet = null;
}
});
mAnimatorSet.start();
mAnimation = createAnimation(true, -mMaxTranslationYSupplier.get(), false, messageHidden);
mAnimation.start();
}
void setOnTouchRunnable(Runnable runnable) {
......@@ -101,74 +135,182 @@ class MessageBannerMediator implements SwipeHandler {
@Override
public void onSwipeStarted(@ScrollDirection int direction, MotionEvent ev) {
mSwipeStartTranslationY = mModel.get(TRANSLATION_Y);
mCurrentState = State.GESTURE;
mSwipeDirection = direction;
mSwipeStartTranslation =
isVertical(mSwipeDirection) ? mModel.get(TRANSLATION_Y) : mModel.get(TRANSLATION_X);
mDidFling = false;
}
@Override
public void onSwipeUpdated(
MotionEvent current, float tx, float ty, float distanceX, float distanceY) {
final float currentGesturePositionY = mSwipeStartTranslationY + ty;
final float currentTranslationY =
MathUtils.clamp(currentGesturePositionY, -mMaxTranslationSupplier.get(), 0);
mModel.set(TRANSLATION_Y, currentTranslationY);
if (isVertical(mSwipeDirection)) {
final float currentGesturePositionY = mSwipeStartTranslation + ty;
final float currentTranslationY =
MathUtils.clamp(currentGesturePositionY, -mMaxTranslationYSupplier.get(), 0);
mModel.set(TRANSLATION_Y, currentTranslationY);
} else {
final float currentGesturePositionX = mSwipeStartTranslation + tx;
final float currentTranslationX = MathUtils.clamp(currentGesturePositionX,
-mMaxHorizontalTranslationPx.get(), mMaxHorizontalTranslationPx.get());
mModel.set(TRANSLATION_X, currentTranslationX);
}
mModel.set(ALPHA, calculateAlphaForTranslation(isVertical(mSwipeDirection)));
}
// TODO(sinansahin): See if we need special handling for #onFling.
@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 (mModel.get(TRANSLATION_Y) == 0.f) return;
if (isResting()) {
mCurrentState = State.IDLE;
return;
}
final boolean isShow = mModel.get(TRANSLATION_Y) > -mHideThresholdPx;
mAnimatorSet = createAnimatorSet(isShow);
mAnimatorSet.addListener(new CancelAwareAnimatorListener() {
@Override
public void onEnd(Animator animator) {
if (!isShow) mMessageDismissed.run();
mAnimatorSet = null;
// 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
: -mMaxTranslationYSupplier.get();
} else {
final float translationX = mModel.get(TRANSLATION_X);
final boolean withinHideThreshold = Math.abs(translationX) < mHorizontalHideThresholdPx;
translateTo = withinHideThreshold
? 0
: MathUtils.flipSignIf(mMaxHorizontalTranslationPx.get(), translationX < 0);
}
mAnimation = createAnimation(
isVertical, translateTo, false, translateTo != 0 ? mMessageDismissed : () -> {});
mAnimation.start();
}
@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) {
translateTo = velocity < 0 ? -mMaxTranslationYSupplier.get() : 0;
} else {
final float translationX = mModel.get(TRANSLATION_X);
if (velocity < 0) {
translateTo = translationX > mHorizontalHideThresholdPx
? 0
: -mMaxHorizontalTranslationPx.get();
} else {
translateTo = translationX < -mHorizontalHideThresholdPx
? 0
: mMaxHorizontalTranslationPx.get();
}
});
mAnimatorSet.start();
}
// TODO(crbug.com/1157213): See if we can use velocity to change the animation
// speed/duration.
mAnimation = createAnimation(isVertical(mSwipeDirection), translateTo, velocity != 0,
translateTo != 0 ? mMessageDismissed : () -> {});
mAnimation.start();
}
@Override
public boolean isSwipeEnabled(@ScrollDirection int direction) {
// TODO(sinansahin): We will implement swiping left/right to dismiss.
return (direction == ScrollDirection.UP || direction == ScrollDirection.DOWN)
&& (mAnimatorSet == null || !mAnimatorSet.isRunning());
return direction != ScrollDirection.UNKNOWN && mCurrentState == State.IDLE;
}
// ---------------------------------------------------------------------------------------------
// endregion
private AnimatorSet createAnimatorSet(boolean isShow) {
final long duration = isShow ? SHOW_DURATION_MS : HIDE_DURATION_MS;
/**
* Create an animation.
* @param vertical Whether the message is being animated vertically.
* @param translateTo Target translation value for the animation.
* @param didFling Whether the animation is the result of a fling gesture.
* @param onEndCallback Callback that will be called after the animation.
* @return The {@link Animator}
*/
private Animator createAnimation(
boolean vertical, float translateTo, boolean didFling, Runnable onEndCallback) {
final long duration = translateTo == 0 ? ENTER_DURATION_MS : EXIT_DURATION_MS;
final boolean isShow = translateTo == 0;
final float alphaTo = isShow ? 1.f : 0.f;
final Animator alphaAnimation =
PropertyModelAnimatorFactory.ofFloat(mModel, ALPHA, alphaTo);
alphaAnimation.setInterpolator(ALPHA_INTERPOLATOR);
alphaAnimation.setInterpolator(isShow ? ALPHA_ENTER_INTERPOLATOR : EXIT_INTERPOLATOR);
alphaAnimation.setDuration(duration);
final float translateTo = isShow ? 0.f : -mMaxTranslationSupplier.get();
final WritableFloatPropertyKey translationProperty =
vertical ? TRANSLATION_Y : TRANSLATION_X;
final Animator translationAnimation =
PropertyModelAnimatorFactory.ofFloat(mModel, TRANSLATION_Y, translateTo);
PropertyModelAnimatorFactory.ofFloat(mModel, translationProperty, translateTo);
translationAnimation.setInterpolator(
isShow ? TRANSLATION_SHOW_INTERPOLATOR : TRANSLATION_HIDE_INTERPOLATOR);
isShow ? TRANSLATION_ENTER_INTERPOLATOR : EXIT_INTERPOLATOR);
translationAnimation.setDuration(duration);
(isShow ? translationAnimation : alphaAnimation).setStartDelay(ANIMATION_DELAY_MS);
// Alpha and translation animations will be played simultaneously if they're the result of a
// fling gesture. Otherwise, we start one with a delay depending on the direction of the
// animation.
if (!didFling) {
(isShow ? translationAnimation : alphaAnimation).setStartDelay(ANIMATION_DELAY_MS);
}
final AnimatorSet animatorSet = new AnimatorSet();
animatorSet.playTogether(alphaAnimation, translationAnimation);
animatorSet.addListener(new CancelAwareAnimatorListener() {
@Override
public void onStart(Animator animator) {
mCurrentState = State.ANIMATING;
}
@Override
public void onEnd(Animator animator) {
mCurrentState = isShow ? State.IDLE : State.HIDDEN;
onEndCallback.run();
mAnimation = null;
}
});
return animatorSet;
}
private void cancelAnyAnimations() {
if (mAnimatorSet != null && mAnimatorSet.isStarted()) mAnimatorSet.cancel();
mAnimatorSet = null;
if (mAnimation != null) mAnimation.cancel();
mAnimation = null;
}
private float calculateAlphaForTranslation(boolean vertical) {
final float displacementRatio = vertical
? Math.abs(mModel.get(TRANSLATION_Y)) / mMaxTranslationYSupplier.get()
: Math.abs(mModel.get(TRANSLATION_X)) / mMaxHorizontalTranslationPx.get();
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;
}
@VisibleForTesting
Supplier<Float> getMaxHorizontalTranslationSupplierForTesting() {
return mMaxHorizontalTranslationPx;
}
}
// Copyright 2021 The Chromium Authors. All rights reserved.
// 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 android.os.Looper.getMainLooper;
import static org.junit.Assert.assertEquals;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.robolectric.Shadows.shadowOf;
import static org.robolectric.annotation.LooperMode.Mode.PAUSED;
import android.content.res.Resources;
import android.util.DisplayMetrics;
import android.view.MotionEvent;
import androidx.test.filters.SmallTest;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnit;
import org.mockito.junit.MockitoRule;
import org.robolectric.annotation.LooperMode;
import org.chromium.base.MathUtils;
import org.chromium.base.supplier.Supplier;
import org.chromium.base.test.BaseRobolectricTestRunner;
import org.chromium.components.browser_ui.widget.gesture.SwipeGestureListener.ScrollDirection;
import org.chromium.ui.modelutil.PropertyModel;
/** Unit tests for {@link MessageBannerMediator}. */
@SmallTest
@RunWith(BaseRobolectricTestRunner.class)
@LooperMode(PAUSED)
public class MessageBannerMediatorUnitTest {
@Rule
public MockitoRule mMockitoRule = MockitoJUnit.rule();
@Mock
private Resources mResources;
@Mock
private DisplayMetrics mDisplayMetrics;
@Mock
private Supplier<Integer> mMaxTranslationSupplier;
@Mock
private Runnable mDismissedRunnable;
@Mock
private Runnable mShownRunnable;
@Mock
private Runnable mHiddenRunnable;
private MessageBannerMediator mMediator;
private PropertyModel mModel;
@Before
public void setUp() {
mModel = new PropertyModel.Builder(MessageBannerProperties.ALL_KEYS)
.with(MessageBannerProperties.TITLE, "Title")
.with(MessageBannerProperties.DESCRIPTION, "Desc")
.build();
when(mResources.getDisplayMetrics()).thenReturn(mDisplayMetrics);
mDisplayMetrics.widthPixels = 500;
when(mResources.getDimensionPixelSize(R.dimen.message_vertical_hide_threshold))
.thenReturn(16);
when(mResources.getDimensionPixelSize(R.dimen.message_horizontal_hide_threshold))
.thenReturn(24);
when(mResources.getDimensionPixelSize(R.dimen.message_max_horizontal_translation))
.thenReturn(120);
mMediator = new MessageBannerMediator(
mModel, mMaxTranslationSupplier, mResources, mDismissedRunnable);
when(mMaxTranslationSupplier.get()).thenReturn(100);
}
@Test
public void testShowMessage() {
mMediator.show(mShownRunnable);
verify(mShownRunnable, times(0)).run();
assertModelState(0, -100, 0, "before showing.");
shadowOf(getMainLooper()).idle();
assertModelState(0, 0, 1, "fully shown.");
verify(mShownRunnable, times(1)).run();
}
@Test
public void testHideMessage() {
mMediator.show(mShownRunnable);
shadowOf(getMainLooper()).idle();
assertModelState(0, 0, 1, "fully shown.");
mMediator.hide(mHiddenRunnable);
verify(mHiddenRunnable, times(0)).run();
shadowOf(getMainLooper()).idle();
assertModelState(0, -100, 0, "after hidden.");
verify(mHiddenRunnable, times(1)).run();
}
@Test
public void testVerticalDismiss() {
mMediator.show(mShownRunnable);
shadowOf(getMainLooper()).idle();
verify(mDismissedRunnable, times(0)).run();
assertModelState(0, 0, 1, "fully shown.");
// More than the threshold to dismiss
swipeVertical(-20, 0);
// .8 is 1 (fully opaque) - 20 (translationY) / 100 (maxTranslation)
assertModelState(0, -20, .8f, "after swipe.");
shadowOf(getMainLooper()).idle();
assertModelState(0, -100, 0, "after dismiss animation.");
verify(mDismissedRunnable, times(1)).run();
}
@Test
public void testVerticalNotDismissed() {
mMediator.show(mShownRunnable);
shadowOf(getMainLooper()).idle();
verify(mDismissedRunnable, times(0)).run();
assertModelState(0, 0, 1, "fully shown.");
// Less than the threshold to dismiss
swipeVertical(-10, 0);
// .9 is 1 (fully opaque) - 20 (translationY) / 100 (maxTranslation)
assertModelState(0, -10, .9f, "after swipe.");
shadowOf(getMainLooper()).idle();
// Should return back to idle position
assertModelState(0, 0, 1, "animated to idle position.");
verify(mDismissedRunnable, times(0)).run();
}
@Test
public void testSwipeDownIsNoop() {
mMediator.show(mShownRunnable);
shadowOf(getMainLooper()).idle();
verify(mDismissedRunnable, times(0)).run();
assertModelState(0, 0, 1, "fully shown.");
swipeVertical(10, 0);
assertModelState(0, 0, 1, "swipe doesn't do anything.");
shadowOf(getMainLooper()).idle();
verify(mDismissedRunnable, times(0)).run();
}
@Test
public void testLeftDismiss() {
mMediator.show(mShownRunnable);
shadowOf(getMainLooper()).idle();
verify(mDismissedRunnable, times(0)).run();
assertModelState(0, 0, 1, "fully shown.");
// More than the threshold to dismiss
swipeHorizontal(-30, 0);
// .75 is 1 (fully opaque) - 30 (translationX) / 120 (maxTranslation)
assertModelState(-30, 0, .75f, "after swipe.");
shadowOf(getMainLooper()).idle();
assertModelState(-120, 0, 0, "after dismiss animation.");
verify(mDismissedRunnable, times(1)).run();
}
@Test
public void testLeftNotDismissed() {
mMediator.show(mShownRunnable);
shadowOf(getMainLooper()).idle();
verify(mDismissedRunnable, times(0)).run();
assertModelState(0, 0, 1, "fully shown.");
// Less than the threshold to dismiss
swipeHorizontal(-12, 0);
// Alpha .9 is 1 (fully opaque) - 12 (translationY) / 120 (maxTranslation)
assertModelState(-12, 0, .9f, "after swipe.");
shadowOf(getMainLooper()).idle();
assertModelState(0, 0, 1, "animated to idle position.");
verify(mDismissedRunnable, times(0)).run();
}
@Test
public void testRightDismiss() {
mMediator.show(mShownRunnable);
shadowOf(getMainLooper()).idle();
verify(mDismissedRunnable, times(0)).run();
assertModelState(0, 0, 1, "fully shown.");
// More than the threshold to dismiss
swipeHorizontal(30, 0);
// Alpha .75 is 1 (fully opaque) - 30 (translationY) / 120 (maxTranslation)
assertModelState(30, 0, .75f, "after swipe.");
shadowOf(getMainLooper()).idle();
assertModelState(120, 0, 0, "after dismiss animation.");
verify(mDismissedRunnable, times(1)).run();
}
@Test
public void testRightNotDismissed() {
mMediator.show(mShownRunnable);
shadowOf(getMainLooper()).idle();
verify(mDismissedRunnable, times(0)).run();
assertModelState(0, 0, 1, "fully shown.");
// Less than the threshold to dismiss
swipeHorizontal(12, 0);
// Alpha .9 is 1 (fully opaque) - 12 (translationY) / 120 (maxTranslation)
assertModelState(12, 0, .9f, "after swipe.");
shadowOf(getMainLooper()).idle();
assertModelState(0, 0, 1, "animated to idle position.");
verify(mDismissedRunnable, times(0)).run();
}
@Test
public void testHorizontalFlingFromOutsideThresholdToCenterNotDismissed() {
mMediator.show(mShownRunnable);
shadowOf(getMainLooper()).idle();
verify(mDismissedRunnable, times(0)).run();
assertModelState(0, 0, 1, "fully shown.");
// More than the threshold to dismiss, fling back to center
swipeHorizontal(60, -100);
// Alpha .5 is 1 (fully opaque) - 60 (translationY) / 120 (maxTranslation)
assertModelState(60, 0, .5f, "after swipe.");
shadowOf(getMainLooper()).idle();
assertModelState(0, 0, 1, "animated to idle position after fling.");
verify(mDismissedRunnable, times(0)).run();
// More than the threshold to dismiss, fling back to center
swipeHorizontal(-30, 100);
// Alpha .75 is 1 (fully opaque) - 30 (translationY) / 120 (maxTranslation)
assertModelState(-30, 0, .75f, "after swipe.");
shadowOf(getMainLooper()).idle();
assertModelState(0, 0, 1, "animated to idle position after fling.");
verify(mDismissedRunnable, times(0)).run();
}
@Test
public void testVerticalFlingDown() {
mMediator.show(mShownRunnable);
shadowOf(getMainLooper()).idle();
verify(mDismissedRunnable, times(0)).run();
assertModelState(0, 0, 1, "fully shown.");
// More than the threshold to dismiss, fling back to center
swipeVertical(-20, 100);
// .8 is 1 (fully opaque) - 20 (translationY) / 100 (maxTranslation)
assertModelState(0, -20, .8f, "after swipe.");
shadowOf(getMainLooper()).idle();
assertModelState(0, 0, 1, "animated to idle position after fling.");
verify(mDismissedRunnable, times(0)).run();
// Less than the threshold to dismiss, fling back to center
swipeVertical(-10, 100);
// .9 is 1 (fully opaque) - 10 (translationY) / 100 (maxTranslation)
assertModelState(0, -10, .9f, "after swipe.");
shadowOf(getMainLooper()).idle();
assertModelState(0, 0, 1, "animated to idle position after fling.");
verify(mDismissedRunnable, times(0)).run();
}
@Test
public void testVerticalFlingDownIsNoop() {
mMediator.show(mShownRunnable);
shadowOf(getMainLooper()).idle();
verify(mDismissedRunnable, times(0)).run();
assertModelState(0, 0, 1, "fully shown.");
// Swipe and fling down
swipeVertical(10, 100);
assertModelState(0, 0, 1, "gesture doesn't do anything.");
shadowOf(getMainLooper()).idle();
verify(mDismissedRunnable, times(0)).run();
}
@Test
public void testVerticalFlingUpWithinThresholdDismisses() {
mMediator.show(mShownRunnable);
shadowOf(getMainLooper()).idle();
verify(mDismissedRunnable, times(0)).run();
assertModelState(0, 0, 1, "fully shown.");
// Swipe less than threshold and fling up
swipeVertical(-10, -100);
// .9 is 1 (fully opaque) - 10 (translationY) / 100 (maxTranslation)
assertModelState(0, -10, .9f, "after swipe.");
shadowOf(getMainLooper()).idle();
assertModelState(0, -100, 0, "after dismiss animation.");
verify(mDismissedRunnable, times(1)).run();
}
@Test
public void testVerticalFlingUpOutsideThresholdDismisses() {
mMediator.show(mShownRunnable);
shadowOf(getMainLooper()).idle();
verify(mDismissedRunnable, times(0)).run();
assertModelState(0, 0, 1, "fully shown.");
// Swipe more than threshold and fling up
swipeVertical(-20, -100);
// .8 is 1 (fully opaque) - 10 (translationY) / 100 (maxTranslation)
assertModelState(0, -20, .8f, "after swipe.");
shadowOf(getMainLooper()).idle();
assertModelState(0, -100, 0, "after dismiss animation.");
verify(mDismissedRunnable, times(1)).run();
}
@Test
public void testLeftFlingWithinThresholdPositiveXDismisses() {
mMediator.show(mShownRunnable);
shadowOf(getMainLooper()).idle();
verify(mDismissedRunnable, times(0)).run();
assertModelState(0, 0, 1, "fully shown.");
// Less than the threshold to dismiss to the right, fling left
swipeHorizontal(12, -100);
// Alpha .9 is 1 (fully opaque) - 12 (translationY) / 120 (maxTranslation)
assertModelState(12, 0, .9f, "after swipe.");
shadowOf(getMainLooper()).idle();
assertModelState(-120, 0, 0, "dismissed to left after fling.");
verify(mDismissedRunnable, times(1)).run();
}
@Test
public void testLeftFlingWithinThresholdNegativeXDismisses() {
mMediator.show(mShownRunnable);
shadowOf(getMainLooper()).idle();
verify(mDismissedRunnable, times(0)).run();
assertModelState(0, 0, 1, "fully shown.");
// Less than the threshold to dismiss to the left, fling left
swipeHorizontal(-12, -100);
// Alpha .9 is 1 (fully opaque) - 12 (translationY) / 120 (maxTranslation)
assertModelState(-12, 0, .9f, "after swipe.");
shadowOf(getMainLooper()).idle();
assertModelState(-120, 0, 0, "dismissed to left after fling.");
verify(mDismissedRunnable, times(1)).run();
}
@Test
public void testRightFlingWithinThresholdNegativeXDismisses() {
mMediator.show(mShownRunnable);
shadowOf(getMainLooper()).idle();
verify(mDismissedRunnable, times(0)).run();
assertModelState(0, 0, 1, "fully shown.");
// Less than the threshold to dismiss to the left, fling right
swipeHorizontal(-12, 100);
// Alpha .9 is 1 (fully opaque) - 12 (translationY) / 120 (maxTranslation)
assertModelState(-12, 0, .9f, "after swipe.");
shadowOf(getMainLooper()).idle();
assertModelState(120, 0, 0, "dismissed to right after fling.");
verify(mDismissedRunnable, times(1)).run();
}
@Test
public void testRightFlingWithinThresholdPositiveXDismisses() {
mMediator.show(mShownRunnable);
shadowOf(getMainLooper()).idle();
verify(mDismissedRunnable, times(0)).run();
assertModelState(0, 0, 1, "fully shown.");
// Less than the threshold to dismiss to the right, fling right
swipeHorizontal(12, 100);
// Alpha .9 is 1 (fully opaque) - 12 (translationY) / 120 (maxTranslation)
assertModelState(12, 0, .9f, "after swipe.");
shadowOf(getMainLooper()).idle();
assertModelState(120, 0, 0, "dismissed to right after fling.");
verify(mDismissedRunnable, times(1)).run();
}
@Test
public void testLeftFlingOutsideThresholdDismisses() {
mMediator.show(mShownRunnable);
shadowOf(getMainLooper()).idle();
verify(mDismissedRunnable, times(0)).run();
assertModelState(0, 0, 1, "fully shown.");
// More than the threshold to dismiss to the left, fling left
swipeHorizontal(-30, -100);
// Alpha .75 is 1 (fully opaque) - 30 (translationY) / 120 (maxTranslation)
assertModelState(-30, 0, .75f, "after swipe.");
shadowOf(getMainLooper()).idle();
assertModelState(-120, 0, 0, "dismissed to left after fling.");
verify(mDismissedRunnable, times(1)).run();
}
@Test
public void testRightFlingOutsideThresholdDismisses() {
mMediator.show(mShownRunnable);
shadowOf(getMainLooper()).idle();
verify(mDismissedRunnable, times(0)).run();
assertModelState(0, 0, 1, "fully shown.");
// More than the threshold to dismiss to the right, fling right
swipeHorizontal(30, 100);
// Alpha .75 is 1 (fully opaque) - 30 (translationY) / 120 (maxTranslation)
assertModelState(30, 0, .75f, "after swipe.");
shadowOf(getMainLooper()).idle();
assertModelState(120, 0, 0, "dismissed to right after fling.");
verify(mDismissedRunnable, times(1)).run();
}
@Test
public void testHorizontalTranslationSupplier() {
// Minimum of max translation dimen (120) and half the screen width (500/2 = 250).
assertEquals("Wrong initial max horizontal translation.", 120,
mMediator.getMaxHorizontalTranslationSupplierForTesting().get(), MathUtils.EPSILON);
// Update the screen width to 200
mDisplayMetrics.widthPixels = 200;
// Minimum of max translation dimen (120) and half the screen width (200/2 = 100).
assertEquals("Max horizontal translation isn't updated width screen width.", 100,
mMediator.getMaxHorizontalTranslationSupplierForTesting().get(), MathUtils.EPSILON);
}
/**
* @param distance Positive is down.
* @param flingVelocityAtEnd Velocity of the fling gesture at the end; 0 if there is no fling.
*/
private void swipeVertical(int distance, int flingVelocityAtEnd) {
MotionEvent e1 = MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 0, 0, 0);
MotionEvent e2 = MotionEvent.obtain(0, 0, MotionEvent.ACTION_MOVE, 0, distance, 0);
mMediator.onSwipeStarted(distance < 0 ? ScrollDirection.UP : ScrollDirection.DOWN, e1);
mMediator.onSwipeUpdated(e2, 0, distance, 0, distance);
if (flingVelocityAtEnd != 0) {
MotionEvent e3 = MotionEvent.obtain(0, 0, MotionEvent.ACTION_UP, 0, distance, 0);
mMediator.onFling(flingVelocityAtEnd < 0 ? ScrollDirection.UP : ScrollDirection.DOWN,
e3, 0, distance, 0, flingVelocityAtEnd);
}
mMediator.onSwipeFinished();
}
/**
* @param distance Positive is right.
* @param flingVelocityAtEnd Velocity of the fling gesture at the end; 0 if there is no fling.
*/
private void swipeHorizontal(int distance, int flingVelocityAtEnd) {
MotionEvent e1 = MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 0, 0, 0);
MotionEvent e2 = MotionEvent.obtain(0, 0, MotionEvent.ACTION_MOVE, distance, 0, 0);
mMediator.onSwipeStarted(distance < 0 ? ScrollDirection.LEFT : ScrollDirection.RIGHT, e1);
mMediator.onSwipeUpdated(e2, distance, 0, distance, 0);
if (flingVelocityAtEnd != 0) {
MotionEvent e3 = MotionEvent.obtain(0, 0, MotionEvent.ACTION_UP, distance, 0, 0);
mMediator.onFling(flingVelocityAtEnd < 0 ? ScrollDirection.LEFT : ScrollDirection.RIGHT,
e3, distance, 0, flingVelocityAtEnd, 0);
}
mMediator.onSwipeFinished();
}
private void assertModelState(float translationXExpected, float translationYExpected,
float alphaExpected, String message) {
assertEquals("Incorrect translation x, " + message, translationXExpected,
mModel.get(MessageBannerProperties.TRANSLATION_X), MathUtils.EPSILON);
assertEquals("Incorrect translation y, " + message, translationYExpected,
mModel.get(MessageBannerProperties.TRANSLATION_Y), MathUtils.EPSILON);
assertEquals("Incorrect alpha, " + message, alphaExpected,
mModel.get(MessageBannerProperties.ALPHA), MathUtils.EPSILON);
}
}
......@@ -42,6 +42,7 @@ public class MessageBannerProperties {
new WritableObjectPropertyKey<>();
// Following properties should only be accessed by the message banner component.
static final WritableFloatPropertyKey TRANSLATION_X = new WritableFloatPropertyKey();
static final WritableFloatPropertyKey TRANSLATION_Y = new WritableFloatPropertyKey();
static final WritableFloatPropertyKey ALPHA = new WritableFloatPropertyKey();
static final WritableObjectPropertyKey<Runnable> ON_TOUCH_RUNNABLE =
......@@ -53,15 +54,15 @@ public class MessageBannerProperties {
// TODO(pavely): There is no need to maintain two lists of property keys. Remove one and clean
// up references.
public static final PropertyKey[] ALL_KEYS =
new PropertyKey[] {PRIMARY_BUTTON_TEXT, PRIMARY_BUTTON_CLICK_LISTENER, TITLE,
DESCRIPTION, ICON, ICON_RESOURCE_ID, SECONDARY_ICON, SECONDARY_ICON_RESOURCE_ID,
SECONDARY_ACTION_TEXT, SECONDARY_ICON_CONTENT_DESCRIPTION, TRANSLATION_Y, ALPHA,
ON_TOUCH_RUNNABLE, ON_PRIMARY_ACTION, ON_SECONDARY_ACTION};
public static final PropertyKey[] ALL_KEYS = new PropertyKey[] {PRIMARY_BUTTON_TEXT,
PRIMARY_BUTTON_CLICK_LISTENER, TITLE, DESCRIPTION, ICON, ICON_RESOURCE_ID,
SECONDARY_ICON, SECONDARY_ICON_RESOURCE_ID, SECONDARY_ACTION_TEXT,
SECONDARY_ICON_CONTENT_DESCRIPTION, TRANSLATION_X, TRANSLATION_Y, ALPHA,
ON_TOUCH_RUNNABLE, ON_PRIMARY_ACTION, ON_SECONDARY_ACTION};
public static final PropertyKey[] SINGLE_ACTION_MESSAGE_KEYS =
new PropertyKey[] {PRIMARY_BUTTON_TEXT, PRIMARY_BUTTON_CLICK_LISTENER, TITLE,
DESCRIPTION, ICON, ICON_RESOURCE_ID, SECONDARY_ICON, SECONDARY_ICON_RESOURCE_ID,
SECONDARY_ACTION_TEXT, ON_DISMISSED, TRANSLATION_Y, ALPHA, ON_TOUCH_RUNNABLE,
ON_PRIMARY_ACTION, ON_SECONDARY_ACTION};
SECONDARY_ACTION_TEXT, ON_DISMISSED, TRANSLATION_X, TRANSLATION_Y, ALPHA,
ON_TOUCH_RUNNABLE, ON_PRIMARY_ACTION, ON_SECONDARY_ACTION};
}
......@@ -17,6 +17,7 @@ import static org.chromium.components.messages.MessageBannerProperties.SECONDARY
import static org.chromium.components.messages.MessageBannerProperties.SECONDARY_ICON_CONTENT_DESCRIPTION;
import static org.chromium.components.messages.MessageBannerProperties.SECONDARY_ICON_RESOURCE_ID;
import static org.chromium.components.messages.MessageBannerProperties.TITLE;
import static org.chromium.components.messages.MessageBannerProperties.TRANSLATION_X;
import static org.chromium.components.messages.MessageBannerProperties.TRANSLATION_Y;
import android.annotation.SuppressLint;
......@@ -68,6 +69,8 @@ public class MessageBannerViewBinder {
}
} else if (propertyKey == ALPHA) {
view.setAlpha(model.get(ALPHA));
} else if (propertyKey == TRANSLATION_X) {
view.setTranslationX(model.get(TRANSLATION_X));
} else if (propertyKey == TRANSLATION_Y) {
view.setTranslationY(model.get(TRANSLATION_Y));
}
......
......@@ -50,17 +50,19 @@ public class MessageContainer extends FrameLayout {
}
/**
* Runs a {@link Runnable} after the initial layout. If the view is already laid out, the
* {@link Runnable} will be called immediately.
* Runs a {@link Runnable} after the message's initial layout. If the view is already laid out,
* the {@link Runnable} will be called immediately.
* @param runnable The {@link Runnable}.
*/
void runAfterInitialLayout(Runnable runnable) {
if (getHeight() > 0) {
void runAfterInitialMessageLayout(Runnable runnable) {
final View message = findViewById(R.id.message_banner);
assert message != null;
if (message.getHeight() > 0) {
runnable.run();
return;
}
addOnLayoutChangeListener(new OnLayoutChangeListener() {
message.addOnLayoutChangeListener(new OnLayoutChangeListener() {
@Override
public void onLayoutChange(View v, int left, int top, int right, int bottom,
int oldLeft, int oldTop, int oldRight, int oldBottom) {
......
......@@ -74,7 +74,7 @@ public class SingleActionMessage implements MessageStateHandler {
// Wait until the message and the container are measured before showing the message. This
// is required in case the animation set-up requires the height of the container, e.g.
// showing messages without the top controls visible.
mContainer.runAfterInitialLayout(showRunnable);
mContainer.runAfterInitialMessageLayout(showRunnable);
}
/**
......
......@@ -6,6 +6,7 @@ package org.chromium.components.messages;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import android.app.Activity;
......@@ -59,17 +60,18 @@ public class SingleActionMessageTest extends DummyUiActivityTestCase {
final MessageBannerCoordinator messageBanner = Mockito.mock(MessageBannerCoordinator.class);
doNothing().when(messageBanner).show(any(Runnable.class));
doNothing().when(messageBanner).setOnTouchRunnable(any(Runnable.class));
final MessageBannerView view = Mockito.mock(MessageBannerView.class);
final MessageBannerView view = new MessageBannerView(getActivity(), null);
view.setId(R.id.message_banner);
message.setMessageBannerForTesting(messageBanner);
message.setViewForTesting(view);
message.show();
Assert.assertEquals(
"Message container should have one message view after the message is shown.", 1,
container.getChildCount());
final ArgumentCaptor<Runnable> runnableCaptor = ArgumentCaptor.forClass(Runnable.class);
doNothing().when(messageBanner).hide(runnableCaptor.capture());
message.hide(true, () -> {});
// Let's pretend the animation ended, and the mediator called the callback as a result.
final ArgumentCaptor<Runnable> runnableCaptor = ArgumentCaptor.forClass(Runnable.class);
verify(messageBanner).hide(runnableCaptor.capture());
runnableCaptor.getValue().run();
Assert.assertEquals(
"Message container should not have any view after the message is hidden.", 0,
......@@ -90,7 +92,8 @@ public class SingleActionMessageTest extends DummyUiActivityTestCase {
final MessageBannerCoordinator messageBanner1 =
Mockito.mock(MessageBannerCoordinator.class);
doNothing().when(messageBanner1).show(any(Runnable.class));
final MessageBannerView view1 = Mockito.mock(MessageBannerView.class);
final MessageBannerView view1 = new MessageBannerView(getActivity(), null);
view1.setId(R.id.message_banner);
message1.setMessageBannerForTesting(messageBanner1);
message1.setViewForTesting(view1);
SingleActionMessage message2 =
......@@ -98,7 +101,8 @@ public class SingleActionMessageTest extends DummyUiActivityTestCase {
final MessageBannerCoordinator messageBanner2 =
Mockito.mock(MessageBannerCoordinator.class);
doNothing().when(messageBanner2).show(any(Runnable.class));
final MessageBannerView view2 = Mockito.mock(MessageBannerView.class);
final MessageBannerView view2 = new MessageBannerView(getActivity(), null);
view2.setId(R.id.message_banner);
message2.setMessageBannerForTesting(messageBanner2);
message2.setViewForTesting(view2);
message1.show();
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment