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 static android.support.v7.widget.StaggeredGridLayoutManager.HORIZONTAL;
19
20import static org.hamcrest.CoreMatchers.is;
21import static org.hamcrest.MatcherAssert.assertThat;
22import static org.junit.Assert.assertEquals;
23import static org.junit.Assert.assertNull;
24import static org.junit.Assert.assertTrue;
25
26import android.app.Activity;
27import android.content.Context;
28import android.graphics.Color;
29import android.graphics.Rect;
30import android.support.annotation.Nullable;
31import android.support.v4.util.LongSparseArray;
32import android.support.v7.widget.TestedFrameLayout.FullControlLayoutParams;
33import android.util.Log;
34import android.view.Gravity;
35import android.view.View;
36import android.view.ViewGroup;
37import android.widget.TextView;
38
39import org.hamcrest.CoreMatchers;
40import org.junit.Test;
41
42import org.hamcrest.CoreMatchers;
43import org.hamcrest.MatcherAssert;
44
45import java.util.ArrayList;
46import java.util.Collections;
47import java.util.List;
48import java.util.concurrent.CountDownLatch;
49import java.util.concurrent.TimeUnit;
50
51/**
52 * Class to test any generic wrap content behavior.
53 * It does so by running the same view scenario twice. Once with match parent setup to record all
54 * dimensions and once with wrap_content setup. Then compares all child locations & ids +
55 * RecyclerView size.
56 */
57abstract public class BaseWrapContentTest extends BaseRecyclerViewInstrumentationTest {
58
59    static final boolean DEBUG = false;
60    static final String TAG = "WrapContentTest";
61    RecyclerView.LayoutManager mLayoutManager;
62
63    TestAdapter mTestAdapter;
64
65    LoggingItemAnimator mLoggingItemAnimator;
66
67    boolean mIsWrapContent;
68
69    protected final WrapContentConfig mWrapContentConfig;
70
71    public BaseWrapContentTest(WrapContentConfig config) {
72        mWrapContentConfig = config;
73    }
74
75    abstract RecyclerView.LayoutManager createLayoutManager();
76
77    void unspecifiedWithHintTest(boolean horizontal) throws Throwable {
78        final int itemHeight = 20;
79        final int itemWidth = 15;
80        RecyclerView.LayoutManager layoutManager = createLayoutManager();
81        WrappedRecyclerView rv = createRecyclerView(getActivity());
82        TestAdapter testAdapter = new TestAdapter(20) {
83            @Override
84            public void onBindViewHolder(TestViewHolder holder,
85                    int position) {
86                super.onBindViewHolder(holder, position);
87                holder.itemView.setLayoutParams(new ViewGroup.LayoutParams(itemWidth, itemHeight));
88            }
89        };
90        rv.setLayoutManager(layoutManager);
91        rv.setAdapter(testAdapter);
92        TestedFrameLayout.FullControlLayoutParams lp =
93                new TestedFrameLayout.FullControlLayoutParams(0, 0);
94        if (horizontal) {
95            lp.wSpec = View.MeasureSpec.makeMeasureSpec(25, View.MeasureSpec.UNSPECIFIED);
96            lp.hSpec = View.MeasureSpec.makeMeasureSpec(50, View.MeasureSpec.AT_MOST);
97        } else {
98            lp.hSpec = View.MeasureSpec.makeMeasureSpec(25, View.MeasureSpec.UNSPECIFIED);
99            lp.wSpec = View.MeasureSpec.makeMeasureSpec(50, View.MeasureSpec.AT_MOST);
100        }
101        rv.setLayoutParams(lp);
102        setRecyclerView(rv);
103        rv.waitUntilLayout();
104
105        // we don't assert against the given size hint because LM will still ask for more if it
106        // lays out more children. This is the correct behavior because the spec is not AT_MOST,
107        // it is UNSPECIFIED.
108        if (horizontal) {
109            int expectedWidth = rv.getPaddingLeft() + rv.getPaddingRight() + itemWidth;
110            while (expectedWidth < 25) {
111                expectedWidth += itemWidth;
112            }
113            assertThat(rv.getWidth(), CoreMatchers.is(expectedWidth));
114        } else {
115            int expectedHeight = rv.getPaddingTop() + rv.getPaddingBottom() + itemHeight;
116            while (expectedHeight < 25) {
117                expectedHeight += itemHeight;
118            }
119            assertThat(rv.getHeight(), CoreMatchers.is(expectedHeight));
120        }
121    }
122
123    protected void testScenerio(Scenario scenario) throws Throwable {
124        FullControlLayoutParams matchParent = new FullControlLayoutParams(
125                ViewGroup.LayoutParams.MATCH_PARENT,
126                ViewGroup.LayoutParams.MATCH_PARENT);
127        FullControlLayoutParams wrapContent = new FullControlLayoutParams(
128                ViewGroup.LayoutParams.WRAP_CONTENT,
129                ViewGroup.LayoutParams.WRAP_CONTENT);
130        if (mWrapContentConfig.isUnlimitedHeight()) {
131            wrapContent.hSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED);
132        }
133        if (mWrapContentConfig.isUnlimitedWidth()) {
134            wrapContent.wSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED);
135        }
136
137        mIsWrapContent = false;
138        List<Snapshot> s1 = runScenario(scenario, matchParent, null);
139        mIsWrapContent = true;
140
141        List<Snapshot> s2 = runScenario(scenario, wrapContent, s1);
142        assertEquals("test sanity", s1.size(), s2.size());
143
144        for (int i = 0; i < s1.size(); i++) {
145            Snapshot step1 = s1.get(i);
146            Snapshot step2 = s2.get(i);
147            step1.assertSame(step2, i);
148        }
149    }
150
151    public List<Snapshot> runScenario(Scenario scenario, ViewGroup.LayoutParams lp,
152            @Nullable List<Snapshot> compareWith)
153            throws Throwable {
154        removeRecyclerView();
155        Item.idCounter.set(0);
156        List<Snapshot> result = new ArrayList<>();
157        RecyclerView.LayoutManager layoutManager = scenario.createLayoutManager();
158        WrappedRecyclerView recyclerView = new WrappedRecyclerView(getActivity());
159        recyclerView.setBackgroundColor(Color.rgb(0, 0, 255));
160        recyclerView.setLayoutManager(layoutManager);
161        recyclerView.setLayoutParams(lp);
162        mLayoutManager = layoutManager;
163        mTestAdapter = new TestAdapter(scenario.getSeedAdapterSize());
164        recyclerView.setAdapter(mTestAdapter);
165        mLoggingItemAnimator = new LoggingItemAnimator();
166        recyclerView.setItemAnimator(mLoggingItemAnimator);
167        setRecyclerView(recyclerView);
168        recyclerView.waitUntilLayout();
169        int stepIndex = 0;
170        for (Step step : scenario.mStepList) {
171            mLoggingItemAnimator.reset();
172            step.onRun();
173            recyclerView.waitUntilLayout();
174            recyclerView.waitUntilAnimations();
175            Snapshot snapshot = takeSnapshot();
176            if (mIsWrapContent) {
177                snapshot.assertRvSize();
178            }
179            result.add(snapshot);
180            if (compareWith != null) {
181                compareWith.get(stepIndex).assertSame(snapshot, stepIndex);
182            }
183            stepIndex++;
184        }
185        recyclerView.waitUntilLayout();
186        recyclerView.waitUntilAnimations();
187        Snapshot snapshot = takeSnapshot();
188        if (mIsWrapContent) {
189            snapshot.assertRvSize();
190        }
191        result.add(snapshot);
192        if (compareWith != null) {
193            compareWith.get(stepIndex).assertSame(snapshot, stepIndex);
194        }
195        return result;
196    }
197
198    protected WrappedRecyclerView createRecyclerView(Activity activity) {
199        return new WrappedRecyclerView(getActivity());
200    }
201
202    void layoutAndCheck(TestedFrameLayout.FullControlLayoutParams lp,
203            BaseWrapContentWithAspectRatioTest.WrapContentAdapter adapter, Rect[] expected,
204            int width, int height) throws Throwable {
205        WrappedRecyclerView recyclerView = createRecyclerView(getActivity());
206        recyclerView.setBackgroundColor(Color.rgb(0, 0, 255));
207        recyclerView.setLayoutManager(createLayoutManager());
208        recyclerView.setAdapter(adapter);
209        recyclerView.setLayoutParams(lp);
210        Rect padding = mWrapContentConfig.padding;
211        recyclerView.setPadding(padding.left, padding.top,
212                padding.right, padding.bottom);
213        setRecyclerView(recyclerView);
214        recyclerView.waitUntilLayout();
215        Snapshot snapshot = takeSnapshot();
216        int index = 0;
217        Rect tmp = new Rect();
218        for (BaseWrapContentWithAspectRatioTest.MeasureBehavior behavior : adapter.behaviors) {
219            tmp.set(expected[index]);
220            tmp.offset(padding.left, padding.top);
221            assertThat("behavior " + index, snapshot.mChildCoordinates.get(behavior.getId()),
222                    is(tmp));
223            index ++;
224        }
225        Rect boundingBox = new Rect(0, 0, 0, 0);
226        for (Rect rect : expected) {
227            boundingBox.union(rect);
228        }
229        assertThat(recyclerView.getWidth(), is(width + padding.left + padding.right));
230        assertThat(recyclerView.getHeight(), is(height + padding.top + padding.bottom));
231    }
232
233
234    abstract protected int getVerticalGravity(RecyclerView.LayoutManager layoutManager);
235
236    abstract protected int getHorizontalGravity(RecyclerView.LayoutManager layoutManager);
237
238    protected Snapshot takeSnapshot() throws Throwable {
239        Snapshot snapshot = new Snapshot(mRecyclerView, mLoggingItemAnimator,
240                getHorizontalGravity(mLayoutManager), getVerticalGravity(mLayoutManager));
241        return snapshot;
242    }
243
244    abstract class Scenario {
245
246        ArrayList<Step> mStepList = new ArrayList<>();
247
248        public Scenario(Step... steps) {
249            Collections.addAll(mStepList, steps);
250        }
251
252        public int getSeedAdapterSize() {
253            return 10;
254        }
255
256        public RecyclerView.LayoutManager createLayoutManager() {
257            return BaseWrapContentTest.this.createLayoutManager();
258        }
259    }
260
261    abstract static class Step {
262
263        abstract void onRun() throws Throwable;
264    }
265
266    class Snapshot {
267
268        Rect mRawChildrenBox = new Rect();
269
270        Rect mRvSize = new Rect();
271
272        Rect mRvPadding = new Rect();
273
274        Rect mRvParentSize = new Rect();
275
276        LongSparseArray<Rect> mChildCoordinates = new LongSparseArray<>();
277
278        LongSparseArray<String> mAppear = new LongSparseArray<>();
279
280        LongSparseArray<String> mDisappear = new LongSparseArray<>();
281
282        LongSparseArray<String> mPersistent = new LongSparseArray<>();
283
284        LongSparseArray<String> mChanged = new LongSparseArray<>();
285
286        int mVerticalGravity;
287
288        int mHorizontalGravity;
289
290        int mOffsetX, mOffsetY;// how much we should offset children
291
292        public Snapshot(RecyclerView recyclerView, LoggingItemAnimator loggingItemAnimator,
293                int horizontalGravity, int verticalGravity)
294                throws Throwable {
295            mRvSize = getViewBounds(recyclerView);
296            mRvParentSize = getViewBounds((View) recyclerView.getParent());
297            mRvPadding = new Rect(recyclerView.getPaddingLeft(), recyclerView.getPaddingTop(),
298                    recyclerView.getPaddingRight(), recyclerView.getPaddingBottom());
299            mVerticalGravity = verticalGravity;
300            mHorizontalGravity = horizontalGravity;
301            if (mVerticalGravity == Gravity.TOP) {
302                mOffsetY = 0;
303            } else {
304                mOffsetY = mRvParentSize.bottom - mRvSize.bottom;
305            }
306
307            if (mHorizontalGravity == Gravity.LEFT) {
308                mOffsetX = 0;
309            } else {
310                mOffsetX = mRvParentSize.right - mRvSize.right;
311            }
312            collectChildCoordinates(recyclerView);
313            if (loggingItemAnimator != null) {
314                collectInto(mAppear, loggingItemAnimator.mAnimateAppearanceList);
315                collectInto(mDisappear, loggingItemAnimator.mAnimateDisappearanceList);
316                collectInto(mPersistent, loggingItemAnimator.mAnimatePersistenceList);
317                collectInto(mChanged, loggingItemAnimator.mAnimateChangeList);
318            }
319        }
320
321        public boolean doesChildrenFitVertically() {
322            return mRawChildrenBox.top >= mRvPadding.top
323                    && mRawChildrenBox.bottom <= mRvSize.bottom - mRvPadding.bottom;
324        }
325
326        public boolean doesChildrenFitHorizontally() {
327            return mRawChildrenBox.left >= mRvPadding.left
328                    && mRawChildrenBox.right <= mRvSize.right - mRvPadding.right;
329        }
330
331        public void assertSame(Snapshot other, int step) {
332            if (mWrapContentConfig.isUnlimitedHeight() &&
333                    (!doesChildrenFitVertically() || !other.doesChildrenFitVertically())) {
334                if (DEBUG) {
335                    Log.d(TAG, "cannot assert coordinates because it does not fit vertically");
336                }
337                return;
338            }
339            if (mWrapContentConfig.isUnlimitedWidth() &&
340                    (!doesChildrenFitHorizontally() || !other.doesChildrenFitHorizontally())) {
341                if (DEBUG) {
342                    Log.d(TAG, "cannot assert coordinates because it does not fit horizontally");
343                }
344                return;
345            }
346            assertMap("child coordinates. step:" + step, mChildCoordinates,
347                    other.mChildCoordinates);
348            if (mWrapContentConfig.isUnlimitedHeight() || mWrapContentConfig.isUnlimitedWidth()) {
349                return;//cannot assert animatinos in unlimited size
350            }
351            assertMap("appearing step:" + step, mAppear, other.mAppear);
352            assertMap("disappearing step:" + step, mDisappear, other.mDisappear);
353            assertMap("persistent step:" + step, mPersistent, other.mPersistent);
354            assertMap("changed step:" + step, mChanged, other.mChanged);
355        }
356
357        private void assertMap(String prefix, LongSparseArray<?> map1, LongSparseArray<?> map2) {
358            StringBuilder logBuilder = new StringBuilder();
359            logBuilder.append(prefix).append("\n");
360            logBuilder.append("map1").append("\n");
361            logInto(map1, logBuilder);
362            logBuilder.append("map2").append("\n");
363            logInto(map2, logBuilder);
364            final String log = logBuilder.toString();
365            assertEquals(log + " same size", map1.size(), map2.size());
366            for (int i = 0; i < map1.size(); i++) {
367                assertAtIndex(log, map1, map2, i);
368            }
369        }
370
371        private void assertAtIndex(String prefix, LongSparseArray<?> map1, LongSparseArray<?> map2,
372                int index) {
373            long key1 = map1.keyAt(index);
374            long key2 = map2.keyAt(index);
375            assertEquals(prefix + "key mismatch at index " + index, key1, key2);
376            Object value1 = map1.valueAt(index);
377            Object value2 = map2.valueAt(index);
378            assertEquals(prefix + " value mismatch at index " + index, value1, value2);
379        }
380
381        private void logInto(LongSparseArray<?> map, StringBuilder sb) {
382            for (int i = 0; i < map.size(); i++) {
383                long key = map.keyAt(i);
384                Object value = map.valueAt(i);
385                sb.append(key).append(" : ").append(value).append("\n");
386            }
387        }
388
389        @Override
390        public String toString() {
391            StringBuilder sb = new StringBuilder("Snapshot{\n");
392            sb.append("child coordinates:\n");
393            logInto(mChildCoordinates, sb);
394            sb.append("appear animations:\n");
395            logInto(mAppear, sb);
396            sb.append("disappear animations:\n");
397            logInto(mDisappear, sb);
398            sb.append("change animations:\n");
399            logInto(mChanged, sb);
400            sb.append("persistent animations:\n");
401            logInto(mPersistent, sb);
402            sb.append("}");
403            return sb.toString();
404        }
405
406        @Override
407        public int hashCode() {
408            int result = mChildCoordinates.hashCode();
409            result = 31 * result + mAppear.hashCode();
410            result = 31 * result + mDisappear.hashCode();
411            result = 31 * result + mPersistent.hashCode();
412            result = 31 * result + mChanged.hashCode();
413            return result;
414        }
415
416        private void collectInto(
417                LongSparseArray<String> target,
418                List<? extends BaseRecyclerViewAnimationsTest.AnimateLogBase> list) {
419            for (BaseRecyclerViewAnimationsTest.AnimateLogBase base : list) {
420                long id = getItemId(base.viewHolder);
421                assertNull(target.get(id));
422                target.put(id, log(base));
423            }
424        }
425
426        private String log(BaseRecyclerViewAnimationsTest.AnimateLogBase base) {
427            return base.getClass().getSimpleName() +
428                    ((TextView) base.viewHolder.itemView).getText() + ": " +
429                    "[pre:" + log(base.postInfo) +
430                    ", post:" + log(base.postInfo) + "]";
431        }
432
433        private String log(BaseRecyclerViewAnimationsTest.LoggingInfo postInfo) {
434            if (postInfo == null) {
435                return "?";
436            }
437            return "PI[flags: " + postInfo.changeFlags
438                    + ",l:" + (postInfo.left + mOffsetX)
439                    + ",t:" + (postInfo.top + mOffsetY)
440                    + ",r:" + (postInfo.right + mOffsetX)
441                    + ",b:" + (postInfo.bottom + mOffsetY) + "]";
442        }
443
444        void collectChildCoordinates(RecyclerView recyclerView) throws Throwable {
445            mRawChildrenBox = new Rect(0, 0, 0, 0);
446            final int childCount = recyclerView.getChildCount();
447            for (int i = 0; i < childCount; i++) {
448                View child = recyclerView.getChildAt(i);
449                Rect childBounds = getChildBounds(recyclerView, child, true);
450                mRawChildrenBox.union(getChildBounds(recyclerView, child, false));
451                RecyclerView.ViewHolder childViewHolder = recyclerView.getChildViewHolder(child);
452                mChildCoordinates.put(getItemId(childViewHolder), childBounds);
453            }
454        }
455
456        private Rect getViewBounds(View view) {
457            return new Rect(view.getLeft(), view.getTop(), view.getRight(), view.getBottom());
458        }
459
460        private Rect getChildBounds(RecyclerView recyclerView, View child, boolean offset) {
461            RecyclerView.LayoutManager layoutManager = recyclerView.getLayoutManager();
462            RecyclerView.LayoutParams lp = (RecyclerView.LayoutParams) child.getLayoutParams();
463            Rect rect = new Rect(layoutManager.getDecoratedLeft(child) - lp.leftMargin,
464                    layoutManager.getDecoratedTop(child) - lp.topMargin,
465                    layoutManager.getDecoratedRight(child) + lp.rightMargin,
466                    layoutManager.getDecoratedBottom(child) + lp.bottomMargin);
467            if (offset) {
468                rect.offset(mOffsetX, mOffsetY);
469            }
470            return rect;
471        }
472
473        private long getItemId(RecyclerView.ViewHolder vh) {
474            if (vh instanceof TestViewHolder) {
475                return ((TestViewHolder) vh).mBoundItem.mId;
476            } else if (vh instanceof BaseWrapContentWithAspectRatioTest.WrapContentViewHolder) {
477                BaseWrapContentWithAspectRatioTest.WrapContentViewHolder casted =
478                        (BaseWrapContentWithAspectRatioTest.WrapContentViewHolder) vh;
479                return casted.mView.mBehavior.getId();
480            } else {
481                throw new IllegalArgumentException("i don't support any VH");
482            }
483        }
484
485        public void assertRvSize() {
486            if (shouldWrapContentHorizontally()) {
487                int expectedW = mRawChildrenBox.width() + mRvPadding.left + mRvPadding.right;
488                assertTrue(mRvSize.width() + " <= " + expectedW, mRvSize.width() <= expectedW);
489            }
490            if (shouldWrapContentVertically()) {
491                int expectedH = mRawChildrenBox.height() + mRvPadding.top + mRvPadding.bottom;
492                assertTrue(mRvSize.height() + "<=" + expectedH, mRvSize.height() <= expectedH);
493            }
494        }
495    }
496
497    protected boolean shouldWrapContentHorizontally() {
498        return true;
499    }
500
501    protected boolean shouldWrapContentVertically() {
502        return true;
503    }
504
505    static class WrappedRecyclerView extends RecyclerView {
506
507        public WrappedRecyclerView(Context context) {
508            super(context);
509        }
510
511        public void waitUntilLayout() {
512            while (isLayoutRequested()) {
513                try {
514                    Thread.sleep(100);
515                } catch (InterruptedException e) {
516                    e.printStackTrace();
517                }
518            }
519        }
520
521        public void waitUntilAnimations() throws InterruptedException {
522            final CountDownLatch latch = new CountDownLatch(1);
523            if (mItemAnimator == null || !mItemAnimator.isRunning(
524                    new ItemAnimator.ItemAnimatorFinishedListener() {
525                        @Override
526                        public void onAnimationsFinished() {
527                            latch.countDown();
528                        }
529                    })) {
530                latch.countDown();
531            }
532            MatcherAssert.assertThat("waiting too long for animations",
533                    latch.await(60, TimeUnit.SECONDS), CoreMatchers.is(true));
534        }
535
536        @Override
537        protected void onLayout(boolean changed, int l, int t, int r, int b) {
538            super.onLayout(changed, l, t, r, b);
539        }
540    }
541
542    static class WrapContentConfig {
543
544        public boolean unlimitedWidth;
545        public boolean unlimitedHeight;
546        public Rect padding = new Rect(0, 0, 0, 0);
547
548        public WrapContentConfig(boolean unlimitedWidth, boolean unlimitedHeight) {
549            this.unlimitedWidth = unlimitedWidth;
550            this.unlimitedHeight = unlimitedHeight;
551        }
552
553        public WrapContentConfig(boolean unlimitedWidth, boolean unlimitedHeight, Rect padding) {
554            this.unlimitedWidth = unlimitedWidth;
555            this.unlimitedHeight = unlimitedHeight;
556            this.padding.set(padding);
557        }
558
559        public boolean isUnlimitedWidth() {
560            return unlimitedWidth;
561        }
562
563        public WrapContentConfig setUnlimitedWidth(boolean unlimitedWidth) {
564            this.unlimitedWidth = unlimitedWidth;
565            return this;
566        }
567
568        public boolean isUnlimitedHeight() {
569            return unlimitedHeight;
570        }
571
572        public WrapContentConfig setUnlimitedHeight(boolean unlimitedHeight) {
573            this.unlimitedHeight = unlimitedHeight;
574            return this;
575        }
576
577        @Override
578        public String toString() {
579            return "WrapContentConfig{"
580                    + "unlimitedWidth=" + unlimitedWidth
581                    + ", unlimitedHeight=" + unlimitedHeight
582                    + ", padding=" + padding
583                    + '}';
584        }
585
586        public TestedFrameLayout.FullControlLayoutParams toLayoutParams(int wDim, int hDim) {
587            TestedFrameLayout.FullControlLayoutParams
588                    lp = new TestedFrameLayout.FullControlLayoutParams(
589                    wDim, hDim);
590            if (unlimitedWidth) {
591                lp.wSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED);
592            }
593            if (unlimitedHeight) {
594                lp.hSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED);
595            }
596            return lp;
597        }
598    }
599}
600