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