1/* 2 * Copyright (C) 2016 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 17package android.support.design.widget; 18 19 20import android.os.SystemClock; 21import android.support.annotation.LayoutRes; 22import android.support.annotation.NonNull; 23import android.support.design.test.R; 24import android.support.test.InstrumentationRegistry; 25import android.support.test.espresso.Espresso; 26import android.support.test.espresso.IdlingResource; 27import android.support.test.espresso.NoMatchingViewException; 28import android.support.test.espresso.UiController; 29import android.support.test.espresso.ViewAction; 30import android.support.test.espresso.ViewAssertion; 31import android.support.test.espresso.action.CoordinatesProvider; 32import android.support.test.espresso.action.GeneralLocation; 33import android.support.test.espresso.action.GeneralSwipeAction; 34import android.support.test.espresso.action.MotionEvents; 35import android.support.test.espresso.action.PrecisionDescriber; 36import android.support.test.espresso.action.Press; 37import android.support.test.espresso.action.Swipe; 38import android.support.test.espresso.action.ViewActions; 39import android.support.test.espresso.assertion.ViewAssertions; 40import android.support.test.espresso.core.deps.guava.base.Preconditions; 41import android.support.test.espresso.matcher.ViewMatchers; 42import android.support.v4.view.ViewCompat; 43import android.support.v4.widget.NestedScrollView; 44import android.test.suitebuilder.annotation.MediumTest; 45import android.test.suitebuilder.annotation.SmallTest; 46import android.view.LayoutInflater; 47import android.view.MotionEvent; 48import android.view.View; 49import android.view.ViewConfiguration; 50import android.view.ViewGroup; 51import android.widget.TextView; 52 53import org.hamcrest.Matcher; 54import org.junit.Test; 55 56import static org.hamcrest.CoreMatchers.is; 57import static org.hamcrest.CoreMatchers.not; 58import static org.hamcrest.MatcherAssert.assertThat; 59 60public class BottomSheetBehaviorTest extends 61 BaseInstrumentationTestCase<BottomSheetBehaviorActivity> { 62 63 public static class Callback extends BottomSheetBehavior.BottomSheetCallback 64 implements IdlingResource { 65 66 private boolean mIsIdle; 67 68 private IdlingResource.ResourceCallback mResourceCallback; 69 70 public Callback(BottomSheetBehavior behavior) { 71 behavior.setBottomSheetCallback(this); 72 int state = behavior.getState(); 73 mIsIdle = isIdleState(state); 74 } 75 76 @Override 77 public void onStateChanged(@NonNull View bottomSheet, 78 @BottomSheetBehavior.State int newState) { 79 boolean wasIdle = mIsIdle; 80 mIsIdle = isIdleState(newState); 81 if (!wasIdle && mIsIdle && mResourceCallback != null) { 82 mResourceCallback.onTransitionToIdle(); 83 } 84 } 85 86 @Override 87 public void onSlide(@NonNull View bottomSheet, float slideOffset) { 88 } 89 90 @Override 91 public String getName() { 92 return Callback.class.getSimpleName(); 93 } 94 95 @Override 96 public boolean isIdleNow() { 97 return mIsIdle; 98 } 99 100 @Override 101 public void registerIdleTransitionCallback(IdlingResource.ResourceCallback callback) { 102 mResourceCallback = callback; 103 } 104 105 private boolean isIdleState(int state) { 106 return state != BottomSheetBehavior.STATE_DRAGGING && 107 state != BottomSheetBehavior.STATE_SETTLING; 108 } 109 110 } 111 112 /** 113 * This is like {@link GeneralSwipeAction}, but it does not send ACTION_UP at the end. 114 */ 115 private static class DragAction implements ViewAction { 116 117 private static final int STEPS = 10; 118 private static final int DURATION = 100; 119 120 private final CoordinatesProvider mStart; 121 private final CoordinatesProvider mEnd; 122 private final PrecisionDescriber mPrecisionDescriber; 123 124 public DragAction(CoordinatesProvider start, CoordinatesProvider end, 125 PrecisionDescriber precisionDescriber) { 126 mStart = start; 127 mEnd = end; 128 mPrecisionDescriber = precisionDescriber; 129 } 130 131 @Override 132 public Matcher<View> getConstraints() { 133 return ViewMatchers.isDisplayed(); 134 } 135 136 @Override 137 public String getDescription() { 138 return "drag"; 139 } 140 141 @Override 142 public void perform(UiController uiController, View view) { 143 float[] precision = mPrecisionDescriber.describePrecision(); 144 float[] start = mStart.calculateCoordinates(view); 145 float[] end = mEnd.calculateCoordinates(view); 146 float[][] steps = interpolate(start, end, STEPS); 147 int delayBetweenMovements = DURATION / steps.length; 148 // Down 149 MotionEvent downEvent = MotionEvents.sendDown(uiController, start, precision).down; 150 try { 151 for (int i = 0; i < steps.length; i++) { 152 // Wait 153 long desiredTime = downEvent.getDownTime() + (long)(delayBetweenMovements * i); 154 long timeUntilDesired = desiredTime - SystemClock.uptimeMillis(); 155 if (timeUntilDesired > 10L) { 156 uiController.loopMainThreadForAtLeast(timeUntilDesired); 157 } 158 // Move 159 if (!MotionEvents.sendMovement(uiController, downEvent, steps[i])) { 160 MotionEvents.sendCancel(uiController, downEvent); 161 throw new RuntimeException("Cannot drag: failed to send a move event."); 162 } 163 BottomSheetBehavior behavior = BottomSheetBehavior.from(view); 164 } 165 int duration = ViewConfiguration.getPressedStateDuration(); 166 if (duration > 0) { 167 uiController.loopMainThreadForAtLeast((long) duration); 168 } 169 } finally { 170 downEvent.recycle(); 171 } 172 } 173 174 private static float[][] interpolate(float[] start, float[] end, int steps) { 175 Preconditions.checkElementIndex(1, start.length); 176 Preconditions.checkElementIndex(1, end.length); 177 float[][] res = new float[steps][2]; 178 for(int i = 1; i < steps + 1; ++i) { 179 res[i - 1][0] = start[0] + (end[0] - start[0]) * (float)i / ((float)steps + 2.0F); 180 res[i - 1][1] = start[1] + (end[1] - start[1]) * (float)i / ((float)steps + 2.0F); 181 } 182 return res; 183 } 184 } 185 186 private static class AddViewAction implements ViewAction { 187 188 private final int mLayout; 189 190 public AddViewAction(@LayoutRes int layout) { 191 mLayout = layout; 192 } 193 194 @Override 195 public Matcher<View> getConstraints() { 196 return ViewMatchers.isAssignableFrom(ViewGroup.class); 197 } 198 199 @Override 200 public String getDescription() { 201 return "add view"; 202 } 203 204 @Override 205 public void perform(UiController uiController, View view) { 206 ViewGroup parent = (ViewGroup) view; 207 View child = LayoutInflater.from(view.getContext()).inflate(mLayout, parent, false); 208 parent.addView(child); 209 } 210 } 211 212 private Callback mCallback; 213 214 public BottomSheetBehaviorTest() { 215 super(BottomSheetBehaviorActivity.class); 216 } 217 218 @Test 219 @SmallTest 220 public void testInitialSetup() { 221 BottomSheetBehavior behavior = getBehavior(); 222 assertThat(behavior.getState(), is(BottomSheetBehavior.STATE_COLLAPSED)); 223 CoordinatorLayout coordinatorLayout = getCoordinatorLayout(); 224 ViewGroup bottomSheet = getBottomSheet(); 225 assertThat(bottomSheet.getTop(), 226 is(coordinatorLayout.getHeight() - behavior.getPeekHeight())); 227 } 228 229 @Test 230 @MediumTest 231 public void testSetStateExpandedToCollapsed() { 232 checkSetState(BottomSheetBehavior.STATE_EXPANDED, ViewMatchers.isDisplayed()); 233 checkSetState(BottomSheetBehavior.STATE_COLLAPSED, ViewMatchers.isDisplayed()); 234 } 235 236 @Test 237 @MediumTest 238 public void testSetStateHiddenToCollapsed() { 239 checkSetState(BottomSheetBehavior.STATE_HIDDEN, not(ViewMatchers.isDisplayed())); 240 checkSetState(BottomSheetBehavior.STATE_COLLAPSED, ViewMatchers.isDisplayed()); 241 } 242 243 @Test 244 @MediumTest 245 public void testSetStateCollapsedToCollapsed() { 246 checkSetState(BottomSheetBehavior.STATE_COLLAPSED, ViewMatchers.isDisplayed()); 247 } 248 249 @Test 250 @MediumTest 251 public void testSwipeDownToCollapse() { 252 checkSetState(BottomSheetBehavior.STATE_EXPANDED, ViewMatchers.isDisplayed()); 253 Espresso.onView(ViewMatchers.withId(R.id.bottom_sheet)) 254 .perform(DesignViewActions.withCustomConstraints(new GeneralSwipeAction( 255 Swipe.FAST, 256 // Manually calculate the starting coordinates to make sure that the touch 257 // actually falls onto the view on Gingerbread 258 new CoordinatesProvider() { 259 @Override 260 public float[] calculateCoordinates(View view) { 261 int[] location = new int[2]; 262 view.getLocationInWindow(location); 263 return new float[]{ 264 view.getWidth() / 2, 265 location[1] + 1 266 }; 267 } 268 }, 269 // Manually calculate the ending coordinates to make sure that the bottom 270 // sheet is collapsed, not hidden 271 new CoordinatesProvider() { 272 @Override 273 public float[] calculateCoordinates(View view) { 274 BottomSheetBehavior behavior = getBehavior(); 275 return new float[]{ 276 // x: center of the bottom sheet 277 view.getWidth() / 2, 278 // y: just above the peek height 279 view.getHeight() - behavior.getPeekHeight()}; 280 } 281 }, Press.FINGER), ViewMatchers.isDisplayingAtLeast(5))); 282 // Avoid a deadlock (b/26160710) 283 registerIdlingResourceCallback(); 284 try { 285 Espresso.onView(ViewMatchers.withId(R.id.bottom_sheet)) 286 .check(ViewAssertions.matches(ViewMatchers.isDisplayed())); 287 assertThat(getBehavior().getState(), is(BottomSheetBehavior.STATE_COLLAPSED)); 288 } finally { 289 unregisterIdlingResourceCallback(); 290 } 291 } 292 293 @Test 294 @MediumTest 295 public void testSwipeDownToHide() { 296 Espresso.onView(ViewMatchers.withId(R.id.bottom_sheet)) 297 .perform(DesignViewActions.withCustomConstraints(ViewActions.swipeDown(), 298 ViewMatchers.isDisplayingAtLeast(5))); 299 // Avoid a deadlock (b/26160710) 300 registerIdlingResourceCallback(); 301 try { 302 Espresso.onView(ViewMatchers.withId(R.id.bottom_sheet)) 303 .check(ViewAssertions.matches(not(ViewMatchers.isDisplayed()))); 304 assertThat(getBehavior().getState(), is(BottomSheetBehavior.STATE_HIDDEN)); 305 } finally { 306 unregisterIdlingResourceCallback(); 307 } 308 } 309 310 @Test 311 public void testSkipCollapsed() { 312 getBehavior().setSkipCollapsed(true); 313 checkSetState(BottomSheetBehavior.STATE_EXPANDED, ViewMatchers.isDisplayed()); 314 Espresso.onView(ViewMatchers.withId(R.id.bottom_sheet)) 315 .perform(DesignViewActions.withCustomConstraints(new GeneralSwipeAction( 316 Swipe.FAST, 317 // Manually calculate the starting coordinates to make sure that the touch 318 // actually falls onto the view on Gingerbread 319 new CoordinatesProvider() { 320 @Override 321 public float[] calculateCoordinates(View view) { 322 int[] location = new int[2]; 323 view.getLocationInWindow(location); 324 return new float[]{ 325 view.getWidth() / 2, 326 location[1] + 1 327 }; 328 } 329 }, 330 // Manually calculate the ending coordinates to make sure that the bottom 331 // sheet is collapsed, not hidden 332 new CoordinatesProvider() { 333 @Override 334 public float[] calculateCoordinates(View view) { 335 BottomSheetBehavior behavior = getBehavior(); 336 return new float[]{ 337 // x: center of the bottom sheet 338 view.getWidth() / 2, 339 // y: just above the peek height 340 view.getHeight() - behavior.getPeekHeight()}; 341 } 342 }, Press.FINGER), ViewMatchers.isDisplayingAtLeast(5))); 343 registerIdlingResourceCallback(); 344 try { 345 Espresso.onView(ViewMatchers.withId(R.id.bottom_sheet)) 346 .check(ViewAssertions.matches(not(ViewMatchers.isDisplayed()))); 347 assertThat(getBehavior().getState(), is(BottomSheetBehavior.STATE_HIDDEN)); 348 } finally { 349 unregisterIdlingResourceCallback(); 350 } 351 } 352 353 @Test 354 @MediumTest 355 public void testSwipeUpToExpand() { 356 Espresso.onView(ViewMatchers.withId(R.id.bottom_sheet)) 357 .perform(DesignViewActions.withCustomConstraints( 358 new GeneralSwipeAction(Swipe.FAST, 359 GeneralLocation.VISIBLE_CENTER, new CoordinatesProvider() { 360 @Override 361 public float[] calculateCoordinates(View view) { 362 return new float[]{view.getWidth() / 2, 0}; 363 } 364 }, Press.FINGER), 365 ViewMatchers.isDisplayingAtLeast(5))); 366 // Avoid a deadlock (b/26160710) 367 registerIdlingResourceCallback(); 368 try { 369 Espresso.onView(ViewMatchers.withId(R.id.bottom_sheet)) 370 .check(ViewAssertions.matches(ViewMatchers.isDisplayed())); 371 assertThat(getBehavior().getState(), is(BottomSheetBehavior.STATE_EXPANDED)); 372 } finally { 373 unregisterIdlingResourceCallback(); 374 } 375 } 376 377 @Test 378 @MediumTest 379 public void testInvisible() { 380 // Make the bottomsheet invisible 381 InstrumentationRegistry.getInstrumentation().runOnMainSync(new Runnable() { 382 @Override 383 public void run() { 384 getBottomSheet().setVisibility(View.INVISIBLE); 385 assertThat(getBehavior().getState(), is(BottomSheetBehavior.STATE_COLLAPSED)); 386 } 387 }); 388 // Swipe up as if to expand it 389 Espresso.onView(ViewMatchers.withId(R.id.bottom_sheet)) 390 .perform(DesignViewActions.withCustomConstraints( 391 new GeneralSwipeAction(Swipe.FAST, 392 GeneralLocation.VISIBLE_CENTER, new CoordinatesProvider() { 393 @Override 394 public float[] calculateCoordinates(View view) { 395 return new float[]{view.getWidth() / 2, 0}; 396 } 397 }, Press.FINGER), 398 not(ViewMatchers.isDisplayed()))); 399 // Check that the bottom sheet stays the same collapsed state 400 InstrumentationRegistry.getInstrumentation().runOnMainSync(new Runnable() { 401 @Override 402 public void run() { 403 assertThat(getBehavior().getState(), is(BottomSheetBehavior.STATE_COLLAPSED)); 404 } 405 }); 406 } 407 408 @Test 409 @MediumTest 410 public void testNestedScroll() { 411 final ViewGroup bottomSheet = getBottomSheet(); 412 final BottomSheetBehavior behavior = getBehavior(); 413 final NestedScrollView scroll = new NestedScrollView(mActivityTestRule.getActivity()); 414 // Set up nested scrolling area 415 InstrumentationRegistry.getInstrumentation().runOnMainSync(new Runnable() { 416 @Override 417 public void run() { 418 bottomSheet.addView(scroll, new ViewGroup.LayoutParams( 419 ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); 420 TextView view = new TextView(mActivityTestRule.getActivity()); 421 StringBuilder sb = new StringBuilder(); 422 for (int i = 0; i < 500; ++i) { 423 sb.append("It is fine today. "); 424 } 425 view.setText(sb); 426 view.setOnClickListener(new View.OnClickListener() { 427 @Override 428 public void onClick(View v) { 429 // Do nothing 430 } 431 }); 432 scroll.addView(view); 433 assertThat(behavior.getState(), is(BottomSheetBehavior.STATE_COLLAPSED)); 434 // The scroll offset is 0 at first 435 assertThat(scroll.getScrollY(), is(0)); 436 } 437 }); 438 // Swipe from the very bottom of the bottom sheet to the top edge of the screen so that the 439 // scrolling content is also scrolled 440 Espresso.onView(ViewMatchers.withId(R.id.coordinator)) 441 .perform(new GeneralSwipeAction(Swipe.FAST, 442 new CoordinatesProvider() { 443 @Override 444 public float[] calculateCoordinates(View view) { 445 return new float[]{view.getWidth() / 2, view.getHeight() - 1}; 446 } 447 }, 448 new CoordinatesProvider() { 449 @Override 450 public float[] calculateCoordinates(View view) { 451 return new float[]{view.getWidth() / 2, 1}; 452 } 453 }, Press.FINGER)); 454 registerIdlingResourceCallback(); 455 try { 456 Espresso.onView(ViewMatchers.withId(R.id.bottom_sheet)) 457 .check(ViewAssertions.matches(ViewMatchers.isDisplayed())); 458 InstrumentationRegistry.getInstrumentation().runOnMainSync(new Runnable() { 459 @Override 460 public void run() { 461 assertThat(behavior.getState(), is(BottomSheetBehavior.STATE_EXPANDED)); 462 // This confirms that the nested scrolling area was scrolled continuously after 463 // the bottom sheet is expanded. 464 assertThat(scroll.getScrollY(), is(not(0))); 465 } 466 }); 467 } finally { 468 unregisterIdlingResourceCallback(); 469 } 470 } 471 472 @Test 473 @MediumTest 474 public void testDragOutside() { 475 // Swipe up outside of the bottom sheet 476 Espresso.onView(ViewMatchers.withId(R.id.coordinator)) 477 .perform(DesignViewActions.withCustomConstraints( 478 new GeneralSwipeAction(Swipe.FAST, 479 // Just above the bottom sheet 480 new CoordinatesProvider() { 481 @Override 482 public float[] calculateCoordinates(View view) { 483 return new float[]{ 484 view.getWidth() / 2, 485 view.getHeight() - getBehavior().getPeekHeight() - 9 486 }; 487 } 488 }, 489 // Top of the CoordinatorLayout 490 new CoordinatesProvider() { 491 @Override 492 public float[] calculateCoordinates(View view) { 493 return new float[]{view.getWidth() / 2, 1}; 494 } 495 }, Press.FINGER), 496 ViewMatchers.isDisplayed())); 497 // Avoid a deadlock (b/26160710) 498 registerIdlingResourceCallback(); 499 try { 500 Espresso.onView(ViewMatchers.withId(R.id.bottom_sheet)) 501 .check(ViewAssertions.matches(ViewMatchers.isDisplayed())); 502 // The bottom sheet should remain collapsed 503 assertThat(getBehavior().getState(), is(BottomSheetBehavior.STATE_COLLAPSED)); 504 } finally { 505 unregisterIdlingResourceCallback(); 506 } 507 } 508 509 @Test 510 @MediumTest 511 public void testLayoutWhileDragging() { 512 Espresso.onView(ViewMatchers.withId(R.id.bottom_sheet)) 513 // Drag (and not release) 514 .perform(new DragAction( 515 GeneralLocation.VISIBLE_CENTER, 516 GeneralLocation.TOP_CENTER, 517 Press.FINGER)) 518 // Check that the bottom sheet is in STATE_DRAGGING 519 .check(new ViewAssertion() { 520 @Override 521 public void check(View view, NoMatchingViewException e) { 522 assertThat(view, is(ViewMatchers.isDisplayed())); 523 BottomSheetBehavior behavior = BottomSheetBehavior.from(view); 524 assertThat(behavior.getState(), is(BottomSheetBehavior.STATE_DRAGGING)); 525 } 526 }) 527 // Add a new view 528 .perform(new AddViewAction(R.layout.frame_layout)) 529 // Check that the newly added view is properly laid out 530 .check(new ViewAssertion() { 531 @Override 532 public void check(View view, NoMatchingViewException e) { 533 ViewGroup parent = (ViewGroup) view; 534 assertThat(parent.getChildCount(), is(1)); 535 View child = parent.getChildAt(0); 536 assertThat(ViewCompat.isLaidOut(child), is(true)); 537 } 538 }); 539 } 540 541 private void checkSetState(final int state, Matcher<View> matcher) { 542 registerIdlingResourceCallback(); 543 try { 544 InstrumentationRegistry.getInstrumentation().runOnMainSync(new Runnable() { 545 @Override 546 public void run() { 547 getBehavior().setState(state); 548 } 549 }); 550 Espresso.onView(ViewMatchers.withId(R.id.bottom_sheet)) 551 .check(ViewAssertions.matches(matcher)); 552 assertThat(getBehavior().getState(), is(state)); 553 } finally { 554 unregisterIdlingResourceCallback(); 555 } 556 } 557 558 private void registerIdlingResourceCallback() { 559 // TODO(yaraki): Move this to setUp() when b/26160710 is fixed 560 mCallback = new Callback(getBehavior()); 561 Espresso.registerIdlingResources(mCallback); 562 } 563 564 private void unregisterIdlingResourceCallback() { 565 if (mCallback != null) { 566 Espresso.unregisterIdlingResources(mCallback); 567 mCallback = null; 568 } 569 } 570 571 private ViewGroup getBottomSheet() { 572 return mActivityTestRule.getActivity().mBottomSheet; 573 } 574 575 private BottomSheetBehavior getBehavior() { 576 return mActivityTestRule.getActivity().mBehavior; 577 } 578 579 private CoordinatorLayout getCoordinatorLayout() { 580 return mActivityTestRule.getActivity().mCoordinatorLayout; 581 } 582 583} 584