• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2020 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.wallpaper.widget;
17 
18 import static com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_COLLAPSED;
19 import static com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED;
20 
21 import android.app.Activity;
22 import android.content.Context;
23 import android.util.AttributeSet;
24 import android.view.LayoutInflater;
25 import android.view.View;
26 import android.view.ViewGroup;
27 import android.widget.FrameLayout;
28 
29 import androidx.annotation.NonNull;
30 import androidx.annotation.Nullable;
31 
32 import com.android.wallpaper.R;
33 import com.android.wallpaper.util.SizeCalculator;
34 
35 import com.google.android.material.bottomsheet.BottomSheetBehavior;
36 import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback;
37 
38 import java.util.ArrayDeque;
39 import java.util.Arrays;
40 import java.util.Deque;
41 import java.util.EnumMap;
42 import java.util.HashSet;
43 import java.util.Map;
44 import java.util.Set;
45 
46 /** A {@code ViewGroup} which provides the specific actions for the user to interact with. */
47 public class BottomActionBar extends FrameLayout {
48 
49     /**
50      * Interface to be implemented by an Activity hosting a {@link BottomActionBar}
51      */
52     public interface BottomActionBarHost {
53         /** Gets {@link BottomActionBar}. */
getBottomActionBar()54         BottomActionBar getBottomActionBar();
55     }
56 
57     /**
58      * The listener for {@link BottomActionBar} visibility change notification.
59      */
60     public interface VisibilityChangeListener {
61         /**
62          * Called when {@link BottomActionBar} visibility changes.
63          *
64          * @param isVisible {@code true} if it's visible; {@code false} otherwise.
65          */
onVisibilityChange(boolean isVisible)66         void onVisibilityChange(boolean isVisible);
67     }
68 
69     /** This listens to changes to an action view's selected state. */
70     public interface OnActionSelectedListener {
71 
72         /**
73          * This is called when an action view's selected state changes.
74          * @param selected whether the action view is selected.
75          */
onActionSelected(boolean selected)76         void onActionSelected(boolean selected);
77     }
78 
79     /**
80      *  A Callback to notify the registrant to change it's accessibility param when
81      *  {@link BottomActionBar} state changes.
82      */
83     public interface AccessibilityCallback {
84         /**
85          * Called when {@link BottomActionBar} collapsed.
86          */
onBottomSheetCollapsed()87         void onBottomSheetCollapsed();
88 
89         /**
90          * Called when {@link BottomActionBar} expanded.
91          */
onBottomSheetExpanded()92         void onBottomSheetExpanded();
93     }
94 
95     // TODO(b/154299462): Separate downloadable related actions from WallpaperPicker.
96     /** The action items in the bottom action bar. */
97     public enum BottomAction {
98         ROTATION, DELETE, INFORMATION, EDIT, CUSTOMIZE, DOWNLOAD, PROGRESS, APPLY
99     }
100 
101     private final Map<BottomAction, View> mActionMap = new EnumMap<>(BottomAction.class);
102     private final Map<BottomAction, View> mContentViewMap = new EnumMap<>(BottomAction.class);
103     private final Map<BottomAction, OnActionSelectedListener> mActionSelectedListeners =
104             new EnumMap<>(BottomAction.class);
105 
106     private final ViewGroup mBottomSheetView;
107     private final QueueStateBottomSheetBehavior<ViewGroup> mBottomSheetBehavior;
108     private final Set<VisibilityChangeListener> mVisibilityChangeListeners = new HashSet<>();
109 
110     // The current selected action in the BottomActionBar, can be null when no action is selected.
111     @Nullable private BottomAction mSelectedAction;
112     @Nullable private AccessibilityCallback mAccessibilityCallback;
113 
BottomActionBar(@onNull Context context, @Nullable AttributeSet attrs)114     public BottomActionBar(@NonNull Context context, @Nullable AttributeSet attrs) {
115         super(context, attrs);
116         LayoutInflater.from(context).inflate(R.layout.bottom_actions_layout, this, true);
117 
118         mActionMap.put(BottomAction.ROTATION, findViewById(R.id.action_rotation));
119         mActionMap.put(BottomAction.DELETE, findViewById(R.id.action_delete));
120         mActionMap.put(BottomAction.INFORMATION, findViewById(R.id.action_information));
121         mActionMap.put(BottomAction.EDIT, findViewById(R.id.action_edit));
122         mActionMap.put(BottomAction.CUSTOMIZE, findViewById(R.id.action_customize));
123         mActionMap.put(BottomAction.DOWNLOAD, findViewById(R.id.action_download));
124         mActionMap.put(BottomAction.PROGRESS, findViewById(R.id.action_progress));
125         mActionMap.put(BottomAction.APPLY, findViewById(R.id.action_apply));
126 
127         mBottomSheetView = findViewById(R.id.action_bottom_sheet);
128         SizeCalculator.adjustBackgroundCornerRadius(mBottomSheetView);
129 
130         mBottomSheetBehavior = (QueueStateBottomSheetBehavior<ViewGroup>) BottomSheetBehavior.from(
131                 mBottomSheetView);
132         mBottomSheetBehavior.setState(STATE_COLLAPSED);
133         mBottomSheetBehavior.setBottomSheetCallback(new BottomSheetCallback() {
134             @Override
135             public void onStateChanged(@NonNull View bottomSheet, int newState) {
136                 if (mBottomSheetBehavior.isQueueProcessing()) {
137                     // Avoid button and bottom sheet mismatching from quick tapping buttons when
138                     // bottom sheet is changing state.
139                     disableActions();
140                     // If bottom sheet is going with expanded-collapsed-expanded, the new content
141                     // will be updated in collapsed state. The first state change from expanded to
142                     // collapsed should still show the previous content view.
143                     if (mSelectedAction != null && newState == STATE_COLLAPSED) {
144                         updateContentViewFor(mSelectedAction);
145                     }
146                     return;
147                 }
148 
149                 notifyAccessibilityCallback(newState);
150 
151                 // Enable all buttons when queue is not processing.
152                 enableActions();
153                 if (!isExpandable(mSelectedAction)) {
154                     return;
155                 }
156                 // Ensure the button state is the same as bottom sheet state to catch up the state
157                 // change from dragging or some unexpected bottom sheet state changes.
158                 if (newState == STATE_COLLAPSED) {
159                     updateSelectedState(mSelectedAction, /* selected= */ false);
160                 } else if (newState == STATE_EXPANDED) {
161                     updateSelectedState(mSelectedAction, /* selected= */ true);
162                 }
163             }
164             @Override
165             public void onSlide(@NonNull View bottomSheet, float slideOffset) { }
166         });
167 
168         setOnApplyWindowInsetsListener((v, windowInsets) -> {
169             v.setPadding(v.getPaddingLeft(), v.getPaddingTop(), v.getPaddingRight(),
170                     windowInsets.getSystemWindowInsetBottom());
171             return windowInsets;
172         });
173     }
174 
175     @Override
onVisibilityAggregated(boolean isVisible)176     public void onVisibilityAggregated(boolean isVisible) {
177         super.onVisibilityAggregated(isVisible);
178         if (!isVisible) {
179             hideBottomSheetAndDeselectButtonIfExpanded();
180             mBottomSheetBehavior.reset();
181         }
182         mVisibilityChangeListeners.forEach(listener -> listener.onVisibilityChange(isVisible));
183     }
184 
185     /**
186      * Adds content view to the bottom sheet and binds with a {@code BottomAction} to
187      * expand / collapse the bottom sheet.
188      *
189      * @param contentView the view with content to be added on the bottom sheet
190      * @param action the action to be bound to expand / collapse the bottom sheet
191      */
attachViewToBottomSheetAndBindAction(View contentView, BottomAction action)192     public void attachViewToBottomSheetAndBindAction(View contentView, BottomAction action) {
193         contentView.setVisibility(GONE);
194         contentView.setFocusable(true);
195         mContentViewMap.put(action, contentView);
196         mBottomSheetView.addView(contentView);
197         setActionClickListener(action, actionView -> {
198             if (mBottomSheetBehavior.getState() == STATE_COLLAPSED) {
199                 updateContentViewFor(action);
200             }
201             mBottomSheetView.setAccessibilityTraversalAfter(actionView.getId());
202         });
203     }
204 
205     /** Collapses the bottom sheet. */
collapseBottomSheetIfExpanded()206     public void collapseBottomSheetIfExpanded() {
207         hideBottomSheetAndDeselectButtonIfExpanded();
208     }
209 
210     /**
211      * Sets a click listener to a specific action.
212      *
213      * @param bottomAction the specific action
214      * @param actionClickListener the click listener for the action
215      */
setActionClickListener( BottomAction bottomAction, OnClickListener actionClickListener)216     public void setActionClickListener(
217             BottomAction bottomAction, OnClickListener actionClickListener) {
218         View buttonView = mActionMap.get(bottomAction);
219         if (buttonView.hasOnClickListeners()) {
220             throw new IllegalStateException(
221                     "Had already set a click listener to button: " + bottomAction);
222         }
223         buttonView.setOnClickListener(view -> {
224             if (mSelectedAction != null && isActionSelected(mSelectedAction)) {
225                 updateSelectedState(mSelectedAction, /* selected= */ false);
226                 if (isExpandable(mSelectedAction)) {
227                     mBottomSheetBehavior.enqueue(STATE_COLLAPSED);
228                 }
229             } else {
230                 // Error handling, set to null if the action is not selected.
231                 mSelectedAction = null;
232             }
233 
234             if (bottomAction == mSelectedAction) {
235                 // Deselect the selected action.
236                 mSelectedAction = null;
237             } else {
238                 // Select a different action from the current selected action.
239                 mSelectedAction = bottomAction;
240                 updateSelectedState(mSelectedAction, /* selected= */ true);
241                 if (isExpandable(mSelectedAction)) {
242                     mBottomSheetBehavior.enqueue(STATE_EXPANDED);
243                 }
244             }
245             actionClickListener.onClick(view);
246             mBottomSheetBehavior.processQueueForStateChange();
247         });
248     }
249 
250     /**
251      * Sets a selected listener to a specific action. This is triggered each time the bottom
252      * action's selected state changes.
253      *
254      * @param bottomAction the specific action
255      * @param actionSelectedListener the selected listener for the action
256      */
setActionSelectedListener( BottomAction bottomAction, OnActionSelectedListener actionSelectedListener)257     public void setActionSelectedListener(
258             BottomAction bottomAction, OnActionSelectedListener actionSelectedListener) {
259         if (mActionSelectedListeners.containsKey(bottomAction)) {
260             throw new IllegalStateException(
261                     "Had already set a selected listener to button: " + bottomAction);
262         }
263         mActionSelectedListeners.put(bottomAction, actionSelectedListener);
264     }
265 
266     /** Binds the cancel button to back key. */
bindBackButtonToSystemBackKey(Activity activity)267     public void bindBackButtonToSystemBackKey(Activity activity) {
268         findViewById(R.id.action_back).setOnClickListener(v -> activity.onBackPressed());
269     }
270 
271     /** Returns {@code true} if visible. */
isVisible()272     public boolean isVisible() {
273         return getVisibility() == VISIBLE;
274     }
275 
276     /** Shows {@link BottomActionBar}. */
show()277     public void show() {
278         setVisibility(VISIBLE);
279     }
280 
281     /** Hides {@link BottomActionBar}. */
hide()282     public void hide() {
283         setVisibility(GONE);
284     }
285 
286     /**
287      * Adds the visibility change listener.
288      *
289      * @param visibilityChangeListener the listener to be notified.
290      */
addVisibilityChangeListener(VisibilityChangeListener visibilityChangeListener)291     public void addVisibilityChangeListener(VisibilityChangeListener visibilityChangeListener) {
292         if (visibilityChangeListener == null) {
293             return;
294         }
295         mVisibilityChangeListeners.add(visibilityChangeListener);
296         visibilityChangeListener.onVisibilityChange(isVisible());
297     }
298 
299     /**
300      * Sets a AccessibilityCallback.
301      *
302      * @param accessibilityCallback the callback to be notified.
303      */
setAccessibilityCallback(@ullable AccessibilityCallback accessibilityCallback)304     public void setAccessibilityCallback(@Nullable AccessibilityCallback accessibilityCallback) {
305         mAccessibilityCallback = accessibilityCallback;
306     }
307 
308     /**
309      * Shows the specific actions.
310      *
311      * @param actions the specific actions
312      */
showActions(BottomAction... actions)313     public void showActions(BottomAction... actions) {
314         for (BottomAction action : actions) {
315             mActionMap.get(action).setVisibility(VISIBLE);
316         }
317     }
318 
319     /**
320      * Hides the specific actions.
321      *
322      * @param actions the specific actions
323      */
hideActions(BottomAction... actions)324     public void hideActions(BottomAction... actions) {
325         for (BottomAction action : actions) {
326             mActionMap.get(action).setVisibility(GONE);
327 
328             if (isExpandable(action) && mSelectedAction == action) {
329                 hideBottomSheetAndDeselectButtonIfExpanded();
330             }
331         }
332     }
333 
334     /**
335      * Shows the specific actions only. In other words, the other actions will be hidden.
336      *
337      * @param actions the specific actions which will be shown. Others will be hidden.
338      */
showActionsOnly(BottomAction... actions)339     public void showActionsOnly(BottomAction... actions) {
340         final Set<BottomAction> actionsSet = new HashSet<>(Arrays.asList(actions));
341 
342         mActionMap.keySet().forEach(action -> {
343             if (actionsSet.contains(action)) {
344                 showActions(action);
345             } else {
346                 hideActions(action);
347             }
348         });
349     }
350 
351     /**
352      * Checks if the specific actions are shown.
353      *
354      * @param actions the specific actions to be verified
355      * @return {@code true} if the actions are shown; {@code false} otherwise
356      */
areActionsShown(BottomAction... actions)357     public boolean areActionsShown(BottomAction... actions) {
358         final Set<BottomAction> actionsSet = new HashSet<>(Arrays.asList(actions));
359         return actionsSet.stream().allMatch(bottomAction -> {
360             View view = mActionMap.get(bottomAction);
361             return view != null && view.getVisibility() == VISIBLE;
362         });
363     }
364 
365     /**
366      * All actions will be hidden.
367      */
hideAllActions()368     public void hideAllActions() {
369         showActionsOnly(/* No actions to show */);
370     }
371 
372     /** Enables all the actions' {@link View}. */
enableActions()373     public void enableActions() {
374         enableActions(BottomAction.values());
375     }
376 
377     /** Disables all the actions' {@link View}. */
disableActions()378     public void disableActions() {
379         disableActions(BottomAction.values());
380     }
381 
382     /**
383      * Enables specified actions' {@link View}.
384      *
385      * @param actions the specified actions to enable their views
386      */
enableActions(BottomAction... actions)387     public void enableActions(BottomAction... actions) {
388         for (BottomAction action : actions) {
389             mActionMap.get(action).setEnabled(true);
390         }
391     }
392 
393     /**
394      * Disables specified actions' {@link View}.
395      *
396      * @param actions the specified actions to disable their views
397      */
disableActions(BottomAction... actions)398     public void disableActions(BottomAction... actions) {
399         for (BottomAction action : actions) {
400             mActionMap.get(action).setEnabled(false);
401         }
402     }
403 
404     /** Sets a default selected action button. */
setDefaultSelectedButton(BottomAction action)405     public void setDefaultSelectedButton(BottomAction action) {
406         if (mSelectedAction == null) {
407             mSelectedAction = action;
408             updateSelectedState(mSelectedAction, /* selected= */ true);
409         }
410     }
411 
412     /** Deselects an action button. */
deselectAction(BottomAction action)413     public void deselectAction(BottomAction action) {
414         if (isExpandable(action)) {
415             mBottomSheetBehavior.setState(STATE_COLLAPSED);
416         }
417         updateSelectedState(action, /* selected= */ false);
418         if (action == mSelectedAction) {
419             mSelectedAction = null;
420         }
421     }
422 
isActionSelected(BottomAction action)423     public boolean isActionSelected(BottomAction action) {
424         return mActionMap.get(action).isSelected();
425     }
426 
427     /** Resets {@link BottomActionBar} to initial state. */
reset()428     public void reset() {
429         // Not visible by default, see res/layout/bottom_action_bar.xml
430         hide();
431         // All actions are hide and enabled by default, see res/layout/bottom_action_bar.xml
432         hideAllActions();
433         enableActions();
434         // Clears all the actions' click listeners
435         mActionMap.values().forEach(v -> v.setOnClickListener(null));
436         findViewById(R.id.action_back).setOnClickListener(null);
437         // Deselect all buttons.
438         mActionMap.keySet().forEach(a -> updateSelectedState(a, /* selected= */ false));
439         // Clear values.
440         mContentViewMap.clear();
441         mActionSelectedListeners.clear();
442         mBottomSheetView.removeAllViews();
443         mBottomSheetBehavior.reset();
444         mSelectedAction = null;
445     }
446 
updateSelectedState(BottomAction bottomAction, boolean selected)447     private void updateSelectedState(BottomAction bottomAction, boolean selected) {
448         View bottomActionView = mActionMap.get(bottomAction);
449         if (bottomActionView.isSelected() == selected) {
450             return;
451         }
452 
453         OnActionSelectedListener listener = mActionSelectedListeners.get(bottomAction);
454         if (listener != null) {
455             listener.onActionSelected(selected);
456         }
457         bottomActionView.setSelected(selected);
458     }
459 
hideBottomSheetAndDeselectButtonIfExpanded()460     private void hideBottomSheetAndDeselectButtonIfExpanded() {
461         if (isExpandable(mSelectedAction) && mBottomSheetBehavior.getState() == STATE_EXPANDED) {
462             mBottomSheetBehavior.setState(STATE_COLLAPSED);
463             updateSelectedState(mSelectedAction, /* selected= */ false);
464             mSelectedAction = null;
465         }
466     }
467 
updateContentViewFor(BottomAction action)468     private void updateContentViewFor(BottomAction action) {
469         mContentViewMap.forEach((a, v) -> v.setVisibility(a.equals(action) ? VISIBLE : GONE));
470     }
471 
isExpandable(BottomAction action)472     private boolean isExpandable(BottomAction action) {
473         return action != null && mContentViewMap.containsKey(action);
474     }
475 
notifyAccessibilityCallback(int state)476     private void notifyAccessibilityCallback(int state) {
477         if (mAccessibilityCallback == null) {
478             return;
479         }
480 
481         if (state == STATE_COLLAPSED) {
482             mAccessibilityCallback.onBottomSheetCollapsed();
483         } else if (state == STATE_EXPANDED) {
484             mAccessibilityCallback.onBottomSheetExpanded();
485         }
486     }
487 
488     /** A {@link BottomSheetBehavior} that can process a queue of bottom sheet states.*/
489     public static class QueueStateBottomSheetBehavior<V extends View>
490             extends BottomSheetBehavior<V> {
491 
492         private final Deque<Integer> mStateQueue = new ArrayDeque<>();
493         private boolean mIsQueueProcessing;
494 
QueueStateBottomSheetBehavior(Context context, @Nullable AttributeSet attrs)495         public QueueStateBottomSheetBehavior(Context context, @Nullable AttributeSet attrs) {
496             super(context, attrs);
497             // Binds the default callback for processing queue.
498             setBottomSheetCallback(null);
499         }
500 
501         /** Enqueues the bottom sheet states. */
enqueue(int state)502         public void enqueue(int state) {
503             if (!mStateQueue.isEmpty() && mStateQueue.getLast() == state) {
504                 return;
505             }
506             mStateQueue.add(state);
507         }
508 
509         /** Processes the queue of bottom sheet state that was set via {@link #enqueue}. */
processQueueForStateChange()510         public void processQueueForStateChange() {
511             if (mStateQueue.isEmpty()) {
512                 return;
513             }
514             setState(mStateQueue.getFirst());
515             mIsQueueProcessing = true;
516         }
517 
518         /**
519          * Returns {@code true} if the queue is processing. For example, if the bottom sheet is
520          * going with expanded-collapsed-expanded, it would return {@code true} until last expanded
521          * state is finished.
522          */
isQueueProcessing()523         public boolean isQueueProcessing() {
524             return mIsQueueProcessing;
525         }
526 
527         /** Resets the queue state. */
reset()528         public void reset() {
529             mStateQueue.clear();
530             mIsQueueProcessing = false;
531         }
532 
533         @Override
setBottomSheetCallback(BottomSheetCallback callback)534         public void setBottomSheetCallback(BottomSheetCallback callback) {
535             super.setBottomSheetCallback(new BottomSheetCallback() {
536                 @Override
537                 public void onStateChanged(@NonNull View bottomSheet, int newState) {
538                     if (!mStateQueue.isEmpty()) {
539                         if (newState == mStateQueue.getFirst()) {
540                             mStateQueue.removeFirst();
541                             if (mStateQueue.isEmpty()) {
542                                 mIsQueueProcessing = false;
543                             } else {
544                                 setState(mStateQueue.getFirst());
545                             }
546                         } else {
547                             setState(mStateQueue.getFirst());
548                         }
549                     }
550 
551                     if (callback != null) {
552                         callback.onStateChanged(bottomSheet, newState);
553                     }
554                 }
555 
556                 @Override
557                 public void onSlide(@NonNull View bottomSheet, float slideOffset) {
558                     if (callback != null) {
559                         callback.onSlide(bottomSheet, slideOffset);
560                     }
561                 }
562             });
563         }
564     }
565 }
566