/* * 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. * *

The view would be created in the constructor. */ public static abstract class BottomSheetContent { 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 mActionMap = new EnumMap<>(BottomAction.class); private final Map> mContentViewMap = new EnumMap<>(BottomAction.class); private final Map mActionSelectedListeners = new EnumMap<>(BottomAction.class); private final ViewGroup mBottomSheetView; private final QueueStateBottomSheetBehavior mBottomSheetBehavior; private final Set 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) 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 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 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 extends BottomSheetBehavior { private final Deque 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); } } }); } } }