/* * Copyright (C) 2010 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.email.activity; import com.android.email.Email; import com.android.email.R; import android.animation.Animator; import android.animation.ObjectAnimator; import android.animation.PropertyValuesHolder; import android.animation.TimeInterpolator; import android.content.Context; import android.content.res.Resources; import android.os.Parcel; import android.os.Parcelable; import android.util.AttributeSet; import android.util.Log; import android.view.View; import android.view.ViewGroup; import android.view.animation.DecelerateInterpolator; import android.widget.LinearLayout; /** * The "three pane" layout used on tablet. * * It'll encapsulate the behavioral differences between portrait mode and landscape mode. * * TODO Unit tests, when UX is settled. */ public class ThreePaneLayout extends LinearLayout implements View.OnClickListener { private static final boolean ANIMATION_DEBUG = false; // DON'T SUBMIT WITH true // STOPSHIP Make sure we're using the same parameters as gmail does private static final int ANIMATION_DURATION = ANIMATION_DEBUG ? 1000 : 80; private static final TimeInterpolator INTERPOLATOR = new DecelerateInterpolator(1.5f); /** Uninitialized state -- {@link #changePaneState} hasn't been called yet. */ private static final int STATE_UNINITIALIZED = 0; /** Mailbox list + message list */ private static final int STATE_LEFT_VISIBLE = 1; /** Message view on portrait, + message list on landscape. */ private static final int STATE_RIGHT_VISIBLE = 2; /** Portrait mode only: message view + expanded message list */ private static final int STATE_PORTRAIT_MIDDLE_EXPANDED = 3; // Flags for getVisiblePanes() public static final int PANE_LEFT = 1 << 2; public static final int PANE_MIDDLE = 1 << 1; public static final int PANE_RIGHT = 1 << 0; /** Current pane state. See {@link #changePaneState} */ private int mPaneState = STATE_UNINITIALIZED; /** See {@link #changePaneState} and {@link #onFirstSizeChanged} */ private int mInitialPaneState = STATE_UNINITIALIZED; private View mLeftPane; private View mMiddlePane; private View mRightPane; // Views used only on portrait private View mFoggedGlass; private boolean mFirstSizeChangedDone; /** Mailbox list width. Comes from resources. */ private int mMailboxListWidth; /** * Message list width, on: * - the message list + message view mode, on landscape. * - the message view + expanded message list mode, on portrait. * Comes from resources. */ private int mMessageListWidth; /** Hold last animator to cancel. */ private Animator mLastAnimator; /** * Hold last animator listener to cancel. See {@link #startLayoutAnimation} for why * we need both {@link #mLastAnimator} and {@link #mLastAnimatorListener} */ private AnimatorListener mLastAnimatorListener; // Arrays used in {@link #changePaneState} private View[] mViewsLeft; private View[] mViewsRight; private View[] mViewsLeftMiddle; private View[] mViewsMiddleRightFogged; private View[] mViewsLeftMiddleFogged; private Callback mCallback = EmptyCallback.INSTANCE; public interface Callback { /** Called when {@link ThreePaneLayout#getVisiblePanes()} has changed. */ public void onVisiblePanesChanged(int previousVisiblePanes); } private static final class EmptyCallback implements Callback { public static final Callback INSTANCE = new EmptyCallback(); @Override public void onVisiblePanesChanged(int previousVisiblePanes) {} } public ThreePaneLayout(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); initView(); } public ThreePaneLayout(Context context, AttributeSet attrs) { super(context, attrs); initView(); } public ThreePaneLayout(Context context) { super(context); initView(); } /** Perform basic initialization */ private void initView() { setOrientation(LinearLayout.HORIZONTAL); // Always horizontal } @Override protected void onFinishInflate() { super.onFinishInflate(); mLeftPane = findViewById(R.id.left_pane); mMiddlePane = findViewById(R.id.middle_pane); mFoggedGlass = findViewById(R.id.fogged_glass); if (mFoggedGlass != null) { // If it's around, it's portrait. mRightPane = findViewById(R.id.right_pane_with_fog); mFoggedGlass.setOnClickListener(this); } else { // landscape mRightPane = findViewById(R.id.right_pane); } mViewsLeft = new View[] {mLeftPane}; mViewsRight = new View[] {mRightPane}; mViewsLeftMiddle = new View[] {mLeftPane, mMiddlePane}; mViewsMiddleRightFogged = new View[] {mMiddlePane, mRightPane, mFoggedGlass}; mViewsLeftMiddleFogged = new View[] {mLeftPane, mMiddlePane, mFoggedGlass}; mInitialPaneState = STATE_LEFT_VISIBLE; final Resources resources = getResources(); mMailboxListWidth = getResources().getDimensionPixelSize( R.dimen.mailbox_list_width); mMessageListWidth = getResources().getDimensionPixelSize(R.dimen.message_list_width); } public void setCallback(Callback callback) { mCallback = (callback == null) ? EmptyCallback.INSTANCE : callback; } private boolean isLandscape() { return mFoggedGlass == null; } @Override protected Parcelable onSaveInstanceState() { SavedState ss = new SavedState(super.onSaveInstanceState()); ss.mPaneState = mPaneState; return ss; } @Override protected void onRestoreInstanceState(Parcelable state) { // Called after onFinishInflate() SavedState ss = (SavedState) state; super.onRestoreInstanceState(ss.getSuperState()); mInitialPaneState = ss.mPaneState; } @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); if (!mFirstSizeChangedDone) { mFirstSizeChangedDone = true; onFirstSizeChanged(); } } /** * @return bit flags for visible panes. Combination of {@link #PANE_LEFT}, {@link #PANE_MIDDLE} * and {@link #PANE_RIGHT}, */ public int getVisiblePanes() { int ret = 0; if (mLeftPane.getVisibility() == View.VISIBLE) ret |= PANE_LEFT; if (mMiddlePane.getVisibility() == View.VISIBLE) ret |= PANE_MIDDLE; if (mRightPane.getVisibility() == View.VISIBLE) ret |= PANE_RIGHT; return ret; } public boolean onBackPressed() { if (isLandscape()) { switch (mPaneState) { case STATE_RIGHT_VISIBLE: changePaneState(STATE_LEFT_VISIBLE, true); // Close the right pane return true; } } else { switch (mPaneState) { case STATE_RIGHT_VISIBLE: changePaneState(STATE_PORTRAIT_MIDDLE_EXPANDED, true); return true; case STATE_PORTRAIT_MIDDLE_EXPANDED: changePaneState(STATE_LEFT_VISIBLE, true); return true; } } return false; } /** * Show the left most pane. (i.e. mailbox list) */ public void showLeftPane() { changePaneState(STATE_LEFT_VISIBLE, true); } /** * Before the first call to {@link #onSizeChanged}, we don't know the width of the view, so we * can't layout properly. We just remember all the requests to {@link #changePaneState} * until the first {@link #onSizeChanged}, at which point we actually change to the last * requested state. */ private void onFirstSizeChanged() { if (mInitialPaneState != STATE_UNINITIALIZED) { changePaneState(mInitialPaneState, false); mInitialPaneState = STATE_UNINITIALIZED; } } /** * Show the right most pane. (i.e. message view) */ public void showRightPane() { changePaneState(STATE_RIGHT_VISIBLE, true); } private void changePaneState(int newState, boolean animate) { if (isLandscape() && (newState == STATE_PORTRAIT_MIDDLE_EXPANDED)) { newState = STATE_RIGHT_VISIBLE; } if (!mFirstSizeChangedDone) { // Before first onSizeChanged(), we don't know the width of the view, so we can't // layout properly. // Just remember the new state and return. mInitialPaneState = newState; return; } if (newState == mPaneState) { return; } // Just make sure the first transition doesn't animate. if (mPaneState == STATE_UNINITIALIZED) { animate = false; } final int previousVisiblePanes = getVisiblePanes(); mPaneState = newState; // Animate to the new state. // (We still use animator even if animate == false; we just use 0 duration.) final int totalWidth = getMeasuredWidth(); final int expectedMailboxLeft; final int expectedMessageListWidth; final String animatorLabel; // for debug purpose final View[] viewsToShow; final View[] viewsToHide; if (isLandscape()) { // Landscape setViewWidth(mLeftPane, mMailboxListWidth); setViewWidth(mRightPane, totalWidth - mMessageListWidth); switch (mPaneState) { case STATE_LEFT_VISIBLE: // mailbox + message list animatorLabel = "moving to [mailbox list + message list]"; expectedMailboxLeft = 0; expectedMessageListWidth = totalWidth - mMailboxListWidth; viewsToShow = mViewsLeft; viewsToHide = mViewsRight; break; case STATE_RIGHT_VISIBLE: // message list + message view animatorLabel = "moving to [message list + message view]"; expectedMailboxLeft = -mMailboxListWidth; expectedMessageListWidth = mMessageListWidth; viewsToShow = mViewsRight; viewsToHide = mViewsLeft; break; default: throw new IllegalStateException(); } } else { // Portrait setViewWidth(mLeftPane, mMailboxListWidth); setViewWidth(mRightPane, totalWidth); switch (mPaneState) { case STATE_LEFT_VISIBLE: // message list + Message view -> mailbox + message list animatorLabel = "moving to [mailbox list + message list]"; expectedMailboxLeft = 0; expectedMessageListWidth = totalWidth - mMailboxListWidth; viewsToShow = mViewsLeftMiddle; viewsToHide = mViewsRight; break; case STATE_PORTRAIT_MIDDLE_EXPANDED: // mailbox + message list -> message list + message view animatorLabel = "moving to [message list + message view]"; expectedMailboxLeft = -mMailboxListWidth; expectedMessageListWidth = mMessageListWidth; viewsToShow = mViewsMiddleRightFogged; viewsToHide = mViewsLeft; break; case STATE_RIGHT_VISIBLE: // message view only animatorLabel = "moving to [message view]"; expectedMailboxLeft = -(mMailboxListWidth + mMessageListWidth); expectedMessageListWidth = mMessageListWidth; viewsToShow = mViewsRight; viewsToHide = mViewsLeftMiddleFogged; break; default: throw new IllegalStateException(); } } final AnimatorListener listener = new AnimatorListener(animatorLabel, viewsToShow, viewsToHide, previousVisiblePanes) ; // Animation properties -- mailbox list left and message list width, at the same time. startLayoutAnimation(animate ? ANIMATION_DURATION : 0, listener, PropertyValuesHolder.ofInt(PROP_MAILBOX_LIST_LEFT, getCurrentMailboxLeft(), expectedMailboxLeft), PropertyValuesHolder.ofInt(PROP_MESSAGE_LIST_WIDTH, getCurrentMessageListWidth(), expectedMessageListWidth) ); } /** * @return The ID of the view for the left pane fragment. (i.e. mailbox list) */ public int getLeftPaneId() { return R.id.left_pane; } /** * @return The ID of the view for the middle pane fragment. (i.e. message list) */ public int getMiddlePaneId() { return R.id.middle_pane; } /** * @return The ID of the view for the right pane fragment. (i.e. message view) */ public int getRightPaneId() { return R.id.right_pane; } @Override public void onClick(View v) { switch (v.getId()) { case R.id.fogged_glass: if (isLandscape()) { return; // Shouldn't happen } changePaneState(STATE_RIGHT_VISIBLE, true); break; } } private void setViewWidth(View v, int value) { v.getLayoutParams().width = value; requestLayout(); } private static final String PROP_MAILBOX_LIST_LEFT = "mailboxListLeftAnim"; private static final String PROP_MESSAGE_LIST_WIDTH = "messageListWidthAnim"; @SuppressWarnings("unused") public void setMailboxListLeftAnim(int value) { ((ViewGroup.MarginLayoutParams) mLeftPane.getLayoutParams()).leftMargin = value; requestLayout(); } @SuppressWarnings("unused") public void setMessageListWidthAnim(int value) { setViewWidth(mMiddlePane, value); } private int getCurrentMailboxLeft() { return ((ViewGroup.MarginLayoutParams) mLeftPane.getLayoutParams()).leftMargin; } private int getCurrentMessageListWidth() { return mMiddlePane.getLayoutParams().width; } /** * Helper method to start animation. */ private void startLayoutAnimation(int duration, AnimatorListener listener, PropertyValuesHolder... values) { if (mLastAnimator != null) { mLastAnimator.cancel(); } if (mLastAnimatorListener != null) { if (ANIMATION_DEBUG) { Log.w(Email.LOG_TAG, "Anim: Cancelling last animation: " + mLastAnimator); } // Animator.cancel() doesn't call listener.cancel() immediately, so sometimes // we end up cancelling the previous one *after* starting the next one. // Directly tell the listener it's cancelled to avoid that. mLastAnimatorListener.cancel(); } final ObjectAnimator animator = ObjectAnimator.ofPropertyValuesHolder( this, values).setDuration(duration); animator.setInterpolator(INTERPOLATOR); if (listener != null) { animator.addListener(listener); } mLastAnimator = animator; mLastAnimatorListener = listener; animator.start(); } /** * Animation listener. * * Update the visibility of each pane before/after an animation. */ private class AnimatorListener implements Animator.AnimatorListener { private final String mLogLabel; private final View[] mViewsToShow; private final View[] mViewsToHide; private final int mPreviousVisiblePanes; private boolean mCancelled; public AnimatorListener(String logLabel, View[] viewsToShow, View[] viewsToHide, int previousVisiblePanes) { mLogLabel = logLabel; mViewsToShow = viewsToShow; mViewsToHide = viewsToHide; mPreviousVisiblePanes = previousVisiblePanes; } private void log(String message) { if (ANIMATION_DEBUG) { Log.w(Email.LOG_TAG, "Anim: " + mLogLabel + "[" + this + "] " + message); } } public void cancel() { log("cancel"); mCancelled = true; } /** * Show the about-to-become-visible panes before an animation. */ @Override public void onAnimationStart(Animator animation) { log("start"); for (View v : mViewsToShow) { v.setVisibility(View.VISIBLE); } mCallback.onVisiblePanesChanged(mPreviousVisiblePanes); } @Override public void onAnimationRepeat(Animator animation) { } @Override public void onAnimationCancel(Animator animation) { } /** * Hide the about-to-become-hidden panes after an animation. */ @Override public void onAnimationEnd(Animator animation) { if (mCancelled) { return; // But they shouldn't be hidden when cancelled. } log("end"); for (View v : mViewsToHide) { v.setVisibility(View.INVISIBLE); } mCallback.onVisiblePanesChanged(mPreviousVisiblePanes); } } private static class SavedState extends BaseSavedState { int mPaneState; /** * Constructor called from {@link ThreePaneLayout#onSaveInstanceState()} */ SavedState(Parcelable superState) { super(superState); } /** * Constructor called from {@link #CREATOR} */ private SavedState(Parcel in) { super(in); mPaneState = in.readInt(); } @Override public void writeToParcel(Parcel out, int flags) { super.writeToParcel(out, flags); out.writeInt(mPaneState); } public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { public SavedState createFromParcel(Parcel in) { return new SavedState(in); } public SavedState[] newArray(int size) { return new SavedState[size]; } }; } }