mirror of
https://github.com/material-components/material-components-android.git
synced 2026-02-20 08:39:55 +08:00
499 lines
16 KiB
Java
499 lines
16 KiB
Java
/*
|
|
* Copyright 2022 The Android Open Source Project
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
package com.google.android.material.carousel;
|
|
|
|
import static com.google.common.truth.Truth.assertWithMessage;
|
|
import static java.util.concurrent.TimeUnit.SECONDS;
|
|
|
|
import android.content.Context;
|
|
import android.graphics.Color;
|
|
import android.graphics.drawable.ColorDrawable;
|
|
import androidx.recyclerview.widget.RecyclerView;
|
|
import androidx.recyclerview.widget.RecyclerView.Recycler;
|
|
import androidx.recyclerview.widget.RecyclerView.State;
|
|
import android.view.View;
|
|
import android.view.View.MeasureSpec;
|
|
import android.view.ViewGroup;
|
|
import android.widget.FrameLayout.LayoutParams;
|
|
import android.widget.ImageView;
|
|
import androidx.annotation.NonNull;
|
|
import androidx.test.platform.app.InstrumentationRegistry;
|
|
import com.google.common.collect.ImmutableList;
|
|
import java.util.concurrent.CountDownLatch;
|
|
import org.hamcrest.CoreMatchers;
|
|
import org.hamcrest.MatcherAssert;
|
|
|
|
/** A helper class to facilitate Carousel tests */
|
|
class CarouselHelper {
|
|
|
|
private static final int DEFAULT_ITEM_COUNT = 10;
|
|
|
|
private CarouselHelper() {}
|
|
|
|
|
|
/** Ensure that as child index increases, adapter position also increases. */
|
|
static void assertChildrenHaveValidOrder(WrappedCarouselLayoutManager layoutManager) {
|
|
// CarouselLayoutManager keeps track of internal start position state and should always have
|
|
// an accurate ordering where adapter position increases as child index increases.
|
|
for (int i = 0; i < layoutManager.getChildCount() - 1; i++) {
|
|
int currentAdapterPosition = layoutManager.getPosition(layoutManager.getChildAt(i));
|
|
int nextAdapterPosition = layoutManager.getPosition(layoutManager.getChildAt(i + 1));
|
|
assertWithMessage(
|
|
"Child at index "
|
|
+ i
|
|
+ " had a greater adapter position ["
|
|
+ currentAdapterPosition
|
|
+ "] than child at index "
|
|
+ (i + 1)
|
|
+ " ["
|
|
+ nextAdapterPosition
|
|
+ "]")
|
|
.that(currentAdapterPosition)
|
|
.isLessThan(nextAdapterPosition);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Explicitly set a view's size.
|
|
*
|
|
* @param view the view to assign the size to
|
|
* @param width the desired width of the view
|
|
* @param height the desired height of the view
|
|
*/
|
|
static void setViewSize(View view, int width, int height) {
|
|
view.setLayoutParams(new LayoutParams(width, height));
|
|
view.measure(
|
|
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
|
|
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY));
|
|
}
|
|
|
|
/**
|
|
* Creates a list of {@link TestItem}s with length of {@code size} to be used as an adapter's data
|
|
* set.
|
|
*/
|
|
static ImmutableList<TestItem> createDataSetWithSize(int size) {
|
|
ImmutableList.Builder<TestItem> builder = ImmutableList.builder();
|
|
for (int i = 0; i < size; i++) {
|
|
builder.add(new TestItem());
|
|
}
|
|
|
|
return builder.build();
|
|
}
|
|
|
|
/**
|
|
* Handles scrolling the recycler view to an adapter position and waiting until the recycler view
|
|
* has made a layout pass.
|
|
*/
|
|
static void scrollToPosition(
|
|
RecyclerView recyclerView, WrappedCarouselLayoutManager layoutManager, int pos)
|
|
throws Throwable {
|
|
layoutManager.expectLayouts(1);
|
|
layoutManager.scrollToPosition(pos);
|
|
// Ping the recycler view to do a measure and layout.
|
|
recyclerView.measure(
|
|
MeasureSpec.makeMeasureSpec(recyclerView.getMeasuredWidth(), MeasureSpec.EXACTLY),
|
|
MeasureSpec.makeMeasureSpec(recyclerView.getMeasuredHeight(), MeasureSpec.EXACTLY));
|
|
// Force the recycler view to do a measure and layout.
|
|
recyclerView.layout(0, 0, recyclerView.getMeasuredWidth(), recyclerView.getMeasuredHeight());
|
|
layoutManager.waitForLayout(3L);
|
|
}
|
|
|
|
static void scrollHorizontallyBy(
|
|
RecyclerView recyclerView, WrappedCarouselLayoutManager layoutManager, int dx)
|
|
throws Throwable {
|
|
layoutManager.expectScrolls(1);
|
|
recyclerView.scrollBy(dx, 0);
|
|
layoutManager.waitForScroll(3L);
|
|
}
|
|
|
|
static void scrollVerticallyBy(
|
|
RecyclerView recyclerView, WrappedCarouselLayoutManager layoutManager, int dy)
|
|
throws Throwable {
|
|
layoutManager.expectScrolls(1);
|
|
recyclerView.scrollBy(0, dy);
|
|
layoutManager.waitForScroll(3L);
|
|
}
|
|
|
|
/**
|
|
* Handles setting the items of the adapter and waiting until the recycler view has made a layout
|
|
* pass.
|
|
*/
|
|
static void setAdapterItems(
|
|
RecyclerView recyclerView,
|
|
WrappedCarouselLayoutManager layoutManager,
|
|
CarouselTestAdapter adapter,
|
|
ImmutableList<TestItem> items)
|
|
throws Throwable {
|
|
layoutManager.expectLayouts(1);
|
|
adapter.setItems(items);
|
|
// Ping the recycler view to do a measure and layout.
|
|
recyclerView.measure(
|
|
MeasureSpec.makeMeasureSpec(recyclerView.getMeasuredWidth(), MeasureSpec.EXACTLY),
|
|
MeasureSpec.makeMeasureSpec(recyclerView.getMeasuredHeight(), MeasureSpec.EXACTLY));
|
|
recyclerView.layout(0, 0, recyclerView.getMeasuredWidth(), recyclerView.getMeasuredHeight());
|
|
layoutManager.waitForLayout(3L);
|
|
}
|
|
|
|
/**
|
|
* Handles setting the orientation of the carousel and waiting until the recycler view has made a
|
|
* layout pass.
|
|
*/
|
|
static void setVerticalOrientation(
|
|
RecyclerView recyclerView,
|
|
WrappedCarouselLayoutManager layoutManager)
|
|
throws Throwable {
|
|
layoutManager.expectLayouts(1);
|
|
layoutManager.setOrientation(CarouselLayoutManager.VERTICAL);
|
|
recyclerView.measure(
|
|
MeasureSpec.makeMeasureSpec(recyclerView.getMeasuredWidth(), MeasureSpec.EXACTLY),
|
|
MeasureSpec.makeMeasureSpec(recyclerView.getMeasuredHeight(), MeasureSpec.EXACTLY));
|
|
recyclerView.layout(0, 0, recyclerView.getMeasuredWidth(), recyclerView.getMeasuredHeight());
|
|
layoutManager.waitForLayout(3L);
|
|
}
|
|
|
|
/** Creates a {@link Carousel} with a specified {@code width}. */
|
|
static Carousel createCarouselWithWidth(int width) {
|
|
return createCarouselWithSizeAndOrientation(width, CarouselLayoutManager.HORIZONTAL);
|
|
}
|
|
|
|
/**
|
|
* Creates a {@link Carousel} with a specified {@code size} for both width and height and the
|
|
* specified orientation.
|
|
*/
|
|
static Carousel createCarouselWithSizeAndOrientation(int size, int orientation) {
|
|
return new Carousel() {
|
|
|
|
@Override
|
|
public int getContainerWidth() {
|
|
return size;
|
|
}
|
|
|
|
@Override
|
|
public int getContainerHeight() {
|
|
return size;
|
|
}
|
|
|
|
@Override
|
|
public boolean isHorizontal() {
|
|
return orientation == CarouselLayoutManager.HORIZONTAL;
|
|
}
|
|
|
|
@Override
|
|
public int getCarouselAlignment() {
|
|
return CarouselLayoutManager.ALIGNMENT_START;
|
|
}
|
|
|
|
@Override
|
|
public int getItemCount() {
|
|
return DEFAULT_ITEM_COUNT;
|
|
}
|
|
};
|
|
}
|
|
|
|
static Carousel createCenterAlignedCarouselWithSize(int size) {
|
|
return new Carousel() {
|
|
@Override
|
|
public int getContainerWidth() {
|
|
return size;
|
|
}
|
|
|
|
@Override
|
|
public int getContainerHeight() {
|
|
return size;
|
|
}
|
|
|
|
@Override
|
|
public boolean isHorizontal() {
|
|
return true;
|
|
}
|
|
|
|
@Override
|
|
public int getCarouselAlignment() {
|
|
return CarouselLayoutManager.ALIGNMENT_CENTER;
|
|
}
|
|
|
|
@Override
|
|
public int getItemCount() {
|
|
return DEFAULT_ITEM_COUNT;
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Creates a {@link Carousel} with a specified {@code size} for both width and height and the
|
|
* specified alignment and orientation.
|
|
*/
|
|
static Carousel createCarousel(int width, int height, int orientation, int alignment) {
|
|
return new Carousel() {
|
|
|
|
@Override
|
|
public int getContainerWidth() {
|
|
return width;
|
|
}
|
|
|
|
@Override
|
|
public int getContainerHeight() {
|
|
return height;
|
|
}
|
|
|
|
@Override
|
|
public boolean isHorizontal() {
|
|
return orientation == CarouselLayoutManager.HORIZONTAL;
|
|
}
|
|
|
|
@Override
|
|
public int getCarouselAlignment() {
|
|
return alignment;
|
|
}
|
|
|
|
@Override
|
|
public int getItemCount() {
|
|
return DEFAULT_ITEM_COUNT;
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Creates a {@link Carousel} with a specified {@code size} for both width and height and the
|
|
* specified item count and alignment.
|
|
*/
|
|
static Carousel createCarouselWithItemCount(int size, int alignment, int itemCount) {
|
|
return new Carousel() {
|
|
|
|
@Override
|
|
public int getContainerWidth() {
|
|
return size;
|
|
}
|
|
|
|
@Override
|
|
public int getContainerHeight() {
|
|
return size;
|
|
}
|
|
|
|
@Override
|
|
public boolean isHorizontal() {
|
|
return true;
|
|
}
|
|
|
|
@Override
|
|
public int getCarouselAlignment() {
|
|
return alignment;
|
|
}
|
|
|
|
@Override
|
|
public int getItemCount() {
|
|
return itemCount;
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Gets the percentage of an item's {@code unmaskedSize} that should be masked away when at a
|
|
* keyline.
|
|
*
|
|
* <p>The larger the mask percentage, the smaller the size of the item when masked. If {@code
|
|
* maskedSize} is 10 and {@code unmaskedSize} is 100, this will return a mask of .9. 90% of the
|
|
* view should be masked.
|
|
*
|
|
* @param maskedSize The size of the item when masked.
|
|
* @param unmaskedSize The size of an item when no mask is applied or is fully unmasked.
|
|
* @return a percentage of the item's unmasked size that should be masked to create an item with a
|
|
* size of {@code maskedSize}
|
|
*/
|
|
static float getKeylineMaskPercentage(float maskedSize, float unmaskedSize) {
|
|
return 1F - (maskedSize / unmaskedSize);
|
|
}
|
|
|
|
/** An empty data class used to represent items in a list */
|
|
static class TestItem {
|
|
public TestItem() {}
|
|
}
|
|
|
|
/** A ViewHolder for {@link TestItem} */
|
|
static class TestItemViewHolder extends RecyclerView.ViewHolder {
|
|
|
|
TestItemViewHolder(@NonNull View itemView) {
|
|
super(itemView);
|
|
}
|
|
}
|
|
|
|
/** An adapter to be used for facilitating tests that use a RecyclerView. */
|
|
static class CarouselTestAdapter extends RecyclerView.Adapter<TestItemViewHolder> {
|
|
|
|
private final int itemWidth;
|
|
private final int itemHeight;
|
|
private ImmutableList<TestItem> items = ImmutableList.of();
|
|
|
|
/**
|
|
* Creates a {@link CarouselTestAdapter}.
|
|
*
|
|
* @param itemWidth the width each item in the adapter would like to be.
|
|
* @param itemHeight the height each item in the adapter would like to be.
|
|
*/
|
|
CarouselTestAdapter(int itemWidth, int itemHeight) {
|
|
this.itemWidth = itemWidth;
|
|
this.itemHeight = itemHeight;
|
|
}
|
|
|
|
/** Sets the items in this adapter. */
|
|
void setItems(ImmutableList<TestItem> items) {
|
|
this.items = items;
|
|
notifyDataSetChanged();
|
|
}
|
|
|
|
@NonNull
|
|
@Override
|
|
public TestItemViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int pos) {
|
|
MaskableFrameLayout frameLayout = new MaskableFrameLayout(viewGroup.getContext());
|
|
setViewSize(frameLayout, itemWidth, itemHeight);
|
|
ImageView imageView = new ImageView(viewGroup.getContext());
|
|
setViewSize(imageView, itemWidth, itemHeight);
|
|
imageView.setImageDrawable(new ColorDrawable(Color.MAGENTA));
|
|
frameLayout.addView(imageView);
|
|
return new TestItemViewHolder(frameLayout);
|
|
}
|
|
|
|
@Override
|
|
public void onBindViewHolder(@NonNull TestItemViewHolder vh, int pos) {}
|
|
|
|
@Override
|
|
public int getItemCount() {
|
|
return items.size();
|
|
}
|
|
}
|
|
|
|
/** A wrapper around {@link CarouselLayoutManager} that is able to wait for layouts to happen. */
|
|
static class WrappedCarouselLayoutManager extends CarouselLayoutManager {
|
|
|
|
WrappedCarouselLayoutManager() {}
|
|
|
|
CountDownLatch scrollLatch;
|
|
CountDownLatch layoutLatch;
|
|
|
|
/**
|
|
* Sets up a new {@link CountDownLatch} that will wait for a specified number of events to
|
|
* occur.
|
|
*
|
|
* @param count the number of events this latch should wait for.
|
|
*/
|
|
void expectLayouts(int count) {
|
|
layoutLatch = new CountDownLatch(count);
|
|
}
|
|
|
|
void expectScrolls(int count) {
|
|
scrollLatch = new CountDownLatch(count);
|
|
}
|
|
|
|
/**
|
|
* Tells an active layout {@link CountDownLatch} to wait a number of seconds for its release
|
|
* until throwing.
|
|
*/
|
|
void waitForLayout(long seconds) throws Throwable {
|
|
waitForLatch(layoutLatch, seconds, "layout");
|
|
}
|
|
|
|
/**
|
|
* Tells an active scroll {@link CountDownLatch} to wait a number of seconds for its release
|
|
* until throwing.
|
|
*/
|
|
void waitForScroll(long seconds) throws Throwable {
|
|
waitForLatch(scrollLatch, seconds, "scroll");
|
|
}
|
|
|
|
private void waitForLatch(CountDownLatch latch, long seconds, String tag) throws Throwable {
|
|
latch.await(seconds, SECONDS);
|
|
MatcherAssert.assertThat(
|
|
"all " + tag + "s should complete on time", latch.getCount(), CoreMatchers.is(0L));
|
|
// use a runnable to ensure RV layout is finished
|
|
InstrumentationRegistry.getInstrumentation()
|
|
.runOnMainSync(
|
|
new Runnable() {
|
|
@Override
|
|
public void run() {}
|
|
});
|
|
}
|
|
|
|
@Override
|
|
public void onLayoutChildren(Recycler recycler, State state) {
|
|
super.onLayoutChildren(recycler, state);
|
|
layoutLatch.countDown();
|
|
}
|
|
|
|
@Override
|
|
public int scrollHorizontallyBy(int dx, Recycler recycler, State state) {
|
|
int scroll = super.scrollHorizontallyBy(dx, recycler, state);
|
|
scrollLatch.countDown();
|
|
return scroll;
|
|
}
|
|
|
|
@Override
|
|
public int scrollVerticallyBy(int dy, Recycler recycler, State state) {
|
|
int scroll = super.scrollVerticallyBy(dy, recycler, state);
|
|
scrollLatch.countDown();
|
|
return scroll;
|
|
}
|
|
}
|
|
|
|
static KeylineState getTestCenteredKeylineState() {
|
|
float smallSize = 56F;
|
|
float extraSmallSize = 10F;
|
|
float largeSize = 450F;
|
|
float mediumSize = 88F;
|
|
|
|
float extraSmallMask = getKeylineMaskPercentage(extraSmallSize, largeSize);
|
|
float smallMask = getKeylineMaskPercentage(smallSize, largeSize);
|
|
float mediumMask = getKeylineMaskPercentage(mediumSize, largeSize);
|
|
|
|
return new KeylineState.Builder(450F, 1320)
|
|
.addKeyline(5F, extraSmallMask, extraSmallSize)
|
|
.addKeylineRange(38F, smallMask, smallSize, 2)
|
|
.addKeyline(166F, mediumMask, mediumSize)
|
|
.addKeylineRange(435F, 0F, largeSize, 2, true)
|
|
.addKeyline(1154F, mediumMask, mediumSize)
|
|
.addKeylineRange(1226F, smallMask, smallSize, 2)
|
|
.addKeyline(1315F, extraSmallMask, extraSmallSize)
|
|
.build();
|
|
}
|
|
|
|
static KeylineState getTestCenteredVerticalKeylineState() {
|
|
// Keylines in the form small-medium-large-medium-small; the sizes of
|
|
// these keylines add up to default recycler view height (200).
|
|
float smallSize = 18F;
|
|
float largeSize = 100F;
|
|
float mediumSize = 32F;
|
|
|
|
float smallMask = getKeylineMaskPercentage(smallSize, largeSize);
|
|
float mediumMask = getKeylineMaskPercentage(mediumSize, largeSize);
|
|
|
|
return new KeylineState.Builder(100F, 200)
|
|
.addKeyline(9F, smallMask, smallSize)
|
|
.addKeyline(25F, mediumMask, mediumSize)
|
|
.addKeyline(66F, 0F, largeSize, true)
|
|
.addKeyline(132F, mediumMask, mediumSize)
|
|
.addKeyline(157F, smallMask, smallSize)
|
|
.build();
|
|
}
|
|
|
|
static View createViewWithSize(Context context, int width, int height) {
|
|
View view = new View(context);
|
|
view.setLayoutParams(new RecyclerView.LayoutParams(width, height));
|
|
view.measure(
|
|
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
|
|
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY));
|
|
return view;
|
|
}
|
|
}
|