1/* 2 * Copyright (C) 2015 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 */ 16package android.support.v7.widget; 17 18import android.app.Instrumentation; 19import android.content.Context; 20import android.graphics.Rect; 21import android.os.SystemClock; 22import android.support.test.InstrumentationRegistry; 23import android.support.v7.app.BaseInstrumentationTestCase; 24import android.support.v7.appcompat.test.R; 25import android.test.suitebuilder.annotation.MediumTest; 26import android.test.suitebuilder.annotation.SmallTest; 27import android.view.LayoutInflater; 28import android.view.MenuItem; 29import android.view.MotionEvent; 30import android.view.View; 31import android.view.ViewGroup; 32import android.widget.AdapterView; 33import android.widget.BaseAdapter; 34import android.widget.Button; 35import android.widget.FrameLayout; 36import android.widget.PopupWindow; 37import android.widget.TextView; 38 39import org.junit.Before; 40import org.junit.Test; 41 42import static android.support.test.espresso.Espresso.onView; 43import static android.support.test.espresso.action.ViewActions.click; 44import static android.support.test.espresso.assertion.ViewAssertions.matches; 45import static android.support.test.espresso.matcher.RootMatchers.withDecorView; 46import static android.support.test.espresso.matcher.ViewMatchers.*; 47import static org.hamcrest.core.Is.is; 48import static org.hamcrest.core.IsNot.not; 49import static org.junit.Assert.assertEquals; 50import static org.junit.Assert.assertFalse; 51import static org.junit.Assert.assertNotNull; 52import static org.junit.Assert.assertTrue; 53import static org.mockito.Matchers.any; 54import static org.mockito.Mockito.*; 55 56public class ListPopupWindowTest extends BaseInstrumentationTestCase<PopupTestActivity> { 57 private FrameLayout mContainer; 58 59 private Button mButton; 60 61 private ListPopupWindow mListPopupWindow; 62 63 private BaseAdapter mListPopupAdapter; 64 65 private AdapterView.OnItemClickListener mItemClickListener; 66 67 /** 68 * Item click listener that dismisses our <code>ListPopupWindow</code> when any item 69 * is clicked. Note that this needs to be a separate class that is also protected (not 70 * private) so that Mockito can "spy" on it. 71 */ 72 protected class PopupItemClickListener implements AdapterView.OnItemClickListener { 73 @Override 74 public void onItemClick(AdapterView<?> parent, View view, int position, 75 long id) { 76 mListPopupWindow.dismiss(); 77 } 78 } 79 80 public ListPopupWindowTest() { 81 super(PopupTestActivity.class); 82 } 83 84 @Before 85 public void setUp() throws Exception { 86 final PopupTestActivity activity = mActivityTestRule.getActivity(); 87 mContainer = (FrameLayout) activity.findViewById(R.id.container); 88 mButton = (Button) mContainer.findViewById(R.id.test_button); 89 mItemClickListener = new PopupItemClickListener(); 90 } 91 92 @Test 93 @SmallTest 94 public void testBasicContent() { 95 Builder popupBuilder = new Builder(); 96 popupBuilder.wireToActionButton(); 97 98 onView(withId(R.id.test_button)).perform(click()); 99 assertNotNull("Popup window created", mListPopupWindow); 100 assertTrue("Popup window showing", mListPopupWindow.isShowing()); 101 102 final View mainDecorView = mActivityTestRule.getActivity().getWindow().getDecorView(); 103 onView(withText("Alice")) 104 .inRoot(withDecorView(not(is(mainDecorView)))) 105 .check(matches(isDisplayed())); 106 onView(withText("Bob")) 107 .inRoot(withDecorView(not(is(mainDecorView)))) 108 .check(matches(isDisplayed())); 109 onView(withText("Charlie")) 110 .inRoot(withDecorView(not(is(mainDecorView)))) 111 .check(matches(isDisplayed())); 112 onView(withText("Deirdre")) 113 .inRoot(withDecorView(not(is(mainDecorView)))) 114 .check(matches(isDisplayed())); 115 onView(withText("El")) 116 .inRoot(withDecorView(not(is(mainDecorView)))) 117 .check(matches(isDisplayed())); 118 } 119 120 @Test 121 @SmallTest 122 public void testAnchoring() { 123 Builder popupBuilder = new Builder(); 124 popupBuilder.wireToActionButton(); 125 126 onView(withId(R.id.test_button)).perform(click()); 127 assertTrue("Popup window showing", mListPopupWindow.isShowing()); 128 assertEquals("Popup window anchor", mButton, mListPopupWindow.getAnchorView()); 129 130 final int[] anchorOnScreenXY = new int[2]; 131 final int[] popupOnScreenXY = new int[2]; 132 final int[] popupInWindowXY = new int[2]; 133 final Rect rect = new Rect(); 134 135 mListPopupWindow.getListView().getLocationOnScreen(popupOnScreenXY); 136 mButton.getLocationOnScreen(anchorOnScreenXY); 137 mListPopupWindow.getListView().getLocationInWindow(popupInWindowXY); 138 mListPopupWindow.getBackground().getPadding(rect); 139 140 assertEquals("Anchoring X", anchorOnScreenXY[0] + popupInWindowXY[0], popupOnScreenXY[0]); 141 assertEquals("Anchoring Y", anchorOnScreenXY[1] + popupInWindowXY[1] + mButton.getHeight(), 142 popupOnScreenXY[1] + rect.top); 143 } 144 145 @Test 146 @SmallTest 147 public void testDismissalViaAPI() { 148 Builder popupBuilder = new Builder().withDismissListener(); 149 popupBuilder.wireToActionButton(); 150 151 onView(withId(R.id.test_button)).perform(click()); 152 assertTrue("Popup window showing", mListPopupWindow.isShowing()); 153 154 InstrumentationRegistry.getInstrumentation().runOnMainSync(new Runnable() { 155 @Override 156 public void run() { 157 mListPopupWindow.dismiss(); 158 } 159 }); 160 161 // Verify that our dismiss listener has been called 162 verify(popupBuilder.mOnDismissListener, times(1)).onDismiss(); 163 assertFalse("Popup window not showing after dismissal", mListPopupWindow.isShowing()); 164 } 165 166 private void testDismissalViaTouch(boolean setupAsModal) throws Throwable { 167 Builder popupBuilder = new Builder().setModal(setupAsModal).withDismissListener(); 168 popupBuilder.wireToActionButton(); 169 170 // Also register a click listener on the top-level container 171 View.OnClickListener mockContainerClickListener = mock(View.OnClickListener.class); 172 mContainer.setOnClickListener(mockContainerClickListener); 173 174 onView(withId(R.id.test_button)).perform(click()); 175 assertTrue("Popup window showing", mListPopupWindow.isShowing()); 176 // Make sure that the modality of the popup window is set up correctly 177 assertEquals("Popup window modality", setupAsModal, mListPopupWindow.isModal()); 178 179 // Determine the location of the popup on the screen so that we can emulate 180 // a tap outside of its bounds to dismiss it 181 final int[] popupOnScreenXY = new int[2]; 182 final Rect rect = new Rect(); 183 mListPopupWindow.getListView().getLocationOnScreen(popupOnScreenXY); 184 mListPopupWindow.getBackground().getPadding(rect); 185 186 int emulatedTapX = popupOnScreenXY[0] - rect.left - 20; 187 int emulatedTapY = popupOnScreenXY[1] - 20; 188 189 // The logic below uses Instrumentation to emulate a tap outside the bounds of the 190 // displayed list popup window. This tap is then treated by the framework to be "split" as 191 // the ACTION_OUTSIDE for the popup itself, as well as DOWN / MOVE / UP for the underlying 192 // view root if the popup is not modal. 193 // It is not correct to emulate these two sequences separately in the test, as it 194 // wouldn't emulate the user-facing interaction for this test. Note that usage 195 // of Instrumentation is necessary here since Espresso's actions operate at the level 196 // of view or data. Also, we don't want to use View.dispatchTouchEvent directly as 197 // that would require emulation of two separate sequences as well. 198 199 Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation(); 200 201 // Inject DOWN event 202 long downTime = SystemClock.uptimeMillis(); 203 MotionEvent eventDown = MotionEvent.obtain( 204 downTime, downTime, MotionEvent.ACTION_DOWN, emulatedTapX, emulatedTapY, 1); 205 instrumentation.sendPointerSync(eventDown); 206 207 // Inject MOVE event 208 long moveTime = SystemClock.uptimeMillis(); 209 MotionEvent eventMove = MotionEvent.obtain( 210 moveTime, moveTime, MotionEvent.ACTION_MOVE, emulatedTapX, emulatedTapY, 1); 211 instrumentation.sendPointerSync(eventMove); 212 213 // Inject UP event 214 long upTime = SystemClock.uptimeMillis(); 215 MotionEvent eventUp = MotionEvent.obtain( 216 upTime, upTime, MotionEvent.ACTION_UP, emulatedTapX, emulatedTapY, 1); 217 instrumentation.sendPointerSync(eventUp); 218 219 // Wait for the system to process all events in the queue 220 instrumentation.waitForIdleSync(); 221 222 // At this point our popup should not be showing and should have notified its 223 // dismiss listener 224 verify(popupBuilder.mOnDismissListener, times(1)).onDismiss(); 225 assertFalse("Popup window not showing after outside click", mListPopupWindow.isShowing()); 226 227 // Also test that the click outside the popup bounds has been "delivered" to the main 228 // container only if the popup is not modal 229 verify(mockContainerClickListener, times(setupAsModal ? 0 : 1)).onClick(mContainer); 230 } 231 232 @Test 233 @SmallTest 234 public void testDismissalOutsideNonModal() throws Throwable { 235 testDismissalViaTouch(false); 236 } 237 238 @Test 239 @SmallTest 240 public void testDismissalOutsideModal() throws Throwable { 241 testDismissalViaTouch(true); 242 } 243 244 @Test 245 @SmallTest 246 public void testItemClickViaEvent() { 247 Builder popupBuilder = new Builder().withItemClickListener(); 248 popupBuilder.wireToActionButton(); 249 250 onView(withId(R.id.test_button)).perform(click()); 251 assertTrue("Popup window showing", mListPopupWindow.isShowing()); 252 253 // Verify that our menu item click listener hasn't been called yet 254 verify(popupBuilder.mOnItemClickListener, never()).onItemClick( 255 any(AdapterView.class), any(View.class), any(int.class), any(int.class)); 256 257 final View mainDecorView = mActivityTestRule.getActivity().getWindow().getDecorView(); 258 onView(withText("Charlie")) 259 .inRoot(withDecorView(not(is(mainDecorView)))) 260 .perform(click()); 261 // Verify that out menu item click listener has been called with the expected item 262 // position. Note that we use any() for other parameters, as we don't want to tie ourselves 263 // to the specific implementation details of how ListPopupWindow displays its content. 264 verify(popupBuilder.mOnItemClickListener, times(1)).onItemClick( 265 any(AdapterView.class), any(View.class), eq(2), any(int.class)); 266 267 // Our item click listener also dismisses the popup 268 assertFalse("Popup window not showing after click", mListPopupWindow.isShowing()); 269 } 270 271 @Test 272 @SmallTest 273 public void testItemClickViaAPI() { 274 Builder popupBuilder = new Builder().withItemClickListener(); 275 popupBuilder.wireToActionButton(); 276 277 onView(withId(R.id.test_button)).perform(click()); 278 assertTrue("Popup window showing", mListPopupWindow.isShowing()); 279 280 // Verify that our menu item click listener hasn't been called yet 281 verify(popupBuilder.mOnItemClickListener, never()).onItemClick( 282 any(AdapterView.class), any(View.class), any(int.class), any(int.class)); 283 284 InstrumentationRegistry.getInstrumentation().runOnMainSync(new Runnable() { 285 @Override 286 public void run() { 287 mListPopupWindow.performItemClick(1); 288 } 289 }); 290 291 // Verify that out menu item click listener has been called with the expected item 292 // position. Note that we use any() for other parameters, as we don't want to tie ourselves 293 // to the specific implementation details of how ListPopupWindow displays its content. 294 verify(popupBuilder.mOnItemClickListener, times(1)).onItemClick( 295 any(AdapterView.class), any(View.class), eq(1), any(int.class)); 296 // Our item click listener also dismisses the popup 297 assertFalse("Popup window not showing after click", mListPopupWindow.isShowing()); 298 } 299 300 /** 301 * Emulates a drag-down gestures by injecting ACTION events with {@link Instrumentation}. 302 */ 303 private void emulateDragDownGesture(int emulatedX, int emulatedStartY, int swipeAmount) { 304 // The logic below uses Instrumentation to emulate a swipe / drag gesture to bring up 305 // the popup content. Note that we don't want to use Espresso's GeneralSwipeAction 306 // as that operates on the level of an individual view. Here we want to test correct 307 // forwarding of events that cross the boundary between the anchor and the popup menu. 308 309 final Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation(); 310 311 // Inject DOWN event 312 long downTime = SystemClock.uptimeMillis(); 313 MotionEvent eventDown = MotionEvent.obtain( 314 downTime, downTime, MotionEvent.ACTION_DOWN, emulatedX, emulatedStartY, 1); 315 instrumentation.sendPointerSync(eventDown); 316 317 // Inject a sequence of MOVE events that emulate a "swipe down" gesture 318 for (int i = 0; i < 10; i++) { 319 long moveTime = SystemClock.uptimeMillis(); 320 final int moveY = emulatedStartY + swipeAmount * i / 10; 321 MotionEvent eventMove = MotionEvent.obtain( 322 moveTime, moveTime, MotionEvent.ACTION_MOVE, emulatedX, moveY, 1); 323 instrumentation.sendPointerSync(eventMove); 324 // sleep for a bit to emulate a 200ms swipe 325 SystemClock.sleep(20); 326 } 327 328 // Inject UP event 329 long upTime = SystemClock.uptimeMillis(); 330 MotionEvent eventUp = MotionEvent.obtain( 331 upTime, upTime, MotionEvent.ACTION_UP, emulatedX, emulatedStartY + swipeAmount, 1); 332 instrumentation.sendPointerSync(eventUp); 333 334 // Wait for the system to process all events in the queue 335 instrumentation.waitForIdleSync(); 336 } 337 338 @Test 339 @MediumTest 340 public void testCreateOnDragListener() throws Throwable { 341 // In this test we want precise control over the height of the popup content since 342 // we need to know by how much to swipe down to end the emulated gesture over the 343 // specific item in the popup. This is why we're using a popup style that removes 344 // all decoration around the popup content, as well as our own row layout with known 345 // height. 346 Builder popupBuilder = new Builder() 347 .withPopupStyleAttr(R.style.PopupEmptyStyle) 348 .withContentRowLayoutId(R.layout.popup_window_item) 349 .withItemClickListener().withDismissListener(); 350 351 // Configure ListPopupWindow without showing it 352 popupBuilder.configure(); 353 354 // Get the anchor view and configure it with ListPopupWindow's drag-to-open listener 355 final View anchor = mActivityTestRule.getActivity().findViewById(R.id.test_button); 356 View.OnTouchListener dragListener = mListPopupWindow.createDragToOpenListener(anchor); 357 anchor.setOnTouchListener(dragListener); 358 // And also configure it to show the popup window on click 359 anchor.setOnClickListener(new View.OnClickListener() { 360 @Override 361 public void onClick(View v) { 362 mListPopupWindow.show(); 363 } 364 }); 365 366 // Get the height of a row item in our popup window 367 final int popupRowHeight = mActivityTestRule.getActivity().getResources() 368 .getDimensionPixelSize(R.dimen.popup_row_height); 369 370 final int[] anchorOnScreenXY = new int[2]; 371 anchor.getLocationOnScreen(anchorOnScreenXY); 372 373 // Compute the start coordinates of a downward swipe and the amount of swipe. We'll 374 // be swiping by twice the row height. That, combined with the swipe originating in the 375 // center of the anchor should result in clicking the second row in the popup. 376 int emulatedX = anchorOnScreenXY[0] + anchor.getWidth() / 2; 377 int emulatedStartY = anchorOnScreenXY[1] + anchor.getHeight() / 2; 378 int swipeAmount = 2 * popupRowHeight; 379 380 // Emulate drag-down gesture with a sequence of motion events 381 emulateDragDownGesture(emulatedX, emulatedStartY, swipeAmount); 382 383 // We expect the swipe / drag gesture to result in clicking the second item in our list. 384 verify(popupBuilder.mOnItemClickListener, times(1)).onItemClick( 385 any(AdapterView.class), any(View.class), eq(1), eq(1L)); 386 // Since our item click listener calls dismiss() on the popup, we expect the popup to not 387 // be showing 388 assertFalse(mListPopupWindow.isShowing()); 389 // At this point our popup should have notified its dismiss listener 390 verify(popupBuilder.mOnDismissListener, times(1)).onDismiss(); 391 } 392 393 /** 394 * Inner helper class to configure an instance of <code>ListPopupWindow</code> for the 395 * specific test. The main reason for its existence is that once a popup window is shown 396 * with the show() method, most of its configuration APIs are no-ops. This means that 397 * we can't add logic that is specific to a certain test (such as dismissing a non-modal 398 * popup window) once it's shown and we have a reference to a displayed ListPopupWindow. 399 */ 400 public class Builder { 401 private boolean mIsModal; 402 private boolean mHasDismissListener; 403 private boolean mHasItemClickListener; 404 405 private AdapterView.OnItemClickListener mOnItemClickListener; 406 private PopupWindow.OnDismissListener mOnDismissListener; 407 408 private int mContentRowLayoutId = R.layout.abc_popup_menu_item_layout; 409 410 private boolean mUseCustomPopupStyle; 411 private int mPopupStyleAttr; 412 413 public Builder setModal(boolean isModal) { 414 mIsModal = isModal; 415 return this; 416 } 417 418 public Builder withContentRowLayoutId(int contentRowLayoutId) { 419 mContentRowLayoutId = contentRowLayoutId; 420 return this; 421 } 422 423 public Builder withPopupStyleAttr(int popupStyleAttr) { 424 mUseCustomPopupStyle = true; 425 mPopupStyleAttr = popupStyleAttr; 426 return this; 427 } 428 429 public Builder withItemClickListener() { 430 mHasItemClickListener = true; 431 return this; 432 } 433 434 public Builder withDismissListener() { 435 mHasDismissListener = true; 436 return this; 437 } 438 439 private void configure() { 440 final Context context = mContainer.getContext(); 441 if (mUseCustomPopupStyle) { 442 mListPopupWindow = new ListPopupWindow(context, null, mPopupStyleAttr, 0); 443 } else { 444 mListPopupWindow = new ListPopupWindow(context); 445 } 446 447 final String[] POPUP_CONTENT = 448 new String[]{"Alice", "Bob", "Charlie", "Deirdre", "El"}; 449 mListPopupAdapter = new BaseAdapter() { 450 class ViewHolder { 451 private TextView title; 452 } 453 454 @Override 455 public int getCount() { 456 return POPUP_CONTENT.length; 457 } 458 459 @Override 460 public Object getItem(int position) { 461 return POPUP_CONTENT[position]; 462 } 463 464 @Override 465 public long getItemId(int position) { 466 return position; 467 } 468 469 @Override 470 public View getView(int position, View convertView, ViewGroup parent) { 471 if (convertView == null) { 472 convertView = LayoutInflater.from(parent.getContext()).inflate( 473 mContentRowLayoutId, parent, false); 474 ViewHolder viewHolder = new ViewHolder(); 475 viewHolder.title = (TextView) convertView.findViewById(R.id.title); 476 convertView.setTag(viewHolder); 477 } 478 479 ViewHolder viewHolder = (ViewHolder) convertView.getTag(); 480 viewHolder.title.setText(POPUP_CONTENT[position]); 481 return convertView; 482 } 483 }; 484 485 mListPopupWindow.setAdapter(mListPopupAdapter); 486 mListPopupWindow.setAnchorView(mButton); 487 488 // The following mock listeners have to be set before the call to show() as 489 // they are set on the internally constructed drop down. 490 if (mHasItemClickListener) { 491 // Wrap our item click listener with a Mockito spy 492 mOnItemClickListener = spy(mItemClickListener); 493 // Register that spy as the item click listener on the ListPopupWindow 494 mListPopupWindow.setOnItemClickListener(mOnItemClickListener); 495 // And configure Mockito to call our original listener with onItemClick. 496 // This way we can have both our item click listener running to dismiss the popup 497 // window, and track the invocations of onItemClick with Mockito APIs. 498 doCallRealMethod().when(mOnItemClickListener).onItemClick( 499 any(AdapterView.class), any(View.class), any(int.class), any(int.class)); 500 } 501 502 if (mHasDismissListener) { 503 mOnDismissListener = mock(PopupWindow.OnDismissListener.class); 504 mListPopupWindow.setOnDismissListener(mOnDismissListener); 505 } 506 507 mListPopupWindow.setModal(mIsModal); 508 } 509 510 private void show() { 511 configure(); 512 mListPopupWindow.show(); 513 } 514 515 public void wireToActionButton() { 516 mButton.setOnClickListener(new View.OnClickListener() { 517 @Override 518 public void onClick(View v) { 519 show(); 520 } 521 }); 522 } 523 } 524} 525