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