blob: 4c03fd6f88b727d65057519fa753964af0bd26f7 [file] [log] [blame]
/*
* Copyright (C) 2016 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 android.support.design.widget;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.CoreMatchers.not;
import static org.hamcrest.MatcherAssert.assertThat;
import android.os.SystemClock;
import android.support.annotation.LayoutRes;
import android.support.annotation.NonNull;
import android.support.design.test.R;
import android.support.test.InstrumentationRegistry;
import android.support.test.espresso.Espresso;
import android.support.test.espresso.IdlingResource;
import android.support.test.espresso.NoMatchingViewException;
import android.support.test.espresso.UiController;
import android.support.test.espresso.ViewAction;
import android.support.test.espresso.ViewAssertion;
import android.support.test.espresso.action.CoordinatesProvider;
import android.support.test.espresso.action.GeneralLocation;
import android.support.test.espresso.action.GeneralSwipeAction;
import android.support.test.espresso.action.MotionEvents;
import android.support.test.espresso.action.PrecisionDescriber;
import android.support.test.espresso.action.Press;
import android.support.test.espresso.action.Swipe;
import android.support.test.espresso.action.ViewActions;
import android.support.test.espresso.assertion.ViewAssertions;
import android.support.test.espresso.core.deps.guava.base.Preconditions;
import android.support.test.espresso.matcher.ViewMatchers;
import android.support.v4.view.ViewCompat;
import android.support.v4.widget.NestedScrollView;
import android.test.suitebuilder.annotation.MediumTest;
import android.test.suitebuilder.annotation.SmallTest;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.ViewGroup;
import android.widget.TextView;
import org.hamcrest.Matcher;
import org.junit.Test;
public class BottomSheetBehaviorTest extends
BaseInstrumentationTestCase<BottomSheetBehaviorActivity> {
public static class Callback extends BottomSheetBehavior.BottomSheetCallback
implements IdlingResource {
private boolean mIsIdle;
private IdlingResource.ResourceCallback mResourceCallback;
public Callback(BottomSheetBehavior behavior) {
behavior.setBottomSheetCallback(this);
int state = behavior.getState();
mIsIdle = isIdleState(state);
}
@Override
public void onStateChanged(@NonNull View bottomSheet,
@BottomSheetBehavior.State int newState) {
boolean wasIdle = mIsIdle;
mIsIdle = isIdleState(newState);
if (!wasIdle && mIsIdle && mResourceCallback != null) {
mResourceCallback.onTransitionToIdle();
}
}
@Override
public void onSlide(@NonNull View bottomSheet, float slideOffset) {
}
@Override
public String getName() {
return Callback.class.getSimpleName();
}
@Override
public boolean isIdleNow() {
return mIsIdle;
}
@Override
public void registerIdleTransitionCallback(IdlingResource.ResourceCallback callback) {
mResourceCallback = callback;
}
private boolean isIdleState(int state) {
return state != BottomSheetBehavior.STATE_DRAGGING &&
state != BottomSheetBehavior.STATE_SETTLING;
}
}
/**
* Wait for a FAB to change its visibility (either shown or hidden).
*/
private static class OnVisibilityChangedListener extends
FloatingActionButton.OnVisibilityChangedListener implements IdlingResource {
private final boolean mShown;
private boolean mIsIdle;
private ResourceCallback mResourceCallback;
OnVisibilityChangedListener(boolean shown) {
mShown = shown;
}
private void transitionToIdle() {
if (!mIsIdle) {
mIsIdle = true;
if (mResourceCallback != null) {
mResourceCallback.onTransitionToIdle();
}
}
}
@Override
public void onShown(FloatingActionButton fab) {
if (mShown) {
transitionToIdle();
}
}
@Override
public void onHidden(FloatingActionButton fab) {
if (!mShown) {
transitionToIdle();
}
}
@Override
public String getName() {
return OnVisibilityChangedListener.class.getSimpleName();
}
@Override
public boolean isIdleNow() {
return mIsIdle;
}
@Override
public void registerIdleTransitionCallback(ResourceCallback resourceCallback) {
mResourceCallback = resourceCallback;
}
}
/**
* This is like {@link GeneralSwipeAction}, but it does not send ACTION_UP at the end.
*/
private static class DragAction implements ViewAction {
private static final int STEPS = 10;
private static final int DURATION = 100;
private final CoordinatesProvider mStart;
private final CoordinatesProvider mEnd;
private final PrecisionDescriber mPrecisionDescriber;
public DragAction(CoordinatesProvider start, CoordinatesProvider end,
PrecisionDescriber precisionDescriber) {
mStart = start;
mEnd = end;
mPrecisionDescriber = precisionDescriber;
}
@Override
public Matcher<View> getConstraints() {
return ViewMatchers.isDisplayed();
}
@Override
public String getDescription() {
return "drag";
}
@Override
public void perform(UiController uiController, View view) {
float[] precision = mPrecisionDescriber.describePrecision();
float[] start = mStart.calculateCoordinates(view);
float[] end = mEnd.calculateCoordinates(view);
float[][] steps = interpolate(start, end, STEPS);
int delayBetweenMovements = DURATION / steps.length;
// Down
MotionEvent downEvent = MotionEvents.sendDown(uiController, start, precision).down;
try {
for (int i = 0; i < steps.length; i++) {
// Wait
long desiredTime = downEvent.getDownTime() + (long)(delayBetweenMovements * i);
long timeUntilDesired = desiredTime - SystemClock.uptimeMillis();
if (timeUntilDesired > 10L) {
uiController.loopMainThreadForAtLeast(timeUntilDesired);
}
// Move
if (!MotionEvents.sendMovement(uiController, downEvent, steps[i])) {
MotionEvents.sendCancel(uiController, downEvent);
throw new RuntimeException("Cannot drag: failed to send a move event.");
}
BottomSheetBehavior behavior = BottomSheetBehavior.from(view);
}
int duration = ViewConfiguration.getPressedStateDuration();
if (duration > 0) {
uiController.loopMainThreadForAtLeast((long) duration);
}
} finally {
downEvent.recycle();
}
}
private static float[][] interpolate(float[] start, float[] end, int steps) {
Preconditions.checkElementIndex(1, start.length);
Preconditions.checkElementIndex(1, end.length);
float[][] res = new float[steps][2];
for(int i = 1; i < steps + 1; ++i) {
res[i - 1][0] = start[0] + (end[0] - start[0]) * (float)i / ((float)steps + 2.0F);
res[i - 1][1] = start[1] + (end[1] - start[1]) * (float)i / ((float)steps + 2.0F);
}
return res;
}
}
private static class AddViewAction implements ViewAction {
private final int mLayout;
public AddViewAction(@LayoutRes int layout) {
mLayout = layout;
}
@Override
public Matcher<View> getConstraints() {
return ViewMatchers.isAssignableFrom(ViewGroup.class);
}
@Override
public String getDescription() {
return "add view";
}
@Override
public void perform(UiController uiController, View view) {
ViewGroup parent = (ViewGroup) view;
View child = LayoutInflater.from(view.getContext()).inflate(mLayout, parent, false);
parent.addView(child);
}
}
private Callback mCallback;
public BottomSheetBehaviorTest() {
super(BottomSheetBehaviorActivity.class);
}
@Test
@SmallTest
public void testInitialSetup() {
BottomSheetBehavior behavior = getBehavior();
assertThat(behavior.getState(), is(BottomSheetBehavior.STATE_COLLAPSED));
CoordinatorLayout coordinatorLayout = getCoordinatorLayout();
ViewGroup bottomSheet = getBottomSheet();
assertThat(bottomSheet.getTop(),
is(coordinatorLayout.getHeight() - behavior.getPeekHeight()));
}
@Test
@MediumTest
public void testSetStateExpandedToCollapsed() {
checkSetState(BottomSheetBehavior.STATE_EXPANDED, ViewMatchers.isDisplayed());
checkSetState(BottomSheetBehavior.STATE_COLLAPSED, ViewMatchers.isDisplayed());
}
@Test
@MediumTest
public void testSetStateHiddenToCollapsed() {
checkSetState(BottomSheetBehavior.STATE_HIDDEN, not(ViewMatchers.isDisplayed()));
checkSetState(BottomSheetBehavior.STATE_COLLAPSED, ViewMatchers.isDisplayed());
}
@Test
@MediumTest
public void testSetStateCollapsedToCollapsed() {
checkSetState(BottomSheetBehavior.STATE_COLLAPSED, ViewMatchers.isDisplayed());
}
@Test
@MediumTest
public void testSwipeDownToCollapse() {
checkSetState(BottomSheetBehavior.STATE_EXPANDED, ViewMatchers.isDisplayed());
Espresso.onView(ViewMatchers.withId(R.id.bottom_sheet))
.perform(DesignViewActions.withCustomConstraints(new GeneralSwipeAction(
Swipe.FAST,
// Manually calculate the starting coordinates to make sure that the touch
// actually falls onto the view on Gingerbread
new CoordinatesProvider() {
@Override
public float[] calculateCoordinates(View view) {
int[] location = new int[2];
view.getLocationInWindow(location);
return new float[]{
view.getWidth() / 2,
location[1] + 1
};
}
},
// Manually calculate the ending coordinates to make sure that the bottom
// sheet is collapsed, not hidden
new CoordinatesProvider() {
@Override
public float[] calculateCoordinates(View view) {
BottomSheetBehavior behavior = getBehavior();
return new float[]{
// x: center of the bottom sheet
view.getWidth() / 2,
// y: just above the peek height
view.getHeight() - behavior.getPeekHeight()};
}
}, Press.FINGER), ViewMatchers.isDisplayingAtLeast(5)));
// Avoid a deadlock (b/26160710)
registerIdlingResourceCallback();
try {
Espresso.onView(ViewMatchers.withId(R.id.bottom_sheet))
.check(ViewAssertions.matches(ViewMatchers.isDisplayed()));
assertThat(getBehavior().getState(), is(BottomSheetBehavior.STATE_COLLAPSED));
} finally {
unregisterIdlingResourceCallback();
}
}
@Test
@MediumTest
public void testSwipeDownToHide() {
Espresso.onView(ViewMatchers.withId(R.id.bottom_sheet))
.perform(DesignViewActions.withCustomConstraints(ViewActions.swipeDown(),
ViewMatchers.isDisplayingAtLeast(5)));
// Avoid a deadlock (b/26160710)
registerIdlingResourceCallback();
try {
Espresso.onView(ViewMatchers.withId(R.id.bottom_sheet))
.check(ViewAssertions.matches(not(ViewMatchers.isDisplayed())));
assertThat(getBehavior().getState(), is(BottomSheetBehavior.STATE_HIDDEN));
} finally {
unregisterIdlingResourceCallback();
}
}
@Test
public void testSkipCollapsed() {
getBehavior().setSkipCollapsed(true);
checkSetState(BottomSheetBehavior.STATE_EXPANDED, ViewMatchers.isDisplayed());
Espresso.onView(ViewMatchers.withId(R.id.bottom_sheet))
.perform(DesignViewActions.withCustomConstraints(new GeneralSwipeAction(
Swipe.FAST,
// Manually calculate the starting coordinates to make sure that the touch
// actually falls onto the view on Gingerbread
new CoordinatesProvider() {
@Override
public float[] calculateCoordinates(View view) {
int[] location = new int[2];
view.getLocationInWindow(location);
return new float[]{
view.getWidth() / 2,
location[1] + 1
};
}
},
// Manually calculate the ending coordinates to make sure that the bottom
// sheet is collapsed, not hidden
new CoordinatesProvider() {
@Override
public float[] calculateCoordinates(View view) {
BottomSheetBehavior behavior = getBehavior();
return new float[]{
// x: center of the bottom sheet
view.getWidth() / 2,
// y: just above the peek height
view.getHeight() - behavior.getPeekHeight()};
}
}, Press.FINGER), ViewMatchers.isDisplayingAtLeast(5)));
registerIdlingResourceCallback();
try {
Espresso.onView(ViewMatchers.withId(R.id.bottom_sheet))
.check(ViewAssertions.matches(not(ViewMatchers.isDisplayed())));
assertThat(getBehavior().getState(), is(BottomSheetBehavior.STATE_HIDDEN));
} finally {
unregisterIdlingResourceCallback();
}
}
@Test
@MediumTest
public void testSwipeUpToExpand() {
Espresso.onView(ViewMatchers.withId(R.id.bottom_sheet))
.perform(DesignViewActions.withCustomConstraints(
new GeneralSwipeAction(Swipe.FAST,
GeneralLocation.VISIBLE_CENTER, new CoordinatesProvider() {
@Override
public float[] calculateCoordinates(View view) {
return new float[]{view.getWidth() / 2, 0};
}
}, Press.FINGER),
ViewMatchers.isDisplayingAtLeast(5)));
// Avoid a deadlock (b/26160710)
registerIdlingResourceCallback();
try {
Espresso.onView(ViewMatchers.withId(R.id.bottom_sheet))
.check(ViewAssertions.matches(ViewMatchers.isDisplayed()));
assertThat(getBehavior().getState(), is(BottomSheetBehavior.STATE_EXPANDED));
} finally {
unregisterIdlingResourceCallback();
}
}
@Test
@MediumTest
public void testInvisible() {
// Make the bottomsheet invisible
InstrumentationRegistry.getInstrumentation().runOnMainSync(new Runnable() {
@Override
public void run() {
getBottomSheet().setVisibility(View.INVISIBLE);
assertThat(getBehavior().getState(), is(BottomSheetBehavior.STATE_COLLAPSED));
}
});
// Swipe up as if to expand it
Espresso.onView(ViewMatchers.withId(R.id.bottom_sheet))
.perform(DesignViewActions.withCustomConstraints(
new GeneralSwipeAction(Swipe.FAST,
GeneralLocation.VISIBLE_CENTER, new CoordinatesProvider() {
@Override
public float[] calculateCoordinates(View view) {
return new float[]{view.getWidth() / 2, 0};
}
}, Press.FINGER),
not(ViewMatchers.isDisplayed())));
// Check that the bottom sheet stays the same collapsed state
InstrumentationRegistry.getInstrumentation().runOnMainSync(new Runnable() {
@Override
public void run() {
assertThat(getBehavior().getState(), is(BottomSheetBehavior.STATE_COLLAPSED));
}
});
}
@Test
@MediumTest
public void testNestedScroll() {
final ViewGroup bottomSheet = getBottomSheet();
final BottomSheetBehavior behavior = getBehavior();
final NestedScrollView scroll = new NestedScrollView(mActivityTestRule.getActivity());
// Set up nested scrolling area
InstrumentationRegistry.getInstrumentation().runOnMainSync(new Runnable() {
@Override
public void run() {
bottomSheet.addView(scroll, new ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
TextView view = new TextView(mActivityTestRule.getActivity());
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 500; ++i) {
sb.append("It is fine today. ");
}
view.setText(sb);
view.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
// Do nothing
}
});
scroll.addView(view);
assertThat(behavior.getState(), is(BottomSheetBehavior.STATE_COLLAPSED));
// The scroll offset is 0 at first
assertThat(scroll.getScrollY(), is(0));
}
});
// Swipe from the very bottom of the bottom sheet to the top edge of the screen so that the
// scrolling content is also scrolled
Espresso.onView(ViewMatchers.withId(R.id.coordinator))
.perform(new GeneralSwipeAction(Swipe.FAST,
new CoordinatesProvider() {
@Override
public float[] calculateCoordinates(View view) {
return new float[]{view.getWidth() / 2, view.getHeight() - 1};
}
},
new CoordinatesProvider() {
@Override
public float[] calculateCoordinates(View view) {
return new float[]{view.getWidth() / 2, 1};
}
}, Press.FINGER));
registerIdlingResourceCallback();
try {
Espresso.onView(ViewMatchers.withId(R.id.bottom_sheet))
.check(ViewAssertions.matches(ViewMatchers.isDisplayed()));
InstrumentationRegistry.getInstrumentation().runOnMainSync(new Runnable() {
@Override
public void run() {
assertThat(behavior.getState(), is(BottomSheetBehavior.STATE_EXPANDED));
// This confirms that the nested scrolling area was scrolled continuously after
// the bottom sheet is expanded.
assertThat(scroll.getScrollY(), is(not(0)));
}
});
} finally {
unregisterIdlingResourceCallback();
}
}
@Test
@MediumTest
public void testDragOutside() {
// Swipe up outside of the bottom sheet
Espresso.onView(ViewMatchers.withId(R.id.coordinator))
.perform(DesignViewActions.withCustomConstraints(
new GeneralSwipeAction(Swipe.FAST,
// Just above the bottom sheet
new CoordinatesProvider() {
@Override
public float[] calculateCoordinates(View view) {
return new float[]{
view.getWidth() / 2,
view.getHeight() - getBehavior().getPeekHeight() - 9
};
}
},
// Top of the CoordinatorLayout
new CoordinatesProvider() {
@Override
public float[] calculateCoordinates(View view) {
return new float[]{view.getWidth() / 2, 1};
}
}, Press.FINGER),
ViewMatchers.isDisplayed()));
// Avoid a deadlock (b/26160710)
registerIdlingResourceCallback();
try {
Espresso.onView(ViewMatchers.withId(R.id.bottom_sheet))
.check(ViewAssertions.matches(ViewMatchers.isDisplayed()));
// The bottom sheet should remain collapsed
assertThat(getBehavior().getState(), is(BottomSheetBehavior.STATE_COLLAPSED));
} finally {
unregisterIdlingResourceCallback();
}
}
@Test
@MediumTest
public void testLayoutWhileDragging() {
Espresso.onView(ViewMatchers.withId(R.id.bottom_sheet))
// Drag (and not release)
.perform(new DragAction(
GeneralLocation.VISIBLE_CENTER,
GeneralLocation.TOP_CENTER,
Press.FINGER))
// Check that the bottom sheet is in STATE_DRAGGING
.check(new ViewAssertion() {
@Override
public void check(View view, NoMatchingViewException e) {
assertThat(view, is(ViewMatchers.isDisplayed()));
BottomSheetBehavior behavior = BottomSheetBehavior.from(view);
assertThat(behavior.getState(), is(BottomSheetBehavior.STATE_DRAGGING));
}
})
// Add a new view
.perform(new AddViewAction(R.layout.frame_layout))
// Check that the newly added view is properly laid out
.check(new ViewAssertion() {
@Override
public void check(View view, NoMatchingViewException e) {
ViewGroup parent = (ViewGroup) view;
assertThat(parent.getChildCount(), is(1));
View child = parent.getChildAt(0);
assertThat(ViewCompat.isLaidOut(child), is(true));
}
});
}
@Test
public void testFabVisibility() {
withFabVisibilityChange(false, new Runnable() {
@Override
public void run() {
checkSetState(BottomSheetBehavior.STATE_EXPANDED, ViewMatchers.isDisplayed());
}
});
withFabVisibilityChange(true, new Runnable() {
@Override
public void run() {
checkSetState(BottomSheetBehavior.STATE_COLLAPSED, ViewMatchers.isDisplayed());
}
});
}
@Test
public void testAutoPeekHeight() {
InstrumentationRegistry.getInstrumentation().runOnMainSync(new Runnable() {
@Override
public void run() {
getBehavior().setPeekHeight(BottomSheetBehavior.PEEK_HEIGHT_AUTO);
}
});
InstrumentationRegistry.getInstrumentation().runOnMainSync(new Runnable() {
@Override
public void run() {
assertThat(getBottomSheet().getTop(),
is(getCoordinatorLayout().getWidth() * 9 / 16));
}
});
}
@Test
public void testDynamicContent() {
registerIdlingResourceCallback();
try {
InstrumentationRegistry.getInstrumentation().runOnMainSync(new Runnable() {
@Override
public void run() {
ViewGroup.LayoutParams params = getBottomSheet().getLayoutParams();
params.height = ViewGroup.LayoutParams.WRAP_CONTENT;
getBottomSheet().setLayoutParams(params);
View view = new View(getBottomSheet().getContext());
int size = getBehavior().getPeekHeight() * 2;
getBottomSheet().addView(view, new ViewGroup.LayoutParams(size, size));
assertThat(getBottomSheet().getChildCount(), is(1));
// Shrink the content height.
ViewGroup.LayoutParams lp = view.getLayoutParams();
lp.height = (int) (size * 0.8);
view.setLayoutParams(lp);
// Immediately expand the bottom sheet.
getBehavior().setState(BottomSheetBehavior.STATE_EXPANDED);
}
});
Espresso.onView(ViewMatchers.withId(R.id.bottom_sheet))
.check(ViewAssertions.matches(ViewMatchers.isDisplayed()));
assertThat(getBehavior().getState(), is(BottomSheetBehavior.STATE_EXPANDED));
// Make sure that the bottom sheet is not floating above the bottom.
assertThat(getBottomSheet().getBottom(), is(getCoordinatorLayout().getBottom()));
} finally {
unregisterIdlingResourceCallback();
}
}
private void checkSetState(final int state, Matcher<View> matcher) {
registerIdlingResourceCallback();
try {
InstrumentationRegistry.getInstrumentation().runOnMainSync(new Runnable() {
@Override
public void run() {
getBehavior().setState(state);
}
});
Espresso.onView(ViewMatchers.withId(R.id.bottom_sheet))
.check(ViewAssertions.matches(matcher));
assertThat(getBehavior().getState(), is(state));
} finally {
unregisterIdlingResourceCallback();
}
}
private void registerIdlingResourceCallback() {
// TODO(yaraki): Move this to setUp() when b/26160710 is fixed
mCallback = new Callback(getBehavior());
Espresso.registerIdlingResources(mCallback);
}
private void unregisterIdlingResourceCallback() {
if (mCallback != null) {
Espresso.unregisterIdlingResources(mCallback);
mCallback = null;
}
}
private void withFabVisibilityChange(boolean shown, Runnable action) {
OnVisibilityChangedListener listener = new OnVisibilityChangedListener(shown);
CoordinatorLayout.LayoutParams lp =
(CoordinatorLayout.LayoutParams) mActivityTestRule.getActivity().mFab
.getLayoutParams();
FloatingActionButton.Behavior behavior = (FloatingActionButton.Behavior) lp.getBehavior();
behavior.setInternalAutoHideListener(listener);
Espresso.registerIdlingResources(listener);
try {
action.run();
} finally {
Espresso.unregisterIdlingResources(listener);
}
}
private ViewGroup getBottomSheet() {
return mActivityTestRule.getActivity().mBottomSheet;
}
private BottomSheetBehavior getBehavior() {
return mActivityTestRule.getActivity().mBehavior;
}
private CoordinatorLayout getCoordinatorLayout() {
return mActivityTestRule.getActivity().mCoordinatorLayout;
}
}