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