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.items; 18 19 import android.content.Context; 20 import android.content.res.TypedArray; 21 import android.graphics.Rect; 22 import android.graphics.drawable.ColorDrawable; 23 import android.graphics.drawable.Drawable; 24 import android.graphics.drawable.GradientDrawable; 25 import android.graphics.drawable.LayerDrawable; 26 import android.os.Build; 27 import android.os.Build.VERSION_CODES; 28 import androidx.recyclerview.widget.RecyclerView; 29 import android.util.Log; 30 import android.view.LayoutInflater; 31 import android.view.View; 32 import android.view.ViewGroup; 33 import android.view.ViewOutlineProvider; 34 import androidx.annotation.VisibleForTesting; 35 import com.google.android.setupcompat.partnerconfig.PartnerConfig; 36 import com.google.android.setupcompat.partnerconfig.PartnerConfigHelper; 37 import com.google.android.setupdesign.R; 38 import com.google.android.setupdesign.util.ItemStyler; 39 import com.google.android.setupdesign.view.HeaderRecyclerView; 40 41 /** 42 * An adapter used with RecyclerView to display an {@link ItemHierarchy}. The item hierarchy used to 43 * create this adapter can be inflated by {@link com.google.android.setupdesign.items.ItemInflater} 44 * from XML. 45 */ 46 public class RecyclerItemAdapter extends RecyclerView.Adapter<ItemViewHolder> 47 implements ItemHierarchy.Observer { 48 49 private static final String TAG = "RecyclerItemAdapter"; 50 51 /** 52 * A view tag set by {@link View#setTag(Object)}. If set on the root view of a layout, it will not 53 * create the default background for the list item. This means the item will not have ripple touch 54 * feedback by default. 55 */ 56 public static final String TAG_NO_BACKGROUND = "noBackground"; 57 58 /** Listener for item selection in this adapter. */ 59 public interface OnItemSelectedListener { 60 61 /** 62 * Called when an item in this adapter is clicked. 63 * 64 * @param item The Item corresponding to the position being clicked. 65 */ onItemSelected(IItem item)66 void onItemSelected(IItem item); 67 } 68 69 private final ItemHierarchy itemHierarchy; 70 @VisibleForTesting public final boolean applyPartnerHeavyThemeResource; 71 @VisibleForTesting public final boolean useFullDynamicColor; 72 private OnItemSelectedListener listener; 73 private RecyclerView recyclerView = null; 74 RecyclerItemAdapter(ItemHierarchy hierarchy)75 public RecyclerItemAdapter(ItemHierarchy hierarchy) { 76 this(hierarchy, false); 77 } 78 RecyclerItemAdapter(ItemHierarchy hierarchy, boolean applyPartnerHeavyThemeResource)79 public RecyclerItemAdapter(ItemHierarchy hierarchy, boolean applyPartnerHeavyThemeResource) { 80 this(hierarchy, applyPartnerHeavyThemeResource, /* useFullDynamicColor= */ false); 81 } 82 RecyclerItemAdapter( ItemHierarchy hierarchy, boolean applyPartnerHeavyThemeResource, boolean useFullDynamicColor)83 public RecyclerItemAdapter( 84 ItemHierarchy hierarchy, 85 boolean applyPartnerHeavyThemeResource, 86 boolean useFullDynamicColor) { 87 this.applyPartnerHeavyThemeResource = applyPartnerHeavyThemeResource; 88 this.useFullDynamicColor = useFullDynamicColor; 89 itemHierarchy = hierarchy; 90 itemHierarchy.registerObserver(this); 91 } 92 setRecyclerView(RecyclerView recyclerView)93 public void setRecyclerView(RecyclerView recyclerView) { 94 this.recyclerView = recyclerView; 95 } 96 97 /** 98 * Gets the item at the given position. 99 * 100 * @see ItemHierarchy#getItemAt(int) 101 */ getItem(int position)102 public IItem getItem(int position) { 103 return itemHierarchy.getItemAt(position); 104 } 105 106 @Override getItemId(int position)107 public long getItemId(int position) { 108 IItem mItem = getItem(position); 109 if (mItem instanceof AbstractItem) { 110 final int id = ((AbstractItem) mItem).getId(); 111 return id > 0 ? id : RecyclerView.NO_ID; 112 } else { 113 return RecyclerView.NO_ID; 114 } 115 } 116 117 @Override getItemCount()118 public int getItemCount() { 119 return itemHierarchy.getCount(); 120 } 121 122 @Override onCreateViewHolder(ViewGroup parent, int viewType)123 public ItemViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { 124 final LayoutInflater inflater = LayoutInflater.from(parent.getContext()); 125 final View view = inflater.inflate(viewType, parent, false); 126 final ItemViewHolder viewHolder = new ItemViewHolder(view); 127 Drawable background = null; 128 final Object viewTag = view.getTag(); 129 if (!TAG_NO_BACKGROUND.equals(viewTag)) { 130 final TypedArray typedArray = 131 parent.getContext().obtainStyledAttributes(R.styleable.SudRecyclerItemAdapter); 132 Drawable selectableItemBackground = 133 typedArray.getDrawable( 134 R.styleable.SudRecyclerItemAdapter_android_selectableItemBackground); 135 if (selectableItemBackground == null) { 136 selectableItemBackground = 137 typedArray.getDrawable(R.styleable.SudRecyclerItemAdapter_selectableItemBackground); 138 } else { 139 background = view.getBackground(); 140 if (background == null) { 141 // If full dynamic color enabled which means this activity is running outside of setup 142 // flow, the colors should refer to R.style.SudFullDynamicColorThemeGlifV3. 143 if (applyPartnerHeavyThemeResource && !useFullDynamicColor) { 144 int color = 145 PartnerConfigHelper.get(view.getContext()) 146 .getColor(view.getContext(), PartnerConfig.CONFIG_LAYOUT_BACKGROUND_COLOR); 147 background = new ColorDrawable(color); 148 } else { 149 background = 150 typedArray.getDrawable(R.styleable.SudRecyclerItemAdapter_android_colorBackground); 151 } 152 } 153 } 154 if (selectableItemBackground == null || background == null) { 155 Log.e( 156 TAG, 157 "Cannot resolve required attributes." 158 + " selectableItemBackground=" 159 + selectableItemBackground 160 + " background=" 161 + background); 162 } else { 163 final Drawable[] layers = {background, selectableItemBackground}; 164 view.setBackgroundDrawable(new PatchedLayerDrawable(layers)); 165 } 166 167 typedArray.recycle(); 168 } 169 170 view.setOnClickListener( 171 new View.OnClickListener() { 172 @Override 173 public void onClick(View view) { 174 final IItem item = viewHolder.getItem(); 175 if (listener != null && item != null && item.isEnabled()) { 176 listener.onItemSelected(item); 177 } 178 } 179 }); 180 181 return viewHolder; 182 } 183 getFirstBackground(Context context, int position)184 private Drawable getFirstBackground(Context context, int position) { 185 IItem item = getItem(position); 186 TypedArray a = context 187 .getTheme() 188 .obtainStyledAttributes( 189 item.isActionable() 190 ? new int[] {R.attr.sudItemBackgroundFirst} 191 : new int[] {R.attr.sudNonActionableItemBackgroundFirst}); 192 Drawable firstBackground = a.getDrawable(0); 193 a.recycle(); 194 return firstBackground; 195 } 196 getLastBackground(Context context, int position)197 private Drawable getLastBackground(Context context, int position) { 198 IItem item = getItem(position); 199 TypedArray a = context 200 .getTheme() 201 .obtainStyledAttributes( 202 item.isActionable() 203 ? new int[] {R.attr.sudItemBackgroundLast} 204 : new int[] {R.attr.sudNonActionableItemBackgroundLast}); 205 Drawable lastBackground = a.getDrawable(0); 206 a.recycle(); 207 return lastBackground; 208 } 209 getMiddleBackground(Context context, int position)210 private Drawable getMiddleBackground(Context context, int position) { 211 IItem item = getItem(position); 212 TypedArray a = context 213 .getTheme() 214 .obtainStyledAttributes( 215 item.isActionable() 216 ? new int[] {R.attr.sudItemBackground} 217 : new int[] {R.attr.sudNonActionableItemBackground}); 218 Drawable middleBackground = a.getDrawable(0); 219 a.recycle(); 220 return middleBackground; 221 } 222 getSingleBackground(Context context, int position)223 private Drawable getSingleBackground(Context context, int position) { 224 IItem item = getItem(position); 225 TypedArray a = context 226 .getTheme() 227 .obtainStyledAttributes( 228 item.isActionable() 229 ? new int[] {R.attr.sudItemBackgroundSingle} 230 : new int[] {R.attr.sudNonActionableItemBackgroundSingle}); 231 Drawable singleBackground = a.getDrawable(0); 232 a.recycle(); 233 return singleBackground; 234 } 235 getCornerRadius(Context context)236 private float getCornerRadius(Context context) { 237 TypedArray a = 238 context.getTheme().obtainStyledAttributes(new int[] {R.attr.sudItemCornerRadius}); 239 float conerRadius = a.getDimension(0, 0); 240 a.recycle(); 241 return conerRadius; 242 } 243 isFirstItemOfGroup(int position)244 private boolean isFirstItemOfGroup(int position) { 245 return position == 0 || getItem(position - 1).isGroupDivider(); 246 } 247 isLastItemOfGroup(int position)248 private boolean isLastItemOfGroup(int position) { 249 return position == getItemCount() - 1 || getItem(position + 1).isGroupDivider(); 250 } 251 updateBackground(View view, int position)252 public void updateBackground(View view, int position) { 253 if (TAG_NO_BACKGROUND.equals(view.getTag())) { 254 return; 255 } 256 if (getItem(position).isGroupDivider()) { 257 return; 258 } 259 float groupCornerRadius = 260 PartnerConfigHelper.get(view.getContext()) 261 .getDimension(view.getContext(), PartnerConfig.CONFIG_ITEMS_GROUP_CORNER_RADIUS); 262 float cornerRadius = getCornerRadius(view.getContext()); 263 Drawable drawable = view.getBackground(); 264 // TODO add test case for list item group corner partner config 265 if (drawable instanceof LayerDrawable && ((LayerDrawable) drawable).getNumberOfLayers() >= 2) { 266 Drawable clickDrawable = ((LayerDrawable) drawable).getDrawable(1); 267 Drawable backgroundDrawable = null; 268 GradientDrawable background = null; 269 270 // TODO add test case in updateBackground for list item to get background for Item 271 if (isFirstItemOfGroup(position) && isLastItemOfGroup(position)) { 272 backgroundDrawable = getSingleBackground(view.getContext(), position); 273 } else if (isFirstItemOfGroup(position)) { 274 backgroundDrawable = getFirstBackground(view.getContext(), position); 275 } else if (isLastItemOfGroup(position)) { 276 backgroundDrawable = getLastBackground(view.getContext(), position); 277 } else { 278 backgroundDrawable = getMiddleBackground(view.getContext(), position); 279 } 280 281 if (backgroundDrawable instanceof GradientDrawable) { 282 float topCornerRadius = cornerRadius; 283 float bottomCornerRadius = cornerRadius; 284 if (isFirstItemOfGroup(position)) { 285 topCornerRadius = groupCornerRadius; 286 } 287 if (isLastItemOfGroup(position)) { 288 bottomCornerRadius = groupCornerRadius; 289 } 290 background = (GradientDrawable) backgroundDrawable; 291 background.setCornerRadii( 292 new float[] { 293 topCornerRadius, 294 topCornerRadius, 295 topCornerRadius, 296 topCornerRadius, 297 bottomCornerRadius, 298 bottomCornerRadius, 299 bottomCornerRadius, 300 bottomCornerRadius 301 }); 302 final Drawable[] layers = {background, clickDrawable}; 303 view.setBackgroundDrawable(new PatchedLayerDrawable(layers)); 304 if (Build.VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) { 305 view.setClipToOutline(true); 306 view.setOutlineProvider(ViewOutlineProvider.BACKGROUND); 307 } 308 } 309 } 310 } 311 312 @Override onBindViewHolder(ItemViewHolder holder, int position)313 public void onBindViewHolder(ItemViewHolder holder, int position) { 314 final IItem item = getItem(position); 315 holder.setEnabled(item.isEnabled()); 316 holder.setItem(item); 317 if (holder.isRecyclable() != item.isRecyclable()) { 318 holder.setIsRecyclable(item.isRecyclable()); 319 } 320 // TODO when getContext is not activity context then fallback to out suw behavior 321 if (PartnerConfigHelper.isGlifExpressiveEnabled(holder.itemView.getContext())) { 322 updateBackground(holder.itemView, position); 323 updateMargin(holder.itemView); 324 } 325 item.onBindView(holder.itemView); 326 } 327 updateMargin(View view)328 private void updateMargin(View view) { 329 // If the item view is inside a recycler layout or list layout with attribute 330 // shouldApplyAdditionalMargin, it needs to adjust the 331 // layout margin start/end here to align other component activity margin. If it is not 332 // inside a recycler layout or list layout with attribute shouldApplyAdditionalMargin, it 333 // will be adjusted by each activity themselves. 334 if (shouldApplyAdditionalMargin()) { 335 ItemStyler.applyPartnerCustomizationLayoutMarginStyle(view); 336 } else { 337 resetMarginStartEnd(view); 338 } 339 } 340 shouldApplyAdditionalMargin()341 private boolean shouldApplyAdditionalMargin() { 342 if (recyclerView instanceof HeaderRecyclerView headerRecyclerView) { 343 return headerRecyclerView.shouldApplyAdditionalMargin(); 344 } 345 return false; 346 } 347 resetMarginStartEnd(View itemView)348 private void resetMarginStartEnd(View itemView) { 349 ViewGroup.MarginLayoutParams layoutParams = 350 (ViewGroup.MarginLayoutParams) itemView.getLayoutParams(); 351 layoutParams.setMarginStart(0); 352 layoutParams.setMarginEnd(0); 353 itemView.setLayoutParams(layoutParams); 354 } 355 356 @Override getItemViewType(int position)357 public int getItemViewType(int position) { 358 // Use layout resource as item view type. RecyclerView item type does not have to be 359 // contiguous. 360 IItem item = getItem(position); 361 return item.getLayoutResource(); 362 } 363 364 @Override onChanged(ItemHierarchy hierarchy)365 public void onChanged(ItemHierarchy hierarchy) { 366 notifyDataSetChanged(); 367 } 368 369 @Override onItemRangeChanged(ItemHierarchy itemHierarchy, int positionStart, int itemCount)370 public void onItemRangeChanged(ItemHierarchy itemHierarchy, int positionStart, int itemCount) { 371 notifyItemRangeChanged(positionStart, itemCount); 372 } 373 374 @Override onItemRangeInserted(ItemHierarchy itemHierarchy, int positionStart, int itemCount)375 public void onItemRangeInserted(ItemHierarchy itemHierarchy, int positionStart, int itemCount) { 376 notifyItemRangeInserted(positionStart, itemCount); 377 } 378 379 @Override onItemRangeMoved( ItemHierarchy itemHierarchy, int fromPosition, int toPosition, int itemCount)380 public void onItemRangeMoved( 381 ItemHierarchy itemHierarchy, int fromPosition, int toPosition, int itemCount) { 382 // There is no notifyItemRangeMoved 383 // https://code.google.com/p/android/issues/detail?id=125984 384 if (itemCount == 1) { 385 notifyItemMoved(fromPosition, toPosition); 386 } else { 387 // If more than one, degenerate into the catch-all data set changed callback, since I'm 388 // not sure how recycler view handles multiple calls to notifyItemMoved (if the result 389 // is committed after every notification then naively calling 390 // notifyItemMoved(from + i, to + i) is wrong). 391 // Logging this in case this is a more common occurrence than expected. 392 Log.i(TAG, "onItemRangeMoved with more than one item"); 393 notifyDataSetChanged(); 394 } 395 } 396 397 @Override onItemRangeRemoved(ItemHierarchy itemHierarchy, int positionStart, int itemCount)398 public void onItemRangeRemoved(ItemHierarchy itemHierarchy, int positionStart, int itemCount) { 399 notifyItemRangeRemoved(positionStart, itemCount); 400 } 401 402 /** 403 * Find an item hierarchy within the root hierarchy. 404 * 405 * @see ItemHierarchy#findItemById(int) 406 */ findItemById(int id)407 public ItemHierarchy findItemById(int id) { 408 return itemHierarchy.findItemById(id); 409 } 410 411 /** Gets the root item hierarchy in this adapter. */ getRootItemHierarchy()412 public ItemHierarchy getRootItemHierarchy() { 413 return itemHierarchy; 414 } 415 416 /** 417 * Sets the listener to listen for when user clicks on a item. 418 * 419 * @see OnItemSelectedListener 420 */ setOnItemSelectedListener(OnItemSelectedListener listener)421 public void setOnItemSelectedListener(OnItemSelectedListener listener) { 422 this.listener = listener; 423 } 424 425 /** 426 * Before Lollipop, LayerDrawable always return true in getPadding, even if the children layers do 427 * not have any padding. Patch the implementation so that getPadding returns false if the padding 428 * is empty. 429 * 430 * <p>When getPadding is true, the padding of the view will be replaced by the padding of the 431 * drawable when {@link View#setBackgroundDrawable(Drawable)} is called. This patched class makes 432 * sure layer drawables without padding does not clear out original padding on the view. 433 */ 434 @VisibleForTesting 435 static class PatchedLayerDrawable extends LayerDrawable { 436 437 /** {@inheritDoc} */ PatchedLayerDrawable(Drawable[] layers)438 PatchedLayerDrawable(Drawable[] layers) { 439 super(layers); 440 } 441 442 @Override getPadding(Rect padding)443 public boolean getPadding(Rect padding) { 444 final boolean superHasPadding = super.getPadding(padding); 445 return superHasPadding 446 && !(padding.left == 0 && padding.top == 0 && padding.right == 0 && padding.bottom == 0); 447 } 448 } 449 } 450