• 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.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