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