1package com.android.mail.ui; 2 3import android.animation.Animator; 4import android.animation.AnimatorListenerAdapter; 5import android.app.Activity; 6import android.content.Context; 7import android.content.res.TypedArray; 8import android.graphics.PixelFormat; 9import android.graphics.Rect; 10import android.util.AttributeSet; 11import android.util.DisplayMetrics; 12import android.view.Gravity; 13import android.view.LayoutInflater; 14import android.view.MotionEvent; 15import android.view.View; 16import android.view.Window; 17import android.view.WindowManager; 18import android.view.animation.AccelerateInterpolator; 19import android.view.animation.DecelerateInterpolator; 20import android.view.animation.Interpolator; 21import android.widget.FrameLayout; 22import android.widget.TextView; 23import android.widget.Toast; 24 25import com.android.mail.ConversationListContext; 26import com.android.mail.R; 27import com.android.mail.analytics.Analytics; 28import com.android.mail.preferences.AccountPreferences; 29import com.android.mail.preferences.MailPrefs; 30import com.android.mail.providers.UIProvider.FolderCapabilities; 31import com.android.mail.providers.UIProvider.FolderType; 32import com.android.mail.ui.ConversationSyncDisabledTipView.ReasonSyncOff; 33import com.android.mail.utils.LogTag; 34import com.android.mail.utils.LogUtils; 35import com.android.mail.utils.Utils; 36 37/** 38 * Conversation list view contains a {@link SwipeableListView} and a sync status bar above it. 39 */ 40public 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