/*
* Copyright (C) 2014 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 androidx.test.espresso.contrib;
import static androidx.test.espresso.contrib.Checks.checkArgument;
import static androidx.test.espresso.contrib.Checks.checkNotNull;
import static androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static org.hamcrest.Matchers.allOf;
import android.support.v7.widget.RecyclerView;
import android.support.v7.widget.RecyclerView.Adapter;
import android.support.v7.widget.RecyclerView.ViewHolder;
import android.util.SparseArray;
import android.view.View;
import android.widget.AdapterView;
import androidx.test.espresso.Espresso;
import androidx.test.espresso.PerformException;
import androidx.test.espresso.UiController;
import androidx.test.espresso.ViewAction;
import androidx.test.espresso.util.HumanReadables;
import java.util.ArrayList;
import java.util.List;
import org.hamcrest.Description;
import org.hamcrest.Matcher;
import org.hamcrest.TypeSafeMatcher;
/**
* {@link ViewAction}s to interact {@link RecyclerView}. RecyclerView works differently than {@link
* AdapterView}. In fact, RecyclerView is not an AdapterView anymore, hence it can't be used in
* combination with {@link Espresso#onData(Matcher)}.
*
* <p>To use {@link ViewAction}s in this class use {@link Espresso#onView(Matcher)} with a <a
* href="http://hamcrest.org/JavaHamcrest/javadoc/1.3/org/hamcrest/Matcher.html"><code>Matcher
* </code></a> that matches your {@link RecyclerView}, then perform a {@link ViewAction} from this
* class.
*/
public final class RecyclerViewActions {
private static final int NO_POSITION = -1;
private RecyclerViewActions() {
// no instance
}
/**
* Most RecyclerViewActions are given a matcher to select a particular view / viewholder within
* the RecyclerView. In this case the default behaviour is to expect that the matcher matches 1
* and only one item within the RecyclerView.
*
* <p>This interface gives users the ability to override that type of behaviour and explicitly
* select an item in the RecyclerView at a given position. This is similar to on the
* onData(...).atPosition() api for AdapterViews.
*/
public interface PositionableRecyclerViewAction extends ViewAction {
/**
* Returns a new ViewAction which will cause the ViewAction to operate upon the position-th
* element which the matcher has selected.
*
* @param position a 0-based index into the list of matching elements within the RecyclerView.
* @return PositionableRecyclerViewAction a new ViewAction focused on a particular position.
* @throws IllegalArgumentException if position < 0.
*/
public PositionableRecyclerViewAction atPosition(int position);
}
/**
* Returns a {@link ViewAction} which scrolls {@link RecyclerView} to the view matched by
* viewHolderMatcher.
*
* <p>This approach uses {@link ViewHolder}s to find the target view. It will create one
* ViewHolder per item type and bind adapter data to the ViewHolder. If the itemViewMatcher
* matches a ViewHolder the current position of the View is used to perform a {@link
* RecyclerView#scrollToPosition(int)}. Note: scrollTo method is not overloaded, method
* overloading with generic parameters is not possible.
*
* @param viewHolderMatcher a <a
* href="http://hamcrest.org/JavaHamcrest/javadoc/1.3/org/hamcrest/Matcher.html"><code>Matcher
* </code></a> that matches an item view holder in {@link RecyclerView}
* @throws PerformException if there are more than one items matching given viewHolderMatcher.
*/
public static <VH extends ViewHolder> PositionableRecyclerViewAction scrollToHolder(
final Matcher<VH> viewHolderMatcher) {
return new ScrollToViewAction<VH>(viewHolderMatcher);
}
/**
* Returns a {@link ViewAction} which scrolls {@link RecyclerView} to the view matched by
* itemViewMatcher.
*
* <p>This approach uses {@link ViewHolder}s to find the target view. It will create one
* ViewHolder per item type and bind adapter data to the ViewHolder. If the itemViewMatcher
* matches a ViewHolder the current position of the View is used to perform a {@link
* RecyclerView#scrollToPosition(int)}.
*
* @param itemViewMatcher a <a
* href="http://hamcrest.org/JavaHamcrest/javadoc/1.3/org/hamcrest/Matcher.html"><code>Matcher
* </code></a> that matches an item view in {@link RecyclerView}
* @throws PerformException if there are more than one items matching given viewHolderMatcher.
*/
public static <VH extends ViewHolder> PositionableRecyclerViewAction scrollTo(
final Matcher<View> itemViewMatcher) {
Matcher<VH> viewHolderMatcher = viewHolderMatcher(itemViewMatcher);
return new ScrollToViewAction<VH>(viewHolderMatcher);
}
/**
* Returns a {@link ViewAction} which scrolls {@link RecyclerView} to a position.
*
* @param position the position of the view to scroll to
*/
public static <VH extends ViewHolder> ViewAction scrollToPosition(final int position) {
return new ScrollToPositionViewAction(position);
}
/**
* Performs a {@link ViewAction} on a view matched by viewHolderMatcher.
*
* <ol>
* <li>Scroll Recycler View to the view matched by itemViewMatcher
* <li>Perform an action on the matched view
* </ol>
*
* @param itemViewMatcher a <a
* href="http://hamcrest.org/JavaHamcrest/javadoc/1.3/org/hamcrest/Matcher.html"><code>Matcher
* </code></a> that matches an item view in {@link RecyclerView}
* @param viewAction the action that is performed on the view matched by itemViewMatcher
* @throws PerformException if there are more than one items matching given viewHolderMatcher.
*/
public static <VH extends ViewHolder> PositionableRecyclerViewAction actionOnItem(
final Matcher<View> itemViewMatcher, final ViewAction viewAction) {
Matcher<VH> viewHolderMatcher = viewHolderMatcher(itemViewMatcher);
return new ActionOnItemViewAction<VH>(viewHolderMatcher, viewAction);
}
/**
* Performs a {@link ViewAction} on a view matched by viewHolderMatcher.
*
* <ol>
* <li>Scroll Recycler View to the view matched by itemViewMatcher
* <li>Perform an action on the matched view
* </ol>
*
* Note: actionOnItem method is not overloaded, method overloading with generic parameters is not
* possible.
*
* @param viewHolderMatcher a <a
* href="http://hamcrest.org/JavaHamcrest/javadoc/1.3/org/hamcrest/Matcher.html"><code>Matcher
* </code></a> that matches an item view holder in {@link RecyclerView}
* @param viewAction the action that is performed on the view matched by viewHolderMatcher
* @throws PerformException if there are more than one items matching given viewHolderMatcher.
*/
public static <VH extends ViewHolder> PositionableRecyclerViewAction actionOnHolderItem(
final Matcher<VH> viewHolderMatcher, final ViewAction viewAction) {
return new ActionOnItemViewAction<VH>(viewHolderMatcher, viewAction);
}
private static final class ActionOnItemViewAction<VH extends ViewHolder>
implements PositionableRecyclerViewAction {
private final Matcher<VH> viewHolderMatcher;
private final ViewAction viewAction;
private final int atPosition;
private final ScrollToViewAction<VH> scroller;
private ActionOnItemViewAction(Matcher<VH> viewHolderMatcher, ViewAction viewAction) {
this(viewHolderMatcher, viewAction, NO_POSITION);
}
private ActionOnItemViewAction(
Matcher<VH> viewHolderMatcher, ViewAction viewAction, int atPosition) {
this.viewHolderMatcher = checkNotNull(viewHolderMatcher);
this.viewAction = checkNotNull(viewAction);
this.atPosition = atPosition;
this.scroller = new ScrollToViewAction<VH>(viewHolderMatcher, atPosition);
}
@SuppressWarnings("unchecked")
@Override
public Matcher<View> getConstraints() {
return allOf(isAssignableFrom(RecyclerView.class), isDisplayed());
}
@Override
public PositionableRecyclerViewAction atPosition(int position) {
checkArgument(position >= 0, "%d is used as an index - must be >= 0", position);
return new ActionOnItemViewAction<VH>(viewHolderMatcher, viewAction, position);
}
@Override
public String getDescription() {
if (atPosition == NO_POSITION) {
return String.format(
"performing ViewAction: %s on item matching: %s",
viewAction.getDescription(), viewHolderMatcher);
} else {
return String.format(
"performing ViewAction: %s on %d-th item matching: %s",
viewAction.getDescription(), atPosition, viewHolderMatcher);
}
}
@Override
public void perform(UiController uiController, View root) {
RecyclerView recyclerView = (RecyclerView) root;
try {
scroller.perform(uiController, root);
uiController.loopMainThreadUntilIdle();
// the above scroller has checked bounds, dupes (maybe) and brought the element into screen.
int max = atPosition == NO_POSITION ? 2 : atPosition + 1;
int selectIndex = atPosition == NO_POSITION ? 0 : atPosition;
List<MatchedItem> matchedItems = itemsMatching(recyclerView, viewHolderMatcher, max);
actionOnItemAtPosition(matchedItems.get(selectIndex).position, viewAction)
.perform(uiController, root);
uiController.loopMainThreadUntilIdle();
} catch (RuntimeException e) {
throw new PerformException.Builder()
.withActionDescription(this.getDescription())
.withViewDescription(HumanReadables.describe(root))
.withCause(e)
.build();
}
}
}
/**
* Performs a {@link ViewAction} on a view at position.
*
* <ol>
* <li>Scroll Recycler View to position
* <li>Perform an action on the view at position
* </ol>
*
* @param position position of a view in {@link RecyclerView}
* @param viewAction the action that is performed on the view matched by itemViewMatcher
*/
public static <VH extends ViewHolder> ViewAction actionOnItemAtPosition(
final int position, final ViewAction viewAction) {
return new ActionOnItemAtPositionViewAction<VH>(position, viewAction);
}
private static final class ActionOnItemAtPositionViewAction<VH extends ViewHolder>
implements ViewAction {
private final int position;
private final ViewAction viewAction;
private ActionOnItemAtPositionViewAction(int position, ViewAction viewAction) {
this.position = position;
this.viewAction = viewAction;
}
@SuppressWarnings("unchecked")
@Override
public Matcher<View> getConstraints() {
return allOf(isAssignableFrom(RecyclerView.class), isDisplayed());
}
@Override
public String getDescription() {
return "actionOnItemAtPosition performing ViewAction: "
+ viewAction.getDescription()
+ " on item at position: "
+ position;
}
@Override
public void perform(UiController uiController, View view) {
RecyclerView recyclerView = (RecyclerView) view;
new ScrollToPositionViewAction(position).perform(uiController, view);
uiController.loopMainThreadUntilIdle();
@SuppressWarnings("unchecked")
VH viewHolderForPosition = (VH) recyclerView.findViewHolderForAdapterPosition(position);
if (null == viewHolderForPosition) {
throw new PerformException.Builder()
.withActionDescription(this.toString())
.withViewDescription(HumanReadables.describe(view))
.withCause(new IllegalStateException("No view holder at position: " + position))
.build();
}
View viewAtPosition = viewHolderForPosition.itemView;
if (null == viewAtPosition) {
throw new PerformException.Builder()
.withActionDescription(this.toString())
.withViewDescription(HumanReadables.describe(viewAtPosition))
.withCause(new IllegalStateException("No view at position: " + position))
.build();
}
viewAction.perform(uiController, viewAtPosition);
}
}
/**
* {@link ViewAction} which scrolls {@link RecyclerView} to the view matched by itemViewMatcher.
* See {@link RecyclerViewActions#scrollTo(Matcher)} for more details.
*/
private static final class ScrollToViewAction<VH extends ViewHolder>
implements PositionableRecyclerViewAction {
private final Matcher<VH> viewHolderMatcher;
private final int atPosition;
private ScrollToViewAction(Matcher<VH> viewHolderMatcher) {
this(viewHolderMatcher, NO_POSITION);
}
private ScrollToViewAction(Matcher<VH> viewHolderMatcher, int atPosition) {
this.viewHolderMatcher = viewHolderMatcher;
this.atPosition = atPosition;
}
@Override
public PositionableRecyclerViewAction atPosition(int position) {
checkArgument(position >= 0, "%d is used as an index - must be >= 0", position);
return new ScrollToViewAction<VH>(viewHolderMatcher, position);
}
@SuppressWarnings("unchecked")
@Override
public Matcher<View> getConstraints() {
return allOf(isAssignableFrom(RecyclerView.class), isDisplayed());
}
@Override
public String getDescription() {
if (atPosition == NO_POSITION) {
return "scroll RecyclerView to: " + viewHolderMatcher;
} else {
return String.format(
"scroll RecyclerView to the: %dth matching %s.", atPosition, viewHolderMatcher);
}
}
@SuppressWarnings("unchecked")
@Override
public void perform(UiController uiController, View view) {
RecyclerView recyclerView = (RecyclerView) view;
try {
int maxMatches = atPosition == NO_POSITION ? 2 : atPosition + 1;
int selectIndex = atPosition == NO_POSITION ? 0 : atPosition;
List<MatchedItem> matchedItems = itemsMatching(recyclerView, viewHolderMatcher, maxMatches);
if (selectIndex >= matchedItems.size()) {
throw new RuntimeException(
String.format(
"Found %d items matching %s, but position %d was requested.",
matchedItems.size(), viewHolderMatcher.toString(), atPosition));
}
if (atPosition == NO_POSITION && matchedItems.size() == 2) {
StringBuilder ambiguousViewError = new StringBuilder();
ambiguousViewError.append(
String.format("Found more than one sub-view matching %s", viewHolderMatcher));
for (MatchedItem item : matchedItems) {
ambiguousViewError.append(item + "\n");
}
throw new RuntimeException(ambiguousViewError.toString());
}
recyclerView.scrollToPosition(matchedItems.get(selectIndex).position);
uiController.loopMainThreadUntilIdle();
} catch (RuntimeException e) {
throw new PerformException.Builder()
.withActionDescription(this.getDescription())
.withViewDescription(HumanReadables.describe(view))
.withCause(e)
.build();
}
}
}
/**
* {@link ViewAction} which scrolls {@link RecyclerView} to a given position. See {@link
* RecyclerViewActions#scrollToPosition(int)} for more details.
*/
private static final class ScrollToPositionViewAction implements ViewAction {
private final int position;
private ScrollToPositionViewAction(int position) {
this.position = position;
}
@SuppressWarnings("unchecked")
@Override
public Matcher<View> getConstraints() {
return allOf(isAssignableFrom(RecyclerView.class), isDisplayed());
}
@Override
public String getDescription() {
return "scroll RecyclerView to position: " + position;
}
@Override
public void perform(UiController uiController, View view) {
RecyclerView recyclerView = (RecyclerView) view;
recyclerView.scrollToPosition(position);
uiController.loopMainThreadUntilIdle();
}
}
/**
* Finds positions of items in {@link RecyclerView} which is matching given viewHolderMatcher.
* This is similar to positionMatching(RecyclerView, Matcher<VH>), except that it returns list of
* multiple positions if there are, rather than throwing Ambiguous view error exception.
*
* @param recyclerView recycler view which is hosting items.
* @param viewHolderMatcher a <a
* href="http://hamcrest.org/JavaHamcrest/javadoc/1.3/org/hamcrest/Matcher.html"><code>Matcher
* </code></a> that matches an item view in {@link RecyclerView}
* @return list of MatchedItem which contains position and description of items in recyclerView.
* @throws RuntimeException if more than one item or item could not be found.
*/
@SuppressWarnings("unchecked")
private static <T extends VH, VH extends ViewHolder> List<MatchedItem> itemsMatching(
final RecyclerView recyclerView, final Matcher<VH> viewHolderMatcher, int max) {
final Adapter<T> adapter = recyclerView.getAdapter();
SparseArray<VH> viewHolderCache = new SparseArray<VH>();
List<MatchedItem> matchedItems = new ArrayList<MatchedItem>();
for (int position = 0; position < adapter.getItemCount(); position++) {
int itemType = adapter.getItemViewType(position);
VH cachedViewHolder = viewHolderCache.get(itemType);
// Create a view holder per type if not exists
if (null == cachedViewHolder) {
cachedViewHolder = adapter.createViewHolder(recyclerView, itemType);
viewHolderCache.put(itemType, cachedViewHolder);
}
// Bind data to ViewHolder and apply matcher to view descendants.
adapter.bindViewHolder((T) cachedViewHolder, position);
if (viewHolderMatcher.matches(cachedViewHolder)) {
matchedItems.add(
new MatchedItem(
position,
HumanReadables.getViewHierarchyErrorMessage(
cachedViewHolder.itemView,
null,
"\n\n*** Matched ViewHolder item at position: " + position + " ***",
null)));
adapter.onViewRecycled((T) cachedViewHolder);
if (matchedItems.size() == max) {
break;
}
} else {
adapter.onViewRecycled((T) cachedViewHolder);
}
}
return matchedItems;
}
/**
* Wrapper for matched items in recycler view which contains position and description of matched
* view.
*/
private static class MatchedItem {
public final int position;
public final String description;
private MatchedItem(int position, String description) {
this.position = position;
this.description = description;
}
@Override
public String toString() {
return description;
}
}
/**
* Creates matcher for view holder with given item view matcher.
*
* @param itemViewMatcher a item view matcher which is used to match item.
* @return a matcher which matches a view holder containing item matching itemViewMatcher.
*/
private static <VH extends ViewHolder> Matcher<VH> viewHolderMatcher(
final Matcher<View> itemViewMatcher) {
return new TypeSafeMatcher<VH>() {
@Override
public boolean matchesSafely(RecyclerView.ViewHolder viewHolder) {
return itemViewMatcher.matches(viewHolder.itemView);
}
@Override
public void describeTo(Description description) {
description.appendText("holder with view: ");
itemViewMatcher.describeTo(description);
}
};
}
}