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