1 /*
2  * Copyright 2019 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.android.car.ui.recyclerview;
18 
19 import static com.android.car.ui.core.CarUi.MIN_TARGET_API;
20 import static com.android.car.ui.utils.CarUiUtils.requireViewByRefId;
21 
22 import android.annotation.TargetApi;
23 import android.graphics.drawable.Drawable;
24 import android.text.TextUtils;
25 import android.util.Log;
26 import android.view.LayoutInflater;
27 import android.view.View;
28 import android.view.ViewGroup;
29 import android.widget.CheckBox;
30 import android.widget.CompoundButton;
31 import android.widget.ImageView;
32 import android.widget.RadioButton;
33 import android.widget.Switch;
34 import android.widget.TextView;
35 
36 import androidx.annotation.NonNull;
37 import androidx.annotation.Nullable;
38 import androidx.recyclerview.widget.RecyclerView;
39 
40 import com.android.car.ui.R;
41 import com.android.car.ui.SecureView;
42 import com.android.car.ui.widget.CarUiTextView;
43 
44 import java.util.List;
45 
46 /**
47  * Adapter for {@link CarUiRecyclerView} to display {@link CarUiContentListItem} and {@link
48  * CarUiHeaderListItem}.
49  *
50  * <ul>
51  * <li> Implements {@link CarUiRecyclerView.ItemCap} - defaults to unlimited item count.
52  * </ul>
53  * <p>
54  * Rendered views will comply with
55  * <a href="https://source.android.com/devices/automotive/hmi/car_ui/appendix_b">customization guardrails</a>
56  */
57 @TargetApi(MIN_TARGET_API)
58 public class CarUiListItemAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> implements
59         CarUiRecyclerView.ItemCap {
60 
61     static final int VIEW_TYPE_LIST_ITEM = 1;
62     static final int VIEW_TYPE_LIST_HEADER = 2;
63 
64     private final List<? extends CarUiListItem> mItems;
65     private final boolean mCompactLayout;
66     private int mMaxItems = CarUiRecyclerView.ItemCap.UNLIMITED;
67 
CarUiListItemAdapter(List<? extends CarUiListItem> items)68     public CarUiListItemAdapter(List<? extends CarUiListItem> items) {
69         this(items, false);
70     }
71 
CarUiListItemAdapter(List<? extends CarUiListItem> items, boolean useCompactLayout)72     public CarUiListItemAdapter(List<? extends CarUiListItem> items, boolean useCompactLayout) {
73         this.mItems = items;
74         this.mCompactLayout = useCompactLayout;
75     }
76 
77     @NonNull
78     @Override
onCreateViewHolder( @onNull ViewGroup parent, int viewType)79     public RecyclerView.ViewHolder onCreateViewHolder(
80             @NonNull ViewGroup parent, int viewType) {
81         LayoutInflater inflater = LayoutInflater.from(parent.getContext());
82 
83         switch (viewType) {
84             case VIEW_TYPE_LIST_ITEM:
85                 if (mCompactLayout) {
86                     return new ListItemViewHolder(
87                             inflater.inflate(R.layout.car_ui_list_item_compact, parent, false));
88                 }
89                 return new ListItemViewHolder(
90                         inflater.inflate(R.layout.car_ui_list_item, parent, false));
91             case VIEW_TYPE_LIST_HEADER:
92                 return new HeaderViewHolder(
93                         inflater.inflate(R.layout.car_ui_header_list_item, parent, false));
94             default:
95                 throw new IllegalStateException("Unknown item type.");
96         }
97     }
98 
99     /**
100      * Returns the data set held by the adapter.
101      *
102      * <p>Any changes performed to this mutable list must be followed with an invocation of the
103      * appropriate notify method for the adapter.
104      */
105     @NonNull
getItems()106     public List<? extends CarUiListItem> getItems() {
107         return mItems;
108     }
109 
110     @Override
getItemViewType(int position)111     public int getItemViewType(int position) {
112         if (mItems.get(position) instanceof CarUiContentListItem) {
113             return VIEW_TYPE_LIST_ITEM;
114         } else if (mItems.get(position) instanceof CarUiHeaderListItem) {
115             return VIEW_TYPE_LIST_HEADER;
116         }
117 
118         throw new IllegalStateException("Unknown view type.");
119     }
120 
121     @Override
onBindViewHolder(@onNull RecyclerView.ViewHolder holder, int position)122     public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
123         switch (holder.getItemViewType()) {
124             case VIEW_TYPE_LIST_ITEM:
125                 if (!(holder instanceof ListItemViewHolder)) {
126                     throw new IllegalStateException("Incorrect view holder type for list item.");
127                 }
128 
129                 CarUiListItem item = mItems.get(position);
130                 if (!(item instanceof CarUiContentListItem)) {
131                     throw new IllegalStateException(
132                             "Expected item to be bound to viewHolder to be instance of "
133                                     + "CarUiContentListItem.");
134                 }
135 
136                 ((ListItemViewHolder) holder).bind((CarUiContentListItem) item);
137                 break;
138             case VIEW_TYPE_LIST_HEADER:
139                 if (!(holder instanceof HeaderViewHolder)) {
140                     throw new IllegalStateException("Incorrect view holder type for list item.");
141                 }
142 
143                 CarUiListItem header = mItems.get(position);
144                 if (!(header instanceof CarUiHeaderListItem)) {
145                     throw new IllegalStateException(
146                             "Expected item to be bound to viewHolder to be instance of "
147                                     + "CarUiHeaderListItem.");
148                 }
149 
150                 ((HeaderViewHolder) holder).bind((CarUiHeaderListItem) header);
151                 break;
152             default:
153                 throw new IllegalStateException("Unknown item view type.");
154         }
155     }
156 
157     @Override
getItemCount()158     public int getItemCount() {
159         return mMaxItems == CarUiRecyclerView.ItemCap.UNLIMITED
160                 ? mItems.size()
161                 : Math.min(mItems.size(), mMaxItems);
162     }
163 
164     @Override
setMaxItems(int maxItems)165     public void setMaxItems(int maxItems) {
166         mMaxItems = maxItems;
167     }
168 
169     /**
170      * Holds views of {@link CarUiContentListItem}.
171      */
172     static class ListItemViewHolder extends RecyclerView.ViewHolder {
173 
174         final CarUiTextView mTitle;
175         final CarUiTextView mBody;
176         final ImageView mIcon;
177         final ImageView mContentIcon;
178         final ImageView mAvatarIcon;
179         final ViewGroup mIconContainer;
180         final ViewGroup mActionContainer;
181         final View mActionDivider;
182         final Switch mSwitch;
183         final CheckBox mCheckBox;
184         final RadioButton mRadioButton;
185         final ImageView mSupplementalIcon;
186         final View mTouchInterceptor;
187         final View mReducedTouchInterceptor;
188         final View mActionContainerTouchInterceptor;
189 
ListItemViewHolder(@onNull View itemView)190         ListItemViewHolder(@NonNull View itemView) {
191             super(itemView);
192             mTitle = requireViewByRefId(itemView, R.id.car_ui_list_item_title);
193             mBody = requireViewByRefId(itemView, R.id.car_ui_list_item_body);
194             mIcon = requireViewByRefId(itemView, R.id.car_ui_list_item_icon);
195             mContentIcon = requireViewByRefId(itemView, R.id.car_ui_list_item_content_icon);
196             mAvatarIcon = requireViewByRefId(itemView, R.id.car_ui_list_item_avatar_icon);
197             mIconContainer = requireViewByRefId(itemView, R.id.car_ui_list_item_icon_container);
198             mActionContainer = requireViewByRefId(itemView, R.id.car_ui_list_item_action_container);
199             mActionDivider = requireViewByRefId(itemView, R.id.car_ui_list_item_action_divider);
200             mSwitch = requireViewByRefId(itemView, R.id.car_ui_list_item_switch_widget);
201             mCheckBox = requireViewByRefId(itemView, R.id.car_ui_list_item_checkbox_widget);
202             mRadioButton = requireViewByRefId(itemView, R.id.car_ui_list_item_radio_button_widget);
203             mSupplementalIcon = requireViewByRefId(itemView,
204                     R.id.car_ui_list_item_supplemental_icon);
205             mReducedTouchInterceptor = requireViewByRefId(itemView,
206                     R.id.car_ui_list_item_reduced_touch_interceptor);
207             mTouchInterceptor = requireViewByRefId(itemView,
208                     R.id.car_ui_list_item_touch_interceptor);
209             mActionContainerTouchInterceptor = requireViewByRefId(itemView,
210                     R.id.car_ui_list_item_action_container_touch_interceptor);
211         }
212 
bind(@onNull CarUiContentListItem item)213         void bind(@NonNull CarUiContentListItem item) {
214             if (item.getTitle() != null) {
215                 mTitle.setText(item.getTitle());
216                 mTitle.setVisibility(View.VISIBLE);
217             } else {
218                 mTitle.setVisibility(View.GONE);
219             }
220 
221             if (item.getBody() != null) {
222                 mBody.setText(item.getBody());
223                 mBody.setVisibility(View.VISIBLE);
224             } else {
225                 mBody.setVisibility(View.GONE);
226             }
227 
228             mIcon.setVisibility(View.GONE);
229             mContentIcon.setVisibility(View.GONE);
230             mAvatarIcon.setVisibility(View.GONE);
231 
232             Drawable icon = item.getIcon();
233             if (icon != null) {
234                 mIconContainer.setVisibility(View.VISIBLE);
235 
236                 switch (item.getPrimaryIconType()) {
237                     case CONTENT:
238                         mContentIcon.setVisibility(View.VISIBLE);
239                         mContentIcon.setImageDrawable(icon);
240                         break;
241                     case STANDARD:
242                         mIcon.setVisibility(View.VISIBLE);
243                         mIcon.setImageDrawable(icon);
244                         break;
245                     case AVATAR:
246                         mAvatarIcon.setVisibility(View.VISIBLE);
247                         mAvatarIcon.setImageDrawable(icon);
248                         mAvatarIcon.setClipToOutline(true);
249                         break;
250                 }
251             } else {
252                 mIconContainer.setVisibility(View.GONE);
253             }
254 
255             boolean logWarning = false;
256             if (mTouchInterceptor instanceof SecureView) {
257                 ((SecureView) mTouchInterceptor).setSecure(item.isSecure());
258             } else {
259                 logWarning = true;
260             }
261 
262             if (mReducedTouchInterceptor instanceof SecureView) {
263                 ((SecureView) mReducedTouchInterceptor).setSecure(item.isSecure());
264             } else {
265                 logWarning = true;
266             }
267 
268             if (mActionContainerTouchInterceptor instanceof SecureView) {
269                 ((SecureView) mActionContainerTouchInterceptor).setSecure(item.isSecure());
270             } else {
271                 logWarning = true;
272             }
273 
274             if (logWarning) {
275                 Log.w("carui", "List item doesn't have a SecureView, but security was requested!");
276             }
277 
278             mActionDivider.setVisibility(
279                     item.isActionDividerVisible() ? View.VISIBLE : View.GONE);
280             mSwitch.setVisibility(View.GONE);
281             mCheckBox.setVisibility(View.GONE);
282             mRadioButton.setVisibility(View.GONE);
283             mSupplementalIcon.setVisibility(View.GONE);
284 
285             CarUiContentListItem.OnClickListener itemOnClickListener = item.getOnClickListener();
286 
287             switch (item.getAction()) {
288                 case NONE:
289                     mActionContainer.setVisibility(View.GONE);
290 
291                     // Display ripple effects across entire item when clicked by using full-sized
292                     // touch interceptor.
293                     mTouchInterceptor.setVisibility(View.VISIBLE);
294                     mTouchInterceptor.setOnClickListener(v -> {
295                         if (itemOnClickListener != null) {
296                             itemOnClickListener.onClick(item);
297                         }
298                     });
299                     mTouchInterceptor.setClickable(itemOnClickListener != null);
300                     mReducedTouchInterceptor.setVisibility(View.GONE);
301                     mActionContainerTouchInterceptor.setVisibility(View.GONE);
302                     break;
303                 case SWITCH:
304                     bindCompoundButton(item, mSwitch, itemOnClickListener);
305                     break;
306                 case CHECK_BOX:
307                     bindCompoundButton(item, mCheckBox, itemOnClickListener);
308                     break;
309                 case RADIO_BUTTON:
310                     bindCompoundButton(item, mRadioButton, itemOnClickListener);
311                     break;
312                 case CHEVRON:
313                     mSupplementalIcon.setVisibility(View.VISIBLE);
314                     mSupplementalIcon.setImageDrawable(itemView.getContext().getDrawable(
315                             R.drawable.car_ui_preference_icon_chevron));
316                     mActionContainer.setVisibility(View.VISIBLE);
317                     mTouchInterceptor.setVisibility(View.VISIBLE);
318                     mTouchInterceptor.setOnClickListener(v -> {
319                         if (itemOnClickListener != null) {
320                             itemOnClickListener.onClick(item);
321                         }
322                     });
323                     mTouchInterceptor.setClickable(itemOnClickListener != null);
324                     mReducedTouchInterceptor.setVisibility(View.GONE);
325                     mActionContainerTouchInterceptor.setVisibility(View.GONE);
326                     break;
327                 case ICON:
328                     mSupplementalIcon.setVisibility(View.VISIBLE);
329                     mSupplementalIcon.setImageDrawable(item.getSupplementalIcon());
330 
331                     mActionContainer.setVisibility(View.VISIBLE);
332 
333                     // If the icon has a click listener, use a reduced touch interceptor to create
334                     // two distinct touch area; the action container and the remainder of the list
335                     // item. Each touch area will have its own ripple effect. If the icon has no
336                     // click listener, it shouldn't be clickable.
337                     if (item.getSupplementalIconOnClickListener() == null) {
338                         mTouchInterceptor.setVisibility(View.VISIBLE);
339                         mTouchInterceptor.setOnClickListener(v -> {
340                             if (itemOnClickListener != null) {
341                                 itemOnClickListener.onClick(item);
342                             }
343                         });
344                         mTouchInterceptor.setClickable(itemOnClickListener != null);
345                         mReducedTouchInterceptor.setVisibility(View.GONE);
346                         mActionContainerTouchInterceptor.setVisibility(View.GONE);
347                     } else {
348                         mReducedTouchInterceptor.setVisibility(View.VISIBLE);
349                         mReducedTouchInterceptor.setOnClickListener(v -> {
350                             if (itemOnClickListener != null) {
351                                 itemOnClickListener.onClick(item);
352                             }
353                         });
354                         mReducedTouchInterceptor.setClickable(itemOnClickListener != null);
355                         mActionContainerTouchInterceptor.setVisibility(View.VISIBLE);
356                         mActionContainerTouchInterceptor.setOnClickListener(
357                                 (container) -> {
358                                     if (item.getSupplementalIconOnClickListener() != null) {
359                                         item.getSupplementalIconOnClickListener().onClick(item);
360                                     }
361                                 });
362                         mActionContainerTouchInterceptor.setClickable(
363                                 item.getSupplementalIconOnClickListener() != null);
364                         mTouchInterceptor.setVisibility(View.GONE);
365                     }
366                     break;
367                 default:
368                     throw new IllegalStateException("Unknown secondary action type.");
369             }
370 
371             itemView.setActivated(item.isActivated());
372             setEnabled(itemView, item.isEnabled());
373         }
374 
setEnabled(View view, boolean enabled)375         void setEnabled(View view, boolean enabled) {
376             view.setEnabled(enabled);
377             if (view instanceof ViewGroup) {
378                 ViewGroup group = (ViewGroup) view;
379 
380                 for (int i = 0; i < group.getChildCount(); i++) {
381                     setEnabled(group.getChildAt(i), enabled);
382                 }
383             }
384         }
385 
bindCompoundButton(@onNull CarUiContentListItem item, @NonNull CompoundButton compoundButton, @Nullable CarUiContentListItem.OnClickListener itemOnClickListener)386         void bindCompoundButton(@NonNull CarUiContentListItem item,
387                 @NonNull CompoundButton compoundButton,
388                 @Nullable CarUiContentListItem.OnClickListener itemOnClickListener) {
389             compoundButton.setVisibility(View.VISIBLE);
390             compoundButton.setOnCheckedChangeListener(null);
391             compoundButton.setChecked(item.isChecked());
392             compoundButton.setOnCheckedChangeListener(
393                     (buttonView, isChecked) -> item.setChecked(isChecked));
394 
395             // Clicks anywhere on the item should toggle the checkbox state. Use full touch
396             // interceptor.
397             mTouchInterceptor.setVisibility(View.VISIBLE);
398             mTouchInterceptor.setOnClickListener(v -> {
399                 compoundButton.toggle();
400                 if (itemOnClickListener != null) {
401                     itemOnClickListener.onClick(item);
402                 }
403             });
404             // Compound button list items should always be clickable
405             mTouchInterceptor.setClickable(true);
406             mReducedTouchInterceptor.setVisibility(View.GONE);
407             mActionContainerTouchInterceptor.setVisibility(View.GONE);
408 
409             mActionContainer.setVisibility(View.VISIBLE);
410             mActionContainer.setClickable(false);
411         }
412     }
413 
414     /**
415      * Holds views of {@link CarUiHeaderListItem}.
416      */
417     static class HeaderViewHolder extends RecyclerView.ViewHolder {
418 
419         private final TextView mTitle;
420         private final TextView mBody;
421 
HeaderViewHolder(@onNull View itemView)422         HeaderViewHolder(@NonNull View itemView) {
423             super(itemView);
424             mTitle = requireViewByRefId(itemView, R.id.car_ui_list_item_title);
425             mBody = requireViewByRefId(itemView, R.id.car_ui_list_item_body);
426         }
427 
bind(@onNull CarUiHeaderListItem item)428         private void bind(@NonNull CarUiHeaderListItem item) {
429             mTitle.setText(item.getTitle());
430 
431             CharSequence body = item.getBody();
432             if (!TextUtils.isEmpty(body)) {
433                 mBody.setText(body);
434             } else {
435                 mBody.setVisibility(View.GONE);
436             }
437         }
438     }
439 }
440