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