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