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 android.content.Context; 19 import android.content.res.Resources; 20 import android.graphics.drawable.Drawable; 21 import android.graphics.drawable.LayerDrawable; 22 import android.text.TextUtils; 23 import android.util.DisplayMetrics; 24 import android.view.Gravity; 25 import android.view.LayoutInflater; 26 import android.view.View; 27 import android.view.ViewGroup; 28 import android.view.WindowManager; 29 import android.widget.TextView; 30 31 import androidx.annotation.NonNull; 32 import androidx.recyclerview.widget.GridLayoutManager; 33 import androidx.recyclerview.widget.LinearLayoutManager; 34 import androidx.recyclerview.widget.RecyclerView; 35 36 import com.android.customization.model.CustomizationManager; 37 import com.android.customization.model.CustomizationOption; 38 import com.android.wallpaper.R; 39 40 import java.util.HashSet; 41 import java.util.List; 42 import java.util.Set; 43 44 /** 45 * Simple controller for a RecyclerView-based widget to hold the options for each customization 46 * section (eg, thumbnails for themes, clocks, grid sizes). 47 * To use, just pass the RV that will contain the tiles and the list of {@link CustomizationOption} 48 * representing each option, and call {@link #initOptions(CustomizationManager)} to populate the 49 * widget. 50 */ 51 public class OptionSelectorController<T extends CustomizationOption<T>> { 52 53 /** 54 * Interface to be notified when an option is selected by the user. 55 */ 56 public interface OptionSelectedListener { 57 58 /** 59 * Called when an option has been selected (and marked as such in the UI) 60 */ onOptionSelected(CustomizationOption selected)61 void onOptionSelected(CustomizationOption selected); 62 } 63 64 private final RecyclerView mContainer; 65 private final List<T> mOptions; 66 private final boolean mUseGrid; 67 private final boolean mShowCheckmark; 68 69 private final Set<OptionSelectedListener> mListeners = new HashSet<>(); 70 private RecyclerView.Adapter<TileViewHolder> mAdapter; 71 private CustomizationOption mSelectedOption; 72 private CustomizationOption mAppliedOption; 73 OptionSelectorController(RecyclerView container, List<T> options)74 public OptionSelectorController(RecyclerView container, List<T> options) { 75 this(container, options, false, true); 76 } 77 OptionSelectorController(RecyclerView container, List<T> options, boolean useGrid, boolean showCheckmark)78 public OptionSelectorController(RecyclerView container, List<T> options, 79 boolean useGrid, boolean showCheckmark) { 80 mContainer = container; 81 mOptions = options; 82 mUseGrid = container.getResources().getBoolean(R.bool.use_grid_for_options) || useGrid; 83 mShowCheckmark = showCheckmark; 84 } 85 addListener(OptionSelectedListener listener)86 public void addListener(OptionSelectedListener listener) { 87 mListeners.add(listener); 88 } 89 removeListener(OptionSelectedListener listener)90 public void removeListener(OptionSelectedListener listener) { 91 mListeners.remove(listener); 92 } 93 setSelectedOption(CustomizationOption option)94 public void setSelectedOption(CustomizationOption option) { 95 if (!mOptions.contains(option)) { 96 throw new IllegalArgumentException("Invalid option"); 97 } 98 updateActivatedStatus(mSelectedOption, false); 99 updateActivatedStatus(option, true); 100 mSelectedOption = option; 101 notifyListeners(); 102 } 103 104 /** 105 * Mark an option as the one which is currently applied on the device. This will result in a 106 * check being displayed in the lower-right corner of the corresponding ViewHolder. 107 * @param option 108 */ setAppliedOption(CustomizationOption option)109 public void setAppliedOption(CustomizationOption option) { 110 if (!mOptions.contains(option)) { 111 throw new IllegalArgumentException("Invalid option"); 112 } 113 CustomizationOption lastAppliedOption = mAppliedOption; 114 mAppliedOption = option; 115 mAdapter.notifyItemChanged(mOptions.indexOf(option)); 116 if (lastAppliedOption != null) { 117 mAdapter.notifyItemChanged(mOptions.indexOf(lastAppliedOption)); 118 } 119 } 120 updateActivatedStatus(CustomizationOption option, boolean isActivated)121 private void updateActivatedStatus(CustomizationOption option, boolean isActivated) { 122 int index = mOptions.indexOf(option); 123 if (index < 0) { 124 return; 125 } 126 RecyclerView.ViewHolder holder = mContainer.findViewHolderForAdapterPosition(index); 127 if (holder != null && holder.itemView != null) { 128 holder.itemView.setActivated(isActivated); 129 130 if (holder instanceof TileViewHolder) { 131 TileViewHolder tileHolder = (TileViewHolder) holder; 132 if (isActivated) { 133 if (option == mAppliedOption && mShowCheckmark) { 134 tileHolder.setContentDescription(mContainer.getContext(), option, 135 R.string.option_applied_previewed_description); 136 } else { 137 tileHolder.setContentDescription(mContainer.getContext(), option, 138 R.string.option_previewed_description); 139 } 140 } else if (option == mAppliedOption && mShowCheckmark) { 141 tileHolder.setContentDescription(mContainer.getContext(), option, 142 R.string.option_applied_description); 143 } else { 144 tileHolder.resetContentDescription(); 145 } 146 } 147 } else { 148 // Item is not visible, make sure the item is re-bound when it becomes visible 149 mAdapter.notifyItemChanged(index); 150 } 151 } 152 153 /** 154 * Initializes the UI for the options passed in the constructor of this class. 155 */ initOptions(final CustomizationManager<T> manager)156 public void initOptions(final CustomizationManager<T> manager) { 157 mAdapter = new RecyclerView.Adapter<TileViewHolder>() { 158 @Override 159 public int getItemViewType(int position) { 160 return mOptions.get(position).getLayoutResId(); 161 } 162 163 @NonNull 164 @Override 165 public TileViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { 166 View v = LayoutInflater.from(parent.getContext()).inflate(viewType, parent, false); 167 return new TileViewHolder(v); 168 } 169 170 @Override 171 public void onBindViewHolder(@NonNull TileViewHolder holder, int position) { 172 CustomizationOption option = mOptions.get(position); 173 if (option.isActive(manager)) { 174 mAppliedOption = option; 175 if (mSelectedOption == null) { 176 mSelectedOption = option; 177 } 178 } 179 if (holder.labelView != null) { 180 holder.labelView.setText(option.getTitle()); 181 } 182 option.bindThumbnailTile(holder.tileView); 183 holder.itemView.setActivated(option.equals(mSelectedOption)); 184 holder.itemView.setOnClickListener(view -> setSelectedOption(option)); 185 186 if (mShowCheckmark && option.equals(mAppliedOption)) { 187 Resources res = mContainer.getContext().getResources(); 188 Drawable checkmark = res.getDrawable(R.drawable.ic_check_circle_filled_24px); 189 Drawable frame = holder.tileView.getForeground(); 190 Drawable[] layers = {frame, checkmark}; 191 if (frame == null) { 192 layers = new Drawable[]{checkmark}; 193 } 194 LayerDrawable checkedFrame = new LayerDrawable(layers); 195 196 // Position at lower right 197 int idx = layers.length - 1; 198 int checkSize = (int) res.getDimension(R.dimen.check_size); 199 int checkOffset = (int) res.getDimensionPixelOffset(R.dimen.check_offset); 200 checkedFrame.setLayerGravity(idx, Gravity.BOTTOM | Gravity.RIGHT); 201 checkedFrame.setLayerWidth(idx, checkSize); 202 checkedFrame.setLayerHeight(idx, checkSize); 203 checkedFrame.setLayerInsetBottom(idx, checkOffset); 204 checkedFrame.setLayerInsetRight(idx, checkOffset); 205 holder.tileView.setForeground(checkedFrame); 206 207 // Initialize the currently applied option 208 holder.setContentDescription(mContainer.getContext(), option, 209 R.string.option_applied_previewed_description); 210 } else if (option.equals(mAppliedOption)) { 211 // Initialize with "previewed" description if we don't show checkmark 212 holder.setContentDescription(mContainer.getContext(), option, 213 R.string.option_previewed_description); 214 } else if (mShowCheckmark) { 215 holder.tileView.setForeground(null); 216 } 217 } 218 219 @Override 220 public int getItemCount() { 221 return mOptions.size(); 222 } 223 }; 224 225 mContainer.setLayoutManager(new LinearLayoutManager(mContainer.getContext(), 226 LinearLayoutManager.HORIZONTAL, false)); 227 Resources res = mContainer.getContext().getResources(); 228 mContainer.setAdapter(mAdapter); 229 230 // Measure RecyclerView to get to the total amount of space used by all options. 231 mContainer.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED); 232 int fixWidth = res.getDimensionPixelSize(R.dimen.options_container_width); 233 int availableWidth; 234 if (fixWidth == 0) { 235 DisplayMetrics metrics = new DisplayMetrics(); 236 mContainer.getContext().getSystemService(WindowManager.class) 237 .getDefaultDisplay().getMetrics(metrics); 238 availableWidth = metrics.widthPixels; 239 } else { 240 availableWidth = fixWidth; 241 } 242 int totalWidth = mContainer.getMeasuredWidth(); 243 244 if (mUseGrid) { 245 int numColumns = res.getInteger(R.integer.options_grid_num_columns); 246 int widthPerItem = totalWidth / mAdapter.getItemCount(); 247 int extraSpace = availableWidth - widthPerItem * numColumns; 248 int containerSidePadding = extraSpace / (numColumns + 1); 249 mContainer.setLayoutManager(new GridLayoutManager(mContainer.getContext(), numColumns)); 250 mContainer.setPaddingRelative(containerSidePadding, 0, containerSidePadding, 0); 251 mContainer.setOverScrollMode(View.OVER_SCROLL_NEVER); 252 return; 253 } 254 255 int extraSpace = availableWidth - totalWidth; 256 if (extraSpace >= 0) { 257 mContainer.setOverScrollMode(View.OVER_SCROLL_NEVER); 258 } 259 int itemSideMargin = res.getDimensionPixelOffset(R.dimen.option_tile_margin_horizontal); 260 int defaultTotalPadding = itemSideMargin * (mAdapter.getItemCount() * 2 + 2); 261 if (extraSpace > defaultTotalPadding) { 262 int spaceBetweenItems = extraSpace / (mAdapter.getItemCount() + 1); 263 itemSideMargin = spaceBetweenItems / 2; 264 } 265 mContainer.addItemDecoration(new HorizontalSpacerItemDecoration(itemSideMargin)); 266 } 267 resetOptions(List<T> options)268 public void resetOptions(List<T> options) { 269 mOptions.clear(); 270 mOptions.addAll(options); 271 mAdapter.notifyDataSetChanged(); 272 } 273 notifyListeners()274 private void notifyListeners() { 275 if (mListeners.isEmpty()) { 276 return; 277 } 278 CustomizationOption option = mSelectedOption; 279 Set<OptionSelectedListener> iterableListeners = new HashSet<>(mListeners); 280 for (OptionSelectedListener listener : iterableListeners) { 281 listener.onOptionSelected(option); 282 } 283 } 284 285 private static class TileViewHolder extends RecyclerView.ViewHolder { 286 TextView labelView; 287 View tileView; 288 CharSequence title; 289 TileViewHolder(@onNull View itemView)290 TileViewHolder(@NonNull View itemView) { 291 super(itemView); 292 labelView = itemView.findViewById(R.id.option_label); 293 tileView = itemView.findViewById(R.id.option_tile); 294 title = null; 295 } 296 297 /** 298 * Set the content description for this holder using the given string id. 299 * If the option does not have a label, the description will be set on the tile view. 300 * @param context The view's context 301 * @param option The customization option 302 * @param id Resource ID of the string to use for the content description 303 */ setContentDescription(Context context, CustomizationOption option, int id)304 public void setContentDescription(Context context, CustomizationOption option, int id) { 305 title = option.getTitle(); 306 if (TextUtils.isEmpty(title) && tileView != null) { 307 title = tileView.getContentDescription(); 308 } 309 310 CharSequence cd = context.getString(id, title); 311 if (labelView != null && !TextUtils.isEmpty(labelView.getText())) { 312 labelView.setContentDescription(cd); 313 } else if (tileView != null) { 314 tileView.setAccessibilityPaneTitle(cd); 315 tileView.setContentDescription(cd); 316 } 317 } 318 resetContentDescription()319 public void resetContentDescription() { 320 if (labelView != null && !TextUtils.isEmpty(labelView.getText())) { 321 labelView.setContentDescription(title); 322 } else if (tileView != null) { 323 tileView.setAccessibilityPaneTitle(title); 324 tileView.setContentDescription(title); 325 } 326 } 327 } 328 } 329