1 /******************************************************************************* 2 * Copyright (C) 2012 Google Inc. 3 * Licensed to The Android Open Source Project. 4 * 5 * Licensed under the Apache License, Version 2.0 (the "License"); 6 * you may not use this file except in compliance with the License. 7 * You may obtain a copy of the License at 8 * 9 * http://www.apache.org/licenses/LICENSE-2.0 10 * 11 * Unless required by applicable law or agreed to in writing, software 12 * distributed under the License is distributed on an "AS IS" BASIS, 13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 * See the License for the specific language governing permissions and 15 * limitations under the License. 16 *******************************************************************************/ 17 18 package com.android.mail.ui; 19 20 import android.animation.Animator; 21 import android.animation.AnimatorListenerAdapter; 22 import android.animation.TimeInterpolator; 23 import android.app.Activity; 24 import android.content.Context; 25 import android.content.res.Resources; 26 import android.support.v4.widget.DrawerLayout; 27 import android.util.AttributeSet; 28 import android.view.Gravity; 29 import android.view.View; 30 import android.view.ViewGroup; 31 import android.view.ViewParent; 32 import android.view.animation.AnimationUtils; 33 import android.widget.FrameLayout; 34 35 import com.android.mail.R; 36 import com.android.mail.ui.ViewMode.ModeChangeListener; 37 import com.android.mail.utils.LogUtils; 38 import com.android.mail.utils.Utils; 39 import com.google.common.annotations.VisibleForTesting; 40 41 /** 42 * This is a custom layout that manages the possible views of Gmail's large screen (read: tablet) 43 * activity, and the transitions between them. 44 * 45 * This is not intended to be a generic layout; it is specific to the {@code Fragment}s 46 * available in {@link MailActivity} and assumes their existence. It merely configures them 47 * according to the specific <i>modes</i> the {@link Activity} can be in. 48 * 49 * Currently, the layout differs in three dimensions: orientation, two aspects of view modes. 50 * This results in essentially three states: One where the folders are on the left and conversation 51 * list is on the right, and two states where the conversation list is on the left: one in which 52 * it's collapsed and another where it is not. 53 * 54 * In folder or conversation list view, conversations are hidden and folders and conversation lists 55 * are visible. This is the case in both portrait and landscape 56 * 57 * In Conversation List or Conversation View, folders are hidden, and conversation lists and 58 * conversation view is visible. This is the case in both portrait and landscape. 59 * 60 * In the Gmail source code, this was called TriStateSplitLayout 61 */ 62 final class TwoPaneLayout extends FrameLayout implements ModeChangeListener { 63 64 private static final String LOG_TAG = "TwoPaneLayout"; 65 private static final long SLIDE_DURATION_MS = 300; 66 67 private final double mConversationListWeight; 68 private final double mFolderListWeight; 69 private final TimeInterpolator mSlideInterpolator; 70 /** 71 * True if and only if the conversation list is collapsible in the current device configuration. 72 * See {@link #isConversationListCollapsed()} to see whether it is currently collapsed 73 * (based on the current view mode). 74 */ 75 private final boolean mListCollapsible; 76 77 /** 78 * The current mode that the tablet layout is in. This is a constant integer that holds values 79 * that are {@link ViewMode} constants like {@link ViewMode#CONVERSATION}. 80 */ 81 private int mCurrentMode = ViewMode.UNKNOWN; 82 /** 83 * This mode represents the current positions of the three panes. This is split out from the 84 * current mode to give context to state transitions. 85 */ 86 private int mPositionedMode = ViewMode.UNKNOWN; 87 88 private AbstractActivityController mController; 89 private LayoutListener mListener; 90 private boolean mIsSearchResult; 91 92 private DrawerLayout mDrawerLayout; 93 94 private View mMiscellaneousView; 95 private View mConversationView; 96 private View mFoldersView; 97 private View mListView; 98 99 public static final int MISCELLANEOUS_VIEW_ID = R.id.miscellaneous_pane; 100 101 private final Runnable mTransitionCompleteRunnable = new Runnable() { 102 @Override 103 public void run() { 104 onTransitionComplete(); 105 } 106 }; 107 /** 108 * A special view used during animation of the conversation list. 109 * <p> 110 * The conversation list changes width when switching view modes, so to visually smooth out 111 * the transition, we cross-fade the old and new widths. During the transition, a bitmap of the 112 * old conversation list is kept here, and this view moves in tandem with the real list view, 113 * but its opacity gradually fades out to give way to the new width. 114 */ 115 private ConversationListCopy mListCopyView; 116 117 /** 118 * During a mode transition, this value is the final width for {@link #mListCopyView}. We want 119 * to avoid changing its width during the animation, as it should match the initial width of 120 * {@link #mListView}. 121 */ 122 private Integer mListCopyWidthOnComplete; 123 124 private final boolean mIsExpansiveLayout; 125 private boolean mDrawerInitialSetupComplete; 126 127 public TwoPaneLayout(Context context) { 128 this(context, null); 129 } 130 131 public TwoPaneLayout(Context context, AttributeSet attrs) { 132 super(context, attrs); 133 134 final Resources res = getResources(); 135 136 // The conversation list might be visible now, depending on the layout: in portrait we 137 // don't show the conversation list, but in landscape we do. This information is stored 138 // in the constants 139 mListCollapsible = res.getBoolean(R.bool.list_collapsible); 140 141 mSlideInterpolator = AnimationUtils.loadInterpolator(context, 142 android.R.interpolator.decelerate_cubic); 143 144 final int folderListWeight = res.getInteger(R.integer.folder_list_weight); 145 final int convListWeight = res.getInteger(R.integer.conversation_list_weight); 146 final int convViewWeight = res.getInteger(R.integer.conversation_view_weight); 147 mFolderListWeight = (double) folderListWeight 148 / (folderListWeight + convListWeight); 149 mConversationListWeight = (double) convListWeight 150 / (convListWeight + convViewWeight); 151 152 mIsExpansiveLayout = res.getBoolean(R.bool.use_expansive_tablet_ui); 153 mDrawerInitialSetupComplete = false; 154 } 155 156 @Override 157 protected void onFinishInflate() { 158 super.onFinishInflate(); 159 160 mFoldersView = findViewById(R.id.content_pane); 161 mListView = findViewById(R.id.conversation_list_pane); 162 mListCopyView = (ConversationListCopy) findViewById(R.id.conversation_list_copy); 163 mConversationView = findViewById(R.id.conversation_pane); 164 mMiscellaneousView = findViewById(MISCELLANEOUS_VIEW_ID); 165 166 // all panes start GONE in initial UNKNOWN mode to avoid drawing misplaced panes 167 mCurrentMode = ViewMode.UNKNOWN; 168 mFoldersView.setVisibility(GONE); 169 mListView.setVisibility(GONE); 170 mListCopyView.setVisibility(GONE); 171 mConversationView.setVisibility(GONE); 172 mMiscellaneousView.setVisibility(GONE); 173 } 174 175 @VisibleForTesting 176 public void setController(AbstractActivityController controller, boolean isSearchResult) { 177 mController = controller; 178 mListener = controller; 179 mIsSearchResult = isSearchResult; 180 } 181 182 public void setDrawerLayout(DrawerLayout drawerLayout) { 183 mDrawerLayout = drawerLayout; 184 } 185 186 @Override 187 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 188 LogUtils.d(Utils.VIEW_DEBUGGING_TAG, "TPL(%s).onMeasure()", this); 189 setupPaneWidths(MeasureSpec.getSize(widthMeasureSpec)); 190 super.onMeasure(widthMeasureSpec, heightMeasureSpec); 191 } 192 193 @Override 194 protected void onLayout(boolean changed, int l, int t, int r, int b) { 195 LogUtils.d(Utils.VIEW_DEBUGGING_TAG, "TPL(%s).onLayout()", this); 196 if (changed || mCurrentMode != mPositionedMode) { 197 positionPanes(getMeasuredWidth()); 198 } 199 super.onLayout(changed, l, t, r, b); 200 } 201 202 /** 203 * Sizes up the three sliding panes. This method will ensure that the LayoutParams of the panes 204 * have the correct widths set for the current overall size and view mode. 205 * 206 * @param parentWidth this view's new width 207 */ 208 private void setupPaneWidths(int parentWidth) { 209 final int foldersWidth = computeFolderListWidth(parentWidth); 210 final int foldersFragmentWidth; 211 if (isDrawerView(mFoldersView)) { 212 foldersFragmentWidth = getResources().getDimensionPixelSize(R.dimen.drawer_width); 213 } else { 214 foldersFragmentWidth = foldersWidth; 215 } 216 final int convWidth = computeConversationWidth(parentWidth); 217 218 setPaneWidth(mFoldersView, foldersFragmentWidth); 219 220 // only adjust the fixed conversation view width when my width changes 221 if (parentWidth != getMeasuredWidth()) { 222 LogUtils.i(LOG_TAG, "setting up new TPL, w=%d fw=%d cv=%d", parentWidth, 223 foldersWidth, convWidth); 224 225 setPaneWidth(mMiscellaneousView, convWidth); 226 setPaneWidth(mConversationView, convWidth); 227 } 228 229 final int currListWidth = getPaneWidth(mListView); 230 int listWidth = currListWidth; 231 switch (mCurrentMode) { 232 case ViewMode.AD: 233 case ViewMode.CONVERSATION: 234 case ViewMode.SEARCH_RESULTS_CONVERSATION: 235 if (!mListCollapsible) { 236 listWidth = parentWidth - convWidth; 237 } 238 break; 239 case ViewMode.CONVERSATION_LIST: 240 case ViewMode.WAITING_FOR_ACCOUNT_INITIALIZATION: 241 case ViewMode.SEARCH_RESULTS_LIST: 242 listWidth = parentWidth - foldersWidth; 243 break; 244 default: 245 break; 246 } 247 LogUtils.d(LOG_TAG, "conversation list width change, w=%d", listWidth); 248 setPaneWidth(mListView, listWidth); 249 250 if ((mCurrentMode != mPositionedMode && mPositionedMode != ViewMode.UNKNOWN) 251 || mListCopyWidthOnComplete != null) { 252 mListCopyWidthOnComplete = listWidth; 253 } else { 254 setPaneWidth(mListCopyView, listWidth); 255 } 256 } 257 258 /** 259 * Positions the three sliding panes at the correct X offset (using {@link View#setX(float)}). 260 * When switching from list->conversation mode or vice versa, animate the change in X. 261 * 262 * @param width 263 */ 264 private void positionPanes(int width) { 265 if (mPositionedMode == mCurrentMode) { 266 return; 267 } 268 269 boolean hasPositions = false; 270 int convX = 0, listX = 0, foldersX = 0; 271 272 switch (mCurrentMode) { 273 case ViewMode.AD: 274 case ViewMode.CONVERSATION: 275 case ViewMode.SEARCH_RESULTS_CONVERSATION: { 276 final int foldersW = getPaneWidth(mFoldersView); 277 final int listW; 278 listW = getPaneWidth(mListView); 279 280 if (mListCollapsible) { 281 convX = 0; 282 listX = -listW; 283 foldersX = listX - foldersW; 284 } else { 285 convX = listW; 286 listX = 0; 287 foldersX = -foldersW; 288 } 289 hasPositions = true; 290 LogUtils.i(LOG_TAG, "conversation mode layout, x=%d/%d/%d", foldersX, listX, convX); 291 break; 292 } 293 case ViewMode.CONVERSATION_LIST: 294 case ViewMode.WAITING_FOR_ACCOUNT_INITIALIZATION: 295 case ViewMode.SEARCH_RESULTS_LIST: { 296 convX = width; 297 listX = getPaneWidth(mFoldersView); 298 foldersX = 0; 299 300 hasPositions = true; 301 LogUtils.i(LOG_TAG, "conv-list mode layout, x=%d/%d/%d", foldersX, listX, convX); 302 break; 303 } 304 default: 305 break; 306 } 307 308 if (hasPositions) { 309 animatePanes(foldersX, listX, convX); 310 } 311 312 mPositionedMode = mCurrentMode; 313 } 314 315 private final AnimatorListenerAdapter mPaneAnimationListener = new AnimatorListenerAdapter() { 316 @Override 317 public void onAnimationEnd(Animator animation) { 318 mListCopyView.unbind(); 319 useHardwareLayer(false); 320 fixupListCopyWidth(); 321 onTransitionComplete(); 322 } 323 @Override 324 public void onAnimationCancel(Animator animation) { 325 mListCopyView.unbind(); 326 useHardwareLayer(false); 327 } 328 }; 329 330 /** 331 * @param foldersX 332 * @param listX 333 * @param convX 334 */ 335 private void animatePanes(int foldersX, int listX, int convX) { 336 // If positioning has not yet happened, we don't need to animate panes into place. 337 // This happens on first layout, rotate, and when jumping straight to a conversation from 338 // a view intent. 339 if (mPositionedMode == ViewMode.UNKNOWN) { 340 mConversationView.setX(convX); 341 mMiscellaneousView.setX(convX); 342 mListView.setX(listX); 343 if (!isDrawerView(mFoldersView)) { 344 mFoldersView.setX(foldersX); 345 } 346 347 // listeners need to know that the "transition" is complete, even if one is not run. 348 // defer notifying listeners because we're in a layout pass, and they might do layout. 349 post(mTransitionCompleteRunnable); 350 return; 351 } 352 353 final boolean useListCopy = getPaneWidth(mListView) != getPaneWidth(mListCopyView); 354 355 if (useListCopy) { 356 // freeze the current list view before it gets redrawn 357 mListCopyView.bind(mListView); 358 mListCopyView.setX(mListView.getX()); 359 360 mListCopyView.setAlpha(1.0f); 361 mListView.setAlpha(0.0f); 362 } 363 364 useHardwareLayer(true); 365 366 if (ViewMode.isAdMode(mCurrentMode)) { 367 mMiscellaneousView.animate().x(convX); 368 } else { 369 mConversationView.animate().x(convX); 370 } 371 372 if (!isDrawerView(mFoldersView)) { 373 mFoldersView.animate().x(foldersX); 374 } 375 if (useListCopy) { 376 mListCopyView.animate().x(listX).alpha(0.0f); 377 } 378 mListView.animate() 379 .x(listX) 380 .alpha(1.0f) 381 .setListener(mPaneAnimationListener); 382 configureAnimations(mConversationView, mFoldersView, mListView, mListCopyView, 383 mMiscellaneousView); 384 } 385 386 private void configureAnimations(View... views) { 387 for (View v : views) { 388 if (isDrawerView(v)) { 389 continue; 390 } 391 v.animate() 392 .setInterpolator(mSlideInterpolator) 393 .setDuration(SLIDE_DURATION_MS); 394 } 395 } 396 397 private void useHardwareLayer(boolean useHardware) { 398 final int layerType = useHardware ? LAYER_TYPE_HARDWARE : LAYER_TYPE_NONE; 399 if (!isDrawerView(mFoldersView)) { 400 mFoldersView.setLayerType(layerType, null); 401 } 402 mListView.setLayerType(layerType, null); 403 mListCopyView.setLayerType(layerType, null); 404 mConversationView.setLayerType(layerType, null); 405 mMiscellaneousView.setLayerType(layerType, null); 406 if (useHardware) { 407 // these buildLayer calls are safe because layout is the only way we get here 408 // (i.e. these views must already be attached) 409 if (!isDrawerView(mFoldersView)) { 410 mFoldersView.buildLayer(); 411 } 412 mListView.buildLayer(); 413 mListCopyView.buildLayer(); 414 mConversationView.buildLayer(); 415 mMiscellaneousView.buildLayer(); 416 } 417 } 418 419 private void fixupListCopyWidth() { 420 if (mListCopyWidthOnComplete == null || 421 getPaneWidth(mListCopyView) == mListCopyWidthOnComplete) { 422 mListCopyWidthOnComplete = null; 423 return; 424 } 425 LogUtils.i(LOG_TAG, "onAnimationEnd of list view, setting copy width to %d", 426 mListCopyWidthOnComplete); 427 setPaneWidth(mListCopyView, mListCopyWidthOnComplete); 428 mListCopyWidthOnComplete = null; 429 } 430 431 private void onTransitionComplete() { 432 if (mController.isDestroyed()) { 433 // quit early if the hosting activity was destroyed before the animation finished 434 LogUtils.i(LOG_TAG, "IN TPL.onTransitionComplete, activity destroyed->quitting early"); 435 return; 436 } 437 438 switch (mCurrentMode) { 439 case ViewMode.CONVERSATION: 440 case ViewMode.SEARCH_RESULTS_CONVERSATION: 441 dispatchConversationVisibilityChanged(true); 442 dispatchConversationListVisibilityChange(!isConversationListCollapsed()); 443 444 break; 445 case ViewMode.CONVERSATION_LIST: 446 case ViewMode.SEARCH_RESULTS_LIST: 447 dispatchConversationVisibilityChanged(false); 448 dispatchConversationListVisibilityChange(true); 449 450 break; 451 case ViewMode.AD: 452 dispatchConversationVisibilityChanged(false); 453 dispatchConversationListVisibilityChange(!isConversationListCollapsed()); 454 455 break; 456 default: 457 break; 458 } 459 } 460 461 /** 462 * Computes the width of the conversation list in stable state of the current mode. 463 */ 464 public int computeConversationListWidth() { 465 return computeConversationListWidth(getMeasuredWidth()); 466 } 467 468 /** 469 * Computes the width of the conversation list in stable state of the current mode. 470 */ 471 private int computeConversationListWidth(int totalWidth) { 472 switch (mCurrentMode) { 473 case ViewMode.CONVERSATION_LIST: 474 case ViewMode.WAITING_FOR_ACCOUNT_INITIALIZATION: 475 case ViewMode.SEARCH_RESULTS_LIST: 476 return totalWidth - computeFolderListWidth(totalWidth); 477 case ViewMode.AD: 478 case ViewMode.CONVERSATION: 479 case ViewMode.SEARCH_RESULTS_CONVERSATION: 480 return (int) (totalWidth * mConversationListWeight); 481 } 482 return 0; 483 } 484 485 public int computeConversationWidth() { 486 return computeConversationWidth(getMeasuredWidth()); 487 } 488 489 /** 490 * Computes the width of the conversation pane in stable state of the 491 * current mode. 492 */ 493 private int computeConversationWidth(int totalWidth) { 494 if (mListCollapsible) { 495 return totalWidth; 496 } else { 497 return totalWidth - (int) (totalWidth * mConversationListWeight); 498 } 499 } 500 501 /** 502 * Computes the width of the folder list in stable state of the current mode. 503 */ 504 private int computeFolderListWidth(int parentWidth) { 505 if (mIsSearchResult) { 506 return 0; 507 } else if (isDrawerView(mFoldersView)) { 508 return 0; 509 } else { 510 return (int) (parentWidth * mFolderListWeight); 511 } 512 } 513 514 private void dispatchConversationListVisibilityChange(boolean visible) { 515 if (mListener != null) { 516 mListener.onConversationListVisibilityChanged(visible); 517 } 518 } 519 520 private void dispatchConversationVisibilityChanged(boolean visible) { 521 if (mListener != null) { 522 mListener.onConversationVisibilityChanged(visible); 523 } 524 } 525 526 // does not apply to drawer children. will return zero for those. 527 private int getPaneWidth(View pane) { 528 return isDrawerView(pane) ? 0 : pane.getLayoutParams().width; 529 } 530 531 private boolean isDrawerView(View child) { 532 return child != null && child.getParent() == mDrawerLayout; 533 } 534 535 /** 536 * @return Whether or not the conversation list is visible on screen. 537 */ 538 public boolean isConversationListCollapsed() { 539 return !ViewMode.isListMode(mCurrentMode) && mListCollapsible; 540 } 541 542 @Override 543 public void onViewModeChanged(int newMode) { 544 // make all initially GONE panes visible only when the view mode is first determined 545 if (mCurrentMode == ViewMode.UNKNOWN) { 546 mFoldersView.setVisibility(VISIBLE); 547 mListView.setVisibility(VISIBLE); 548 mListCopyView.setVisibility(VISIBLE); 549 } 550 551 if (ViewMode.isAdMode(newMode)) { 552 mMiscellaneousView.setVisibility(VISIBLE); 553 mConversationView.setVisibility(GONE); 554 } else { 555 mConversationView.setVisibility(VISIBLE); 556 mMiscellaneousView.setVisibility(GONE); 557 } 558 559 // set up the drawer as appropriate for the configuration 560 final ViewParent foldersParent = mFoldersView.getParent(); 561 if (mIsExpansiveLayout && foldersParent != this) { 562 if (foldersParent != mDrawerLayout) { 563 throw new IllegalStateException("invalid Folders fragment parent: " + 564 foldersParent); 565 } 566 mDrawerLayout.removeView(mFoldersView); 567 addView(mFoldersView, 0); 568 mFoldersView.findViewById(R.id.folders_pane_edge).setVisibility(VISIBLE); 569 mFoldersView.setBackgroundDrawable(null); 570 } else if (!mIsExpansiveLayout && foldersParent == this) { 571 removeView(mFoldersView); 572 mDrawerLayout.addView(mFoldersView); 573 final DrawerLayout.LayoutParams lp = 574 (DrawerLayout.LayoutParams) mFoldersView.getLayoutParams(); 575 lp.gravity = Gravity.START; 576 mFoldersView.setLayoutParams(lp); 577 mFoldersView.findViewById(R.id.folders_pane_edge).setVisibility(GONE); 578 mFoldersView.setBackgroundResource(R.color.list_background_color); 579 } 580 581 // detach the pager immediately from its data source (to prevent processing updates) 582 if (ViewMode.isConversationMode(mCurrentMode)) { 583 mController.disablePagerUpdates(); 584 } 585 586 mDrawerInitialSetupComplete = true; 587 mCurrentMode = newMode; 588 LogUtils.i(LOG_TAG, "onViewModeChanged(%d)", newMode); 589 590 // do all the real work in onMeasure/onLayout, when panes are sized and positioned for the 591 // current width/height anyway 592 requestLayout(); 593 } 594 595 public boolean isModeChangePending() { 596 return mPositionedMode != mCurrentMode; 597 } 598 599 private void setPaneWidth(View pane, int w) { 600 final ViewGroup.LayoutParams lp = pane.getLayoutParams(); 601 if (lp.width == w) { 602 return; 603 } 604 lp.width = w; 605 pane.setLayoutParams(lp); 606 if (LogUtils.isLoggable(LOG_TAG, LogUtils.DEBUG)) { 607 final String s; 608 if (pane == mFoldersView) { 609 s = "folders"; 610 } else if (pane == mListView) { 611 s = "conv-list"; 612 } else if (pane == mConversationView) { 613 s = "conv-view"; 614 } else if (pane == mMiscellaneousView) { 615 s = "misc-view"; 616 } else { 617 s = "???:" + pane; 618 } 619 LogUtils.d(LOG_TAG, "TPL: setPaneWidth, w=%spx pane=%s", w, s); 620 } 621 } 622 623 public boolean isDrawerEnabled() { 624 return !mIsExpansiveLayout && mDrawerInitialSetupComplete; 625 } 626 627 public boolean isExpansiveLayout() { 628 return mIsExpansiveLayout; 629 } 630 } 631