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