Home | History | Annotate | Download | only in ui
      1 package com.android.mail.ui;
      2 
      3 import android.animation.Animator;
      4 import android.animation.AnimatorListenerAdapter;
      5 import android.app.Activity;
      6 import android.content.Context;
      7 import android.content.res.TypedArray;
      8 import android.graphics.PixelFormat;
      9 import android.graphics.Rect;
     10 import android.util.AttributeSet;
     11 import android.util.DisplayMetrics;
     12 import android.view.Gravity;
     13 import android.view.LayoutInflater;
     14 import android.view.MotionEvent;
     15 import android.view.View;
     16 import android.view.Window;
     17 import android.view.WindowManager;
     18 import android.view.animation.AccelerateInterpolator;
     19 import android.view.animation.DecelerateInterpolator;
     20 import android.view.animation.Interpolator;
     21 import android.widget.FrameLayout;
     22 import android.widget.TextView;
     23 import android.widget.Toast;
     24 
     25 import com.android.mail.ConversationListContext;
     26 import com.android.mail.R;
     27 import com.android.mail.analytics.Analytics;
     28 import com.android.mail.preferences.AccountPreferences;
     29 import com.android.mail.preferences.MailPrefs;
     30 import com.android.mail.providers.UIProvider.FolderCapabilities;
     31 import com.android.mail.providers.UIProvider.FolderType;
     32 import com.android.mail.ui.ConversationSyncDisabledTipView.ReasonSyncOff;
     33 import com.android.mail.utils.LogTag;
     34 import com.android.mail.utils.LogUtils;
     35 import com.android.mail.utils.Utils;
     36 
     37 /**
     38  * Conversation list view contains a {@link SwipeableListView} and a sync status bar above it.
     39  */
     40 public class ConversationListView extends FrameLayout implements SwipeableListView.SwipeListener {
     41 
     42     private static final int MIN_DISTANCE_TO_TRIGGER_SYNC = 150; // dp
     43     private static final int MAX_DISTANCE_TO_TRIGGER_SYNC = 300; // dp
     44 
     45     private static final int DISTANCE_TO_IGNORE = 15; // dp
     46     private static final int DISTANCE_TO_TRIGGER_CANCEL = 10; // dp
     47     private static final int SHOW_CHECKING_FOR_MAIL_DURATION_IN_MILLIS = 1 * 1000; // 1 seconds
     48 
     49     private static final int SWIPE_TEXT_APPEAR_DURATION_IN_MILLIS = 200;
     50     private static final int SYNC_STATUS_BAR_FADE_DURATION_IN_MILLIS = 150;
     51     private static final int SYNC_TRIGGER_SHRINK_DURATION_IN_MILLIS = 250;
     52 
     53     // Max number of times we display the same sync turned off warning message in a toast.
     54     // After we reach this max, and device/account still has sync off, we assume user has
     55     // intentionally disabled sync and no longer warn.
     56     private static final int MAX_NUM_OF_SYNC_TOASTS = 5;
     57 
     58     private static final String LOG_TAG = LogTag.getLogTag();
     59 
     60     private View mSyncTriggerBar;
     61     private View mSyncProgressBar;
     62     private final AnimatorListenerAdapter mSyncDismissListener;
     63     private SwipeableListView mListView;
     64 
     65     // Whether to ignore events in {#dispatchTouchEvent}.
     66     private boolean mIgnoreTouchEvents = false;
     67 
     68     private boolean mTrackingScrollMovement = false;
     69     // Y coordinate of where scroll started
     70     private float mTrackingScrollStartY;
     71     // Max Y coordinate reached since starting scroll, this is used to know whether
     72     // user moved back up which should cancel the current tracking state and hide the
     73     // sync trigger bar.
     74     private float mTrackingScrollMaxY;
     75     private boolean mIsSyncing = false;
     76 
     77     private final Interpolator mAccelerateInterpolator = new AccelerateInterpolator(1.5f);
     78     private final Interpolator mDecelerateInterpolator = new DecelerateInterpolator(1.5f);
     79 
     80     private float mDensity;
     81 
     82     private ControllableActivity mActivity;
     83     private final WindowManager mWindowManager;
     84     private final HintText mHintText;
     85     private boolean mHasHintTextViewBeenAdded = false;
     86 
     87     // Minimum vertical distance (in dips) of swipe to trigger a sync.
     88     // This value can be different based on the device.
     89     private float mDistanceToTriggerSyncDp = MIN_DISTANCE_TO_TRIGGER_SYNC;
     90 
     91     private ConversationListContext mConvListContext;
     92 
     93     private final MailPrefs mMailPrefs;
     94     private AccountPreferences mAccountPreferences;
     95 
     96     // Instantiated through view inflation
     97     @SuppressWarnings("unused")
     98     public ConversationListView(Context context) {
     99         this(context, null);
    100     }
    101 
    102     public ConversationListView(Context context, AttributeSet attrs) {
    103         this(context, attrs, -1);
    104     }
    105 
    106     public ConversationListView(Context context, AttributeSet attrs, int defStyle) {
    107         super(context, attrs, defStyle);
    108 
    109         mWindowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
    110         mHintText = new ConversationListView.HintText(context);
    111 
    112         mSyncDismissListener = new AnimatorListenerAdapter() {
    113             @Override
    114             public void onAnimationEnd(Animator arg0) {
    115                 mSyncProgressBar.setVisibility(GONE);
    116                 mSyncTriggerBar.setVisibility(GONE);
    117             }
    118         };
    119 
    120         mMailPrefs = MailPrefs.get(context);
    121     }
    122 
    123     @Override
    124     protected void onFinishInflate() {
    125         super.onFinishInflate();
    126         mListView = (SwipeableListView) findViewById(android.R.id.list);
    127         mListView.setSwipeListener(this);
    128 
    129         DisplayMetrics displayMetrics = getResources().getDisplayMetrics();
    130         mDensity = displayMetrics.density;
    131 
    132         // Calculate distance threshold for triggering a sync based on
    133         // screen height.  Apply a min and max cutoff.
    134         float threshold = (displayMetrics.heightPixels) / mDensity / 2.5f;
    135         mDistanceToTriggerSyncDp = Math.max(
    136                 Math.min(threshold, MAX_DISTANCE_TO_TRIGGER_SYNC),
    137                 MIN_DISTANCE_TO_TRIGGER_SYNC);
    138     }
    139 
    140     protected void setActivity(ControllableActivity activity) {
    141         mActivity = activity;
    142     }
    143 
    144     protected void setConversationContext(ConversationListContext convListContext) {
    145         mConvListContext = convListContext;
    146         mAccountPreferences = AccountPreferences.get(getContext(),
    147                 convListContext.account.getEmailAddress());
    148     }
    149 
    150     @Override
    151     public void onBeginSwipe() {
    152         mIgnoreTouchEvents = true;
    153         if (mTrackingScrollMovement) {
    154             cancelMovementTracking();
    155         }
    156     }
    157 
    158     private void addHintTextViewIfNecessary() {
    159         if (!mHasHintTextViewBeenAdded) {
    160             mWindowManager.addView(mHintText, getRefreshHintTextLayoutParams());
    161             mHasHintTextViewBeenAdded = true;
    162         }
    163     }
    164 
    165     @Override
    166     public boolean dispatchTouchEvent(MotionEvent event) {
    167         // Delayed to this step because activity has to be running in order for view to be
    168         // successfully added to the window manager.
    169         addHintTextViewIfNecessary();
    170 
    171         // First check for any events that can trigger end of a swipe, so we can reset
    172         // mIgnoreTouchEvents back to false (it can only be set to true at beginning of swipe)
    173         // via {#onBeginSwipe()} callback.
    174         switch (event.getAction()) {
    175             case MotionEvent.ACTION_DOWN:
    176             case MotionEvent.ACTION_UP:
    177             case MotionEvent.ACTION_CANCEL:
    178                 mIgnoreTouchEvents = false;
    179         }
    180 
    181         if (mIgnoreTouchEvents) {
    182             return super.dispatchTouchEvent(event);
    183         }
    184 
    185         float y = event.getY(0);
    186         switch (event.getAction()) {
    187             case MotionEvent.ACTION_DOWN:
    188                 if (mIsSyncing) {
    189                     break;
    190                 }
    191                 // Disable swipe to refresh in search results page
    192                 if (ConversationListContext.isSearchResult(mConvListContext)) {
    193                     break;
    194                 }
    195                 // Disable swipe to refresh in CAB mode
    196                 if (mActivity.getSelectedSet() != null &&
    197                         mActivity.getSelectedSet().size() > 0) {
    198                     break;
    199                 }
    200                 // Only if we have reached the top of the list, any further scrolling
    201                 // can potentially trigger a sync.
    202                 if (mListView.getChildCount() == 0 || mListView.getChildAt(0).getTop() == 0) {
    203                     startMovementTracking(y);
    204                 }
    205                 break;
    206             case MotionEvent.ACTION_MOVE:
    207                 if (mTrackingScrollMovement) {
    208                     if (mActivity.getFolderController().getFolder().isDraft()) {
    209                         // Don't allow refreshing of DRAFT folders. See b/11158759
    210                         LogUtils.d(LOG_TAG, "ignoring swipe to refresh on DRAFT folder");
    211                         break;
    212                     }
    213                     if (mActivity.getFolderController().getFolder().supportsCapability(
    214                             FolderCapabilities.IS_VIRTUAL)) {
    215                         // Don't allow refreshing of virtual folders.
    216                         LogUtils.d(LOG_TAG, "ignoring swipe to refresh on virtual folder");
    217                         break;
    218                     }
    219                     // Sync is triggered when tap and drag distance goes over a certain threshold
    220                     float verticalDistancePx = y - mTrackingScrollStartY;
    221                     float verticalDistanceDp = verticalDistancePx / mDensity;
    222                     if (verticalDistanceDp > mDistanceToTriggerSyncDp) {
    223                         LogUtils.i(LOG_TAG, "Sync triggered from distance");
    224                         triggerSync();
    225                         break;
    226                     }
    227 
    228                     // Moving back up vertically should be handled the same as CANCEL / UP:
    229                     float verticalDistanceFromMaxPx = mTrackingScrollMaxY - y;
    230                     float verticalDistanceFromMaxDp = verticalDistanceFromMaxPx / mDensity;
    231                     if (verticalDistanceFromMaxDp > DISTANCE_TO_TRIGGER_CANCEL) {
    232                         cancelMovementTracking();
    233                         break;
    234                     }
    235 
    236                     // Otherwise hint how much further user needs to drag to trigger sync by
    237                     // expanding the sync status bar proportional to how far they have dragged.
    238                     if (verticalDistanceDp < DISTANCE_TO_IGNORE) {
    239                         // Ignore small movements such as tap
    240                         verticalDistanceDp = 0;
    241                     } else {
    242                         mHintText.displaySwipeToRefresh();
    243                     }
    244                     setTriggerScale(mAccelerateInterpolator.getInterpolation(
    245                             verticalDistanceDp/mDistanceToTriggerSyncDp));
    246 
    247                     if (y > mTrackingScrollMaxY) {
    248                         mTrackingScrollMaxY = y;
    249                     }
    250                 }
    251                 break;
    252             case MotionEvent.ACTION_CANCEL:
    253             case MotionEvent.ACTION_UP:
    254                 if (mTrackingScrollMovement) {
    255                     cancelMovementTracking();
    256                 }
    257                 break;
    258         }
    259 
    260         return super.dispatchTouchEvent(event);
    261     }
    262 
    263     private void startMovementTracking(float y) {
    264         LogUtils.d(LOG_TAG, "Start swipe to refresh tracking");
    265         mTrackingScrollMovement = true;
    266         mTrackingScrollStartY = y;
    267         mTrackingScrollMaxY = mTrackingScrollStartY;
    268     }
    269 
    270     private void cancelMovementTracking() {
    271         if (mTrackingScrollMovement) {
    272             // Shrink the status bar when user lifts finger and no sync has happened yet
    273             if (mSyncTriggerBar != null) {
    274                 mSyncTriggerBar.animate()
    275                         .scaleX(0f)
    276                         .setInterpolator(mDecelerateInterpolator)
    277                         .setDuration(SYNC_TRIGGER_SHRINK_DURATION_IN_MILLIS)
    278                         .setListener(mSyncDismissListener)
    279                         .start();
    280             }
    281             mTrackingScrollMovement = false;
    282         }
    283         mHintText.hide();
    284     }
    285 
    286     private void setTriggerScale(float scale) {
    287         if (scale == 0f && mSyncTriggerBar == null) {
    288             // No-op. A null trigger means it's uninitialized, and setting it to zero-scale
    289             // means we're trying to reset state, so there's nothing to reset in this case.
    290             return;
    291         } else if (mSyncTriggerBar != null) {
    292             // reset any leftover trigger visual state
    293             mSyncTriggerBar.animate().cancel();
    294             mSyncTriggerBar.setVisibility(VISIBLE);
    295         }
    296         ensureProgressBars();
    297         mSyncTriggerBar.setScaleX(scale);
    298     }
    299 
    300     private void ensureProgressBars() {
    301         if (mSyncTriggerBar == null || mSyncProgressBar == null) {
    302             final LayoutInflater inflater = LayoutInflater.from(getContext());
    303             inflater.inflate(R.layout.conversation_list_progress, this, true /* attachToRoot */);
    304             mSyncTriggerBar = findViewById(R.id.sync_trigger);
    305             mSyncProgressBar = findViewById(R.id.progress);
    306         }
    307     }
    308 
    309     private void triggerSync() {
    310         ensureProgressBars();
    311         mSyncTriggerBar.setVisibility(View.GONE);
    312 
    313         Analytics.getInstance().sendEvent(Analytics.EVENT_CATEGORY_MENU_ITEM, "swipe_refresh", null,
    314                 0);
    315 
    316         // This will call back to showSyncStatusBar():
    317         mActivity.getFolderController().requestFolderRefresh();
    318 
    319         // Any continued dragging after this should have no effect
    320         mTrackingScrollMovement = false;
    321 
    322         mHintText.displayCheckingForMailAndHideAfterDelay();
    323     }
    324 
    325     protected void showSyncStatusBar() {
    326         if (!mIsSyncing) {
    327             mIsSyncing = true;
    328 
    329             LogUtils.i(LOG_TAG, "ConversationListView show sync status bar");
    330             ensureProgressBars();
    331             mSyncTriggerBar.setVisibility(GONE);
    332             mSyncProgressBar.setVisibility(VISIBLE);
    333             mSyncProgressBar.setAlpha(1f);
    334 
    335             showToastIfSyncIsOff();
    336         }
    337     }
    338 
    339     // If sync is turned off on this device or account, remind the user with a toast.
    340     private void showToastIfSyncIsOff() {
    341         final int reasonSyncOff = ConversationSyncDisabledTipView.calculateReasonSyncOff(
    342                 mMailPrefs, mConvListContext.account, mAccountPreferences);
    343         switch (reasonSyncOff) {
    344             case ReasonSyncOff.AUTO_SYNC_OFF:
    345                 // TODO: make this an actionable toast, tapping on it goes to Settings
    346                 int num = mMailPrefs.getNumOfDismissesForAutoSyncOff();
    347                 if (num > 0 && num <= MAX_NUM_OF_SYNC_TOASTS) {
    348                     Toast.makeText(getContext(), R.string.auto_sync_off, Toast.LENGTH_SHORT)
    349                             .show();
    350                     mMailPrefs.incNumOfDismissesForAutoSyncOff();
    351                 }
    352                 break;
    353             case ReasonSyncOff.ACCOUNT_SYNC_OFF:
    354                 // TODO: make this an actionable toast, tapping on it goes to Settings
    355                 num = mAccountPreferences.getNumOfDismissesForAccountSyncOff();
    356                 if (num > 0 && num <= MAX_NUM_OF_SYNC_TOASTS) {
    357                     Toast.makeText(getContext(), R.string.account_sync_off, Toast.LENGTH_SHORT)
    358                             .show();
    359                     mAccountPreferences.incNumOfDismissesForAccountSyncOff();
    360                 }
    361                 break;
    362         }
    363     }
    364 
    365     protected void onSyncFinished() {
    366         // onSyncFinished() can get called several times as result of folder updates that maybe
    367         // or may not be related to sync.
    368         if (mIsSyncing) {
    369             LogUtils.i(LOG_TAG, "ConversationListView hide sync status bar");
    370             // Hide both the sync progress bar and sync trigger bar
    371             mSyncProgressBar.animate().alpha(0f)
    372                     .setDuration(SYNC_STATUS_BAR_FADE_DURATION_IN_MILLIS)
    373                     .setListener(mSyncDismissListener);
    374             mSyncTriggerBar.setVisibility(GONE);
    375             // Hide the "Checking for mail" text in action bar if it isn't hidden already:
    376             mHintText.hide();
    377             mIsSyncing = false;
    378         }
    379     }
    380 
    381     @Override
    382     protected void onDetachedFromWindow() {
    383         if (mHasHintTextViewBeenAdded) {
    384             try {
    385                 mWindowManager.removeView(mHintText);
    386             } catch (IllegalArgumentException e) {
    387                 // Have seen this happen on occasion during orientation change.
    388             }
    389         }
    390     }
    391 
    392     private WindowManager.LayoutParams getRefreshHintTextLayoutParams() {
    393         // Create the "Swipe down to refresh" text view that covers the action bar.
    394         Rect rect= new Rect();
    395         Window window = mActivity.getWindow();
    396         window.getDecorView().getWindowVisibleDisplayFrame(rect);
    397         int statusBarHeight = rect.top;
    398 
    399         final TypedArray actionBarSize = ((Activity) mActivity).obtainStyledAttributes(
    400                 new int[]{android.R.attr.actionBarSize});
    401         int actionBarHeight = actionBarSize.getDimensionPixelSize(0, 0);
    402         actionBarSize.recycle();
    403 
    404         WindowManager.LayoutParams params = new WindowManager.LayoutParams(
    405                 WindowManager.LayoutParams.MATCH_PARENT,
    406                 actionBarHeight,
    407                 WindowManager.LayoutParams.TYPE_APPLICATION_PANEL,
    408                 WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
    409                 PixelFormat.TRANSLUCENT);
    410         params.gravity = Gravity.TOP;
    411         params.x = 0;
    412         params.y = statusBarHeight;
    413         return params;
    414     }
    415 
    416     /**
    417      * A text view that covers the entire action bar, used for displaying
    418      * "Swipe down to refresh" hint text if user has initiated a downward swipe.
    419      */
    420     protected static class HintText extends FrameLayout {
    421 
    422         private static final int NONE = 0;
    423         private static final int SWIPE_TO_REFRESH = 1;
    424         private static final int CHECKING_FOR_MAIL = 2;
    425 
    426         // Can be one of NONE, SWIPE_TO_REFRESH, CHECKING_FOR_MAIL
    427         private int mDisplay;
    428 
    429         private final TextView mTextView;
    430 
    431         private final Interpolator mDecelerateInterpolator = new DecelerateInterpolator(1.5f);
    432         private final Interpolator mAccelerateInterpolator = new AccelerateInterpolator(1.5f);
    433 
    434         private final Runnable mHideHintTextRunnable = new Runnable() {
    435             @Override
    436             public void run() {
    437                 hide();
    438             }
    439         };
    440         private final Runnable mSetVisibilityGoneRunnable = new Runnable() {
    441             @Override
    442             public void run() {
    443                 setVisibility(View.GONE);
    444             }
    445         };
    446 
    447         public HintText(final Context context) {
    448             this(context, null);
    449         }
    450 
    451         public HintText(final Context context, final AttributeSet attrs) {
    452             this(context, attrs, -1);
    453         }
    454 
    455         public HintText(final Context context, final AttributeSet attrs, final int defStyle) {
    456             super(context, attrs, defStyle);
    457 
    458             final LayoutInflater factory = LayoutInflater.from(context);
    459             factory.inflate(R.layout.swipe_to_refresh, this);
    460 
    461             mTextView = (TextView) findViewById(R.id.swipe_text);
    462 
    463             mDisplay = NONE;
    464             setVisibility(View.GONE);
    465 
    466             // Set background color to be same as action bar color
    467             final int actionBarRes = Utils.getActionBarBackgroundResource(context);
    468             setBackgroundResource(actionBarRes);
    469         }
    470 
    471         private void displaySwipeToRefresh() {
    472             if (mDisplay != SWIPE_TO_REFRESH) {
    473                 mTextView.setText(getResources().getText(R.string.swipe_down_to_refresh));
    474                 // Covers the current action bar:
    475                 setVisibility(View.VISIBLE);
    476                 setAlpha(1f);
    477                 // Animate text sliding down onto action bar:
    478                 mTextView.setY(-mTextView.getHeight());
    479                 mTextView.animate().y(0)
    480                         .setInterpolator(mDecelerateInterpolator)
    481                         .setDuration(SWIPE_TEXT_APPEAR_DURATION_IN_MILLIS);
    482                 mDisplay = SWIPE_TO_REFRESH;
    483             }
    484         }
    485 
    486         private void displayCheckingForMailAndHideAfterDelay() {
    487             mTextView.setText(getResources().getText(R.string.checking_for_mail));
    488             setVisibility(View.VISIBLE);
    489             mDisplay = CHECKING_FOR_MAIL;
    490             postDelayed(mHideHintTextRunnable, SHOW_CHECKING_FOR_MAIL_DURATION_IN_MILLIS);
    491         }
    492 
    493         private void hide() {
    494             if (mDisplay != NONE) {
    495                 // Animate text sliding up leaving behind a blank action bar
    496                 mTextView.animate().y(-mTextView.getHeight())
    497                         .setInterpolator(mAccelerateInterpolator)
    498                         .setDuration(SWIPE_TEXT_APPEAR_DURATION_IN_MILLIS)
    499                         .start();
    500                 animate().alpha(0f)
    501                         .setDuration(SWIPE_TEXT_APPEAR_DURATION_IN_MILLIS);
    502                 postDelayed(mSetVisibilityGoneRunnable, SWIPE_TEXT_APPEAR_DURATION_IN_MILLIS);
    503                 mDisplay = NONE;
    504             }
    505         }
    506     }
    507 }
    508