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