1 /* 2 * Copyright (C) 2018 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 package com.android.customization.widget; 17 18 import static com.android.internal.util.Preconditions.checkNotNull; 19 20 import android.content.Context; 21 import android.content.res.Resources; 22 import android.graphics.Rect; 23 import android.graphics.drawable.Drawable; 24 import android.graphics.drawable.LayerDrawable; 25 import android.text.TextUtils; 26 import android.util.DisplayMetrics; 27 import android.view.Gravity; 28 import android.view.LayoutInflater; 29 import android.view.View; 30 import android.view.ViewGroup; 31 import android.view.WindowManager; 32 import android.view.accessibility.AccessibilityEvent; 33 import android.widget.TextView; 34 35 import androidx.annotation.Dimension; 36 import androidx.annotation.IntDef; 37 import androidx.annotation.NonNull; 38 import androidx.recyclerview.widget.GridLayoutManager; 39 import androidx.recyclerview.widget.LinearLayoutManager; 40 import androidx.recyclerview.widget.RecyclerView; 41 import androidx.recyclerview.widget.RecyclerViewAccessibilityDelegate; 42 43 import com.android.customization.model.CustomizationManager; 44 import com.android.customization.model.CustomizationOption; 45 import com.android.wallpaper.R; 46 47 import java.util.HashSet; 48 import java.util.List; 49 import java.util.Set; 50 51 /** 52 * Simple controller for a RecyclerView-based widget to hold the options for each customization 53 * section (eg, thumbnails for themes, clocks, grid sizes). 54 * To use, just pass the RV that will contain the tiles and the list of {@link CustomizationOption} 55 * representing each option, and call {@link #initOptions(CustomizationManager)} to populate the 56 * widget. 57 */ 58 public class OptionSelectorController<T extends CustomizationOption<T>> { 59 60 /** 61 * Interface to be notified when an option is selected by the user. 62 */ 63 public interface OptionSelectedListener { 64 65 /** 66 * Called when an option has been selected (and marked as such in the UI) 67 */ onOptionSelected(CustomizationOption selected)68 void onOptionSelected(CustomizationOption selected); 69 } 70 71 @IntDef({CheckmarkStyle.NONE, CheckmarkStyle.CORNER, CheckmarkStyle.CENTER}) 72 public @interface CheckmarkStyle { 73 int NONE = 0; 74 int CORNER = 1; 75 int CENTER = 2; 76 } 77 78 private static final float LINEAR_LAYOUT_HORIZONTAL_DISPLAY_OPTIONS_MAX = 4.35f; 79 80 private final RecyclerView mContainer; 81 private final List<T> mOptions; 82 private final boolean mUseGrid; 83 @CheckmarkStyle private final int mCheckmarkStyle; 84 85 private final Set<OptionSelectedListener> mListeners = new HashSet<>(); 86 private RecyclerView.Adapter<TileViewHolder> mAdapter; 87 private T mSelectedOption; 88 private T mAppliedOption; 89 OptionSelectorController(RecyclerView container, List<T> options)90 public OptionSelectorController(RecyclerView container, List<T> options) { 91 this(container, options, true, CheckmarkStyle.CORNER); 92 } 93 OptionSelectorController(RecyclerView container, List<T> options, boolean useGrid, @CheckmarkStyle int checkmarkStyle)94 public OptionSelectorController(RecyclerView container, List<T> options, 95 boolean useGrid, @CheckmarkStyle int checkmarkStyle) { 96 mContainer = container; 97 mOptions = options; 98 mUseGrid = useGrid; 99 mCheckmarkStyle = checkmarkStyle; 100 } 101 addListener(OptionSelectedListener listener)102 public void addListener(OptionSelectedListener listener) { 103 mListeners.add(listener); 104 } 105 removeListener(OptionSelectedListener listener)106 public void removeListener(OptionSelectedListener listener) { 107 mListeners.remove(listener); 108 } 109 110 /** 111 * Mark the given option as selected 112 */ setSelectedOption(T option)113 public void setSelectedOption(T option) { 114 if (!mOptions.contains(option)) { 115 throw new IllegalArgumentException("Invalid option"); 116 } 117 updateActivatedStatus(mSelectedOption, false); 118 updateActivatedStatus(option, true); 119 T lastSelectedOption = mSelectedOption; 120 mSelectedOption = option; 121 mAdapter.notifyItemChanged(mOptions.indexOf(option)); 122 if (lastSelectedOption != null) { 123 mAdapter.notifyItemChanged(mOptions.indexOf(lastSelectedOption)); 124 } 125 notifyListeners(); 126 } 127 128 /** 129 * @return whether this controller contains the given option 130 */ containsOption(T option)131 public boolean containsOption(T option) { 132 return mOptions.contains(option); 133 } 134 135 /** 136 * Mark an option as the one which is currently applied on the device. This will result in a 137 * check being displayed in the lower-right corner of the corresponding ViewHolder. 138 * @param option 139 */ setAppliedOption(T option)140 public void setAppliedOption(T option) { 141 if (!mOptions.contains(option)) { 142 throw new IllegalArgumentException("Invalid option"); 143 } 144 T lastAppliedOption = mAppliedOption; 145 mAppliedOption = option; 146 mAdapter.notifyItemChanged(mOptions.indexOf(option)); 147 if (lastAppliedOption != null) { 148 mAdapter.notifyItemChanged(mOptions.indexOf(lastAppliedOption)); 149 } 150 } 151 updateActivatedStatus(T option, boolean isActivated)152 private void updateActivatedStatus(T option, boolean isActivated) { 153 int index = mOptions.indexOf(option); 154 if (index < 0) { 155 return; 156 } 157 RecyclerView.ViewHolder holder = mContainer.findViewHolderForAdapterPosition(index); 158 if (holder != null && holder.itemView != null) { 159 holder.itemView.setActivated(isActivated); 160 161 if (holder instanceof TileViewHolder) { 162 TileViewHolder tileHolder = (TileViewHolder) holder; 163 if (isActivated) { 164 if (option == mAppliedOption && mCheckmarkStyle != CheckmarkStyle.NONE) { 165 tileHolder.setContentDescription(mContainer.getContext(), option, 166 R.string.option_applied_previewed_description); 167 } else { 168 tileHolder.setContentDescription(mContainer.getContext(), option, 169 R.string.option_previewed_description); 170 } 171 } else if (option == mAppliedOption && mCheckmarkStyle != CheckmarkStyle.NONE) { 172 tileHolder.setContentDescription(mContainer.getContext(), option, 173 R.string.option_applied_description); 174 } else { 175 tileHolder.resetContentDescription(); 176 } 177 } 178 } else { 179 // Item is not visible, make sure the item is re-bound when it becomes visible 180 mAdapter.notifyItemChanged(index); 181 } 182 } 183 184 /** 185 * Notify that a given option has changed. 186 * @param option the option that changed 187 */ optionChanged(T option)188 public void optionChanged(T option) { 189 if (!mOptions.contains(option)) { 190 throw new IllegalArgumentException("Invalid option"); 191 } 192 mAdapter.notifyItemChanged(mOptions.indexOf(option)); 193 } 194 195 /** 196 * Initializes the UI for the options passed in the constructor of this class. 197 */ initOptions(final CustomizationManager<T> manager)198 public void initOptions(final CustomizationManager<T> manager) { 199 mContainer.setAccessibilityDelegateCompat( 200 new OptionSelectorAccessibilityDelegate(mContainer)); 201 202 mAdapter = new RecyclerView.Adapter<TileViewHolder>() { 203 @Override 204 public int getItemViewType(int position) { 205 return mOptions.get(position).getLayoutResId(); 206 } 207 208 @NonNull 209 @Override 210 public TileViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { 211 View v = LayoutInflater.from(parent.getContext()).inflate(viewType, parent, false); 212 return new TileViewHolder(v); 213 } 214 215 @Override 216 public void onBindViewHolder(@NonNull TileViewHolder holder, int position) { 217 T option = mOptions.get(position); 218 if (option.isActive(manager)) { 219 mAppliedOption = option; 220 if (mSelectedOption == null) { 221 mSelectedOption = option; 222 } 223 } 224 if (holder.labelView != null) { 225 holder.labelView.setText(option.getTitle()); 226 } 227 holder.itemView.setActivated(option.equals(mSelectedOption)); 228 option.bindThumbnailTile(holder.tileView); 229 holder.itemView.setOnClickListener(view -> setSelectedOption(option)); 230 231 Resources res = mContainer.getContext().getResources(); 232 if (mCheckmarkStyle == CheckmarkStyle.CORNER && option.equals(mAppliedOption)) { 233 drawCheckmark(option, holder, 234 res.getDrawable(R.drawable.check_circle_accent_24dp, 235 mContainer.getContext().getTheme()), 236 Gravity.BOTTOM | Gravity.RIGHT, 237 res.getDimensionPixelSize(R.dimen.check_size), 238 res.getDimensionPixelOffset(R.dimen.check_offset)); 239 } else if (mCheckmarkStyle == CheckmarkStyle.CENTER 240 && option.equals(mAppliedOption)) { 241 drawCheckmark(option, holder, 242 res.getDrawable(R.drawable.check_circle_grey_large, 243 mContainer.getContext().getTheme()), 244 Gravity.CENTER, res.getDimensionPixelSize(R.dimen.center_check_size), 245 0); 246 } else if (option.equals(mAppliedOption)) { 247 // Initialize with "previewed" description if we don't show checkmark 248 holder.setContentDescription(mContainer.getContext(), option, 249 R.string.option_previewed_description); 250 } else if (mCheckmarkStyle != CheckmarkStyle.NONE) { 251 holder.tileView.setForeground(null); 252 } 253 } 254 255 @Override 256 public int getItemCount() { 257 return mOptions.size(); 258 } 259 260 private void drawCheckmark(CustomizationOption<?> option, TileViewHolder holder, 261 Drawable checkmark, int gravity, @Dimension int checkSize, 262 @Dimension int checkOffset) { 263 Drawable frame = holder.tileView.getForeground(); 264 Drawable[] layers = {frame, checkmark}; 265 if (frame == null) { 266 layers = new Drawable[]{checkmark}; 267 } 268 LayerDrawable checkedFrame = new LayerDrawable(layers); 269 270 // Position according to the given gravity and offset 271 int idx = layers.length - 1; 272 checkedFrame.setLayerGravity(idx, gravity); 273 checkedFrame.setLayerWidth(idx, checkSize); 274 checkedFrame.setLayerHeight(idx, checkSize); 275 checkedFrame.setLayerInsetBottom(idx, checkOffset); 276 checkedFrame.setLayerInsetRight(idx, checkOffset); 277 holder.tileView.setForeground(checkedFrame); 278 279 // Initialize the currently applied option 280 holder.setContentDescription(mContainer.getContext(), option, 281 R.string.option_applied_previewed_description); 282 } 283 }; 284 285 Resources res = mContainer.getContext().getResources(); 286 if (mUseGrid) { 287 mContainer.setLayoutManager(new GridLayoutManager(mContainer.getContext(), 288 res.getInteger(R.integer.options_grid_num_columns))); 289 } else { 290 mContainer.setLayoutManager(new LinearLayoutManager(mContainer.getContext(), 291 LinearLayoutManager.HORIZONTAL, false)); 292 } 293 294 mContainer.setAdapter(mAdapter); 295 296 // Measure RecyclerView to get to the total amount of space used by all options. 297 mContainer.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED); 298 int fixWidth = res.getDimensionPixelSize(R.dimen.options_container_width); 299 int availableWidth; 300 if (fixWidth == 0) { 301 DisplayMetrics metrics = new DisplayMetrics(); 302 mContainer.getContext().getSystemService(WindowManager.class) 303 .getDefaultDisplay().getMetrics(metrics); 304 availableWidth = metrics.widthPixels; 305 } else { 306 availableWidth = fixWidth; 307 } 308 int totalWidth = mContainer.getMeasuredWidth(); 309 int widthPerItem = res.getDimensionPixelOffset(R.dimen.option_tile_width); 310 311 if (mUseGrid) { 312 int numColumns = res.getInteger(R.integer.options_grid_num_columns); 313 int extraSpace = availableWidth - widthPerItem * numColumns; 314 while (extraSpace < 0) { 315 numColumns -= 1; 316 extraSpace = availableWidth - widthPerItem * numColumns; 317 } 318 319 if (mContainer.getLayoutManager() != null) { 320 ((GridLayoutManager) mContainer.getLayoutManager()).setSpanCount(numColumns); 321 } 322 return; 323 } 324 325 int extraSpace = availableWidth - totalWidth; 326 if (extraSpace >= 0) { 327 mContainer.setOverScrollMode(View.OVER_SCROLL_NEVER); 328 } 329 330 if (mAdapter.getItemCount() >= LINEAR_LAYOUT_HORIZONTAL_DISPLAY_OPTIONS_MAX) { 331 int spaceBetweenItems = availableWidth 332 - Math.round(widthPerItem * LINEAR_LAYOUT_HORIZONTAL_DISPLAY_OPTIONS_MAX) 333 - mContainer.getPaddingLeft(); 334 int itemEndMargin = 335 spaceBetweenItems / (int) LINEAR_LAYOUT_HORIZONTAL_DISPLAY_OPTIONS_MAX; 336 if (itemEndMargin <= 0) { 337 itemEndMargin = res.getDimensionPixelOffset(R.dimen.option_tile_margin_horizontal); 338 } 339 mContainer.addItemDecoration(new ItemEndHorizontalSpaceItemDecoration( 340 mContainer.getContext(), itemEndMargin)); 341 return; 342 } 343 344 int spaceBetweenItems = extraSpace / (mAdapter.getItemCount() + 1); 345 int itemSideMargin = spaceBetweenItems / 2; 346 mContainer.addItemDecoration(new HorizontalSpacerItemDecoration(itemSideMargin)); 347 } 348 resetOptions(List<T> options)349 public void resetOptions(List<T> options) { 350 mOptions.clear(); 351 mOptions.addAll(options); 352 mAdapter.notifyDataSetChanged(); 353 } 354 notifyListeners()355 private void notifyListeners() { 356 if (mListeners.isEmpty()) { 357 return; 358 } 359 T option = mSelectedOption; 360 Set<OptionSelectedListener> iterableListeners = new HashSet<>(mListeners); 361 for (OptionSelectedListener listener : iterableListeners) { 362 listener.onOptionSelected(option); 363 } 364 } 365 366 private static class TileViewHolder extends RecyclerView.ViewHolder { 367 TextView labelView; 368 View tileView; 369 CharSequence title; 370 TileViewHolder(@onNull View itemView)371 TileViewHolder(@NonNull View itemView) { 372 super(itemView); 373 labelView = itemView.findViewById(R.id.option_label); 374 tileView = itemView.findViewById(R.id.option_tile); 375 title = null; 376 } 377 378 /** 379 * Set the content description for this holder using the given string id. 380 * If the option does not have a label, the description will be set on the tile view. 381 * @param context The view's context 382 * @param option The customization option 383 * @param id Resource ID of the string to use for the content description 384 */ setContentDescription(Context context, CustomizationOption<?> option, int id)385 public void setContentDescription(Context context, CustomizationOption<?> option, int id) { 386 title = option.getTitle(); 387 if (TextUtils.isEmpty(title) && tileView != null) { 388 title = tileView.getContentDescription(); 389 } 390 391 CharSequence cd = context.getString(id, title); 392 if (labelView != null && !TextUtils.isEmpty(labelView.getText())) { 393 labelView.setAccessibilityPaneTitle(cd); 394 labelView.setContentDescription(cd); 395 } else if (tileView != null) { 396 tileView.setAccessibilityPaneTitle(cd); 397 tileView.setContentDescription(cd); 398 } 399 } 400 resetContentDescription()401 public void resetContentDescription() { 402 if (labelView != null && !TextUtils.isEmpty(labelView.getText())) { 403 labelView.setAccessibilityPaneTitle(title); 404 labelView.setContentDescription(title); 405 } else if (tileView != null) { 406 tileView.setAccessibilityPaneTitle(title); 407 tileView.setContentDescription(title); 408 } 409 } 410 } 411 412 private class OptionSelectorAccessibilityDelegate extends RecyclerViewAccessibilityDelegate { 413 OptionSelectorAccessibilityDelegate(RecyclerView recyclerView)414 OptionSelectorAccessibilityDelegate(RecyclerView recyclerView) { 415 super(recyclerView); 416 } 417 418 @Override onRequestSendAccessibilityEvent( ViewGroup host, View child, AccessibilityEvent event)419 public boolean onRequestSendAccessibilityEvent( 420 ViewGroup host, View child, AccessibilityEvent event) { 421 422 // Apply this workaround to horizontal recyclerview only, 423 // since the symptom is TalkBack will lose focus when navigating horizontal list items. 424 if (mContainer.getLayoutManager() != null 425 && mContainer.getLayoutManager().canScrollHorizontally() 426 && event.getEventType() == AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED) { 427 int itemPos = mContainer.getChildLayoutPosition(child); 428 int itemWidth = mContainer.getContext().getResources() 429 .getDimensionPixelOffset(R.dimen.option_tile_width); 430 int itemMarginHorizontal = mContainer.getContext().getResources() 431 .getDimensionPixelOffset(R.dimen.option_tile_margin_horizontal) * 2; 432 int scrollOffset = itemWidth + itemMarginHorizontal; 433 434 // Make focusing item's previous/next item totally visible when changing focus, 435 // ensure TalkBack won't lose focus when recyclerview scrolling. 436 if (itemPos >= ((LinearLayoutManager) mContainer.getLayoutManager()) 437 .findLastCompletelyVisibleItemPosition()) { 438 mContainer.scrollBy(scrollOffset, 0); 439 } else if (itemPos <= ((LinearLayoutManager) mContainer.getLayoutManager()) 440 .findFirstCompletelyVisibleItemPosition() && itemPos != 0) { 441 mContainer.scrollBy(-scrollOffset, 0); 442 } 443 } 444 return super.onRequestSendAccessibilityEvent(host, child, event); 445 } 446 } 447 448 /** Custom ItemDecorator to add specific spacing between items in the list. */ 449 private static final class ItemEndHorizontalSpaceItemDecoration 450 extends RecyclerView.ItemDecoration { 451 private final int mHorizontalSpacePx; 452 private final boolean mDirectionLTR; 453 ItemEndHorizontalSpaceItemDecoration(Context context, int horizontalSpacePx)454 private ItemEndHorizontalSpaceItemDecoration(Context context, int horizontalSpacePx) { 455 mDirectionLTR = context.getResources().getConfiguration().getLayoutDirection() 456 == View.LAYOUT_DIRECTION_LTR; 457 mHorizontalSpacePx = horizontalSpacePx; 458 } 459 460 @Override getItemOffsets(Rect outRect, View view, RecyclerView recyclerView, RecyclerView.State state)461 public void getItemOffsets(Rect outRect, View view, RecyclerView recyclerView, 462 RecyclerView.State state) { 463 if (recyclerView.getAdapter() == null) { 464 return; 465 } 466 467 if (recyclerView.getChildAdapterPosition(view) 468 != checkNotNull(recyclerView.getAdapter()).getItemCount() - 1) { 469 // Don't add spacing behind the last item 470 if (mDirectionLTR) { 471 outRect.right = mHorizontalSpacePx; 472 } else { 473 outRect.left = mHorizontalSpacePx; 474 } 475 } 476 } 477 } 478 } 479