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