blob: b07cbdf3fc2e41f21ed061d0daa158d9702948a4 [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.content.res.ColorStateList;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.accessibility.AccessibilityEvent;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.ProgressBar;
import androidx.annotation.LayoutRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.widget.ImageViewCompat;
import com.android.internal.util.ArrayUtils;
import com.android.wallpaper.R;
import com.android.wallpaper.util.ResourceUtils;
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);
}
/**
* A Callback to notify the registrant to change it's accessibility param when
* {@link BottomActionBar} state changes.
*/
public interface AccessibilityCallback {
/**
* Called when {@link BottomActionBar} collapsed.
*/
void onBottomSheetCollapsed();
/**
* Called when {@link BottomActionBar} expanded.
*/
void onBottomSheetExpanded();
}
/**
* Object to host content view for bottom sheet to display.
*
* <p> The view would be created in the constructor.
*/
public static abstract class BottomSheetContent<T extends View> {
private T mContentView;
private boolean mIsVisible;
public BottomSheetContent(Context context) {
mContentView = createView(context);
setVisibility(false);
}
/** Gets the view id to inflate. */
@LayoutRes
public abstract int getViewId();
/** Gets called when the content view is created. */
public abstract void onViewCreated(T view);
/** Gets called when the current content view is going to recreate. */
public void onRecreateView(T oldView) {}
private void recreateView(Context context) {
// Inform that the view is going to recreate.
onRecreateView(mContentView);
// Create a new view with the given context.
mContentView = createView(context);
setVisibility(mIsVisible);
}
private T createView(Context context) {
T contentView = (T) LayoutInflater.from(context).inflate(getViewId(), null);
onViewCreated(contentView);
contentView.setFocusable(true);
return contentView;
}
protected void setVisibility(boolean isVisible) {
mIsVisible = isVisible;
mContentView.setVisibility(mIsVisible ? VISIBLE : GONE);
}
}
// TODO(b/154299462): Separate downloadable related actions from WallpaperPicker.
/** The action items in the bottom action bar. */
public enum BottomAction {
ROTATION,
DELETE,
INFORMATION(R.string.accessibility_info_shown, R.string.accessibility_info_hidden),
EDIT,
CUSTOMIZE(R.string.accessibility_customize_shown, R.string.accessibility_customize_hidden),
EFFECTS,
DOWNLOAD,
PROGRESS,
APPLY,
APPLY_TEXT;
private final int mShownAccessibilityResId;
private final int mHiddenAccessibilityResId;
BottomAction() {
this(/* shownAccessibilityLabelResId= */ 0, /* shownAccessibilityLabelResId= */ 0);
}
BottomAction(int shownAccessibilityLabelResId, int hiddenAccessibilityLabelResId) {
mShownAccessibilityResId = shownAccessibilityLabelResId;
mHiddenAccessibilityResId = hiddenAccessibilityLabelResId;
}
/**
* Returns the string resource id of the currently bottom action for its shown or hidden
* state.
*/
public int getAccessibilityStringRes(boolean isShown) {
return isShown ? mShownAccessibilityResId : mHiddenAccessibilityResId;
}
}
private final Map<BottomAction, View> mActionMap = new EnumMap<>(BottomAction.class);
private final Map<BottomAction, BottomSheetContent<?>> 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;
// The last selected action in the BottomActionBar.
@Nullable private BottomAction mLastSelectedAction;
@Nullable private AccessibilityCallback mAccessibilityCallback;
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.EFFECTS, findViewById(R.id.action_effects));
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));
mActionMap.put(BottomAction.APPLY_TEXT, findViewById(R.id.action_apply_text_button));
mBottomSheetView = findViewById(R.id.action_bottom_sheet);
SizeCalculator.adjustBackgroundCornerRadius(mBottomSheetView);
setColor(context);
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;
}
notifyAccessibilityCallback(newState);
// 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;
});
// Skip "info selected" and "customize selected" Talkback while double tapping on info and
// customize action.
skipAccessibilityEvent(new BottomAction[]{BottomAction.INFORMATION, BottomAction.CUSTOMIZE},
new int[]{AccessibilityEvent.TYPE_VIEW_CLICKED,
AccessibilityEvent.TYPE_VIEW_SELECTED});
}
@Override
public void onVisibilityAggregated(boolean isVisible) {
super.onVisibilityAggregated(isVisible);
mVisibilityChangeListeners.forEach(listener -> listener.onVisibilityChange(isVisible));
}
/**
* Binds the {@code bottomSheetContent} with the {@code action}, the {@code action} button
* would be able to expand/collapse the bottom sheet to show the content.
*
* @param bottomSheetContent the content object with view being added to the bottom sheet
* @param action the action to be bound to expand / collapse the bottom sheet
*/
public void bindBottomSheetContentWithAction(BottomSheetContent<?> bottomSheetContent,
BottomAction action) {
mContentViewMap.put(action, bottomSheetContent);
mBottomSheetView.addView(bottomSheetContent.mContentView);
setActionClickListener(action, actionView -> {
if (mBottomSheetBehavior.getState() == STATE_COLLAPSED) {
updateContentViewFor(action);
}
mBottomSheetView.setAccessibilityTraversalAfter(actionView.getId());
});
}
/** Collapses the bottom sheet. */
public void collapseBottomSheetIfExpanded() {
hideBottomSheetAndDeselectButtonIfExpanded();
}
/** Enables or disables action buttons that show the bottom sheet. */
public void enableActionButtonsWithBottomSheet(boolean enabled) {
if (enabled) {
enableActions(mContentViewMap.keySet().toArray(new BottomAction[0]));
} else {
disableActions(mContentViewMap.keySet().toArray(new BottomAction[0]));
}
}
/**
* 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.
// Also keep the same action for unselected case for a11y.
mLastSelectedAction = 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);
}
/** Set back button visibility. */
public void setBackButtonVisibility(int visibility) {
findViewById(R.id.action_back).setVisibility(visibility);
}
/** 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());
}
/**
* Sets a AccessibilityCallback.
*
* @param accessibilityCallback the callback to be notified.
*/
public void setAccessibilityCallback(@Nullable AccessibilityCallback accessibilityCallback) {
mAccessibilityCallback = accessibilityCallback;
}
/**
* 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();
}
}
}
/**
* Focus the specific action.
*
* @param action the specific action
*/
public void focusAccessibilityAction(BottomAction action) {
mActionMap.get(action).sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED);
}
/**
* 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();
}
/** Returns {@code true} if the state of bottom sheet is collapsed. */
public boolean isBottomSheetCollapsed() {
return mBottomSheetBehavior.getState() == STATE_COLLAPSED;
}
/** 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;
}
/** Dynamic update color with {@code Context}. */
public void setColor(Context context) {
// Set bottom sheet background.
mBottomSheetView.setBackground(context.getDrawable(R.drawable.bottom_sheet_background));
if (mBottomSheetView.getChildCount() > 0) {
// Update the bottom sheet content view if any.
mBottomSheetView.removeAllViews();
mContentViewMap.values().forEach(bottomSheetContent -> {
bottomSheetContent.recreateView(context);
mBottomSheetView.addView(bottomSheetContent.mContentView);
});
}
// Set the bar background and action buttons.
ViewGroup actionTabs = findViewById(R.id.action_tabs);
actionTabs.setBackgroundColor(
ResourceUtils.getColorAttr(context, android.R.attr.colorBackground));
ColorStateList colorStateList = context.getColorStateList(
R.color.bottom_action_button_color_tint);
for (int i = 0; i < actionTabs.getChildCount(); i++) {
View v = actionTabs.getChildAt(i);
if (v instanceof ImageView) {
v.setBackground(context.getDrawable(R.drawable.bottom_action_button_background));
ImageViewCompat.setImageTintList((ImageView) v, colorStateList);
} else if (v instanceof ProgressBar) {
((ProgressBar) v).setIndeterminateTintList(colorStateList);
}
}
}
/** Sets action button accessibility traversal after. */
public void setActionAccessibilityTraversalAfter(BottomAction action, int afterId) {
View bottomActionView = mActionMap.get(action);
bottomActionView.setAccessibilityTraversalAfter(afterId);
}
/** Sets action button accessibility traversal before. */
public void setActionAccessibilityTraversalBefore(BottomAction action, int beforeId) {
View bottomActionView = mActionMap.get(action);
bottomActionView.setAccessibilityTraversalBefore(beforeId);
}
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, content) -> content.setVisibility(a.equals(action)));
}
private boolean isExpandable(BottomAction action) {
return action != null && mContentViewMap.containsKey(action);
}
private void notifyAccessibilityCallback(int state) {
if (mAccessibilityCallback == null) {
return;
}
if (state == STATE_COLLAPSED) {
CharSequence text = getAccessibilityText(mLastSelectedAction, /* isShown= */ false);
if (!TextUtils.isEmpty(text)) {
setAccessibilityPaneTitle(text);
}
mAccessibilityCallback.onBottomSheetCollapsed();
} else if (state == STATE_EXPANDED) {
CharSequence text = getAccessibilityText(mSelectedAction, /* isShown= */ true);
if (!TextUtils.isEmpty(text)) {
setAccessibilityPaneTitle(text);
}
mAccessibilityCallback.onBottomSheetExpanded();
}
}
private CharSequence getAccessibilityText(BottomAction action, boolean isShown) {
if (action == null) {
return null;
}
int resId = action.getAccessibilityStringRes(isShown);
if (resId != 0) {
return mContext.getText(resId);
}
return null;
}
/**
* Skip bottom action's Accessibility event.
*
* @param actions the {@link BottomAction} actions to be skipped.
* @param eventTypes the {@link AccessibilityEvent} event types to be skipped.
*/
private void skipAccessibilityEvent(BottomAction[] actions, int[] eventTypes) {
for (BottomAction action : actions) {
View view = mActionMap.get(action);
view.setAccessibilityDelegate(new AccessibilityDelegate() {
@Override
public void sendAccessibilityEvent(View host, int eventType) {
if (!ArrayUtils.contains(eventTypes, eventType)) {
super.sendAccessibilityEvent(host, eventType);
}
}
});
}
}
/** 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);
}
}
});
}
}
}