1 /* 2 * Copyright (C) 2015 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 com.google.android.setupdesign.view; 18 19 import android.content.Context; 20 import android.content.res.TypedArray; 21 import android.os.Build; 22 import androidx.recyclerview.widget.RecyclerView; 23 import android.util.AttributeSet; 24 import android.view.KeyEvent; 25 import android.view.LayoutInflater; 26 import android.view.View; 27 import android.view.ViewGroup; 28 import android.view.accessibility.AccessibilityEvent; 29 import android.widget.FrameLayout; 30 import com.google.android.setupdesign.DividerItemDecoration; 31 import com.google.android.setupdesign.R; 32 33 /** 34 * A RecyclerView that can display a header item at the start of the list. The header can be set by 35 * {@code app:sudHeader} in XML. Note that the header will not be inflated until a layout manager is 36 * set. 37 */ 38 public class HeaderRecyclerView extends RecyclerView { 39 40 private static class HeaderViewHolder extends ViewHolder 41 implements DividerItemDecoration.DividedViewHolder { 42 HeaderViewHolder(View itemView)43 HeaderViewHolder(View itemView) { 44 super(itemView); 45 } 46 47 @Override isDividerAllowedAbove()48 public boolean isDividerAllowedAbove() { 49 return false; 50 } 51 52 @Override isDividerAllowedBelow()53 public boolean isDividerAllowedBelow() { 54 return false; 55 } 56 } 57 58 /** 59 * An adapter that can optionally add one header item to the RecyclerView. 60 * 61 * @param <CVH> Type of the content view holder. i.e. view holder type of the wrapped adapter. 62 */ 63 public static class HeaderAdapter<CVH extends ViewHolder> 64 extends RecyclerView.Adapter<ViewHolder> { 65 66 private static final int HEADER_VIEW_TYPE = Integer.MAX_VALUE; 67 68 private final RecyclerView.Adapter<CVH> adapter; 69 private View header; 70 71 private final AdapterDataObserver observer = 72 new AdapterDataObserver() { 73 74 @Override 75 public void onChanged() { 76 notifyDataSetChanged(); 77 } 78 79 @Override 80 public void onItemRangeChanged(int positionStart, int itemCount) { 81 if (header != null) { 82 positionStart++; 83 } 84 notifyItemRangeChanged(positionStart, itemCount); 85 } 86 87 @Override 88 public void onItemRangeInserted(int positionStart, int itemCount) { 89 if (header != null) { 90 positionStart++; 91 } 92 notifyItemRangeInserted(positionStart, itemCount); 93 } 94 95 @Override 96 public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) { 97 if (header != null) { 98 fromPosition++; 99 toPosition++; 100 } 101 // Why is there no notifyItemRangeMoved? 102 for (int i = 0; i < itemCount; i++) { 103 notifyItemMoved(fromPosition + i, toPosition + i); 104 } 105 } 106 107 @Override 108 public void onItemRangeRemoved(int positionStart, int itemCount) { 109 if (header != null) { 110 positionStart++; 111 } 112 notifyItemRangeRemoved(positionStart, itemCount); 113 } 114 }; 115 HeaderAdapter(RecyclerView.Adapter<CVH> adapter)116 public HeaderAdapter(RecyclerView.Adapter<CVH> adapter) { 117 this.adapter = adapter; 118 this.adapter.registerAdapterDataObserver(observer); 119 setHasStableIds(this.adapter.hasStableIds()); 120 } 121 122 @Override onCreateViewHolder(ViewGroup parent, int viewType)123 public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { 124 // Returning the same view (header) results in crash ".. but view is not a real child." 125 // The framework creates more than one instance of header because of "disappear" 126 // animations applied on the header and this necessitates creation of another header 127 // view to use after the animation. We work around this restriction by returning an 128 // empty FrameLayout to which the header is attached using #onBindViewHolder method. 129 if (viewType == HEADER_VIEW_TYPE) { 130 FrameLayout frameLayout = new FrameLayout(parent.getContext()); 131 FrameLayout.LayoutParams params = 132 new FrameLayout.LayoutParams( 133 FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT); 134 frameLayout.setLayoutParams(params); 135 return new HeaderViewHolder(frameLayout); 136 } else { 137 return adapter.onCreateViewHolder(parent, viewType); 138 } 139 } 140 141 @Override 142 @SuppressWarnings("unchecked") // Non-header position always return type CVH onBindViewHolder(ViewHolder holder, int position)143 public void onBindViewHolder(ViewHolder holder, int position) { 144 if (header != null) { 145 position--; 146 } 147 148 if (holder instanceof HeaderViewHolder) { 149 if (header == null) { 150 throw new IllegalStateException("HeaderViewHolder cannot find mHeader"); 151 } 152 if (header.getParent() != null) { 153 ((ViewGroup) header.getParent()).removeView(header); 154 } 155 FrameLayout mHeaderParent = (FrameLayout) holder.itemView; 156 mHeaderParent.addView(header); 157 } else { 158 adapter.onBindViewHolder((CVH) holder, position); 159 } 160 } 161 162 @Override getItemViewType(int position)163 public int getItemViewType(int position) { 164 if (header != null) { 165 position--; 166 } 167 if (position < 0) { 168 return HEADER_VIEW_TYPE; 169 } 170 return adapter.getItemViewType(position); 171 } 172 173 @Override getItemCount()174 public int getItemCount() { 175 int count = adapter.getItemCount(); 176 if (header != null) { 177 count++; 178 } 179 return count; 180 } 181 182 @Override getItemId(int position)183 public long getItemId(int position) { 184 if (header != null) { 185 position--; 186 } 187 if (position < 0) { 188 return Long.MAX_VALUE; 189 } 190 return adapter.getItemId(position); 191 } 192 setHeader(View header)193 public void setHeader(View header) { 194 this.header = header; 195 } 196 getWrappedAdapter()197 public RecyclerView.Adapter<CVH> getWrappedAdapter() { 198 return adapter; 199 } 200 } 201 202 private View header; 203 private int headerRes; 204 HeaderRecyclerView(Context context)205 public HeaderRecyclerView(Context context) { 206 super(context); 207 init(null, 0); 208 } 209 HeaderRecyclerView(Context context, AttributeSet attrs)210 public HeaderRecyclerView(Context context, AttributeSet attrs) { 211 super(context, attrs); 212 init(attrs, 0); 213 } 214 HeaderRecyclerView(Context context, AttributeSet attrs, int defStyleAttr)215 public HeaderRecyclerView(Context context, AttributeSet attrs, int defStyleAttr) { 216 super(context, attrs, defStyleAttr); 217 init(attrs, defStyleAttr); 218 } 219 init(AttributeSet attrs, int defStyleAttr)220 private void init(AttributeSet attrs, int defStyleAttr) { 221 if (isInEditMode()) { 222 return; 223 } 224 225 final TypedArray a = 226 getContext() 227 .obtainStyledAttributes(attrs, R.styleable.SudHeaderRecyclerView, defStyleAttr, 0); 228 headerRes = a.getResourceId(R.styleable.SudHeaderRecyclerView_sudHeader, 0); 229 a.recycle(); 230 } 231 232 @Override onInitializeAccessibilityEvent(AccessibilityEvent event)233 public void onInitializeAccessibilityEvent(AccessibilityEvent event) { 234 super.onInitializeAccessibilityEvent(event); 235 236 // Decoration-only headers should not count as an item for accessibility, adjust the 237 // accessibility event to account for that. 238 final int numberOfHeaders = header != null ? 1 : 0; 239 event.setItemCount(event.getItemCount() - numberOfHeaders); 240 event.setFromIndex(Math.max(event.getFromIndex() - numberOfHeaders, 0)); 241 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) { 242 event.setToIndex(Math.max(event.getToIndex() - numberOfHeaders, 0)); 243 } 244 } 245 handleDpadDown()246 private boolean handleDpadDown() { 247 View focusedView = findFocus(); 248 if (focusedView == null) { 249 return false; 250 } 251 252 int[] focusdLocationInWindow = new int[2]; 253 int[] myLocationInWindow = new int[2]; 254 255 focusedView.getLocationInWindow(focusdLocationInWindow); 256 getLocationInWindow(myLocationInWindow); 257 258 int offset = 259 (focusdLocationInWindow[1] + focusedView.getMeasuredHeight()) 260 - (myLocationInWindow[1] + getMeasuredHeight()); 261 262 /* 263 (focusdLocationInWindow[1] + focusedView.getMeasuredHeight()) 264 is the bottom position of focused view 265 266 (myLocationInWindow[1] + getMeasuredHeight()) 267 is the bottom position of recycler view 268 269 If the bottom of focused view is out of recycler view, means we need to scroll down to show 270 more detail 271 272 We scroll 70% of recycler view to make sure user can have 30% of previous information, to make 273 sure user can keep reading easily. 274 */ 275 if (offset > 0) { 276 // We expect only scroll 70% of recycler view 277 int scrollLength = (int) (getMeasuredHeight() * 0.7f); 278 smoothScrollBy(0, Math.min(scrollLength, offset)); 279 return true; 280 } 281 282 return false; 283 } 284 handleDpadUp()285 private boolean handleDpadUp() { 286 View focusedView = findFocus(); 287 if (focusedView == null) { 288 return false; 289 } 290 291 int[] focusedLocationInWindow = new int[2]; 292 int[] myLocationInWindow = new int[2]; 293 294 focusedView.getLocationInWindow(focusedLocationInWindow); 295 getLocationInWindow(myLocationInWindow); 296 297 int offset = (focusedLocationInWindow[1] - myLocationInWindow[1]); 298 299 /* 300 focusedLocationInWindow[1] is top of focused view 301 myLocationInWindow[1] is top of recycler view 302 303 If top of focused view is higher than recycler view we need scroll up to show more information 304 we try to scroll up 70% of recycler view ot scroll up to the top of focused view 305 */ 306 if (offset < 0) { 307 // We expect only scroll 70% of recycler view 308 int scrollLength = (int) (getMeasuredHeight() * -0.7f); 309 310 smoothScrollBy(0, Math.max(scrollLength, offset)); 311 return true; 312 } 313 return false; 314 } 315 316 private boolean shouldHandleActionUp = false; 317 handleKeyEvent(KeyEvent keyEvent)318 private boolean handleKeyEvent(KeyEvent keyEvent) { 319 if (shouldHandleActionUp && keyEvent.getAction() == KeyEvent.ACTION_UP) { 320 shouldHandleActionUp = false; 321 return true; 322 } else if (keyEvent.getAction() == KeyEvent.ACTION_DOWN) { 323 boolean eventHandled = false; 324 switch (keyEvent.getKeyCode()) { 325 case KeyEvent.KEYCODE_DPAD_DOWN: 326 eventHandled = handleDpadDown(); 327 break; 328 case KeyEvent.KEYCODE_DPAD_UP: 329 eventHandled = handleDpadUp(); 330 break; 331 default: // fall out 332 } 333 shouldHandleActionUp = eventHandled; 334 return eventHandled; 335 } 336 return false; 337 } 338 339 @Override dispatchKeyEvent(KeyEvent event)340 public boolean dispatchKeyEvent(KeyEvent event) { 341 if (handleKeyEvent(event)) { 342 return true; 343 } 344 return super.dispatchKeyEvent(event); 345 } 346 347 /** Gets the header view of this RecyclerView, or {@code null} if there are no headers. */ getHeader()348 public View getHeader() { 349 return header; 350 } 351 352 /** 353 * Set the view to use as the header of this recycler view. Note: This must be called before 354 * setAdapter. 355 */ setHeader(View header)356 public void setHeader(View header) { 357 this.header = header; 358 } 359 360 @Override setLayoutManager(LayoutManager layout)361 public void setLayoutManager(LayoutManager layout) { 362 super.setLayoutManager(layout); 363 if (layout != null && header == null && headerRes != 0) { 364 // Inflating a child view requires the layout manager to be set. Check here to see if 365 // any header item is specified in XML and inflate them. 366 final LayoutInflater inflater = LayoutInflater.from(getContext()); 367 header = inflater.inflate(headerRes, this, false); 368 } 369 } 370 371 @Override 372 @SuppressWarnings("rawtypes,unchecked") // RecyclerView.setAdapter uses raw type :( setAdapter(Adapter adapter)373 public void setAdapter(Adapter adapter) { 374 if (header != null && adapter != null) { 375 final HeaderAdapter headerAdapter = new HeaderAdapter(adapter); 376 headerAdapter.setHeader(header); 377 adapter = headerAdapter; 378 } 379 super.setAdapter(adapter); 380 } 381 } 382