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