Home | History | Annotate | Download | only in ui
      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 {
    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      */
    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 
    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 
    291     public void cancelDismissCounter() {
    292         cancelLeaveBehindFadeInAnimation();
    293         mHandler.removeCallbacks(mCountDown);
    294     }
    295 
    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 
    304     public final void destroy() {
    305         // Set a null cursor in the adapter
    306         swapCursor(null);
    307         mAccountListener.unregisterAndDestroy();
    308     }
    309 
    310     @Override
    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      */
    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 
    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 
    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 
    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
    393     public boolean hasStableIds() {
    394         return true;
    395     }
    396 
    397     @Override
    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
    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      */
    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      */
    454     public void delete(Collection<Conversation> conversations, ListItemsRemovedListener listener) {
    455         delete(conversations, listener, mDeletingItems);
    456     }
    457 
    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
    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 
    564     private boolean hasLeaveBehinds() {
    565         return !mLeaveBehindItems.isEmpty();
    566     }
    567 
    568     private boolean hasFadeLeaveBehinds() {
    569         return !mFadeLeaveBehindItems.isEmpty();
    570     }
    571 
    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 
    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.
    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 
    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 
    650     private void cancelLeaveBehindFadeInAnimation() {
    651         LeaveBehindItem leaveBehind = getLastLeaveBehindItem();
    652         if (leaveBehind != null) {
    653             leaveBehind.cancelFadeInTextAnimation();
    654         }
    655     }
    656 
    657     public CoordinatesCache getCoordinatesCache() {
    658         return mCoordinatesCache;
    659     }
    660 
    661     public BidiFormatter getBidiFormatter() {
    662         return mBidiFormatter;
    663     }
    664 
    665     public SwipeableListView getListView() {
    666         return mListView;
    667     }
    668 
    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 
    708     private LeaveBehindItem getLeaveBehindItem(Conversation target) {
    709         return mLeaveBehindItems.get(target.id);
    710     }
    711 
    712     private LeaveBehindItem getFadeLeaveBehindItem(int position, Conversation target) {
    713         return mFadeLeaveBehindItems.get(target.id);
    714     }
    715 
    716     @Override
    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      */
    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      */
    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
    777     public View newView(Context context, Cursor cursor, ViewGroup parent) {
    778         return new SwipeableConversationItemView(context, mAccount);
    779     }
    780 
    781     @Override
    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 
    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 
    798     private int getCheckboxSetting() {
    799         return mAccount != null ? mAccount.settings.convListIcon :
    800             ConversationListIcon.DEFAULT;
    801     }
    802 
    803     @Override
    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 
    817     private boolean isPositionDeleting(long id) {
    818         return mDeletingItems.contains(id);
    819     }
    820 
    821     private boolean isPositionSwipeDeleting(long id) {
    822         return mSwipeDeletingItems.contains(id);
    823     }
    824 
    825     private boolean isPositionUndoing(long id) {
    826         return mUndoingItems.contains(id);
    827     }
    828 
    829     private boolean isPositionUndoingSwipe(long id) {
    830         return mSwipeUndoingItems.contains(id);
    831     }
    832 
    833     private boolean isPositionLeaveBehind(Conversation conv) {
    834         return hasLeaveBehinds()
    835                 && mLeaveBehindItems.containsKey(conv.id)
    836                 && conv.isMostlyDead();
    837     }
    838 
    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      */
    850     private void performAndSetNextAction(ListItemsRemovedListener next) {
    851         if (mPendingDestruction != null) {
    852             mPendingDestruction.onListItemsRemoved();
    853         }
    854         mPendingDestruction = next;
    855     }
    856 
    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
    873     public boolean areAllItemsEnabled() {
    874         // The animating items and some special views are not enabled.
    875         return false;
    876     }
    877 
    878     @Override
    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 
    889     public void setFooterVisibility(boolean show) {
    890         if (mShowCustomFooter != show) {
    891             mShowCustomFooter = show;
    892             notifyDataSetChanged();
    893         }
    894     }
    895 
    896     public void addFooter(View footerView) {
    897         mFooter = footerView;
    898     }
    899 
    900     public void addHeader(View headerView) {
    901         mHeaders.add(headerView);
    902     }
    903 
    904     public void setFolder(Folder folder) {
    905         mFolder = folder;
    906     }
    907 
    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 
    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 
    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      */
    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      */
    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
    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      */
   1015     public ConversationCursor getConversationCursor() {
   1016         return (ConversationCursor) getCursor();
   1017     }
   1018 
   1019     /**
   1020      * Get the currently visible leave behind item.
   1021      */
   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      */
   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      */
   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      */
   1081     public int getSpecialViewPosition(final ConversationSpecialItemView view) {
   1082         return mSpecialViews.indexOfValue(view);
   1083     }
   1084 
   1085     @Override
   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
   1098     public void changeCursor(final Cursor cursor) {
   1099         super.changeCursor(cursor);
   1100         updateSpecialViews();
   1101     }
   1102 
   1103     @Override
   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
   1110     public Cursor swapCursor(final Cursor c) {
   1111         final Cursor oldCursor = super.swapCursor(c);
   1112         updateSpecialViews();
   1113 
   1114         return oldCursor;
   1115     }
   1116 
   1117     public BitmapCache getSendersImagesCache() {
   1118         return mSendersImagesCache;
   1119     }
   1120 
   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      */
   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      */
   1148     private int getSpecialViewsPos(final int position) {
   1149         return position - mHeaders.size();
   1150     }
   1151 
   1152     public void cleanup() {
   1153         // Clean up teaser views.
   1154         for (final ConversationSpecialItemView view : mFleetingViews) {
   1155             view.cleanup();
   1156         }
   1157     }
   1158 
   1159     public void onConversationSelected() {
   1160         // Notify teaser views.
   1161         for (final ConversationSpecialItemView specialView : mFleetingViews) {
   1162             specialView.onConversationSelected();
   1163         }
   1164     }
   1165 
   1166     public void onCabModeEntered() {
   1167         for (final ConversationSpecialItemView specialView : mFleetingViews) {
   1168             specialView.onCabModeEntered();
   1169         }
   1170     }
   1171 
   1172     public void onCabModeExited() {
   1173         for (final ConversationSpecialItemView specialView : mFleetingViews) {
   1174             specialView.onCabModeExited();
   1175         }
   1176     }
   1177 
   1178     public void onConversationListVisibilityChanged(final boolean visible) {
   1179         for (final ConversationSpecialItemView specialView : mFleetingViews) {
   1180             specialView.onConversationListVisibilityChanged(visible);
   1181         }
   1182     }
   1183 
   1184     public int getViewMode() {
   1185         return mActivity.getViewMode().getMode();
   1186     }
   1187 
   1188     public boolean isInCabMode() {
   1189         // If we have conversation in our selected set, we're in CAB mode
   1190         return !mBatchConversations.isEmpty();
   1191     }
   1192 
   1193     public void saveSpecialItemInstanceState(final Bundle outState) {
   1194         for (final ConversationSpecialItemView specialView : mFleetingViews) {
   1195             specialView.saveInstanceState(outState);
   1196         }
   1197     }
   1198 }
   1199