Home | History | Annotate | Download | only in widget
      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  */
     16 
     17 package androidx.recyclerview.widget;
     18 
     19 
     20 import static androidx.recyclerview.widget.RecyclerView.HORIZONTAL;
     21 import static androidx.recyclerview.widget.RecyclerView.SCROLL_STATE_IDLE;
     22 import static androidx.recyclerview.widget.RecyclerView.VERTICAL;
     23 
     24 import static org.hamcrest.CoreMatchers.is;
     25 import static org.hamcrest.CoreMatchers.notNullValue;
     26 import static org.hamcrest.MatcherAssert.assertThat;
     27 import static org.junit.Assert.assertTrue;
     28 
     29 import android.app.Activity;
     30 import android.content.Context;
     31 import android.os.Build;
     32 import android.support.test.InstrumentationRegistry;
     33 import android.support.test.filters.LargeTest;
     34 import android.support.test.rule.ActivityTestRule;
     35 import android.view.LayoutInflater;
     36 import android.view.View;
     37 import android.view.ViewGroup;
     38 import android.view.ViewParent;
     39 import android.widget.LinearLayout;
     40 
     41 import androidx.annotation.NonNull;
     42 import androidx.core.view.ViewCompat;
     43 import androidx.recyclerview.test.R;
     44 import androidx.recyclerview.test.RecyclerViewTestActivity;
     45 
     46 import org.hamcrest.BaseMatcher;
     47 import org.hamcrest.CoreMatchers;
     48 import org.hamcrest.Description;
     49 import org.junit.Rule;
     50 import org.junit.Test;
     51 import org.junit.runner.RunWith;
     52 import org.junit.runners.Parameterized;
     53 
     54 import java.util.Arrays;
     55 import java.util.List;
     56 import java.util.concurrent.CountDownLatch;
     57 import java.util.concurrent.TimeUnit;
     58 
     59 /**
     60  * This class tests RecyclerView focus search failure handling by using a real LayoutManager.
     61  */
     62 @LargeTest
     63 @RunWith(Parameterized.class)
     64 public class FocusSearchNavigationTest {
     65     @Rule
     66     public ActivityTestRule<RecyclerViewTestActivity> mActivityRule =
     67             new ActivityTestRule<>(RecyclerViewTestActivity.class);
     68 
     69     private final int mOrientation;
     70     private final int mLayoutDir;
     71 
     72     public FocusSearchNavigationTest(int orientation, int layoutDir) {
     73         mOrientation = orientation;
     74         mLayoutDir = layoutDir;
     75     }
     76 
     77     @Parameterized.Parameters(name = "orientation:{0},layoutDir:{1}")
     78     public static List<Object[]> params() {
     79         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
     80             return Arrays.asList(
     81                     new Object[]{VERTICAL, ViewCompat.LAYOUT_DIRECTION_LTR},
     82                     new Object[]{HORIZONTAL, ViewCompat.LAYOUT_DIRECTION_LTR},
     83                     new Object[]{HORIZONTAL, ViewCompat.LAYOUT_DIRECTION_RTL}
     84             );
     85         } else {
     86             // Do not test RTL before API 17
     87             return Arrays.asList(
     88                     new Object[]{VERTICAL, ViewCompat.LAYOUT_DIRECTION_LTR},
     89                     new Object[]{HORIZONTAL, ViewCompat.LAYOUT_DIRECTION_LTR}
     90             );
     91         }
     92     }
     93 
     94     private Activity mActivity;
     95     private RecyclerView mRecyclerView;
     96     private View mBefore;
     97     private View mAfter;
     98 
     99     private void setup(final int itemCount) throws Throwable {
    100         mActivity = mActivityRule.getActivity();
    101         mActivityRule.runOnUiThread(new Runnable() {
    102             @Override
    103             public void run() {
    104                 mActivity.setContentView(R.layout.focus_search_activity);
    105                 ViewCompat.setLayoutDirection(mActivity.getWindow().getDecorView(), mLayoutDir);
    106                 LinearLayout linearLayout = (LinearLayout) mActivity.findViewById(R.id.root);
    107                 linearLayout.setOrientation(mOrientation);
    108                 mRecyclerView = (RecyclerView) mActivity.findViewById(R.id.recycler_view);
    109                 ViewCompat.setLayoutDirection(mRecyclerView, mLayoutDir);
    110                 LinearLayoutManager layout = new LinearLayoutManager(mActivity.getBaseContext());
    111                 layout.setOrientation(mOrientation);
    112                 mRecyclerView.setLayoutManager(layout);
    113                 mRecyclerView.setAdapter(new FocusSearchAdapter(itemCount, mOrientation));
    114                 if (mOrientation == VERTICAL) {
    115                     mRecyclerView.setLayoutParams(new LinearLayout.LayoutParams(
    116                             ViewGroup.LayoutParams.MATCH_PARENT, 250));
    117                 } else {
    118                     mRecyclerView.setLayoutParams(new LinearLayout.LayoutParams(
    119                             250, ViewGroup.LayoutParams.MATCH_PARENT));
    120                 }
    121 
    122                 mBefore = mActivity.findViewById(R.id.before);
    123                 mAfter = mActivity.findViewById(R.id.after);
    124             }
    125         });
    126         waitForIdleSync();
    127         assertThat("test sanity", mRecyclerView.getLayoutManager().getLayoutDirection(),
    128                 is(mLayoutDir));
    129         assertThat("test sanity", ViewCompat.getLayoutDirection(mRecyclerView), is(mLayoutDir));
    130     }
    131 
    132     @Test
    133     public void focusSearchForward() throws Throwable {
    134         setup(20);
    135         requestFocus(mBefore);
    136         assertThat(mBefore, hasFocus());
    137         View focused = mBefore;
    138         for (int i = 0; i < 20; i++) {
    139             focusSearchAndGive(focused, View.FOCUS_FORWARD);
    140             RecyclerView.ViewHolder viewHolder = mRecyclerView.findViewHolderForAdapterPosition(i);
    141             assertThat("vh at " + i, viewHolder, hasFocus());
    142             focused = viewHolder.itemView;
    143         }
    144         focusSearchAndGive(focused, View.FOCUS_FORWARD);
    145         assertThat(mAfter, hasFocus());
    146         focusSearchAndGive(mAfter, View.FOCUS_FORWARD);
    147         assertThat(mBefore, hasFocus());
    148         focusSearchAndGive(mBefore, View.FOCUS_FORWARD);
    149         focused = mActivity.getCurrentFocus();
    150         //noinspection ConstantConditions
    151         assertThat(focused.getParent(), CoreMatchers.<ViewParent>sameInstance(mRecyclerView));
    152     }
    153 
    154     @Test
    155     public void focusSearchBackwards() throws Throwable {
    156         setup(20);
    157         requestFocus(mAfter);
    158         assertThat(mAfter, hasFocus());
    159         View focused = mAfter;
    160         RecyclerView.ViewHolder lastViewHolder = null;
    161         int i = 20;
    162         while(lastViewHolder == null) {
    163             lastViewHolder = mRecyclerView.findViewHolderForAdapterPosition(--i);
    164         }
    165         assertThat(lastViewHolder, notNullValue());
    166 
    167         while(i >= 0) {
    168             focusSearchAndGive(focused, View.FOCUS_BACKWARD);
    169             RecyclerView.ViewHolder viewHolder = mRecyclerView.findViewHolderForAdapterPosition(i);
    170             assertThat("vh at " + i, viewHolder, hasFocus());
    171             focused = viewHolder.itemView;
    172             i--;
    173         }
    174         focusSearchAndGive(focused, View.FOCUS_BACKWARD);
    175         assertThat(mBefore, hasFocus());
    176         focusSearchAndGive(mBefore, View.FOCUS_BACKWARD);
    177         assertThat(mAfter, hasFocus());
    178     }
    179 
    180     private View focusSearchAndGive(final View view, final int focusDir) throws Throwable {
    181         View next = focusSearch(view, focusDir);
    182         if (next != null && next != view) {
    183             requestFocus(next);
    184             return next;
    185         }
    186         return null;
    187     }
    188 
    189     private View focusSearch(final View view, final int focusDir) throws Throwable {
    190         final View[] result = new View[1];
    191         mActivityRule.runOnUiThread(new Runnable() {
    192             @Override
    193             public void run() {
    194                 result[0] = view.focusSearch(focusDir);
    195             }
    196         });
    197         waitForIdleSync();
    198         return result[0];
    199     }
    200 
    201     private void waitForIdleSync() throws Throwable {
    202         waitForIdleScroll(mRecyclerView);
    203         InstrumentationRegistry.getInstrumentation().waitForIdleSync();
    204     }
    205 
    206     private void requestFocus(final View view) throws Throwable {
    207         mActivityRule.runOnUiThread(new Runnable() {
    208             @Override
    209             public void run() {
    210                 view.requestFocus();
    211             }
    212         });
    213         waitForIdleSync();
    214     }
    215 
    216     public void waitForIdleScroll(final RecyclerView recyclerView) throws Throwable {
    217         final CountDownLatch latch = new CountDownLatch(1);
    218         mActivityRule.runOnUiThread(new Runnable() {
    219             @Override
    220             public void run() {
    221                 RecyclerView.OnScrollListener listener = new RecyclerView.OnScrollListener() {
    222                     @Override
    223                     public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
    224                         if (newState == SCROLL_STATE_IDLE) {
    225                             latch.countDown();
    226                             recyclerView.removeOnScrollListener(this);
    227                         }
    228                     }
    229                 };
    230                 if (recyclerView.getScrollState() == SCROLL_STATE_IDLE) {
    231                     latch.countDown();
    232                 } else {
    233                     recyclerView.addOnScrollListener(listener);
    234                 }
    235             }
    236         });
    237         assertTrue("should go idle in 10 seconds", latch.await(10, TimeUnit.SECONDS));
    238     }
    239 
    240     static class FocusSearchAdapter extends RecyclerView.Adapter {
    241         private int mItemCount;
    242         private int mOrientation;
    243         public FocusSearchAdapter(int itemCount, int orientation) {
    244             mItemCount = itemCount;
    245             mOrientation = orientation;
    246         }
    247 
    248         @Override
    249         public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent,
    250         int viewType) {
    251             View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_view,
    252                     parent, false);
    253             if (mOrientation == VERTICAL) {
    254                 view.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
    255                         50));
    256             } else {
    257                 view.setLayoutParams(new ViewGroup.LayoutParams(50,
    258                         ViewGroup.LayoutParams.MATCH_PARENT));
    259             }
    260             return new RecyclerView.ViewHolder(view) {};
    261         }
    262 
    263         @Override
    264         public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
    265             holder.itemView.setTag("pos " + position);
    266         }
    267 
    268         @Override
    269         public int getItemCount() {
    270             return mItemCount;
    271         }
    272     }
    273 
    274     static HasFocusMatcher hasFocus() {
    275         return new HasFocusMatcher();
    276     }
    277 
    278     static class HasFocusMatcher extends BaseMatcher<Object> {
    279         @Override
    280         public boolean matches(Object item) {
    281             if (item instanceof RecyclerView.ViewHolder) {
    282                 item = ((RecyclerView.ViewHolder) item).itemView;
    283             }
    284             return item instanceof View && ((View) item).hasFocus();
    285         }
    286 
    287         @Override
    288         public void describeTo(Description description) {
    289             description.appendText("view has focus");
    290         }
    291 
    292         private String objectToLog(Object item) {
    293             if (item instanceof RecyclerView.ViewHolder) {
    294                 RecyclerView.ViewHolder vh = (RecyclerView.ViewHolder) item;
    295                 return vh.toString();
    296             }
    297             if (item instanceof View) {
    298                 final Object tag = ((View) item).getTag();
    299                 return tag == null ? item.toString() : tag.toString();
    300             }
    301             final String classLog = item == null ? "null" : item.getClass().getSimpleName();
    302             return classLog;
    303         }
    304 
    305         @Override
    306         public void describeMismatch(Object item, Description description) {
    307             String noun = objectToLog(item);
    308             description.appendText(noun + " does not have focus");
    309             Context context = null;
    310             if (item instanceof RecyclerView.ViewHolder) {
    311                 context = ((RecyclerView.ViewHolder)item).itemView.getContext();
    312             } else  if (item instanceof View) {
    313                 context = ((View) item).getContext();
    314             }
    315             if (context instanceof Activity) {
    316                 View currentFocus = ((Activity) context).getWindow().getCurrentFocus();
    317                 description.appendText(". Current focus is in " + objectToLog(currentFocus));
    318             }
    319         }
    320     }
    321 }
    322