blob: d6cc8517e0bcd34dbc082c5ce2218b5dde3f0732 [file] [log] [blame]
package com.android.mail.ui;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.app.Activity;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.PixelFormat;
import android.graphics.Rect;
import android.util.AttributeSet;
import android.util.DisplayMetrics;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.view.Window;
import android.view.WindowManager;
import android.view.animation.AccelerateInterpolator;
import android.view.animation.DecelerateInterpolator;
import android.view.animation.Interpolator;
import android.widget.FrameLayout;
import android.widget.TextView;
import android.widget.Toast;
import com.android.mail.ConversationListContext;
import com.android.mail.R;
import com.android.mail.analytics.Analytics;
import com.android.mail.preferences.AccountPreferences;
import com.android.mail.preferences.MailPrefs;
import com.android.mail.providers.UIProvider.FolderCapabilities;
import com.android.mail.providers.UIProvider.FolderType;
import com.android.mail.ui.ConversationSyncDisabledTipView.ReasonSyncOff;
import com.android.mail.utils.LogTag;
import com.android.mail.utils.LogUtils;
import com.android.mail.utils.Utils;
/**
* Conversation list view contains a {@link SwipeableListView} and a sync status bar above it.
*/
public class ConversationListView extends FrameLayout implements SwipeableListView.SwipeListener {
private static final int MIN_DISTANCE_TO_TRIGGER_SYNC = 150; // dp
private static final int MAX_DISTANCE_TO_TRIGGER_SYNC = 300; // dp
private static final int DISTANCE_TO_IGNORE = 15; // dp
private static final int DISTANCE_TO_TRIGGER_CANCEL = 10; // dp
private static final int SHOW_CHECKING_FOR_MAIL_DURATION_IN_MILLIS = 1 * 1000; // 1 seconds
private static final int SWIPE_TEXT_APPEAR_DURATION_IN_MILLIS = 200;
private static final int SYNC_STATUS_BAR_FADE_DURATION_IN_MILLIS = 150;
private static final int SYNC_TRIGGER_SHRINK_DURATION_IN_MILLIS = 250;
// Max number of times we display the same sync turned off warning message in a toast.
// After we reach this max, and device/account still has sync off, we assume user has
// intentionally disabled sync and no longer warn.
private static final int MAX_NUM_OF_SYNC_TOASTS = 5;
private static final String LOG_TAG = LogTag.getLogTag();
private View mSyncTriggerBar;
private View mSyncProgressBar;
private final AnimatorListenerAdapter mSyncDismissListener;
private SwipeableListView mListView;
// Whether to ignore events in {#dispatchTouchEvent}.
private boolean mIgnoreTouchEvents = false;
private boolean mTrackingScrollMovement = false;
// Y coordinate of where scroll started
private float mTrackingScrollStartY;
// Max Y coordinate reached since starting scroll, this is used to know whether
// user moved back up which should cancel the current tracking state and hide the
// sync trigger bar.
private float mTrackingScrollMaxY;
private boolean mIsSyncing = false;
private final Interpolator mAccelerateInterpolator = new AccelerateInterpolator(1.5f);
private final Interpolator mDecelerateInterpolator = new DecelerateInterpolator(1.5f);
private float mDensity;
private ControllableActivity mActivity;
private final WindowManager mWindowManager;
private final HintText mHintText;
private boolean mHasHintTextViewBeenAdded = false;
// Minimum vertical distance (in dips) of swipe to trigger a sync.
// This value can be different based on the device.
private float mDistanceToTriggerSyncDp = MIN_DISTANCE_TO_TRIGGER_SYNC;
private ConversationListContext mConvListContext;
private final MailPrefs mMailPrefs;
private AccountPreferences mAccountPreferences;
// Instantiated through view inflation
@SuppressWarnings("unused")
public ConversationListView(Context context) {
this(context, null);
}
public ConversationListView(Context context, AttributeSet attrs) {
this(context, attrs, -1);
}
public ConversationListView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
mWindowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
mHintText = new ConversationListView.HintText(context);
mSyncDismissListener = new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator arg0) {
mSyncProgressBar.setVisibility(GONE);
mSyncTriggerBar.setVisibility(GONE);
}
};
mMailPrefs = MailPrefs.get(context);
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
mListView = (SwipeableListView) findViewById(android.R.id.list);
mListView.setSwipeListener(this);
DisplayMetrics displayMetrics = getResources().getDisplayMetrics();
mDensity = displayMetrics.density;
// Calculate distance threshold for triggering a sync based on
// screen height. Apply a min and max cutoff.
float threshold = (displayMetrics.heightPixels) / mDensity / 2.5f;
mDistanceToTriggerSyncDp = Math.max(
Math.min(threshold, MAX_DISTANCE_TO_TRIGGER_SYNC),
MIN_DISTANCE_TO_TRIGGER_SYNC);
}
protected void setActivity(ControllableActivity activity) {
mActivity = activity;
}
protected void setConversationContext(ConversationListContext convListContext) {
mConvListContext = convListContext;
mAccountPreferences = AccountPreferences.get(getContext(), convListContext.account.name);
}
@Override
public void onBeginSwipe() {
mIgnoreTouchEvents = true;
if (mTrackingScrollMovement) {
cancelMovementTracking();
}
}
private void addHintTextViewIfNecessary() {
if (!mHasHintTextViewBeenAdded) {
mWindowManager.addView(mHintText, getRefreshHintTextLayoutParams());
mHasHintTextViewBeenAdded = true;
}
}
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
// Delayed to this step because activity has to be running in order for view to be
// successfully added to the window manager.
addHintTextViewIfNecessary();
// First check for any events that can trigger end of a swipe, so we can reset
// mIgnoreTouchEvents back to false (it can only be set to true at beginning of swipe)
// via {#onBeginSwipe()} callback.
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
mIgnoreTouchEvents = false;
}
if (mIgnoreTouchEvents) {
return super.dispatchTouchEvent(event);
}
float y = event.getY(0);
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
if (mIsSyncing) {
break;
}
// Disable swipe to refresh in search results page
if (ConversationListContext.isSearchResult(mConvListContext)) {
break;
}
// Disable swipe to refresh in CAB mode
if (mActivity.getSelectedSet() != null &&
mActivity.getSelectedSet().size() > 0) {
break;
}
// Only if we have reached the top of the list, any further scrolling
// can potentially trigger a sync.
if (mListView.getChildCount() == 0 || mListView.getChildAt(0).getTop() == 0) {
startMovementTracking(y);
}
break;
case MotionEvent.ACTION_MOVE:
if (mTrackingScrollMovement) {
if (mActivity.getFolderController().getFolder().isDraft()) {
// Don't allow refreshing of DRAFT folders. See b/11158759
LogUtils.d(LOG_TAG, "ignoring swipe to refresh on DRAFT folder");
break;
}
if (mActivity.getFolderController().getFolder().supportsCapability(
FolderCapabilities.IS_VIRTUAL)) {
// Don't allow refreshing of virtual folders.
LogUtils.d(LOG_TAG, "ignoring swipe to refresh on virtual folder");
break;
}
// Sync is triggered when tap and drag distance goes over a certain threshold
float verticalDistancePx = y - mTrackingScrollStartY;
float verticalDistanceDp = verticalDistancePx / mDensity;
if (verticalDistanceDp > mDistanceToTriggerSyncDp) {
LogUtils.i(LOG_TAG, "Sync triggered from distance");
triggerSync();
break;
}
// Moving back up vertically should be handled the same as CANCEL / UP:
float verticalDistanceFromMaxPx = mTrackingScrollMaxY - y;
float verticalDistanceFromMaxDp = verticalDistanceFromMaxPx / mDensity;
if (verticalDistanceFromMaxDp > DISTANCE_TO_TRIGGER_CANCEL) {
cancelMovementTracking();
break;
}
// Otherwise hint how much further user needs to drag to trigger sync by
// expanding the sync status bar proportional to how far they have dragged.
if (verticalDistanceDp < DISTANCE_TO_IGNORE) {
// Ignore small movements such as tap
verticalDistanceDp = 0;
} else {
mHintText.displaySwipeToRefresh();
}
setTriggerScale(mAccelerateInterpolator.getInterpolation(
verticalDistanceDp/mDistanceToTriggerSyncDp));
if (y > mTrackingScrollMaxY) {
mTrackingScrollMaxY = y;
}
}
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
if (mTrackingScrollMovement) {
cancelMovementTracking();
}
break;
}
return super.dispatchTouchEvent(event);
}
private void startMovementTracking(float y) {
LogUtils.d(LOG_TAG, "Start swipe to refresh tracking");
mTrackingScrollMovement = true;
mTrackingScrollStartY = y;
mTrackingScrollMaxY = mTrackingScrollStartY;
}
private void cancelMovementTracking() {
if (mTrackingScrollMovement) {
// Shrink the status bar when user lifts finger and no sync has happened yet
if (mSyncTriggerBar != null) {
mSyncTriggerBar.animate()
.scaleX(0f)
.setInterpolator(mDecelerateInterpolator)
.setDuration(SYNC_TRIGGER_SHRINK_DURATION_IN_MILLIS)
.setListener(mSyncDismissListener)
.start();
}
mTrackingScrollMovement = false;
}
mHintText.hide();
}
private void setTriggerScale(float scale) {
if (scale == 0f && mSyncTriggerBar == null) {
// No-op. A null trigger means it's uninitialized, and setting it to zero-scale
// means we're trying to reset state, so there's nothing to reset in this case.
return;
} else if (mSyncTriggerBar != null) {
// reset any leftover trigger visual state
mSyncTriggerBar.animate().cancel();
mSyncTriggerBar.setVisibility(VISIBLE);
}
ensureProgressBars();
mSyncTriggerBar.setScaleX(scale);
}
private void ensureProgressBars() {
if (mSyncTriggerBar == null || mSyncProgressBar == null) {
final LayoutInflater inflater = LayoutInflater.from(getContext());
inflater.inflate(R.layout.conversation_list_progress, this, true /* attachToRoot */);
mSyncTriggerBar = findViewById(R.id.sync_trigger);
mSyncProgressBar = findViewById(R.id.progress);
}
}
private void triggerSync() {
ensureProgressBars();
mSyncTriggerBar.setVisibility(View.GONE);
Analytics.getInstance().sendEvent(Analytics.EVENT_CATEGORY_MENU_ITEM, "swipe_refresh", null,
0);
// This will call back to showSyncStatusBar():
mActivity.getFolderController().requestFolderRefresh();
// Any continued dragging after this should have no effect
mTrackingScrollMovement = false;
mHintText.displayCheckingForMailAndHideAfterDelay();
}
protected void showSyncStatusBar() {
if (!mIsSyncing) {
mIsSyncing = true;
LogUtils.i(LOG_TAG, "ConversationListView show sync status bar");
ensureProgressBars();
mSyncTriggerBar.setVisibility(GONE);
mSyncProgressBar.setVisibility(VISIBLE);
mSyncProgressBar.setAlpha(1f);
showToastIfSyncIsOff();
}
}
// If sync is turned off on this device or account, remind the user with a toast.
private void showToastIfSyncIsOff() {
final int reasonSyncOff = ConversationSyncDisabledTipView.calculateReasonSyncOff(
mMailPrefs, mConvListContext.account, mAccountPreferences);
switch (reasonSyncOff) {
case ReasonSyncOff.AUTO_SYNC_OFF:
// TODO: make this an actionable toast, tapping on it goes to Settings
int num = mMailPrefs.getNumOfDismissesForAutoSyncOff();
if (num > 0 && num <= MAX_NUM_OF_SYNC_TOASTS) {
Toast.makeText(getContext(), R.string.auto_sync_off, Toast.LENGTH_SHORT)
.show();
mMailPrefs.incNumOfDismissesForAutoSyncOff();
}
break;
case ReasonSyncOff.ACCOUNT_SYNC_OFF:
// TODO: make this an actionable toast, tapping on it goes to Settings
num = mAccountPreferences.getNumOfDismissesForAccountSyncOff();
if (num > 0 && num <= MAX_NUM_OF_SYNC_TOASTS) {
Toast.makeText(getContext(), R.string.account_sync_off, Toast.LENGTH_SHORT)
.show();
mAccountPreferences.incNumOfDismissesForAccountSyncOff();
}
break;
}
}
protected void onSyncFinished() {
// onSyncFinished() can get called several times as result of folder updates that maybe
// or may not be related to sync.
if (mIsSyncing) {
LogUtils.i(LOG_TAG, "ConversationListView hide sync status bar");
// Hide both the sync progress bar and sync trigger bar
mSyncProgressBar.animate().alpha(0f)
.setDuration(SYNC_STATUS_BAR_FADE_DURATION_IN_MILLIS)
.setListener(mSyncDismissListener);
mSyncTriggerBar.setVisibility(GONE);
// Hide the "Checking for mail" text in action bar if it isn't hidden already:
mHintText.hide();
mIsSyncing = false;
}
}
@Override
protected void onDetachedFromWindow() {
if (mHasHintTextViewBeenAdded) {
try {
mWindowManager.removeView(mHintText);
} catch (IllegalArgumentException e) {
// Have seen this happen on occasion during orientation change.
}
}
}
private WindowManager.LayoutParams getRefreshHintTextLayoutParams() {
// Create the "Swipe down to refresh" text view that covers the action bar.
Rect rect= new Rect();
Window window = mActivity.getWindow();
window.getDecorView().getWindowVisibleDisplayFrame(rect);
int statusBarHeight = rect.top;
final TypedArray actionBarSize = ((Activity) mActivity).obtainStyledAttributes(
new int[]{android.R.attr.actionBarSize});
int actionBarHeight = actionBarSize.getDimensionPixelSize(0, 0);
actionBarSize.recycle();
WindowManager.LayoutParams params = new WindowManager.LayoutParams(
WindowManager.LayoutParams.MATCH_PARENT,
actionBarHeight,
WindowManager.LayoutParams.TYPE_APPLICATION_PANEL,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
PixelFormat.TRANSLUCENT);
params.gravity = Gravity.TOP;
params.x = 0;
params.y = statusBarHeight;
return params;
}
/**
* A text view that covers the entire action bar, used for displaying
* "Swipe down to refresh" hint text if user has initiated a downward swipe.
*/
protected static class HintText extends FrameLayout {
private static final int NONE = 0;
private static final int SWIPE_TO_REFRESH = 1;
private static final int CHECKING_FOR_MAIL = 2;
// Can be one of NONE, SWIPE_TO_REFRESH, CHECKING_FOR_MAIL
private int mDisplay;
private final TextView mTextView;
private final Interpolator mDecelerateInterpolator = new DecelerateInterpolator(1.5f);
private final Interpolator mAccelerateInterpolator = new AccelerateInterpolator(1.5f);
private final Runnable mHideHintTextRunnable = new Runnable() {
@Override
public void run() {
hide();
}
};
private final Runnable mSetVisibilityGoneRunnable = new Runnable() {
@Override
public void run() {
setVisibility(View.GONE);
}
};
public HintText(final Context context) {
this(context, null);
}
public HintText(final Context context, final AttributeSet attrs) {
this(context, attrs, -1);
}
public HintText(final Context context, final AttributeSet attrs, final int defStyle) {
super(context, attrs, defStyle);
final LayoutInflater factory = LayoutInflater.from(context);
factory.inflate(R.layout.swipe_to_refresh, this);
mTextView = (TextView) findViewById(R.id.swipe_text);
mDisplay = NONE;
setVisibility(View.GONE);
// Set background color to be same as action bar color
final int actionBarRes = Utils.getActionBarBackgroundResource(context);
setBackgroundResource(actionBarRes);
}
private void displaySwipeToRefresh() {
if (mDisplay != SWIPE_TO_REFRESH) {
mTextView.setText(getResources().getText(R.string.swipe_down_to_refresh));
// Covers the current action bar:
setVisibility(View.VISIBLE);
setAlpha(1f);
// Animate text sliding down onto action bar:
mTextView.setY(-mTextView.getHeight());
mTextView.animate().y(0)
.setInterpolator(mDecelerateInterpolator)
.setDuration(SWIPE_TEXT_APPEAR_DURATION_IN_MILLIS);
mDisplay = SWIPE_TO_REFRESH;
}
}
private void displayCheckingForMailAndHideAfterDelay() {
mTextView.setText(getResources().getText(R.string.checking_for_mail));
setVisibility(View.VISIBLE);
mDisplay = CHECKING_FOR_MAIL;
postDelayed(mHideHintTextRunnable, SHOW_CHECKING_FOR_MAIL_DURATION_IN_MILLIS);
}
private void hide() {
if (mDisplay != NONE) {
// Animate text sliding up leaving behind a blank action bar
mTextView.animate().y(-mTextView.getHeight())
.setInterpolator(mAccelerateInterpolator)
.setDuration(SWIPE_TEXT_APPEAR_DURATION_IN_MILLIS)
.start();
animate().alpha(0f)
.setDuration(SWIPE_TEXT_APPEAR_DURATION_IN_MILLIS);
postDelayed(mSetVisibilityGoneRunnable, SWIPE_TEXT_APPEAR_DURATION_IN_MILLIS);
mDisplay = NONE;
}
}
}
}