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 boolean shouldApplyAdditionalMargin; 204 private int headerRes; 205 HeaderRecyclerView(Context context)206 public HeaderRecyclerView(Context context) { 207 super(context); 208 init(null, 0); 209 } 210 HeaderRecyclerView(Context context, AttributeSet attrs)211 public HeaderRecyclerView(Context context, AttributeSet attrs) { 212 super(context, attrs); 213 init(attrs, 0); 214 } 215 HeaderRecyclerView(Context context, AttributeSet attrs, int defStyleAttr)216 public HeaderRecyclerView(Context context, AttributeSet attrs, int defStyleAttr) { 217 super(context, attrs, defStyleAttr); 218 init(attrs, defStyleAttr); 219 } 220 init(AttributeSet attrs, int defStyleAttr)221 private void init(AttributeSet attrs, int defStyleAttr) { 222 if (isInEditMode()) { 223 return; 224 } 225 226 final TypedArray a = 227 getContext() 228 .obtainStyledAttributes(attrs, R.styleable.SudHeaderRecyclerView, defStyleAttr, 0); 229 headerRes = a.getResourceId(R.styleable.SudHeaderRecyclerView_sudHeader, 0); 230 shouldApplyAdditionalMargin = 231 a.getBoolean(R.styleable.SudHeaderRecyclerView_sudShouldApplyAdditionalMargin, false); 232 a.recycle(); 233 } 234 shouldApplyAdditionalMargin()235 public boolean shouldApplyAdditionalMargin() { 236 return shouldApplyAdditionalMargin; 237 } 238 239 @Override onInitializeAccessibilityEvent(AccessibilityEvent event)240 public void onInitializeAccessibilityEvent(AccessibilityEvent event) { 241 super.onInitializeAccessibilityEvent(event); 242 243 // Decoration-only headers should not count as an item for accessibility, adjust the 244 // accessibility event to account for that. 245 final int numberOfHeaders = header != null ? 1 : 0; 246 event.setItemCount(event.getItemCount() - numberOfHeaders); 247 event.setFromIndex(Math.max(event.getFromIndex() - numberOfHeaders, 0)); 248 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) { 249 event.setToIndex(Math.max(event.getToIndex() - numberOfHeaders, 0)); 250 } 251 } 252 handleDpadDown()253 private boolean handleDpadDown() { 254 View focusedView = findFocus(); 255 if (focusedView == null) { 256 return false; 257 } 258 259 int[] focusdLocationInWindow = new int[2]; 260 int[] myLocationInWindow = new int[2]; 261 262 focusedView.getLocationInWindow(focusdLocationInWindow); 263 getLocationInWindow(myLocationInWindow); 264 265 int offset = 266 (focusdLocationInWindow[1] + focusedView.getMeasuredHeight()) 267 - (myLocationInWindow[1] + getMeasuredHeight()); 268 269 /* 270 (focusdLocationInWindow[1] + focusedView.getMeasuredHeight()) 271 is the bottom position of focused view 272 273 (myLocationInWindow[1] + getMeasuredHeight()) 274 is the bottom position of recycler view 275 276 If the bottom of focused view is out of recycler view, means we need to scroll down to show 277 more detail 278 279 We scroll 70% of recycler view to make sure user can have 30% of previous information, to make 280 sure user can keep reading easily. 281 */ 282 if (offset > 0) { 283 // We expect only scroll 70% of recycler view 284 int scrollLength = (int) (getMeasuredHeight() * 0.7f); 285 smoothScrollBy(0, Math.min(scrollLength, offset)); 286 return true; 287 } 288 289 return false; 290 } 291 handleDpadUp()292 private boolean handleDpadUp() { 293 View focusedView = findFocus(); 294 if (focusedView == null) { 295 return false; 296 } 297 298 int[] focusedLocationInWindow = new int[2]; 299 int[] myLocationInWindow = new int[2]; 300 301 focusedView.getLocationInWindow(focusedLocationInWindow); 302 getLocationInWindow(myLocationInWindow); 303 304 int offset = (focusedLocationInWindow[1] - myLocationInWindow[1]); 305 306 /* 307 focusedLocationInWindow[1] is top of focused view 308 myLocationInWindow[1] is top of recycler view 309 310 If top of focused view is higher than recycler view we need scroll up to show more information 311 we try to scroll up 70% of recycler view ot scroll up to the top of focused view 312 */ 313 if (offset < 0) { 314 // We expect only scroll 70% of recycler view 315 int scrollLength = (int) (getMeasuredHeight() * -0.7f); 316 317 smoothScrollBy(0, Math.max(scrollLength, offset)); 318 return true; 319 } 320 return false; 321 } 322 323 private boolean shouldHandleActionUp = false; 324 handleKeyEvent(KeyEvent keyEvent)325 private boolean handleKeyEvent(KeyEvent keyEvent) { 326 if (shouldHandleActionUp && keyEvent.getAction() == KeyEvent.ACTION_UP) { 327 shouldHandleActionUp = false; 328 return true; 329 } else if (keyEvent.getAction() == KeyEvent.ACTION_DOWN) { 330 boolean eventHandled = false; 331 switch (keyEvent.getKeyCode()) { 332 case KeyEvent.KEYCODE_DPAD_DOWN: 333 eventHandled = handleDpadDown(); 334 break; 335 case KeyEvent.KEYCODE_DPAD_UP: 336 eventHandled = handleDpadUp(); 337 break; 338 default: // fall out 339 } 340 shouldHandleActionUp = eventHandled; 341 return eventHandled; 342 } 343 return false; 344 } 345 346 @Override dispatchKeyEvent(KeyEvent event)347 public boolean dispatchKeyEvent(KeyEvent event) { 348 if (handleKeyEvent(event)) { 349 return true; 350 } 351 return super.dispatchKeyEvent(event); 352 } 353 354 /** Gets the header view of this RecyclerView, or {@code null} if there are no headers. */ getHeader()355 public View getHeader() { 356 return header; 357 } 358 359 /** 360 * Set the view to use as the header of this recycler view. Note: This must be called before 361 * setAdapter. 362 */ setHeader(View header)363 public void setHeader(View header) { 364 this.header = header; 365 } 366 367 @Override setLayoutManager(LayoutManager layout)368 public void setLayoutManager(LayoutManager layout) { 369 super.setLayoutManager(layout); 370 if (layout != null && header == null && headerRes != 0) { 371 // Inflating a child view requires the layout manager to be set. Check here to see if 372 // any header item is specified in XML and inflate them. 373 final LayoutInflater inflater = LayoutInflater.from(getContext()); 374 header = inflater.inflate(headerRes, this, false); 375 } 376 } 377 378 @Override 379 @SuppressWarnings("rawtypes,unchecked") // RecyclerView.setAdapter uses raw type :( setAdapter(Adapter adapter)380 public void setAdapter(Adapter adapter) { 381 if (header != null && adapter != null) { 382 final HeaderAdapter headerAdapter = new HeaderAdapter(adapter); 383 headerAdapter.setHeader(header); 384 adapter = headerAdapter; 385 } 386 super.setAdapter(adapter); 387 } 388 } 389