1 /* 2 * Copyright (C) 2015 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 android.car.ui.provider; 17 18 import android.car.app.menu.CarMenuCallbacks; 19 import android.content.Context; 20 import android.graphics.Canvas; 21 import android.os.Bundle; 22 import android.support.car.ui.PagedListView; 23 import android.support.car.ui.R; 24 import android.support.v7.widget.CardView; 25 import android.support.v7.widget.RecyclerView; 26 import android.text.TextUtils; 27 import android.util.Log; 28 import android.view.View; 29 import android.view.animation.Animation; 30 import android.view.animation.AnimationUtils; 31 import android.widget.ProgressBar; 32 33 import java.util.ArrayList; 34 import java.util.Arrays; 35 import java.util.LinkedList; 36 import java.util.List; 37 import java.util.Queue; 38 import java.util.Stack; 39 40 import static android.car.app.menu.CarMenuConstants.MenuItemConstants.FLAG_BROWSABLE; 41 import static android.car.app.menu.CarMenuConstants.MenuItemConstants.KEY_FLAGS; 42 import static android.car.app.menu.CarMenuConstants.MenuItemConstants.KEY_ID; 43 import static android.car.app.menu.CarMenuConstants.MenuItemConstants.KEY_TITLE; 44 45 /** 46 * Controls the drawer for SDK app 47 */ 48 public class DrawerController 49 implements CarDrawerLayout.DrawerListener, DrawerApiAdapter.OnItemSelectedListener, 50 CarDrawerLayout.DrawerControllerListener { 51 private static final String TAG = "CAR.UI.DrawerController"; 52 // Qualify with full package name to make it less likely there will be a collision 53 private static final String KEY_IDS = "android.support.car.ui.drawer.sdk.IDS"; 54 private static final String KEY_DRAWERSTATE = 55 "android.support.car.ui.drawer.sdk.DRAWER_STATE"; 56 private static final String KEY_TITLES = "android.support.car.ui.drawer.sdk.TITLES"; 57 private static final String KEY_ROOT = "android.support.car.ui.drawer.sdk.ROOT"; 58 private static final String KEY_ID_UNAVAILABLE_CATEGORY = "UNAVAILABLE_CATEGORY"; 59 private static final String KEY_CLICK_STACK = 60 "android.support.car.ui.drawer.sdk.CLICK_STACK"; 61 private static final String KEY_MAX_PAGES = 62 "android.support.car.ui.drawer.sdk.MAX_PAGES"; 63 private static final String KEY_IS_CAPPED = 64 "android.support.car.ui.drawer.sdk.IS_CAPPED"; 65 66 /** Drawer is in Auto dark/light mode */ 67 private static final int MODE_AUTO = 0; 68 /** Drawer is in Light mode */ 69 private static final int MODE_LIGHT = 1; 70 /** Drawer is in Dark mode */ 71 private static final int MODE_DARK = 2; 72 73 private final Stack<String> mSubscriptionIds = new Stack<>(); 74 private final Stack<CharSequence> mTitles = new Stack<>(); 75 private final SubscriptionCallbacks mSubscriptionCallbacks = new SubscriptionCallbacks(); 76 // Named to be consistent with CarDrawerFragment to make copying code easier and less error 77 // prone 78 private final CarDrawerLayout mContainer; 79 private final PagedListView mListView; 80 // private final CardView mTruncatedListCardView; 81 private final ProgressBar mProgressBar; 82 private final Context mContext; 83 private final ViewAnimationController mPlvAnimationController; 84 private final CardView mTruncatedListCardView; 85 private final Stack<Integer> mClickCountStack = new Stack<>(); 86 87 private CarMenuCallbacks mCarMenuCallbacks; 88 private DrawerApiAdapter mAdapter; 89 private int mScrimColor = CarDrawerLayout.DEFAULT_SCRIM_COLOR; 90 private boolean mIsDrawerOpen; 91 private boolean mIsDrawerAnimating; 92 private boolean mIsCapped; 93 private int mItemsNumber; 94 private int mDrawerMode; 95 private CharSequence mContentTitle; 96 private String mRootId; 97 private boolean mRestartedFromDayNightMode; 98 private CarUiEntry mUiEntry; 99 100 public DrawerController(CarUiEntry uiEntry, View menuButton, CarDrawerLayout drawerLayout, 101 PagedListView listView, CardView cardView) { 102 //mCarAppLayout = appLayout; 103 menuButton.setOnClickListener(mMenuClickListener); 104 mContainer = drawerLayout; 105 mListView = listView; 106 mUiEntry = uiEntry; 107 mTruncatedListCardView = cardView; 108 mListView.setDefaultItemDecoration(new DrawerMenuListDecoration(mListView.getContext())); 109 mProgressBar = (ProgressBar) mContainer.findViewById(R.id.progress); 110 mContext = mListView.getContext(); 111 mPlvAnimationController = new ViewAnimationController( 112 mListView, R.anim.car_list_in, R.anim.sdk_list_out, R.anim.car_list_pop_out); 113 mRootId = null; 114 115 mContainer.setDrawerListener(this); 116 mContainer.setDrawerControllerListener(this); 117 setAutoLightDarkMode(); 118 } 119 120 121 @Override 122 public void onDrawerOpened(View drawerView) { 123 mIsDrawerOpen = true; 124 mIsDrawerAnimating = false; 125 mUiEntry.setMenuProgress(1.0f); 126 // This can be null on day/night mode changes 127 if (mCarMenuCallbacks != null) { 128 mCarMenuCallbacks.onCarMenuOpened(); 129 } 130 } 131 132 @Override 133 public void onDrawerClosed(View drawerView) { 134 mIsDrawerOpen = false; 135 mIsDrawerAnimating = false; 136 clearMenu(); 137 mUiEntry.setMenuProgress(0); 138 mUiEntry.setTitle(mContentTitle); 139 // This can be null on day/night mode changes 140 if (mCarMenuCallbacks != null) { 141 mCarMenuCallbacks.onCarMenuClosed(); 142 } 143 } 144 145 @Override 146 public void onDrawerStateChanged(int newState) { 147 } 148 149 @Override 150 public void onDrawerOpening(View drawerView) { 151 mIsDrawerAnimating = true; 152 // This can be null on day/night mode changes 153 if (mCarMenuCallbacks != null) { 154 mCarMenuCallbacks.onCarMenuOpening(); 155 } 156 } 157 158 @Override 159 public void onDrawerSlide(View drawerView, float slideOffset) { 160 mUiEntry.setMenuProgress(slideOffset); 161 } 162 163 @Override 164 public void onDrawerClosing(View drawerView) { 165 mIsDrawerAnimating = true; 166 // This can be null on day/night mode changes 167 if (mCarMenuCallbacks != null) { 168 mCarMenuCallbacks.onCarMenuClosing(); 169 } 170 } 171 172 @Override 173 public void onItemClicked(Bundle item, int position) { 174 // Don't allow selection while animating 175 if (mPlvAnimationController.isAnimating()) { 176 return; 177 } 178 int flags = item.getInt(KEY_FLAGS); 179 String id = item.getString(KEY_ID); 180 181 // Page number is 0 index, + 1 for the actual click. 182 int clicksUsed = mListView.getPage(position) + 1; 183 mClickCountStack.push(clicksUsed); 184 mListView.setMaxPages(mListView.getMaxPages() - clicksUsed); 185 mCarMenuCallbacks.onItemClicked(id); 186 if ((flags & FLAG_BROWSABLE) != 0) { 187 if (mListView.getMaxPages() == 0) { 188 mIsCapped = true; 189 } 190 CharSequence title = item.getString(KEY_TITLE); 191 if (TextUtils.isEmpty(title)) { 192 title = mContentTitle; 193 } 194 mUiEntry.setTitleText(title); 195 mTitles.push(title); 196 if (!mSubscriptionIds.isEmpty()) { 197 mPlvAnimationController.enqueueExitAnimation(mClearAdapterRunnable); 198 } 199 mProgressBar.setVisibility(View.VISIBLE); 200 if (!mSubscriptionIds.isEmpty()) { 201 mCarMenuCallbacks.unsubscribe(mSubscriptionIds.peek(), mSubscriptionCallbacks); 202 } 203 mSubscriptionIds.push(id); 204 subscribe(id); 205 } else { 206 closeDrawer(); 207 } 208 } 209 210 @Override 211 public boolean onItemLongClicked(Bundle item) { 212 return mCarMenuCallbacks.onItemLongClicked(item.getString(KEY_ID)); 213 } 214 215 @Override 216 public void onBack() { 217 backOrClose(); 218 } 219 220 @Override 221 public boolean onScroll() { 222 // Consume scroll event if we are animating. 223 return mPlvAnimationController.isAnimating(); 224 } 225 226 public void setTitle(CharSequence title) { 227 Log.d(TAG, "setTitle in drawer" + title); 228 if (!TextUtils.isEmpty(title)) { 229 mContentTitle = title; 230 mUiEntry.showTitle(); 231 mUiEntry.setTitleText(title); 232 } else { 233 mUiEntry.hideTitle(); 234 } 235 } 236 237 public void setRootAndCallbacks(String rootId, CarMenuCallbacks callbacks) { 238 mAdapter = new DrawerApiAdapter(); 239 mAdapter.setItemSelectedListener(this); 240 mListView.setAdapter(mAdapter); 241 mCarMenuCallbacks = callbacks; 242 // HACK: Due to the handler, setRootId will be called after onRestoreState. 243 // If onRestoreState has been called, the root id will already be set. So nothing to do. 244 if (mSubscriptionIds.isEmpty()) { 245 setRootId(rootId); 246 } else { 247 subscribe(mSubscriptionIds.peek()); 248 openDrawer(); 249 } 250 } 251 252 public void saveState(Bundle out) { 253 out.putStringArray(KEY_IDS, mSubscriptionIds.toArray(new String[mSubscriptionIds.size()])); 254 out.putStringArray(KEY_TITLES, mTitles.toArray(new String[mTitles.size()])); 255 out.putString(KEY_ROOT, mRootId); 256 out.putBoolean(KEY_DRAWERSTATE, mIsDrawerOpen); 257 out.putIntegerArrayList(KEY_CLICK_STACK, new ArrayList<Integer>(mClickCountStack)); 258 out.putBoolean(KEY_IS_CAPPED, mIsCapped); 259 out.putInt(KEY_MAX_PAGES, mListView.getMaxPages()); 260 } 261 262 public void restoreState(Bundle in) { 263 if (in != null) { 264 // Restore subscribed CarMenu ids 265 String[] ids = in.getStringArray(KEY_IDS); 266 mSubscriptionIds.clear(); 267 if (ids != null) { 268 mSubscriptionIds.addAll(Arrays.asList(ids)); 269 } 270 // Restore drawer titles if there are any 271 String[] titles = in.getStringArray(KEY_TITLES); 272 mTitles.clear(); 273 if (titles != null) { 274 mTitles.addAll(Arrays.asList(titles)); 275 } 276 if (!mTitles.isEmpty()) { 277 mUiEntry.setTitleText(mTitles.peek()); 278 } 279 mRootId = in.getString(KEY_ROOT); 280 mIsDrawerOpen = in.getBoolean(KEY_DRAWERSTATE); 281 ArrayList<Integer> clickCount = in.getIntegerArrayList(KEY_CLICK_STACK); 282 mClickCountStack.clear(); 283 if (clickCount != null) { 284 mClickCountStack.addAll(clickCount); 285 } 286 mIsCapped = in.getBoolean(KEY_IS_CAPPED); 287 mListView.setMaxPages(in.getInt(KEY_MAX_PAGES)); 288 if (!mRestartedFromDayNightMode && mIsDrawerOpen) { 289 closeDrawer(); 290 } 291 } 292 } 293 294 public void setScrimColor(int color) { 295 mScrimColor = color; 296 mContainer.setScrimColor(color); 297 updateViewFaders(); 298 } 299 300 public void setAutoLightDarkMode() { 301 mDrawerMode = MODE_AUTO; 302 mContainer.setAutoDayNightMode(); 303 updateViewFaders(); 304 } 305 306 public void setLightMode() { 307 mDrawerMode = MODE_LIGHT; 308 mContainer.setLightMode(); 309 updateViewFaders(); 310 } 311 312 public void setDarkMode() { 313 mDrawerMode = MODE_DARK; 314 mContainer.setDarkMode(); 315 updateViewFaders(); 316 } 317 318 public void openDrawer() { 319 // If we have no root, then we can't open the drawer. 320 if (mRootId == null) { 321 return; 322 } 323 mContainer.openDrawer(); 324 } 325 326 public void closeDrawer() { 327 if (mRootId == null) { 328 return; 329 } 330 mTruncatedListCardView.setVisibility(View.GONE); 331 mPlvAnimationController.stopAndClearAnimations(); 332 mContainer.closeDrawer(); 333 mUiEntry.setTitle(mContentTitle); 334 } 335 336 public void setDrawerEnabled(boolean enabled) { 337 if (enabled) { 338 mContainer.setDrawerLockMode(CarDrawerLayout.LOCK_MODE_UNLOCKED); 339 } else { 340 mContainer.setDrawerLockMode(CarDrawerLayout.LOCK_MODE_LOCKED_CLOSED); 341 } 342 } 343 344 public void showMenu(String id, String title) { 345 // The app wants to show the menu associated with the given id. Create a fake item using the 346 // given inputs and then pretend as if the user clicked on the item, so that the drawer 347 // will subscribe to that menu id, set the title appropriately, and properly handle the 348 // subscription stack. 349 Bundle bundle = new Bundle(); 350 bundle.putString(KEY_ID, id); 351 bundle.putString(KEY_TITLE, title); 352 bundle.putInt(KEY_FLAGS, FLAG_BROWSABLE); 353 onItemClicked(bundle, 0 /* position */); 354 } 355 356 public void setRootId(String rootId) { 357 mRootId = rootId; 358 } 359 360 public void setRestartedFromDayNightMode(boolean restarted) { 361 mRestartedFromDayNightMode = restarted; 362 } 363 364 public boolean isTruncatedList() { 365 int maxItems = mAdapter.getMaxItemsNumber(); 366 return maxItems != PagedListView.ItemCap.UNLIMITED && mItemsNumber > maxItems; 367 } 368 369 private void clearMenu() { 370 if (!mSubscriptionIds.isEmpty()) { 371 mCarMenuCallbacks.unsubscribe(mSubscriptionIds.peek(), mSubscriptionCallbacks); 372 mSubscriptionIds.clear(); 373 mTitles.clear(); 374 } 375 mListView.setVisibility(View.GONE); 376 mListView.resetMaxPages(); 377 mClickCountStack.clear(); 378 mIsCapped = false; 379 } 380 381 /** 382 * Check if the drawer is inside of a CarAppLayout and add the relevant views if it is, 383 * automagically add view faders for the correct views 384 */ 385 private void updateViewFaders() { 386 mContainer.removeViewFader(mStatusViewViewFader); 387 mContainer.addViewFader(mStatusViewViewFader); 388 } 389 390 private void subscribe(String id) { 391 mProgressBar.setVisibility(View.VISIBLE); 392 mCarMenuCallbacks.subscribe(id, mSubscriptionCallbacks); 393 } 394 395 private final CarDrawerLayout.ViewFader mStatusViewViewFader = new CarDrawerLayout.ViewFader() { 396 @Override 397 public void setColor(int color) { 398 mUiEntry.setMenuButtonColor(color); 399 } 400 }; 401 402 private void backOrClose() { 403 if (mSubscriptionIds.size() > 1) { 404 mPlvAnimationController.enqueueBackAnimation(mClearAdapterRunnable); 405 mProgressBar.setVisibility(View.VISIBLE); 406 mCarMenuCallbacks.unsubscribe(mSubscriptionIds.pop(), 407 mSubscriptionCallbacks); 408 subscribe(mSubscriptionIds.peek()); 409 // Restore the title for this menu level. 410 mTitles.pop(); 411 CharSequence title = mTitles.peek(); 412 if (TextUtils.isEmpty(title)) { 413 title = mContentTitle; 414 } 415 mUiEntry.setTitleText(title); 416 } else { 417 closeDrawer(); 418 } 419 } 420 421 private final View.OnClickListener mMenuClickListener = new View.OnClickListener() { 422 @Override 423 public void onClick(View view) { 424 if (mIsDrawerAnimating || mCarMenuCallbacks.onMenuClicked()) { 425 return; 426 } 427 // Check if drawer has root set. 428 if (mRootId == null) { 429 return; 430 } 431 mTruncatedListCardView.setVisibility(View.GONE); 432 if (mIsDrawerOpen) { 433 if (!mClickCountStack.isEmpty()) { 434 mListView.setMaxPages(mListView.getMaxPages() + mClickCountStack.pop()); 435 } 436 mIsCapped = false; 437 backOrClose(); 438 } else { 439 mSubscriptionIds.push(mRootId); 440 mTitles.push(mContentTitle); 441 subscribe(mRootId); 442 openDrawer(); 443 } 444 } 445 }; 446 447 private final Runnable mClearAdapterRunnable = new Runnable() { 448 @Override 449 public void run() { 450 mListView.setVisibility(View.GONE); 451 } 452 }; 453 454 public void updateDayNightMode() { 455 mContainer.findViewById(R.id.drawer).setBackgroundColor( 456 mContext.getResources().getColor(R.color.car_card)); 457 mListView.setAutoDayNightMode(); 458 switch (mDrawerMode) { 459 case MODE_AUTO: 460 setAutoLightDarkMode(); 461 break; 462 case MODE_LIGHT: 463 setLightMode(); 464 break; 465 case MODE_DARK: 466 setDarkMode(); 467 break; 468 } 469 updateViewFaders(); 470 RecyclerView rv = mListView.getRecyclerView(); 471 for (int i = 0; i < mAdapter.getItemCount(); ++i) { 472 mAdapter.setDayNightModeColors(rv.findViewHolderForAdapterPosition(i)); 473 } 474 } 475 476 private static class ViewAnimationController implements Animation.AnimationListener { 477 private final Animation mExitAnim; 478 private final Animation mEnterAnim; 479 private final Animation mBackAnim; 480 private final View mView; 481 private final Context mContext; 482 private final Queue<Animation> mQueue = new LinkedList<>(); 483 484 private Runnable mOnEnterAnimStartRunnable; 485 private Runnable mOnExitAnimCompleteRunnable; 486 487 private Animation mCurrentAnimation; 488 489 public ViewAnimationController(View view, int enter, int exit, int back) { 490 mView = view; 491 mContext = view.getContext(); 492 493 mEnterAnim = AnimationUtils.loadAnimation(mContext, enter); 494 mExitAnim = AnimationUtils.loadAnimation(mContext, exit); 495 mBackAnim = AnimationUtils.loadAnimation(mContext, back); 496 497 mExitAnim.setAnimationListener(this); 498 mEnterAnim.setAnimationListener(this); 499 mBackAnim.setAnimationListener(this); 500 } 501 502 @Override 503 public void onAnimationStart(Animation animation) { 504 if (animation == mEnterAnim && mOnEnterAnimStartRunnable != null) { 505 mOnEnterAnimStartRunnable.run(); 506 mOnEnterAnimStartRunnable = null; 507 } 508 } 509 510 @Override 511 public void onAnimationEnd(Animation animation) { 512 if ((animation == mExitAnim || animation == mBackAnim) 513 && mOnExitAnimCompleteRunnable != null) { 514 mOnExitAnimCompleteRunnable.run(); 515 mOnExitAnimCompleteRunnable = null; 516 } 517 Animation nextAnimation = mQueue.poll(); 518 if (nextAnimation != null) { 519 mCurrentAnimation = animation; 520 mView.startAnimation(nextAnimation); 521 } else { 522 mCurrentAnimation = null; 523 } 524 } 525 526 @Override 527 public void onAnimationRepeat(Animation animation) { 528 529 } 530 531 public void enqueueEnterAnimation(Runnable r) { 532 if (r != null) { 533 mOnEnterAnimStartRunnable = r; 534 } 535 enqueueAnimation(mEnterAnim); 536 } 537 538 public void enqueueExitAnimation(Runnable r) { 539 // If the view isn't visible, don't play the exit animation. 540 // It will cause flicker. 541 if (mView.getVisibility() != View.VISIBLE) { 542 return; 543 } 544 if (r != null) { 545 mOnExitAnimCompleteRunnable = r; 546 } 547 enqueueAnimation(mExitAnim); 548 } 549 550 public void enqueueBackAnimation(Runnable r) { 551 // If the view isn't visible, don't play the back animation. 552 if (mView.getVisibility() != View.VISIBLE) { 553 return; 554 } 555 if (r != null) { 556 mOnExitAnimCompleteRunnable = r; 557 } 558 enqueueAnimation(mBackAnim); 559 } 560 561 public synchronized void stopAndClearAnimations() { 562 if (mExitAnim.hasStarted()) { 563 mExitAnim.cancel(); 564 } 565 566 if (mEnterAnim.hasStarted()) { 567 mEnterAnim.cancel(); 568 } 569 570 mQueue.clear(); 571 mCurrentAnimation = null; 572 } 573 574 public boolean isAnimating() { 575 return mCurrentAnimation != null; 576 } 577 578 private synchronized void enqueueAnimation(final Animation animation) { 579 if (mQueue.contains(animation)) { 580 return; 581 } 582 if (mCurrentAnimation != null) { 583 mQueue.add(animation); 584 } else { 585 mCurrentAnimation = animation; 586 mView.startAnimation(animation); 587 } 588 } 589 } 590 591 private class SubscriptionCallbacks extends android.car.app.menu.SubscriptionCallbacks { 592 private final Object mItemLock = new Object(); 593 private volatile List<Bundle> mItems; 594 595 @Override 596 public void onChildrenLoaded(String parentId, final List<Bundle> items) { 597 if (mSubscriptionIds.isEmpty() || parentId.equals(mSubscriptionIds.peek())) { 598 // Add unavailable category explanation at the first item of menu. 599 if (mIsCapped) { 600 Bundle extra = new Bundle(); 601 extra.putString(KEY_ID, KEY_ID_UNAVAILABLE_CATEGORY); 602 items.add(0, extra); 603 } 604 mItems = items; 605 mItemsNumber = mItems.size(); 606 mProgressBar.setVisibility(View.GONE); 607 mPlvAnimationController.enqueueEnterAnimation(new Runnable() { 608 @Override 609 public void run() { 610 synchronized (mItemLock) { 611 mAdapter.setItems(mItems, mIsCapped); 612 mListView.setVisibility(View.VISIBLE); 613 mItems = null; 614 } 615 mListView.scrollToPosition(mAdapter.getFirstItemIndex()); 616 } 617 }); 618 } 619 } 620 621 @Override 622 public void onError(String id) { 623 // TODO: do something useful here. 624 } 625 626 @Override 627 public void onChildChanged(String parentId, Bundle bundle) { 628 if (!mSubscriptionIds.isEmpty() && parentId.equals(mSubscriptionIds.peek())) { 629 // List is still animating, so adapter hasn't been updated. Update the list that 630 // needs to be set. 631 String id = bundle.getString(KEY_ID); 632 synchronized (mItemLock) { 633 if (mItems != null) { 634 for (Bundle item : mItems) { 635 if (item.getString(KEY_ID).equals(id)) { 636 item.putAll(bundle); 637 break; 638 } 639 } 640 return; 641 } 642 } 643 RecyclerView rv = mListView.getRecyclerView(); 644 RecyclerView.ViewHolder holder = rv.findViewHolderForItemId(id.hashCode()); 645 mAdapter.onChildChanged(holder, bundle); 646 } 647 } 648 } 649 650 private class DrawerMenuListDecoration extends PagedListView.Decoration { 651 652 public DrawerMenuListDecoration(Context context) { 653 super(context); 654 } 655 656 @Override 657 public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) { 658 if (mAdapter != null && mAdapter.isEmptyPlaceholder()) { 659 return; 660 } 661 super.onDrawOver(c, parent, state); 662 } 663 } 664 } 665