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