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