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