blob: 805a78459ee1596b1009950175c9600d54c55b2b [file] [log] [blame]
/*
* Copyright (C) 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.wallpaper.widget;
import static com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_COLLAPSED;
import static com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED;
import android.app.Activity;
import android.content.Context;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.android.wallpaper.R;
import com.android.wallpaper.util.SizeCalculator;
import com.google.android.material.bottomsheet.BottomSheetBehavior;
import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback;
import java.util.ArrayDeque;
import java.util.Arrays;
import java.util.Deque;
import java.util.EnumMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
/** A {@code ViewGroup} which provides the specific actions for the user to interact with. */
public class BottomActionBar extends FrameLayout {
/**
* Interface to be implemented by an Activity hosting a {@link BottomActionBar}
*/
public interface BottomActionBarHost {
/** Gets {@link BottomActionBar}. */
BottomActionBar getBottomActionBar();
}
/**
* The listener for {@link BottomActionBar} visibility change notification.
*/
public interface VisibilityChangeListener {
/**
* Called when {@link BottomActionBar} visibility changes.
*
* @param isVisible {@code true} if it's visible; {@code false} otherwise.
*/
void onVisibilityChange(boolean isVisible);
}
/** This listens to changes to an action view's selected state. */
public interface OnActionSelectedListener {
/**
* This is called when an action view's selected state changes.
* @param selected whether the action view is selected.
*/
void onActionSelected(boolean selected);
}
// TODO(b/154299462): Separate downloadable related actions from WallpaperPicker.
/** The action items in the bottom action bar. */
public enum BottomAction {
ROTATION, DELETE, INFORMATION, EDIT, CUSTOMIZE, DOWNLOAD, PROGRESS, APPLY
}
private final Map<BottomAction, View> mActionMap = new EnumMap<>(BottomAction.class);
private final Map<BottomAction, View> mContentViewMap = new EnumMap<>(BottomAction.class);
private final Map<BottomAction, OnActionSelectedListener> mActionSelectedListeners =
new EnumMap<>(BottomAction.class);
private final ViewGroup mBottomSheetView;
private final QueueStateBottomSheetBehavior<ViewGroup> mBottomSheetBehavior;
private final Set<VisibilityChangeListener> mVisibilityChangeListeners = new HashSet<>();
// The current selected action in the BottomActionBar, can be null when no action is selected.
@Nullable private BottomAction mSelectedAction;
public BottomActionBar(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
LayoutInflater.from(context).inflate(R.layout.bottom_actions_layout, this, true);
mActionMap.put(BottomAction.ROTATION, findViewById(R.id.action_rotation));
mActionMap.put(BottomAction.DELETE, findViewById(R.id.action_delete));
mActionMap.put(BottomAction.INFORMATION, findViewById(R.id.action_information));
mActionMap.put(BottomAction.EDIT, findViewById(R.id.action_edit));
mActionMap.put(BottomAction.CUSTOMIZE, findViewById(R.id.action_customize));
mActionMap.put(BottomAction.DOWNLOAD, findViewById(R.id.action_download));
mActionMap.put(BottomAction.PROGRESS, findViewById(R.id.action_progress));
mActionMap.put(BottomAction.APPLY, findViewById(R.id.action_apply));
mBottomSheetView = findViewById(R.id.action_bottom_sheet);
SizeCalculator.adjustBackgroundCornerRadius(mBottomSheetView);
mBottomSheetBehavior = (QueueStateBottomSheetBehavior<ViewGroup>) BottomSheetBehavior.from(
mBottomSheetView);
mBottomSheetBehavior.setState(STATE_COLLAPSED);
mBottomSheetBehavior.setBottomSheetCallback(new BottomSheetCallback() {
@Override
public void onStateChanged(@NonNull View bottomSheet, int newState) {
if (mBottomSheetBehavior.isQueueProcessing()) {
// Avoid button and bottom sheet mismatching from quick tapping buttons when
// bottom sheet is changing state.
disableActions();
// If bottom sheet is going with expanded-collapsed-expanded, the new content
// will be updated in collapsed state. The first state change from expanded to
// collapsed should still show the previous content view.
if (mSelectedAction != null && newState == STATE_COLLAPSED) {
updateContentViewFor(mSelectedAction);
}
return;
}
// Enable all buttons when queue is not processing.
enableActions();
if (!isExpandable(mSelectedAction)) {
return;
}
// Ensure the button state is the same as bottom sheet state to catch up the state
// change from dragging or some unexpected bottom sheet state changes.
if (newState == STATE_COLLAPSED) {
updateSelectedState(mSelectedAction, /* selected= */ false);
} else if (newState == STATE_EXPANDED) {
updateSelectedState(mSelectedAction, /* selected= */ true);
}
}
@Override
public void onSlide(@NonNull View bottomSheet, float slideOffset) { }
});
setOnApplyWindowInsetsListener((v, windowInsets) -> {
v.setPadding(v.getPaddingLeft(), v.getPaddingTop(), v.getPaddingRight(),
windowInsets.getSystemWindowInsetBottom());
return windowInsets;
});
}
@Override
public void onVisibilityAggregated(boolean isVisible) {
super.onVisibilityAggregated(isVisible);
if (!isVisible) {
hideBottomSheetAndDeselectButtonIfExpanded();
mBottomSheetBehavior.reset();
}
mVisibilityChangeListeners.forEach(listener -> listener.onVisibilityChange(isVisible));
}
/**
* Adds content view to the bottom sheet and binds with a {@code BottomAction} to
* expand / collapse the bottom sheet.
*
* @param contentView the view with content to be added on the bottom sheet
* @param action the action to be bound to expand / collapse the bottom sheet
*/
public void attachViewToBottomSheetAndBindAction(View contentView, BottomAction action) {
contentView.setVisibility(GONE);
contentView.setFocusable(true);
mContentViewMap.put(action, contentView);
mBottomSheetView.addView(contentView);
setActionClickListener(action, actionView -> {
if (mBottomSheetBehavior.getState() == STATE_COLLAPSED) {
updateContentViewFor(action);
}
mBottomSheetView.setAccessibilityTraversalAfter(actionView.getId());
});
}
/** Collapses the bottom sheet. */
public void collapseBottomSheetIfExpanded() {
hideBottomSheetAndDeselectButtonIfExpanded();
}
/**
* Sets a click listener to a specific action.
*
* @param bottomAction the specific action
* @param actionClickListener the click listener for the action
*/
public void setActionClickListener(
BottomAction bottomAction, OnClickListener actionClickListener) {
View buttonView = mActionMap.get(bottomAction);
if (buttonView.hasOnClickListeners()) {
throw new IllegalStateException(
"Had already set a click listener to button: " + bottomAction);
}
buttonView.setOnClickListener(view -> {
if (mSelectedAction != null && isActionSelected(mSelectedAction)) {
updateSelectedState(mSelectedAction, /* selected= */ false);
if (isExpandable(mSelectedAction)) {
mBottomSheetBehavior.enqueue(STATE_COLLAPSED);
}
} else {
// Error handling, set to null if the action is not selected.
mSelectedAction = null;
}
if (bottomAction == mSelectedAction) {
// Deselect the selected action.
mSelectedAction = null;
} else {
// Select a different action from the current selected action.
mSelectedAction = bottomAction;
updateSelectedState(mSelectedAction, /* selected= */ true);
if (isExpandable(mSelectedAction)) {
mBottomSheetBehavior.enqueue(STATE_EXPANDED);
}
}
actionClickListener.onClick(view);
mBottomSheetBehavior.processQueueForStateChange();
});
}
/**
* Sets a selected listener to a specific action. This is triggered each time the bottom
* action's selected state changes.
*
* @param bottomAction the specific action
* @param actionSelectedListener the selected listener for the action
*/
public void setActionSelectedListener(
BottomAction bottomAction, OnActionSelectedListener actionSelectedListener) {
if (mActionSelectedListeners.containsKey(bottomAction)) {
throw new IllegalStateException(
"Had already set a selected listener to button: " + bottomAction);
}
mActionSelectedListeners.put(bottomAction, actionSelectedListener);
}
/** Binds the cancel button to back key. */
public void bindBackButtonToSystemBackKey(Activity activity) {
findViewById(R.id.action_back).setOnClickListener(v -> activity.onBackPressed());
}
/** Returns {@code true} if visible. */
public boolean isVisible() {
return getVisibility() == VISIBLE;
}
/** Shows {@link BottomActionBar}. */
public void show() {
setVisibility(VISIBLE);
}
/** Hides {@link BottomActionBar}. */
public void hide() {
setVisibility(GONE);
}
/**
* Adds the visibility change listener.
*
* @param visibilityChangeListener the listener to be notified.
*/
public void addVisibilityChangeListener(VisibilityChangeListener visibilityChangeListener) {
if (visibilityChangeListener == null) {
return;
}
mVisibilityChangeListeners.add(visibilityChangeListener);
visibilityChangeListener.onVisibilityChange(isVisible());
}
/**
* Shows the specific actions.
*
* @param actions the specific actions
*/
public void showActions(BottomAction... actions) {
for (BottomAction action : actions) {
mActionMap.get(action).setVisibility(VISIBLE);
}
}
/**
* Hides the specific actions.
*
* @param actions the specific actions
*/
public void hideActions(BottomAction... actions) {
for (BottomAction action : actions) {
mActionMap.get(action).setVisibility(GONE);
if (isExpandable(action) && mSelectedAction == action) {
hideBottomSheetAndDeselectButtonIfExpanded();
}
}
}
/**
* Shows the specific actions only. In other words, the other actions will be hidden.
*
* @param actions the specific actions which will be shown. Others will be hidden.
*/
public void showActionsOnly(BottomAction... actions) {
final Set<BottomAction> actionsSet = new HashSet<>(Arrays.asList(actions));
mActionMap.keySet().forEach(action -> {
if (actionsSet.contains(action)) {
showActions(action);
} else {
hideActions(action);
}
});
}
/**
* Checks if the specific actions are shown.
*
* @param actions the specific actions to be verified
* @return {@code true} if the actions are shown; {@code false} otherwise
*/
public boolean areActionsShown(BottomAction... actions) {
final Set<BottomAction> actionsSet = new HashSet<>(Arrays.asList(actions));
return actionsSet.stream().allMatch(bottomAction -> {
View view = mActionMap.get(bottomAction);
return view != null && view.getVisibility() == VISIBLE;
});
}
/**
* All actions will be hidden.
*/
public void hideAllActions() {
showActionsOnly(/* No actions to show */);
}
/** Enables all the actions' {@link View}. */
public void enableActions() {
enableActions(BottomAction.values());
}
/** Disables all the actions' {@link View}. */
public void disableActions() {
disableActions(BottomAction.values());
}
/**
* Enables specified actions' {@link View}.
*
* @param actions the specified actions to enable their views
*/
public void enableActions(BottomAction... actions) {
for (BottomAction action : actions) {
mActionMap.get(action).setEnabled(true);
}
}
/**
* Disables specified actions' {@link View}.
*
* @param actions the specified actions to disable their views
*/
public void disableActions(BottomAction... actions) {
for (BottomAction action : actions) {
mActionMap.get(action).setEnabled(false);
}
}
/** Sets a default selected action button. */
public void setDefaultSelectedButton(BottomAction action) {
if (mSelectedAction == null) {
mSelectedAction = action;
updateSelectedState(mSelectedAction, /* selected= */ true);
}
}
/** Deselects an action button. */
public void deselectAction(BottomAction action) {
if (isExpandable(action)) {
mBottomSheetBehavior.setState(STATE_COLLAPSED);
}
updateSelectedState(action, /* selected= */ false);
if (action == mSelectedAction) {
mSelectedAction = null;
}
}
public boolean isActionSelected(BottomAction action) {
return mActionMap.get(action).isSelected();
}
/** Resets {@link BottomActionBar} to initial state. */
public void reset() {
// Not visible by default, see res/layout/bottom_action_bar.xml
hide();
// All actions are hide and enabled by default, see res/layout/bottom_action_bar.xml
hideAllActions();
enableActions();
// Clears all the actions' click listeners
mActionMap.values().forEach(v -> v.setOnClickListener(null));
findViewById(R.id.action_back).setOnClickListener(null);
// Deselect all buttons.
mActionMap.keySet().forEach(a -> updateSelectedState(a, /* selected= */ false));
// Clear values.
mContentViewMap.clear();
mActionSelectedListeners.clear();
mBottomSheetView.removeAllViews();
mBottomSheetBehavior.reset();
mSelectedAction = null;
}
private void updateSelectedState(BottomAction bottomAction, boolean selected) {
View bottomActionView = mActionMap.get(bottomAction);
if (bottomActionView.isSelected() == selected) {
return;
}
OnActionSelectedListener listener = mActionSelectedListeners.get(bottomAction);
if (listener != null) {
listener.onActionSelected(selected);
}
bottomActionView.setSelected(selected);
}
private void hideBottomSheetAndDeselectButtonIfExpanded() {
if (isExpandable(mSelectedAction) && mBottomSheetBehavior.getState() == STATE_EXPANDED) {
mBottomSheetBehavior.setState(STATE_COLLAPSED);
updateSelectedState(mSelectedAction, /* selected= */ false);
mSelectedAction = null;
}
}
private void updateContentViewFor(BottomAction action) {
mContentViewMap.forEach((a, v) -> v.setVisibility(a.equals(action) ? VISIBLE : GONE));
}
private boolean isExpandable(BottomAction action) {
return action != null && mContentViewMap.containsKey(action);
}
/** A {@link BottomSheetBehavior} that can process a queue of bottom sheet states.*/
public static class QueueStateBottomSheetBehavior<V extends View>
extends BottomSheetBehavior<V> {
private final Deque<Integer> mStateQueue = new ArrayDeque<>();
private boolean mIsQueueProcessing;
public QueueStateBottomSheetBehavior(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
// Binds the default callback for processing queue.
setBottomSheetCallback(null);
}
/** Enqueues the bottom sheet states. */
public void enqueue(int state) {
if (!mStateQueue.isEmpty() && mStateQueue.getLast() == state) {
return;
}
mStateQueue.add(state);
}
/** Processes the queue of bottom sheet state that was set via {@link #enqueue}. */
public void processQueueForStateChange() {
if (mStateQueue.isEmpty()) {
return;
}
setState(mStateQueue.getFirst());
mIsQueueProcessing = true;
}
/**
* Returns {@code true} if the queue is processing. For example, if the bottom sheet is
* going with expanded-collapsed-expanded, it would return {@code true} until last expanded
* state is finished.
*/
public boolean isQueueProcessing() {
return mIsQueueProcessing;
}
/** Resets the queue state. */
public void reset() {
mStateQueue.clear();
mIsQueueProcessing = false;
}
@Override
public void setBottomSheetCallback(BottomSheetCallback callback) {
super.setBottomSheetCallback(new BottomSheetCallback() {
@Override
public void onStateChanged(@NonNull View bottomSheet, int newState) {
if (!mStateQueue.isEmpty()) {
if (newState == mStateQueue.getFirst()) {
mStateQueue.removeFirst();
if (mStateQueue.isEmpty()) {
mIsQueueProcessing = false;
} else {
setState(mStateQueue.getFirst());
}
} else {
setState(mStateQueue.getFirst());
}
}
if (callback != null) {
callback.onStateChanged(bottomSheet, newState);
}
}
@Override
public void onSlide(@NonNull View bottomSheet, float slideOffset) {
if (callback != null) {
callback.onSlide(bottomSheet, slideOffset);
}
}
});
}
}
}