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