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