| /******************************************************************************* |
| * Copyright (C) 2012 Google Inc. |
| * Licensed to 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.mail.ui; |
| |
| import android.animation.Animator; |
| import android.animation.AnimatorListenerAdapter; |
| import android.animation.TimeInterpolator; |
| import android.app.Activity; |
| import android.content.Context; |
| import android.content.res.Resources; |
| import android.support.v4.widget.DrawerLayout; |
| import android.util.AttributeSet; |
| import android.view.Gravity; |
| import android.view.View; |
| import android.view.ViewGroup; |
| import android.view.ViewParent; |
| import android.view.animation.AnimationUtils; |
| import android.widget.FrameLayout; |
| |
| import com.android.mail.R; |
| import com.android.mail.ui.ViewMode.ModeChangeListener; |
| import com.android.mail.utils.LogUtils; |
| import com.android.mail.utils.Utils; |
| import com.android.mail.utils.ViewUtils; |
| import com.google.common.annotations.VisibleForTesting; |
| |
| /** |
| * This is a custom layout that manages the possible views of Gmail's large screen (read: tablet) |
| * activity, and the transitions between them. |
| * |
| * This is not intended to be a generic layout; it is specific to the {@code Fragment}s |
| * available in {@link MailActivity} and assumes their existence. It merely configures them |
| * according to the specific <i>modes</i> the {@link Activity} can be in. |
| * |
| * Currently, the layout differs in three dimensions: orientation, two aspects of view modes. |
| * This results in essentially three states: One where the folders are on the left and conversation |
| * list is on the right, and two states where the conversation list is on the left: one in which |
| * it's collapsed and another where it is not. |
| * |
| * In folder or conversation list view, conversations are hidden and folders and conversation lists |
| * are visible. This is the case in both portrait and landscape |
| * |
| * In Conversation List or Conversation View, folders are hidden, and conversation lists and |
| * conversation view is visible. This is the case in both portrait and landscape. |
| * |
| * In the Gmail source code, this was called TriStateSplitLayout |
| */ |
| final class TwoPaneLayout extends FrameLayout implements ModeChangeListener { |
| |
| private static final String LOG_TAG = "TwoPaneLayout"; |
| private static final long SLIDE_DURATION_MS = 300; |
| |
| private final double mConversationListWeight; |
| private final double mFolderListWeight; |
| private final TimeInterpolator mSlideInterpolator; |
| /** |
| * True if and only if the conversation list is collapsible in the current device configuration. |
| * See {@link #isConversationListCollapsed()} to see whether it is currently collapsed |
| * (based on the current view mode). |
| */ |
| private final boolean mListCollapsible; |
| |
| /** |
| * The current mode that the tablet layout is in. This is a constant integer that holds values |
| * that are {@link ViewMode} constants like {@link ViewMode#CONVERSATION}. |
| */ |
| private int mCurrentMode = ViewMode.UNKNOWN; |
| /** |
| * This mode represents the current positions of the three panes. This is split out from the |
| * current mode to give context to state transitions. |
| */ |
| private int mPositionedMode = ViewMode.UNKNOWN; |
| |
| private AbstractActivityController mController; |
| private LayoutListener mListener; |
| private boolean mIsSearchResult; |
| |
| private DrawerLayout mDrawerLayout; |
| |
| private View mMiscellaneousView; |
| private View mConversationView; |
| private View mFoldersView; |
| private View mListView; |
| |
| public static final int MISCELLANEOUS_VIEW_ID = R.id.miscellaneous_pane; |
| |
| private final Runnable mTransitionCompleteRunnable = new Runnable() { |
| @Override |
| public void run() { |
| onTransitionComplete(); |
| } |
| }; |
| /** |
| * A special view used during animation of the conversation list. |
| * <p> |
| * The conversation list changes width when switching view modes, so to visually smooth out |
| * the transition, we cross-fade the old and new widths. During the transition, a bitmap of the |
| * old conversation list is kept here, and this view moves in tandem with the real list view, |
| * but its opacity gradually fades out to give way to the new width. |
| */ |
| private ConversationListCopy mListCopyView; |
| |
| /** |
| * During a mode transition, this value is the final width for {@link #mListCopyView}. We want |
| * to avoid changing its width during the animation, as it should match the initial width of |
| * {@link #mListView}. |
| */ |
| private Integer mListCopyWidthOnComplete; |
| |
| private final boolean mIsExpansiveLayout; |
| private boolean mDrawerInitialSetupComplete; |
| |
| public TwoPaneLayout(Context context) { |
| this(context, null); |
| } |
| |
| public TwoPaneLayout(Context context, AttributeSet attrs) { |
| super(context, attrs); |
| |
| final Resources res = getResources(); |
| |
| // The conversation list might be visible now, depending on the layout: in portrait we |
| // don't show the conversation list, but in landscape we do. This information is stored |
| // in the constants |
| mListCollapsible = res.getBoolean(R.bool.list_collapsible); |
| |
| mSlideInterpolator = AnimationUtils.loadInterpolator(context, |
| android.R.interpolator.decelerate_cubic); |
| |
| final int folderListWeight = res.getInteger(R.integer.folder_list_weight); |
| final int convListWeight = res.getInteger(R.integer.conversation_list_weight); |
| final int convViewWeight = res.getInteger(R.integer.conversation_view_weight); |
| mFolderListWeight = (double) folderListWeight |
| / (folderListWeight + convListWeight); |
| mConversationListWeight = (double) convListWeight |
| / (convListWeight + convViewWeight); |
| |
| mIsExpansiveLayout = res.getBoolean(R.bool.use_expansive_tablet_ui); |
| mDrawerInitialSetupComplete = false; |
| } |
| |
| @Override |
| protected void onFinishInflate() { |
| super.onFinishInflate(); |
| |
| mFoldersView = findViewById(R.id.content_pane); |
| mListView = findViewById(R.id.conversation_list_pane); |
| mListCopyView = (ConversationListCopy) findViewById(R.id.conversation_list_copy); |
| mConversationView = findViewById(R.id.conversation_pane); |
| mMiscellaneousView = findViewById(MISCELLANEOUS_VIEW_ID); |
| |
| // all panes start GONE in initial UNKNOWN mode to avoid drawing misplaced panes |
| mCurrentMode = ViewMode.UNKNOWN; |
| mFoldersView.setVisibility(GONE); |
| mListView.setVisibility(GONE); |
| mListCopyView.setVisibility(GONE); |
| mConversationView.setVisibility(GONE); |
| mMiscellaneousView.setVisibility(GONE); |
| } |
| |
| @VisibleForTesting |
| public void setController(AbstractActivityController controller, boolean isSearchResult) { |
| mController = controller; |
| mListener = controller; |
| mIsSearchResult = isSearchResult; |
| } |
| |
| public void setDrawerLayout(DrawerLayout drawerLayout) { |
| mDrawerLayout = drawerLayout; |
| } |
| |
| @Override |
| protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { |
| LogUtils.d(Utils.VIEW_DEBUGGING_TAG, "TPL(%s).onMeasure()", this); |
| setupPaneWidths(MeasureSpec.getSize(widthMeasureSpec)); |
| super.onMeasure(widthMeasureSpec, heightMeasureSpec); |
| } |
| |
| @Override |
| protected void onLayout(boolean changed, int l, int t, int r, int b) { |
| LogUtils.d(Utils.VIEW_DEBUGGING_TAG, "TPL(%s).onLayout()", this); |
| if (changed || mCurrentMode != mPositionedMode) { |
| positionPanes(getMeasuredWidth()); |
| } |
| super.onLayout(changed, l, t, r, b); |
| } |
| |
| /** |
| * Sizes up the three sliding panes. This method will ensure that the LayoutParams of the panes |
| * have the correct widths set for the current overall size and view mode. |
| * |
| * @param parentWidth this view's new width |
| */ |
| private void setupPaneWidths(int parentWidth) { |
| final int foldersWidth = computeFolderListWidth(parentWidth); |
| final int foldersFragmentWidth; |
| if (isDrawerView(mFoldersView)) { |
| foldersFragmentWidth = getResources().getDimensionPixelSize(R.dimen.drawer_width); |
| } else { |
| foldersFragmentWidth = foldersWidth; |
| } |
| final int convWidth = computeConversationWidth(parentWidth); |
| |
| setPaneWidth(mFoldersView, foldersFragmentWidth); |
| |
| // only adjust the fixed conversation view width when my width changes |
| if (parentWidth != getMeasuredWidth()) { |
| LogUtils.i(LOG_TAG, "setting up new TPL, w=%d fw=%d cv=%d", parentWidth, |
| foldersWidth, convWidth); |
| |
| setPaneWidth(mMiscellaneousView, convWidth); |
| setPaneWidth(mConversationView, convWidth); |
| } |
| |
| final int currListWidth = getPaneWidth(mListView); |
| int listWidth = currListWidth; |
| switch (mCurrentMode) { |
| case ViewMode.AD: |
| case ViewMode.CONVERSATION: |
| case ViewMode.SEARCH_RESULTS_CONVERSATION: |
| if (!mListCollapsible) { |
| listWidth = parentWidth - convWidth; |
| } |
| break; |
| case ViewMode.CONVERSATION_LIST: |
| case ViewMode.WAITING_FOR_ACCOUNT_INITIALIZATION: |
| case ViewMode.SEARCH_RESULTS_LIST: |
| listWidth = parentWidth - foldersWidth; |
| break; |
| default: |
| break; |
| } |
| LogUtils.d(LOG_TAG, "conversation list width change, w=%d", listWidth); |
| setPaneWidth(mListView, listWidth); |
| |
| if ((mCurrentMode != mPositionedMode && mPositionedMode != ViewMode.UNKNOWN) |
| || mListCopyWidthOnComplete != null) { |
| mListCopyWidthOnComplete = listWidth; |
| } else { |
| setPaneWidth(mListCopyView, listWidth); |
| } |
| } |
| |
| /** |
| * Positions the three sliding panes at the correct X offset (using {@link View#setX(float)}). |
| * When switching from list->conversation mode or vice versa, animate the change in X. |
| * |
| * @param width |
| */ |
| private void positionPanes(int width) { |
| if (mPositionedMode == mCurrentMode) { |
| return; |
| } |
| |
| boolean hasPositions = false; |
| int convX = 0, listX = 0, foldersX = 0; |
| final boolean isRtl = ViewUtils.isViewRtl(this); |
| |
| switch (mCurrentMode) { |
| case ViewMode.AD: |
| case ViewMode.CONVERSATION: |
| case ViewMode.SEARCH_RESULTS_CONVERSATION: { |
| final int foldersW = getPaneWidth(mFoldersView); |
| final int listW = getPaneWidth(mListView); |
| |
| if (mListCollapsible) { |
| if (isRtl) { |
| convX = 0; |
| listX = width; |
| foldersX = width + listW; |
| } else { |
| convX = 0; |
| listX = -listW; |
| foldersX = listX - foldersW; |
| } |
| } else { |
| if (isRtl) { |
| convX = 0; |
| listX = getPaneWidth(mConversationView); |
| foldersX = width; |
| } else { |
| convX = listW; |
| listX = 0; |
| foldersX = -foldersW; |
| } |
| } |
| hasPositions = true; |
| LogUtils.i(LOG_TAG, "conversation mode layout, x=%d/%d/%d", foldersX, listX, convX); |
| break; |
| } |
| case ViewMode.CONVERSATION_LIST: |
| case ViewMode.WAITING_FOR_ACCOUNT_INITIALIZATION: |
| case ViewMode.SEARCH_RESULTS_LIST: { |
| if (isRtl) { |
| convX = -getPaneWidth(mConversationView); |
| listX = 0; |
| foldersX = getPaneWidth(mListView); |
| } else { |
| convX = width; |
| listX = getPaneWidth(mFoldersView); |
| foldersX = 0; |
| } |
| |
| hasPositions = true; |
| LogUtils.i(LOG_TAG, "conv-list mode layout, fX:%d/lX:%d/cX:%d", |
| foldersX, listX, convX); |
| break; |
| } |
| default: |
| break; |
| } |
| |
| if (hasPositions) { |
| animatePanes(foldersX, listX, convX); |
| } |
| |
| mPositionedMode = mCurrentMode; |
| } |
| |
| private final AnimatorListenerAdapter mPaneAnimationListener = new AnimatorListenerAdapter() { |
| @Override |
| public void onAnimationEnd(Animator animation) { |
| mListCopyView.unbind(); |
| useHardwareLayer(false); |
| fixupListCopyWidth(); |
| onTransitionComplete(); |
| } |
| @Override |
| public void onAnimationCancel(Animator animation) { |
| mListCopyView.unbind(); |
| useHardwareLayer(false); |
| } |
| }; |
| |
| private void animatePanes(int foldersX, int listX, int convX) { |
| // If positioning has not yet happened, we don't need to animate panes into place. |
| // This happens on first layout, rotate, and when jumping straight to a conversation from |
| // a view intent. |
| if (mPositionedMode == ViewMode.UNKNOWN) { |
| mConversationView.setX(convX); |
| mMiscellaneousView.setX(convX); |
| mListView.setX(listX); |
| if (!isDrawerView(mFoldersView)) { |
| mFoldersView.setX(foldersX); |
| } |
| |
| // listeners need to know that the "transition" is complete, even if one is not run. |
| // defer notifying listeners because we're in a layout pass, and they might do layout. |
| post(mTransitionCompleteRunnable); |
| return; |
| } |
| |
| final boolean useListCopy = getPaneWidth(mListView) != getPaneWidth(mListCopyView); |
| |
| if (useListCopy) { |
| // freeze the current list view before it gets redrawn |
| mListCopyView.bind(mListView); |
| mListCopyView.setX(mListView.getX()); |
| |
| mListCopyView.setAlpha(1.0f); |
| mListView.setAlpha(0.0f); |
| } |
| |
| useHardwareLayer(true); |
| |
| if (ViewMode.isAdMode(mCurrentMode)) { |
| mMiscellaneousView.animate().x(convX); |
| } else { |
| mConversationView.animate().x(convX); |
| } |
| |
| if (!isDrawerView(mFoldersView)) { |
| mFoldersView.animate().x(foldersX); |
| } |
| if (useListCopy) { |
| mListCopyView.animate().x(listX).alpha(0.0f); |
| } |
| mListView.animate() |
| .x(listX) |
| .alpha(1.0f) |
| .setListener(mPaneAnimationListener); |
| configureAnimations(mConversationView, mFoldersView, mListView, mListCopyView, |
| mMiscellaneousView); |
| } |
| |
| private void configureAnimations(View... views) { |
| for (View v : views) { |
| if (isDrawerView(v)) { |
| continue; |
| } |
| v.animate() |
| .setInterpolator(mSlideInterpolator) |
| .setDuration(SLIDE_DURATION_MS); |
| } |
| } |
| |
| private void useHardwareLayer(boolean useHardware) { |
| final int layerType = useHardware ? LAYER_TYPE_HARDWARE : LAYER_TYPE_NONE; |
| if (!isDrawerView(mFoldersView)) { |
| mFoldersView.setLayerType(layerType, null); |
| } |
| mListView.setLayerType(layerType, null); |
| mListCopyView.setLayerType(layerType, null); |
| mConversationView.setLayerType(layerType, null); |
| mMiscellaneousView.setLayerType(layerType, null); |
| if (useHardware) { |
| // these buildLayer calls are safe because layout is the only way we get here |
| // (i.e. these views must already be attached) |
| if (!isDrawerView(mFoldersView)) { |
| mFoldersView.buildLayer(); |
| } |
| mListView.buildLayer(); |
| mListCopyView.buildLayer(); |
| mConversationView.buildLayer(); |
| mMiscellaneousView.buildLayer(); |
| } |
| } |
| |
| private void fixupListCopyWidth() { |
| if (mListCopyWidthOnComplete == null || |
| getPaneWidth(mListCopyView) == mListCopyWidthOnComplete) { |
| mListCopyWidthOnComplete = null; |
| return; |
| } |
| LogUtils.i(LOG_TAG, "onAnimationEnd of list view, setting copy width to %d", |
| mListCopyWidthOnComplete); |
| setPaneWidth(mListCopyView, mListCopyWidthOnComplete); |
| mListCopyWidthOnComplete = null; |
| } |
| |
| private void onTransitionComplete() { |
| if (mController.isDestroyed()) { |
| // quit early if the hosting activity was destroyed before the animation finished |
| LogUtils.i(LOG_TAG, "IN TPL.onTransitionComplete, activity destroyed->quitting early"); |
| return; |
| } |
| |
| switch (mCurrentMode) { |
| case ViewMode.CONVERSATION: |
| case ViewMode.SEARCH_RESULTS_CONVERSATION: |
| dispatchConversationVisibilityChanged(true); |
| dispatchConversationListVisibilityChange(!isConversationListCollapsed()); |
| |
| break; |
| case ViewMode.CONVERSATION_LIST: |
| case ViewMode.SEARCH_RESULTS_LIST: |
| dispatchConversationVisibilityChanged(false); |
| dispatchConversationListVisibilityChange(true); |
| |
| break; |
| case ViewMode.AD: |
| dispatchConversationVisibilityChanged(false); |
| dispatchConversationListVisibilityChange(!isConversationListCollapsed()); |
| |
| break; |
| default: |
| break; |
| } |
| } |
| |
| /** |
| * Computes the width of the conversation list in stable state of the current mode. |
| */ |
| public int computeConversationListWidth() { |
| return computeConversationListWidth(getMeasuredWidth()); |
| } |
| |
| /** |
| * Computes the width of the conversation list in stable state of the current mode. |
| */ |
| private int computeConversationListWidth(int totalWidth) { |
| switch (mCurrentMode) { |
| case ViewMode.CONVERSATION_LIST: |
| case ViewMode.WAITING_FOR_ACCOUNT_INITIALIZATION: |
| case ViewMode.SEARCH_RESULTS_LIST: |
| return totalWidth - computeFolderListWidth(totalWidth); |
| case ViewMode.AD: |
| case ViewMode.CONVERSATION: |
| case ViewMode.SEARCH_RESULTS_CONVERSATION: |
| return (int) (totalWidth * mConversationListWeight); |
| } |
| return 0; |
| } |
| |
| public int computeConversationWidth() { |
| return computeConversationWidth(getMeasuredWidth()); |
| } |
| |
| /** |
| * Computes the width of the conversation pane in stable state of the |
| * current mode. |
| */ |
| private int computeConversationWidth(int totalWidth) { |
| if (mListCollapsible) { |
| return totalWidth; |
| } else { |
| return totalWidth - (int) (totalWidth * mConversationListWeight); |
| } |
| } |
| |
| /** |
| * Computes the width of the folder list in stable state of the current mode. |
| */ |
| private int computeFolderListWidth(int parentWidth) { |
| if (mIsSearchResult) { |
| return 0; |
| } else if (isDrawerView(mFoldersView)) { |
| return 0; |
| } else { |
| return (int) (parentWidth * mFolderListWeight); |
| } |
| } |
| |
| private void dispatchConversationListVisibilityChange(boolean visible) { |
| if (mListener != null) { |
| mListener.onConversationListVisibilityChanged(visible); |
| } |
| } |
| |
| private void dispatchConversationVisibilityChanged(boolean visible) { |
| if (mListener != null) { |
| mListener.onConversationVisibilityChanged(visible); |
| } |
| } |
| |
| // does not apply to drawer children. will return zero for those. |
| private int getPaneWidth(View pane) { |
| return isDrawerView(pane) ? 0 : pane.getLayoutParams().width; |
| } |
| |
| private boolean isDrawerView(View child) { |
| return child != null && child.getParent() == mDrawerLayout; |
| } |
| |
| /** |
| * @return Whether or not the conversation list is visible on screen. |
| */ |
| public boolean isConversationListCollapsed() { |
| return !ViewMode.isListMode(mCurrentMode) && mListCollapsible; |
| } |
| |
| @Override |
| public void onViewModeChanged(int newMode) { |
| // make all initially GONE panes visible only when the view mode is first determined |
| if (mCurrentMode == ViewMode.UNKNOWN) { |
| mFoldersView.setVisibility(VISIBLE); |
| mListView.setVisibility(VISIBLE); |
| mListCopyView.setVisibility(VISIBLE); |
| } |
| |
| if (ViewMode.isAdMode(newMode)) { |
| mMiscellaneousView.setVisibility(VISIBLE); |
| mConversationView.setVisibility(GONE); |
| } else { |
| mConversationView.setVisibility(VISIBLE); |
| mMiscellaneousView.setVisibility(GONE); |
| } |
| |
| // set up the drawer as appropriate for the configuration |
| final ViewParent foldersParent = mFoldersView.getParent(); |
| if (mIsExpansiveLayout && foldersParent != this) { |
| if (foldersParent != mDrawerLayout) { |
| throw new IllegalStateException("invalid Folders fragment parent: " + |
| foldersParent); |
| } |
| mDrawerLayout.removeView(mFoldersView); |
| addView(mFoldersView, 0); |
| mFoldersView.findViewById(R.id.folders_pane_edge).setVisibility(VISIBLE); |
| mFoldersView.setBackgroundDrawable(null); |
| } else if (!mIsExpansiveLayout && foldersParent == this) { |
| removeView(mFoldersView); |
| mDrawerLayout.addView(mFoldersView); |
| final DrawerLayout.LayoutParams lp = |
| (DrawerLayout.LayoutParams) mFoldersView.getLayoutParams(); |
| lp.gravity = Gravity.START; |
| mFoldersView.setLayoutParams(lp); |
| mFoldersView.findViewById(R.id.folders_pane_edge).setVisibility(GONE); |
| mFoldersView.setBackgroundResource(R.color.list_background_color); |
| } |
| |
| // detach the pager immediately from its data source (to prevent processing updates) |
| if (ViewMode.isConversationMode(mCurrentMode)) { |
| mController.disablePagerUpdates(); |
| } |
| |
| mDrawerInitialSetupComplete = true; |
| mCurrentMode = newMode; |
| LogUtils.i(LOG_TAG, "onViewModeChanged(%d)", newMode); |
| |
| // do all the real work in onMeasure/onLayout, when panes are sized and positioned for the |
| // current width/height anyway |
| requestLayout(); |
| } |
| |
| public boolean isModeChangePending() { |
| return mPositionedMode != mCurrentMode; |
| } |
| |
| private void setPaneWidth(View pane, int w) { |
| final ViewGroup.LayoutParams lp = pane.getLayoutParams(); |
| if (lp.width == w) { |
| return; |
| } |
| lp.width = w; |
| pane.setLayoutParams(lp); |
| if (LogUtils.isLoggable(LOG_TAG, LogUtils.DEBUG)) { |
| final String s; |
| if (pane == mFoldersView) { |
| s = "folders"; |
| } else if (pane == mListView) { |
| s = "conv-list"; |
| } else if (pane == mConversationView) { |
| s = "conv-view"; |
| } else if (pane == mMiscellaneousView) { |
| s = "misc-view"; |
| } else { |
| s = "???:" + pane; |
| } |
| LogUtils.d(LOG_TAG, "TPL: setPaneWidth, w=%spx pane=%s", w, s); |
| } |
| } |
| |
| public boolean isDrawerEnabled() { |
| return !mIsExpansiveLayout && mDrawerInitialSetupComplete; |
| } |
| |
| public boolean isExpansiveLayout() { |
| return mIsExpansiveLayout; |
| } |
| } |