| 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; |
| } |
| } |
| } |
| } |