AnimatedAdapter.java revision f21787ad2ac7d68e3620ffa3ae6e8e7fadf0bd54
1/* 2 * Copyright (C) 2012 Google Inc. 3 * Licensed to The Android Open Source Project. 4 * 5 * Licensed under the Apache License, Version 2.0 (the "License"); 6 * you may not use this file except in compliance with the License. 7 * You may obtain a copy of the License at 8 * 9 * http://www.apache.org/licenses/LICENSE-2.0 10 * 11 * Unless required by applicable law or agreed to in writing, software 12 * distributed under the License is distributed on an "AS IS" BASIS, 13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 * See the License for the specific language governing permissions and 15 * limitations under the License. 16 */ 17 18package com.android.mail.ui; 19 20import android.animation.Animator; 21import android.animation.Animator.AnimatorListener; 22import android.animation.AnimatorListenerAdapter; 23import android.animation.AnimatorSet; 24import android.animation.ObjectAnimator; 25import android.content.Context; 26import android.content.res.Resources; 27import android.database.Cursor; 28import android.os.Bundle; 29import android.os.Handler; 30import android.os.Looper; 31import android.support.v4.text.BidiFormatter; 32import android.util.SparseArray; 33import android.view.LayoutInflater; 34import android.view.View; 35import android.view.ViewGroup; 36import android.widget.AbsListView.OnScrollListener; 37import android.widget.SimpleCursorAdapter; 38 39import com.android.oldbitmap.AltBitmapCache; 40import com.android.oldbitmap.BitmapCache; 41import com.android.oldbitmap.DecodeAggregator; 42import com.android.mail.R; 43import com.android.mail.analytics.Analytics; 44import com.android.mail.bitmap.ContactResolver; 45import com.android.mail.browse.ConversationCursor; 46import com.android.mail.browse.ConversationItemView; 47import com.android.mail.browse.ConversationItemViewCoordinates.CoordinatesCache; 48import com.android.mail.browse.SwipeableConversationItemView; 49import com.android.mail.preferences.MailPrefs; 50import com.android.mail.providers.Account; 51import com.android.mail.providers.AccountObserver; 52import com.android.mail.providers.Conversation; 53import com.android.mail.providers.Folder; 54import com.android.mail.providers.UIProvider; 55import com.android.mail.providers.UIProvider.ConversationListIcon; 56import com.android.mail.ui.SwipeableListView.ListItemsRemovedListener; 57import com.android.mail.utils.LogTag; 58import com.android.mail.utils.LogUtils; 59import com.android.mail.utils.Utils; 60import com.google.common.collect.Maps; 61 62import java.util.ArrayList; 63import java.util.Collection; 64import java.util.HashMap; 65import java.util.HashSet; 66import java.util.Iterator; 67import java.util.List; 68import java.util.Map.Entry; 69 70public class AnimatedAdapter extends SimpleCursorAdapter { 71 private static int sDismissAllShortDelay = -1; 72 private static int sDismissAllLongDelay = -1; 73 private static final String LAST_DELETING_ITEMS = "last_deleting_items"; 74 private static final String LEAVE_BEHIND_ITEM_DATA = "leave_behind_item_data"; 75 private static final String LEAVE_BEHIND_ITEM_ID = "leave_behind_item_id"; 76 private final static int TYPE_VIEW_CONVERSATION = 0; 77 private final static int TYPE_VIEW_FOOTER = 1; 78 private final static int TYPE_VIEW_DONT_RECYCLE = -1; 79 private final HashSet<Long> mDeletingItems = new HashSet<Long>(); 80 private final ArrayList<Long> mLastDeletingItems = new ArrayList<Long>(); 81 private final HashSet<Long> mUndoingItems = new HashSet<Long>(); 82 private final HashSet<Long> mSwipeDeletingItems = new HashSet<Long>(); 83 private final HashSet<Long> mSwipeUndoingItems = new HashSet<Long>(); 84 private final HashMap<Long, SwipeableConversationItemView> mAnimatingViews = 85 new HashMap<Long, SwipeableConversationItemView>(); 86 private final HashMap<Long, LeaveBehindItem> mFadeLeaveBehindItems = 87 new HashMap<Long, LeaveBehindItem>(); 88 /** The current account */ 89 private Account mAccount; 90 private final Context mContext; 91 private final ConversationSelectionSet mBatchConversations; 92 private Runnable mCountDown; 93 private final Handler mHandler; 94 protected long mLastLeaveBehind = -1; 95 96 private final AnimatorListener mAnimatorListener = new AnimatorListenerAdapter() { 97 98 @Override 99 public void onAnimationStart(Animator animation) { 100 if (!mUndoingItems.isEmpty()) { 101 mDeletingItems.clear(); 102 mLastDeletingItems.clear(); 103 mSwipeDeletingItems.clear(); 104 } 105 } 106 107 @Override 108 public void onAnimationEnd(Animator animation) { 109 Object obj; 110 if (animation instanceof AnimatorSet) { 111 AnimatorSet set = (AnimatorSet) animation; 112 obj = ((ObjectAnimator) set.getChildAnimations().get(0)).getTarget(); 113 } else { 114 obj = ((ObjectAnimator) animation).getTarget(); 115 } 116 updateAnimatingConversationItems(obj, mSwipeDeletingItems); 117 updateAnimatingConversationItems(obj, mDeletingItems); 118 updateAnimatingConversationItems(obj, mSwipeUndoingItems); 119 updateAnimatingConversationItems(obj, mUndoingItems); 120 if (hasFadeLeaveBehinds() && obj instanceof LeaveBehindItem) { 121 LeaveBehindItem objItem = (LeaveBehindItem) obj; 122 clearLeaveBehind(objItem.getConversationId()); 123 objItem.commit(); 124 if (!hasFadeLeaveBehinds()) { 125 // Cancel any existing animations on the remaining leave behind 126 // item and start fading in text immediately. 127 LeaveBehindItem item = getLastLeaveBehindItem(); 128 if (item != null) { 129 boolean cancelled = item.cancelFadeInTextAnimationIfNotStarted(); 130 if (cancelled) { 131 item.startFadeInTextAnimation(0 /* delay start */); 132 } 133 } 134 } 135 // The view types have changed, since the animating views are gone. 136 notifyDataSetChanged(); 137 } 138 139 if (!isAnimating()) { 140 mActivity.onAnimationEnd(AnimatedAdapter.this); 141 } 142 } 143 144 }; 145 146 /** 147 * The next action to perform. Do not read or write this. All accesses should 148 * be in {@link #performAndSetNextAction(SwipeableListView.ListItemsRemovedListener)} which 149 * commits the previous action, if any. 150 */ 151 private ListItemsRemovedListener mPendingDestruction; 152 153 /** 154 * A destructive action that refreshes the list and performs no other action. 155 */ 156 private final ListItemsRemovedListener mRefreshAction = new ListItemsRemovedListener() { 157 @Override 158 public void onListItemsRemoved() { 159 notifyDataSetChanged(); 160 } 161 }; 162 163 public interface Listener { 164 void onAnimationEnd(AnimatedAdapter adapter); 165 } 166 167 private View mFooter; 168 private boolean mShowFooter; 169 private Folder mFolder; 170 private final SwipeableListView mListView; 171 private boolean mSwipeEnabled; 172 private final HashMap<Long, LeaveBehindItem> mLeaveBehindItems = Maps.newHashMap(); 173 /** True if importance markers are enabled, false otherwise. */ 174 private boolean mImportanceMarkersEnabled; 175 /** 176 * True if chevrons (personal level indicators) should be shown: 177 * an arrow ( › ) by messages sent to my address (not a mailing list), 178 * and a double arrow ( » ) by messages sent only to me. 179 */ 180 private boolean mShowChevronsEnabled; 181 private final ControllableActivity mActivity; 182 private final AccountObserver mAccountListener = new AccountObserver() { 183 @Override 184 public void onChanged(Account newAccount) { 185 if (setAccount(newAccount)) { 186 notifyDataSetChanged(); 187 } 188 } 189 }; 190 191 /** 192 * A list of all views that are not conversations. These include temporary views from 193 * {@link #mFleetingViews}. 194 */ 195 private final SparseArray<ConversationSpecialItemView> mSpecialViews; 196 197 private final CoordinatesCache mCoordinatesCache = new CoordinatesCache(); 198 199 /** 200 * Temporary views insert at specific positions relative to conversations. These can be 201 * related to showing new features (on-boarding) or showing information about new mailboxes 202 * that have been added by the system. 203 */ 204 private final List<ConversationSpecialItemView> mFleetingViews; 205 206 private final BidiFormatter mBidiFormatter = BidiFormatter.getInstance(); 207 208 /** 209 * @return <code>true</code> if a relevant part of the account has changed, <code>false</code> 210 * otherwise 211 */ 212 private boolean setAccount(Account newAccount) { 213 final boolean accountChanged; 214 if (mAccount != null && mAccount.uri.equals(newAccount.uri) 215 && mAccount.settings.importanceMarkersEnabled == 216 newAccount.settings.importanceMarkersEnabled 217 && mAccount.supportsCapability(UIProvider.AccountCapabilities.UNDO) == 218 newAccount.supportsCapability(UIProvider.AccountCapabilities.UNDO) 219 && mAccount.settings.convListIcon == newAccount.settings.convListIcon 220 && mAccount.settings.convListAttachmentPreviews == 221 newAccount.settings.convListAttachmentPreviews) { 222 accountChanged = false; 223 } else { 224 accountChanged = true; 225 } 226 227 mAccount = newAccount; 228 mImportanceMarkersEnabled = mAccount.settings.importanceMarkersEnabled; 229 mShowChevronsEnabled = mAccount.settings.showChevronsEnabled; 230 mSwipeEnabled = mAccount.supportsCapability(UIProvider.AccountCapabilities.UNDO); 231 232 Analytics.getInstance().setCustomDimension(Analytics.CD_INDEX_SENDER_IMAGES_ENABLED, Boolean 233 .toString(newAccount.settings.convListIcon == ConversationListIcon.SENDER_IMAGE)); 234 Analytics.getInstance().setCustomDimension(Analytics.CD_INDEX_ATTACHMENT_PREVIEWS_ENABLED, 235 Boolean.toString(newAccount.settings.convListAttachmentPreviews)); 236 Analytics.getInstance().setCustomDimension(Analytics.CD_INDEX_REPLY_ALL_SETTING, 237 (newAccount.settings.replyBehavior == UIProvider.DefaultReplyBehavior.REPLY) 238 ? "reply" 239 : "reply_all"); 240 Analytics.getInstance().setCustomDimension(Analytics.CD_INDEX_AUTO_ADVANCE, 241 UIProvider.AutoAdvance.getAutoAdvanceStr( 242 newAccount.settings.getAutoAdvanceSetting())); 243 244 return accountChanged; 245 } 246 247 private static final String LOG_TAG = LogTag.getLogTag(); 248 private static final int INCREASE_WAIT_COUNT = 2; 249 250 private final BitmapCache mAttachmentPreviewsCache; 251 private final DecodeAggregator mAttachmentPreviewsDecodeAggregator; 252 private final BitmapCache mSendersImagesCache; 253 private final ContactResolver mContactResolver; 254 255 private static final int ATTACHMENT_PREVIEWS_CACHE_TARGET_SIZE_BYTES = 0; // TODO: enable cache 256 /** 339KB cache fits 10 bitmaps at 33856 bytes each. */ 257 private static final int SENDERS_IMAGES_CACHE_TARGET_SIZE_BYTES = 1024 * 339; 258 /** 259 * This is the fractional portion of the total cache size above that's dedicated to non-pooled 260 * bitmaps. (This is basically the portion of cache dedicated to GIFs.) 261 */ 262 private static final float ATTACHMENT_PREVIEWS_CACHE_NON_POOLED_FRACTION = 0.1f; 263 private static final float SENDERS_IMAGES_PREVIEWS_CACHE_NON_POOLED_FRACTION = 0f; 264 /** Each string has upper estimate of 50 bytes, so this cache would be 5KB. */ 265 private static final int SENDERS_IMAGES_PREVIEWS_CACHE_NULL_CAPACITY = 100; 266 267 public AnimatedAdapter(Context context, ConversationCursor cursor, 268 ConversationSelectionSet batch, ControllableActivity activity, 269 SwipeableListView listView, final List<ConversationSpecialItemView> specialViews) { 270 super(context, -1, cursor, UIProvider.CONVERSATION_PROJECTION, null, 0); 271 mContext = context; 272 mBatchConversations = batch; 273 setAccount(mAccountListener.initialize(activity.getAccountController())); 274 mActivity = activity; 275 mShowFooter = false; 276 mListView = listView; 277 278 mAttachmentPreviewsCache = new AltBitmapCache(ATTACHMENT_PREVIEWS_CACHE_TARGET_SIZE_BYTES, 279 ATTACHMENT_PREVIEWS_CACHE_NON_POOLED_FRACTION, 0); 280 mAttachmentPreviewsDecodeAggregator = new DecodeAggregator(); 281 mSendersImagesCache = new AltBitmapCache(Utils.isLowRamDevice(mContext) ? 282 0 : SENDERS_IMAGES_CACHE_TARGET_SIZE_BYTES, 283 SENDERS_IMAGES_PREVIEWS_CACHE_NON_POOLED_FRACTION, 284 SENDERS_IMAGES_PREVIEWS_CACHE_NULL_CAPACITY); 285 mContactResolver = new ContactResolver(mContext.getContentResolver(), mSendersImagesCache); 286 287 mHandler = new Handler(); 288 if (sDismissAllShortDelay == -1) { 289 final Resources r = context.getResources(); 290 sDismissAllShortDelay = r.getInteger(R.integer.dismiss_all_leavebehinds_short_delay); 291 sDismissAllLongDelay = r.getInteger(R.integer.dismiss_all_leavebehinds_long_delay); 292 } 293 if (specialViews != null) { 294 mFleetingViews = new ArrayList<ConversationSpecialItemView>(specialViews); 295 } else { 296 mFleetingViews = new ArrayList<ConversationSpecialItemView>(0); 297 } 298 /** Total number of special views */ 299 final int size = mFleetingViews.size(); 300 mSpecialViews = new SparseArray<ConversationSpecialItemView>(size); 301 302 // Set the adapter in teaser views. 303 for (final ConversationSpecialItemView view : mFleetingViews) { 304 view.setAdapter(this); 305 } 306 updateSpecialViews(); 307 } 308 309 public void cancelDismissCounter() { 310 cancelLeaveBehindFadeInAnimation(); 311 mHandler.removeCallbacks(mCountDown); 312 } 313 314 public void startDismissCounter() { 315 if (mLeaveBehindItems.size() > INCREASE_WAIT_COUNT) { 316 mHandler.postDelayed(mCountDown, sDismissAllLongDelay); 317 } else { 318 mHandler.postDelayed(mCountDown, sDismissAllShortDelay); 319 } 320 } 321 322 public final void destroy() { 323 // Set a null cursor in the adapter 324 swapCursor(null); 325 mAccountListener.unregisterAndDestroy(); 326 } 327 328 @Override 329 public int getCount() { 330 // mSpecialViews only contains the views that are currently being displayed 331 final int specialViewCount = mSpecialViews.size(); 332 333 final int count = super.getCount() + specialViewCount; 334 return mShowFooter ? count + 1 : count; 335 } 336 337 /** 338 * Add a conversation to the undo set, but only if its deletion is still cached. If the 339 * deletion has already been written through and the cursor doesn't have it anymore, we can't 340 * handle it here, and should instead rely on the cursor refresh to restore the item. 341 * @param item id for the conversation that is being undeleted. 342 * @return true if the conversation is still cached and therefore we will handle the undo. 343 */ 344 private boolean addUndoingItem(final long item) { 345 if (getConversationCursor().getUnderlyingPosition(item) >= 0) { 346 mUndoingItems.add(item); 347 return true; 348 } 349 return false; 350 } 351 352 public void setUndo(boolean undo) { 353 if (undo) { 354 boolean itemAdded = false; 355 if (!mLastDeletingItems.isEmpty()) { 356 for (Long item : mLastDeletingItems) { 357 itemAdded |= addUndoingItem(item); 358 } 359 mLastDeletingItems.clear(); 360 } 361 if (mLastLeaveBehind != -1) { 362 itemAdded |= addUndoingItem(mLastLeaveBehind); 363 mLastLeaveBehind = -1; 364 } 365 // Start animation, only if we're handling the undo. 366 if (itemAdded) { 367 notifyDataSetChanged(); 368 performAndSetNextAction(mRefreshAction); 369 } 370 } 371 } 372 373 public void setSwipeUndo(boolean undo) { 374 if (undo) { 375 if (!mLastDeletingItems.isEmpty()) { 376 mSwipeUndoingItems.addAll(mLastDeletingItems); 377 mLastDeletingItems.clear(); 378 } 379 if (mLastLeaveBehind != -1) { 380 mSwipeUndoingItems.add(mLastLeaveBehind); 381 mLastLeaveBehind = -1; 382 } 383 // Start animation 384 notifyDataSetChanged(); 385 performAndSetNextAction(mRefreshAction); 386 } 387 } 388 389 public View createConversationItemView(SwipeableConversationItemView view, Context context, 390 Conversation conv) { 391 if (view == null) { 392 view = new SwipeableConversationItemView(context, mAccount.getEmailAddress()); 393 } 394 view.bind(conv, mActivity, mBatchConversations, mFolder, getCheckboxSetting(), 395 getAttachmentPreviewsSetting(), getParallaxSpeedAlternativeSetting(), 396 getParallaxDirectionAlternativeSetting(), mSwipeEnabled, mImportanceMarkersEnabled, 397 mShowChevronsEnabled, this); 398 return view; 399 } 400 401 @Override 402 public boolean hasStableIds() { 403 return true; 404 } 405 406 @Override 407 public int getViewTypeCount() { 408 // TYPE_VIEW_CONVERSATION, TYPE_VIEW_DELETING, TYPE_VIEW_UNDOING, and 409 // TYPE_VIEW_FOOTER, TYPE_VIEW_LEAVEBEHIND. 410 return 5; 411 } 412 413 @Override 414 public int getItemViewType(int position) { 415 // Try to recycle views. 416 if (mShowFooter && position == getCount() - 1) { 417 return TYPE_VIEW_FOOTER; 418 } else if (hasLeaveBehinds() || isAnimating()) { 419 // Setting as type -1 means the recycler won't take this view and 420 // return it in get view. This is a bit of a "hammer" in that it 421 // won't let even safe views be recycled here, 422 // but its safer and cheaper than trying to determine individual 423 // types. In a future release, use position/id map to try to make 424 // this cleaner / faster to determine if the view is animating. 425 return TYPE_VIEW_DONT_RECYCLE; 426 } else if (mSpecialViews.get(position) != null) { 427 // Don't recycle the special views 428 return TYPE_VIEW_DONT_RECYCLE; 429 } 430 return TYPE_VIEW_CONVERSATION; 431 } 432 433 /** 434 * Deletes the selected conversations from the conversation list view with a 435 * translation and then a shrink. These conversations <b>must</b> have their 436 * {@link Conversation#position} set to the position of these conversations 437 * among the list. This will only remove the element from the list. The job 438 * of deleting the actual element is left to the the listener. This listener 439 * will be called when the animations are complete and is required to delete 440 * the conversation. 441 * @param conversations 442 * @param listener 443 */ 444 public void swipeDelete(Collection<Conversation> conversations, 445 ListItemsRemovedListener listener) { 446 delete(conversations, listener, mSwipeDeletingItems); 447 } 448 449 450 /** 451 * Deletes the selected conversations from the conversation list view by 452 * shrinking them away. These conversations <b>must</b> have their 453 * {@link Conversation#position} set to the position of these conversations 454 * among the list. This will only remove the element from the list. The job 455 * of deleting the actual element is left to the the listener. This listener 456 * will be called when the animations are complete and is required to delete 457 * the conversation. 458 * @param conversations 459 * @param listener 460 */ 461 public void delete(Collection<Conversation> conversations, ListItemsRemovedListener listener) { 462 delete(conversations, listener, mDeletingItems); 463 } 464 465 private void delete(Collection<Conversation> conversations, ListItemsRemovedListener listener, 466 HashSet<Long> list) { 467 // Clear out any remaining items and add the new ones 468 mLastDeletingItems.clear(); 469 // Since we are deleting new items, clear any remaining undo items 470 mUndoingItems.clear(); 471 472 final int startPosition = mListView.getFirstVisiblePosition(); 473 final int endPosition = mListView.getLastVisiblePosition(); 474 475 // Only animate visible items 476 for (Conversation c: conversations) { 477 if (c.position >= startPosition && c.position <= endPosition) { 478 mLastDeletingItems.add(c.id); 479 list.add(c.id); 480 } 481 } 482 483 if (list.isEmpty()) { 484 // If we have no deleted items on screen, skip the animation 485 listener.onListItemsRemoved(); 486 // If we have an action queued up, perform it 487 performAndSetNextAction(null); 488 } else { 489 performAndSetNextAction(listener); 490 } 491 notifyDataSetChanged(); 492 } 493 494 @Override 495 public View getView(int position, View convertView, ViewGroup parent) { 496 if (mShowFooter && position == getCount() - 1) { 497 return mFooter; 498 } 499 500 // Check if this is a special view 501 final ConversationSpecialItemView specialView = mSpecialViews.get(position); 502 if (specialView != null) { 503 specialView.onGetView(); 504 return (View) specialView; 505 } 506 507 Utils.traceBeginSection("AA.getView"); 508 509 final ConversationCursor cursor = (ConversationCursor) getItem(position); 510 final Conversation conv = cursor.getConversation(); 511 512 // Notify the provider of this change in the position of Conversation cursor 513 cursor.notifyUIPositionChange(); 514 515 if (isPositionUndoing(conv.id)) { 516 return getUndoingView(position - getPositionOffset(position), conv, parent, 517 false /* don't show swipe background */); 518 } if (isPositionUndoingSwipe(conv.id)) { 519 return getUndoingView(position - getPositionOffset(position), conv, parent, 520 true /* show swipe background */); 521 } else if (isPositionDeleting(conv.id)) { 522 return getDeletingView(position - getPositionOffset(position), conv, parent, false); 523 } else if (isPositionSwipeDeleting(conv.id)) { 524 return getDeletingView(position - getPositionOffset(position), conv, parent, true); 525 } 526 if (hasFadeLeaveBehinds()) { 527 if(isPositionFadeLeaveBehind(conv)) { 528 LeaveBehindItem fade = getFadeLeaveBehindItem(position, conv); 529 fade.startShrinkAnimation(mAnimatorListener); 530 Utils.traceEndSection(); 531 return fade; 532 } 533 } 534 if (hasLeaveBehinds()) { 535 if (isPositionLeaveBehind(conv)) { 536 final LeaveBehindItem fadeIn = getLeaveBehindItem(conv); 537 if (conv.id == mLastLeaveBehind) { 538 // If it looks like the person is doing a lot of rapid 539 // swipes, wait patiently before animating 540 if (mLeaveBehindItems.size() > INCREASE_WAIT_COUNT) { 541 if (fadeIn.isAnimating()) { 542 fadeIn.increaseFadeInDelay(sDismissAllLongDelay); 543 } else { 544 fadeIn.startFadeInTextAnimation(sDismissAllLongDelay); 545 } 546 } else { 547 // Otherwise, assume they are just doing 1 and wait less time 548 fadeIn.startFadeInTextAnimation(sDismissAllShortDelay /* delay start */); 549 } 550 } 551 Utils.traceEndSection(); 552 return fadeIn; 553 } 554 } 555 556 if (convertView != null && !(convertView instanceof SwipeableConversationItemView)) { 557 LogUtils.w(LOG_TAG, "Incorrect convert view received; nulling it out"); 558 convertView = newView(mContext, cursor, parent); 559 } else if (convertView != null) { 560 ((SwipeableConversationItemView) convertView).reset(); 561 } 562 final View v = createConversationItemView((SwipeableConversationItemView) convertView, 563 mContext, conv); 564 Utils.traceEndSection(); 565 return v; 566 } 567 568 private boolean hasLeaveBehinds() { 569 return !mLeaveBehindItems.isEmpty(); 570 } 571 572 private boolean hasFadeLeaveBehinds() { 573 return !mFadeLeaveBehindItems.isEmpty(); 574 } 575 576 public LeaveBehindItem setupLeaveBehind(Conversation target, ToastBarOperation undoOp, 577 int deletedRow, int viewHeight) { 578 cancelLeaveBehindFadeInAnimation(); 579 mLastLeaveBehind = target.id; 580 fadeOutLeaveBehindItems(); 581 582 final LeaveBehindItem leaveBehind = (LeaveBehindItem) LayoutInflater.from(mContext) 583 .inflate(R.layout.swipe_leavebehind, mListView, false); 584 leaveBehind.bind(deletedRow, mAccount, this, undoOp, target, mFolder, viewHeight); 585 mLeaveBehindItems.put(target.id, leaveBehind); 586 mLastDeletingItems.add(target.id); 587 return leaveBehind; 588 } 589 590 public void fadeOutSpecificLeaveBehindItem(long id) { 591 if (mLastLeaveBehind == id) { 592 mLastLeaveBehind = -1; 593 } 594 startFadeOutLeaveBehindItemsAnimations(); 595 } 596 597 // This should kick off a timer such that there is a minimum time each item 598 // shows up before being dismissed. That way if the user is swiping away 599 // items in rapid succession, their finger position is maintained. 600 public void fadeOutLeaveBehindItems() { 601 if (mCountDown == null) { 602 mCountDown = new Runnable() { 603 @Override 604 public void run() { 605 startFadeOutLeaveBehindItemsAnimations(); 606 } 607 }; 608 } else { 609 mHandler.removeCallbacks(mCountDown); 610 } 611 // Clear all the text since these are no longer clickable 612 Iterator<Entry<Long, LeaveBehindItem>> i = mLeaveBehindItems.entrySet().iterator(); 613 LeaveBehindItem item; 614 while (i.hasNext()) { 615 item = i.next().getValue(); 616 Conversation conv = item.getData(); 617 if (mLastLeaveBehind == -1 || conv.id != mLastLeaveBehind) { 618 item.cancelFadeInTextAnimation(); 619 item.makeInert(); 620 } 621 } 622 startDismissCounter(); 623 } 624 625 protected void startFadeOutLeaveBehindItemsAnimations() { 626 final int startPosition = mListView.getFirstVisiblePosition(); 627 final int endPosition = mListView.getLastVisiblePosition(); 628 629 if (hasLeaveBehinds()) { 630 // If the item is visible, fade it out. Otherwise, just remove 631 // it. 632 Iterator<Entry<Long, LeaveBehindItem>> i = mLeaveBehindItems.entrySet().iterator(); 633 LeaveBehindItem item; 634 while (i.hasNext()) { 635 item = i.next().getValue(); 636 Conversation conv = item.getData(); 637 if (mLastLeaveBehind == -1 || conv.id != mLastLeaveBehind) { 638 if (conv.position >= startPosition && conv.position <= endPosition) { 639 mFadeLeaveBehindItems.put(conv.id, item); 640 } else { 641 item.commit(); 642 } 643 i.remove(); 644 } 645 } 646 cancelLeaveBehindFadeInAnimation(); 647 } 648 if (!mLastDeletingItems.isEmpty()) { 649 mLastDeletingItems.clear(); 650 } 651 notifyDataSetChanged(); 652 } 653 654 private void cancelLeaveBehindFadeInAnimation() { 655 LeaveBehindItem leaveBehind = getLastLeaveBehindItem(); 656 if (leaveBehind != null) { 657 leaveBehind.cancelFadeInTextAnimation(); 658 } 659 } 660 661 public CoordinatesCache getCoordinatesCache() { 662 return mCoordinatesCache; 663 } 664 665 public BidiFormatter getBidiFormatter() { 666 return mBidiFormatter; 667 } 668 669 public SwipeableListView getListView() { 670 return mListView; 671 } 672 673 public void commitLeaveBehindItems(boolean animate) { 674 // Remove any previously existing leave behinds. 675 boolean changed = false; 676 if (hasLeaveBehinds()) { 677 for (LeaveBehindItem item : mLeaveBehindItems.values()) { 678 if (animate) { 679 mFadeLeaveBehindItems.put(item.getConversationId(), item); 680 } else { 681 item.commit(); 682 } 683 } 684 changed = true; 685 mLastLeaveBehind = -1; 686 mLeaveBehindItems.clear(); 687 } 688 if (hasFadeLeaveBehinds() && !animate) { 689 // Find any fading leave behind items and commit them all, too. 690 for (LeaveBehindItem item : mFadeLeaveBehindItems.values()) { 691 item.commit(); 692 } 693 mFadeLeaveBehindItems.clear(); 694 changed = true; 695 } 696 if (!mLastDeletingItems.isEmpty()) { 697 mLastDeletingItems.clear(); 698 changed = true; 699 } 700 701 for (final ConversationSpecialItemView view : mFleetingViews) { 702 if (view.commitLeaveBehindItem()) { 703 changed = true; 704 } 705 } 706 707 if (changed) { 708 notifyDataSetChanged(); 709 } 710 } 711 712 private LeaveBehindItem getLeaveBehindItem(Conversation target) { 713 return mLeaveBehindItems.get(target.id); 714 } 715 716 private LeaveBehindItem getFadeLeaveBehindItem(int position, Conversation target) { 717 return mFadeLeaveBehindItems.get(target.id); 718 } 719 720 @Override 721 public long getItemId(int position) { 722 if (mShowFooter && position == getCount() - 1) { 723 return -1; 724 } 725 726 final ConversationSpecialItemView specialView = mSpecialViews.get(position); 727 if (specialView != null) { 728 // TODO(skennedy) We probably want something better than this 729 return specialView.hashCode(); 730 } 731 732 final int cursorPos = position - getPositionOffset(position); 733 // advance the cursor to the right position and read the cached conversation, if present 734 // 735 // (no need to have CursorAdapter check mDataValid because in our incarnation without 736 // FLAG_REGISTER_CONTENT_OBSERVER, mDataValid is effectively identical to mCursor being 737 // non-null) 738 final ConversationCursor cursor = getConversationCursor(); 739 if (cursor != null && cursor.moveToPosition(cursorPos)) { 740 final Conversation conv = cursor.getCachedConversation(); 741 if (conv != null) { 742 return conv.id; 743 } 744 } 745 return super.getItemId(cursorPos); 746 } 747 748 /** 749 * @param position The position in the cursor 750 */ 751 private View getDeletingView(int position, Conversation conversation, ViewGroup parent, 752 boolean swipe) { 753 conversation.position = position; 754 SwipeableConversationItemView deletingView = mAnimatingViews.get(conversation.id); 755 if (deletingView == null) { 756 // The undo animation consists of fading in the conversation that 757 // had been destroyed. 758 deletingView = newConversationItemView(position, parent, conversation); 759 deletingView.startDeleteAnimation(mAnimatorListener, swipe); 760 } 761 return deletingView; 762 } 763 764 /** 765 * @param position The position in the cursor 766 */ 767 private View getUndoingView(int position, Conversation conv, ViewGroup parent, boolean swipe) { 768 conv.position = position; 769 SwipeableConversationItemView undoView = mAnimatingViews.get(conv.id); 770 if (undoView == null) { 771 // The undo animation consists of fading in the conversation that 772 // had been destroyed. 773 undoView = newConversationItemView(position, parent, conv); 774 undoView.startUndoAnimation(mAnimatorListener, swipe); 775 } 776 return undoView; 777 } 778 779 @Override 780 public View newView(Context context, Cursor cursor, ViewGroup parent) { 781 return new SwipeableConversationItemView(context, mAccount.getEmailAddress()); 782 } 783 784 @Override 785 public void bindView(View view, Context context, Cursor cursor) { 786 // no-op. we only get here from newConversationItemView(), which will immediately bind 787 // on its own. 788 } 789 790 private SwipeableConversationItemView newConversationItemView(int position, ViewGroup parent, 791 Conversation conversation) { 792 SwipeableConversationItemView view = (SwipeableConversationItemView) super.getView( 793 position, null, parent); 794 view.reset(); 795 view.bind(conversation, mActivity, mBatchConversations, mFolder, getCheckboxSetting(), 796 getAttachmentPreviewsSetting(), getParallaxSpeedAlternativeSetting(), 797 getParallaxDirectionAlternativeSetting(), mSwipeEnabled, mImportanceMarkersEnabled, 798 mShowChevronsEnabled, this); 799 mAnimatingViews.put(conversation.id, view); 800 return view; 801 } 802 803 private int getCheckboxSetting() { 804 return mAccount != null ? mAccount.settings.convListIcon : 805 ConversationListIcon.DEFAULT; 806 } 807 808 private boolean getAttachmentPreviewsSetting() { 809 return mAccount == null || mAccount.settings.convListAttachmentPreviews; 810 } 811 812 private boolean getParallaxSpeedAlternativeSetting() { 813 return MailPrefs.get(mContext).getParallaxSpeedAlternative(); 814 } 815 816 private boolean getParallaxDirectionAlternativeSetting() { 817 return MailPrefs.get(mContext).getParallaxDirectionAlternative(); 818 } 819 820 @Override 821 public Object getItem(int position) { 822 if (mShowFooter && position == getCount() - 1) { 823 return mFooter; 824 } else if (mSpecialViews.get(position) != null) { 825 return mSpecialViews.get(position); 826 } 827 return super.getItem(position - getPositionOffset(position)); 828 } 829 830 private boolean isPositionDeleting(long id) { 831 return mDeletingItems.contains(id); 832 } 833 834 private boolean isPositionSwipeDeleting(long id) { 835 return mSwipeDeletingItems.contains(id); 836 } 837 838 private boolean isPositionUndoing(long id) { 839 return mUndoingItems.contains(id); 840 } 841 842 private boolean isPositionUndoingSwipe(long id) { 843 return mSwipeUndoingItems.contains(id); 844 } 845 846 private boolean isPositionLeaveBehind(Conversation conv) { 847 return hasLeaveBehinds() 848 && mLeaveBehindItems.containsKey(conv.id) 849 && conv.isMostlyDead(); 850 } 851 852 private boolean isPositionFadeLeaveBehind(Conversation conv) { 853 return hasFadeLeaveBehinds() 854 && mFadeLeaveBehindItems.containsKey(conv.id) 855 && conv.isMostlyDead(); 856 } 857 858 /** 859 * Performs the pending destruction, if any and assigns the next pending action. 860 * @param next The next action that is to be performed, possibly null (if no next action is 861 * needed). 862 */ 863 private void performAndSetNextAction(ListItemsRemovedListener next) { 864 if (mPendingDestruction != null) { 865 mPendingDestruction.onListItemsRemoved(); 866 } 867 mPendingDestruction = next; 868 } 869 870 private void updateAnimatingConversationItems(Object obj, HashSet<Long> items) { 871 if (!items.isEmpty()) { 872 if (obj instanceof ConversationItemView) { 873 final ConversationItemView target = (ConversationItemView) obj; 874 final long id = target.getConversation().id; 875 items.remove(id); 876 mAnimatingViews.remove(id); 877 if (items.isEmpty()) { 878 performAndSetNextAction(null); 879 notifyDataSetChanged(); 880 } 881 } 882 } 883 } 884 885 @Override 886 public boolean areAllItemsEnabled() { 887 // The animating items and some special views are not enabled. 888 return false; 889 } 890 891 @Override 892 public boolean isEnabled(final int position) { 893 final ConversationSpecialItemView view = mSpecialViews.get(position); 894 if (view != null) { 895 final boolean enabled = view.acceptsUserTaps(); 896 LogUtils.d(LOG_TAG, "AA.isEnabled(%d) = %b", position, enabled); 897 return enabled; 898 } 899 return !isPositionDeleting(position) && !isPositionUndoing(position); 900 } 901 902 public void setFooterVisibility(boolean show) { 903 if (mShowFooter != show) { 904 mShowFooter = show; 905 notifyDataSetChanged(); 906 } 907 } 908 909 public void addFooter(View footerView) { 910 mFooter = footerView; 911 } 912 913 public void setFolder(Folder folder) { 914 mFolder = folder; 915 } 916 917 public void clearLeaveBehind(long itemId) { 918 if (hasLeaveBehinds() && mLeaveBehindItems.containsKey(itemId)) { 919 mLeaveBehindItems.remove(itemId); 920 } else if (hasFadeLeaveBehinds()) { 921 mFadeLeaveBehindItems.remove(itemId); 922 } else { 923 LogUtils.d(LOG_TAG, "Trying to clear a non-existant leave behind"); 924 } 925 if (mLastLeaveBehind == itemId) { 926 mLastLeaveBehind = -1; 927 } 928 } 929 930 public void onSaveInstanceState(Bundle outState) { 931 long[] lastDeleting = new long[mLastDeletingItems.size()]; 932 for (int i = 0; i < lastDeleting.length; i++) { 933 lastDeleting[i] = mLastDeletingItems.get(i); 934 } 935 outState.putLongArray(LAST_DELETING_ITEMS, lastDeleting); 936 if (hasLeaveBehinds()) { 937 if (mLastLeaveBehind != -1) { 938 outState.putParcelable(LEAVE_BEHIND_ITEM_DATA, 939 mLeaveBehindItems.get(mLastLeaveBehind).getLeaveBehindData()); 940 outState.putLong(LEAVE_BEHIND_ITEM_ID, mLastLeaveBehind); 941 } 942 for (LeaveBehindItem item : mLeaveBehindItems.values()) { 943 if (mLastLeaveBehind == -1 || item.getData().id != mLastLeaveBehind) { 944 item.commit(); 945 } 946 } 947 } 948 } 949 950 public void onRestoreInstanceState(Bundle outState) { 951 if (outState.containsKey(LAST_DELETING_ITEMS)) { 952 final long[] lastDeleting = outState.getLongArray(LAST_DELETING_ITEMS); 953 for (final long aLastDeleting : lastDeleting) { 954 mLastDeletingItems.add(aLastDeleting); 955 } 956 } 957 if (outState.containsKey(LEAVE_BEHIND_ITEM_DATA)) { 958 LeaveBehindData left = 959 (LeaveBehindData) outState.getParcelable(LEAVE_BEHIND_ITEM_DATA); 960 mLeaveBehindItems.put(outState.getLong(LEAVE_BEHIND_ITEM_ID), 961 setupLeaveBehind(left.data, left.op, left.data.position, left.height)); 962 } 963 } 964 965 /** 966 * Return if the adapter is in the process of animating anything. 967 */ 968 public boolean isAnimating() { 969 return !mUndoingItems.isEmpty() 970 || !mSwipeUndoingItems.isEmpty() 971 || hasFadeLeaveBehinds() 972 || !mDeletingItems.isEmpty() 973 || !mSwipeDeletingItems.isEmpty(); 974 } 975 976 /** 977 * Forcibly clear any internal state that would cause {@link #isAnimating()} to return true. 978 * Call this in times of desperation, when you really, really want to trash state and just 979 * start over. 980 */ 981 public void clearAnimationState() { 982 if (!isAnimating()) { 983 return; 984 } 985 986 mUndoingItems.clear(); 987 mSwipeUndoingItems.clear(); 988 mFadeLeaveBehindItems.clear(); 989 mDeletingItems.clear(); 990 mSwipeDeletingItems.clear(); 991 mAnimatingViews.clear(); 992 LogUtils.w(LOG_TAG, "AA.clearAnimationState forcibly cleared state, this=%s", this); 993 } 994 995 @Override 996 public String toString() { 997 final StringBuilder sb = new StringBuilder("{"); 998 sb.append(super.toString()); 999 sb.append(" mUndoingItems="); 1000 sb.append(mUndoingItems); 1001 sb.append(" mSwipeUndoingItems="); 1002 sb.append(mSwipeUndoingItems); 1003 sb.append(" mDeletingItems="); 1004 sb.append(mDeletingItems); 1005 sb.append(" mSwipeDeletingItems="); 1006 sb.append(mSwipeDeletingItems); 1007 sb.append(" mLeaveBehindItems="); 1008 sb.append(mLeaveBehindItems); 1009 sb.append(" mFadeLeaveBehindItems="); 1010 sb.append(mFadeLeaveBehindItems); 1011 sb.append(" mLastDeletingItems="); 1012 sb.append(mLastDeletingItems); 1013 sb.append(" mAnimatingViews="); 1014 sb.append(mAnimatingViews); 1015 sb.append(" mPendingDestruction="); 1016 sb.append(mPendingDestruction); 1017 sb.append("}"); 1018 return sb.toString(); 1019 } 1020 1021 /** 1022 * Get the ConversationCursor associated with this adapter. 1023 */ 1024 public ConversationCursor getConversationCursor() { 1025 return (ConversationCursor) getCursor(); 1026 } 1027 1028 /** 1029 * Get the currently visible leave behind item. 1030 */ 1031 public LeaveBehindItem getLastLeaveBehindItem() { 1032 if (mLastLeaveBehind != -1) { 1033 return mLeaveBehindItems.get(mLastLeaveBehind); 1034 } 1035 return null; 1036 } 1037 1038 /** 1039 * Cancel fading out the text displayed in the leave behind item currently 1040 * shown. 1041 */ 1042 public void cancelFadeOutLastLeaveBehindItemText() { 1043 LeaveBehindItem item = getLastLeaveBehindItem(); 1044 if (item != null) { 1045 item.cancelFadeOutText(); 1046 } 1047 } 1048 1049 /** 1050 * Updates special (non-conversation view) when {@link #mFleetingViews} changed 1051 */ 1052 private void updateSpecialViews() { 1053 // We recreate all the special views using mFleetingViews. 1054 mSpecialViews.clear(); 1055 1056 // Fleeting (temporary) views specify a position, which is 0-indexed and 1057 // has to be adjusted for the number of fleeting views above it. 1058 for (final ConversationSpecialItemView specialView : mFleetingViews) { 1059 specialView.onUpdate(mFolder, getConversationCursor()); 1060 1061 if (specialView.getShouldDisplayInList()) { 1062 // If the special view asks for position 0, it wants to be at the top. 1063 int position = (specialView.getPosition()); 1064 1065 // insert the special view into the position, but if there is 1066 // already an item occupying that position, move that item back 1067 // one position, and repeat 1068 ConversationSpecialItemView insert = specialView; 1069 while (insert != null) { 1070 final ConversationSpecialItemView kickedOut = mSpecialViews.get(position); 1071 mSpecialViews.put(position, insert); 1072 insert = kickedOut; 1073 position++; 1074 } 1075 } 1076 } 1077 } 1078 1079 /** 1080 * Gets the position of the specified {@link ConversationSpecialItemView}, as determined by 1081 * the adapter. 1082 * 1083 * @return The position in the list, or a negative value if it could not be found 1084 */ 1085 public int getSpecialViewPosition(final ConversationSpecialItemView view) { 1086 return mSpecialViews.indexOfValue(view); 1087 } 1088 1089 @Override 1090 public void notifyDataSetChanged() { 1091 // This may be a temporary catch for a problem, or we may leave it here. 1092 // b/9527863 1093 if (Looper.getMainLooper() != Looper.myLooper()) { 1094 LogUtils.wtf(LOG_TAG, "notifyDataSetChanged() called off the main thread"); 1095 } 1096 1097 updateSpecialViews(); 1098 super.notifyDataSetChanged(); 1099 } 1100 1101 @Override 1102 public void changeCursor(final Cursor cursor) { 1103 super.changeCursor(cursor); 1104 updateSpecialViews(); 1105 } 1106 1107 @Override 1108 public void changeCursorAndColumns(final Cursor c, final String[] from, final int[] to) { 1109 super.changeCursorAndColumns(c, from, to); 1110 updateSpecialViews(); 1111 } 1112 1113 @Override 1114 public Cursor swapCursor(final Cursor c) { 1115 final Cursor oldCursor = super.swapCursor(c); 1116 updateSpecialViews(); 1117 1118 return oldCursor; 1119 } 1120 1121 public BitmapCache getAttachmentPreviewsCache() { 1122 return mAttachmentPreviewsCache; 1123 } 1124 1125 public DecodeAggregator getAttachmentPreviewsDecodeAggregator() { 1126 return mAttachmentPreviewsDecodeAggregator; 1127 } 1128 1129 public BitmapCache getSendersImagesCache() { 1130 return mSendersImagesCache; 1131 } 1132 1133 public ContactResolver getContactResolver() { 1134 return mContactResolver; 1135 } 1136 1137 /** 1138 * Gets the offset for the given position in the underlying cursor, based on any special views 1139 * that may be above it. 1140 */ 1141 public int getPositionOffset(final int position) { 1142 int viewsAbove = 0; 1143 1144 for (int i = 0, size = mSpecialViews.size(); i < size; i++) { 1145 final int bidPosition = mSpecialViews.keyAt(i); 1146 // If the view bid for a position above the cursor position, 1147 // it is above the conversation. 1148 if (bidPosition <= position) { 1149 viewsAbove++; 1150 } 1151 } 1152 1153 return viewsAbove; 1154 } 1155 1156 public void cleanup() { 1157 // Clean up teaser views. 1158 for (final ConversationSpecialItemView view : mFleetingViews) { 1159 view.cleanup(); 1160 } 1161 } 1162 1163 public void onConversationSelected() { 1164 // Notify teaser views. 1165 for (final ConversationSpecialItemView specialView : mFleetingViews) { 1166 specialView.onConversationSelected(); 1167 } 1168 } 1169 1170 public void onCabModeEntered() { 1171 for (final ConversationSpecialItemView specialView : mFleetingViews) { 1172 specialView.onCabModeEntered(); 1173 } 1174 } 1175 1176 public void onCabModeExited() { 1177 for (final ConversationSpecialItemView specialView : mFleetingViews) { 1178 specialView.onCabModeExited(); 1179 } 1180 } 1181 1182 public void onConversationListVisibilityChanged(final boolean visible) { 1183 for (final ConversationSpecialItemView specialView : mFleetingViews) { 1184 specialView.onConversationListVisibilityChanged(visible); 1185 } 1186 } 1187 1188 public void onScrollStateChanged(final int scrollState) { 1189 final boolean scrolling = scrollState != OnScrollListener.SCROLL_STATE_IDLE; 1190 mAttachmentPreviewsCache.setBlocking(scrolling); 1191 } 1192 1193 public int getViewMode() { 1194 return mActivity.getViewMode().getMode(); 1195 } 1196 1197 public boolean isInCabMode() { 1198 // If we have conversation in our selected set, we're in CAB mode 1199 return !mBatchConversations.isEmpty(); 1200 } 1201 1202 public void saveSpecialItemInstanceState(final Bundle outState) { 1203 for (final ConversationSpecialItemView specialView : mFleetingViews) { 1204 specialView.saveInstanceState(outState); 1205 } 1206 } 1207} 1208