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