AnimatedAdapter.java revision e949616a8516115a544e1a8fca42d8d9e2817419
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.AnimatorSet;
22import android.animation.ObjectAnimator;
23import android.content.Context;
24import android.database.Cursor;
25import android.os.Bundle;
26import android.view.LayoutInflater;
27import android.view.View;
28import android.view.ViewGroup;
29import android.widget.SimpleCursorAdapter;
30
31import com.android.mail.R;
32import com.android.mail.browse.ConversationCursor;
33import com.android.mail.browse.ConversationItemView;
34import com.android.mail.browse.ConversationItemViewCoordinates;
35import com.android.mail.browse.SwipeableConversationItemView;
36import com.android.mail.providers.Account;
37import com.android.mail.providers.AccountObserver;
38import com.android.mail.providers.Conversation;
39import com.android.mail.providers.Folder;
40import com.android.mail.providers.UIProvider;
41import com.android.mail.ui.SwipeableListView.ListItemsRemovedListener;
42import com.android.mail.utils.LogTag;
43import com.android.mail.utils.LogUtils;
44
45import java.util.ArrayList;
46import java.util.Collection;
47import java.util.HashMap;
48import java.util.HashSet;
49
50public class AnimatedAdapter extends SimpleCursorAdapter implements
51        android.animation.Animator.AnimatorListener {
52    private static final String LAST_DELETING_ITEMS = "last_deleting_items";
53    private static final String LEAVE_BEHIND_ITEM = "leave_behind_item";
54    private final static int TYPE_VIEW_CONVERSATION = 0;
55    private final static int TYPE_VIEW_FOOTER = 1;
56    private final static int TYPE_VIEW_DONT_RECYCLE = -1;
57    private final HashSet<Long> mDeletingItems = new HashSet<Long>();
58    private final ArrayList<Long> mLastDeletingItems = new ArrayList<Long>();
59    private final HashSet<Long> mUndoingItems = new HashSet<Long>();
60    private final HashSet<Long> mSwipeDeletingItems = new HashSet<Long>();
61    private final HashSet<Long> mSwipeUndoingItems = new HashSet<Long>();
62    private final HashMap<Long, SwipeableConversationItemView> mAnimatingViews =
63            new HashMap<Long, SwipeableConversationItemView>();
64    private final HashMap<Long, LeaveBehindItem> mFadeLeaveBehindItems =
65            new HashMap<Long, LeaveBehindItem>();
66    /** The current account */
67    private Account mAccount;
68    private final Context mContext;
69    private final ConversationSelectionSet mBatchConversations;
70    /**
71     * The next action to perform. Do not read or write this. All accesses should
72     * be in {@link #performAndSetNextAction(DestructiveAction)} which commits the
73     * previous action, if any.
74     */
75    private ListItemsRemovedListener mPendingDestruction;
76    /**
77     * A destructive action that refreshes the list and performs no other action.
78     */
79    private final ListItemsRemovedListener mRefreshAction = new ListItemsRemovedListener() {
80        @Override
81        public void onListItemsRemoved() {
82            notifyDataSetChanged();
83        }
84    };
85
86    public interface Listener {
87        void onAnimationEnd(AnimatedAdapter adapter);
88    }
89
90    private View mFooter;
91    private boolean mShowFooter;
92    private Folder mFolder;
93    private final SwipeableListView mListView;
94    private boolean mSwipeEnabled;
95    private LeaveBehindItem mLeaveBehindItem;
96    /** True if priority inbox markers are enabled, false otherwise. */
97    private boolean mPriorityMarkersEnabled;
98    private ControllableActivity mActivity;
99    private final AccountObserver mAccountListener = new AccountObserver() {
100        @Override
101        public void onChanged(Account newAccount) {
102            setAccount(newAccount);
103            notifyDataSetChanged();
104        }
105    };
106
107    private final void setAccount(Account newAccount) {
108        mAccount = newAccount;
109        mPriorityMarkersEnabled = mAccount.settings.priorityArrowsEnabled;
110        mSwipeEnabled = mAccount.supportsCapability(UIProvider.AccountCapabilities.UNDO);
111    }
112
113    /**
114     * Used only for debugging.
115     */
116    private static final String LOG_TAG = LogTag.getLogTag();
117
118    public AnimatedAdapter(Context context, int textViewResourceId, ConversationCursor cursor,
119            ConversationSelectionSet batch,
120            ControllableActivity activity, SwipeableListView listView) {
121        super(context, textViewResourceId, cursor, UIProvider.CONVERSATION_PROJECTION, null, 0);
122        mContext = context;
123        mBatchConversations = batch;
124        setAccount(mAccountListener.initialize(activity.getAccountController()));
125        mActivity = activity;
126        mShowFooter = false;
127        mListView = listView;
128    }
129
130    public final void destroy() {
131        // Set a null cursor in the adapter
132        swapCursor(null);
133        mAccountListener.unregisterAndDestroy();
134    }
135
136    @Override
137    public int getCount() {
138        final int count = super.getCount();
139        return mShowFooter ? count + 1 : count;
140    }
141
142    public void setUndo(boolean undo) {
143        if (undo && !mLastDeletingItems.isEmpty()) {
144            mUndoingItems.addAll(mLastDeletingItems);
145            mLastDeletingItems.clear();
146            // Start animation
147            notifyDataSetChanged();
148            performAndSetNextAction(mRefreshAction);
149        }
150    }
151
152    public void setSwipeUndo(boolean undo) {
153        if (undo && !mLastDeletingItems.isEmpty()) {
154            mSwipeUndoingItems.addAll(mLastDeletingItems);
155            mLastDeletingItems.clear();
156            // Start animation
157            notifyDataSetChanged();
158            performAndSetNextAction(mRefreshAction);
159        }
160    }
161
162    public View createConversationItemView(SwipeableConversationItemView view, Context context,
163            Conversation conv) {
164        if (view == null) {
165            view = new SwipeableConversationItemView(context, mAccount.name);
166        }
167        view.bind(conv, mActivity, mBatchConversations, mFolder,
168                mAccount != null ? mAccount.settings.hideCheckboxes : false, mSwipeEnabled,
169                mPriorityMarkersEnabled, this);
170        return view;
171    }
172
173    @Override
174    public boolean hasStableIds() {
175        return true;
176    }
177
178    @Override
179    public int getViewTypeCount() {
180        // TYPE_VIEW_CONVERSATION, TYPE_VIEW_DELETING, TYPE_VIEW_UNDOING, and
181        // TYPE_VIEW_FOOTER, TYPE_VIEW_LEAVEBEHIND.
182        return 5;
183    }
184
185    @Override
186    public int getItemViewType(int position) {
187        // Try to recycle views.
188        if (mShowFooter && position == super.getCount()) {
189            return TYPE_VIEW_FOOTER;
190        } else if (hasLeaveBehinds() || isAnimating()) {
191            // Setting as type -1 means the recycler won't take this view and
192            // return it in get view. This is a bit of a "hammer" in that it
193            // won't let even safe views be recycled here,
194            // but its safer and cheaper than trying to determine individual
195            // types. In a future release, use position/id map to try to make
196            // this cleaner / faster to determine if the view is animating.
197            return TYPE_VIEW_DONT_RECYCLE;
198        }
199        return TYPE_VIEW_CONVERSATION;
200    }
201
202    /**
203     * Deletes the selected conversations from the conversation list view with a
204     * translation and then a shrink. These conversations <b>must</b> have their
205     * {@link Conversation#position} set to the position of these conversations
206     * among the list. This will only remove the element from the list. The job
207     * of deleting the actual element is left to the the listener. This listener
208     * will be called when the animations are complete and is required to delete
209     * the conversation.
210     * @param conversations
211     * @param listener
212     */
213    public void swipeDelete(Collection<Conversation> conversations,
214            ListItemsRemovedListener listener) {
215        delete(conversations, listener, mSwipeDeletingItems);
216    }
217
218
219    /**
220     * Deletes the selected conversations from the conversation list view by
221     * shrinking them away. These conversations <b>must</b> have their
222     * {@link Conversation#position} set to the position of these conversations
223     * among the list. This will only remove the element from the list. The job
224     * of deleting the actual element is left to the the listener. This listener
225     * will be called when the animations are complete and is required to delete
226     * the conversation.
227     * @param conversations
228     * @param listener
229     */
230    public void delete(Collection<Conversation> conversations, ListItemsRemovedListener listener) {
231        delete(conversations, listener, mDeletingItems);
232    }
233
234    private void delete(Collection<Conversation> conversations, ListItemsRemovedListener listener,
235            HashSet<Long> list) {
236        // Clear out any remaining items and add the new ones
237        mLastDeletingItems.clear();
238        // Since we are deleting new items, clear any remaining undo items
239        mUndoingItems.clear();
240
241        final int startPosition = mListView.getFirstVisiblePosition();
242        final int endPosition = mListView.getLastVisiblePosition();
243
244        // Only animate visible items
245        for (Conversation c: conversations) {
246            if (c.position >= startPosition && c.position <= endPosition) {
247                mLastDeletingItems.add(c.id);
248                list.add(c.id);
249            }
250        }
251
252        if (list.isEmpty()) {
253            // If we have no deleted items on screen, skip the animation
254            listener.onListItemsRemoved();
255        } else {
256            performAndSetNextAction(listener);
257        }
258        notifyDataSetChanged();
259    }
260
261    @Override
262    public View getView(int position, View convertView, ViewGroup parent) {
263        if (mShowFooter && position == super.getCount()) {
264            return mFooter;
265        }
266        ConversationCursor cursor = (ConversationCursor) getItem(position);
267        Conversation conv = new Conversation(cursor);
268        if (isPositionUndoing(conv.id)) {
269            return getUndoingView(position, conv, parent, false /* don't show swipe background */);
270        } if (isPositionUndoingSwipe(conv.id)) {
271            return getUndoingView(position, conv, parent, true /* show swipe background */);
272        } else if (isPositionDeleting(conv.id)) {
273            return getDeletingView(position, conv, parent, false);
274        } else if (isPositionSwipeDeleting(conv.id)) {
275            return getDeletingView(position, conv, parent, true);
276        }
277        if (hasFadeLeaveBehinds()) {
278            if(isPositionFadeLeaveBehind(conv)) {
279                LeaveBehindItem fade  = getFadeLeaveBehindItem(position, conv);
280                fade.startAnimation(mActivity.getViewMode(), this);
281                return fade;
282            }
283        }
284        if (hasLeaveBehinds()) {
285            if(isPositionLeaveBehind(conv)) {
286                LeaveBehindItem fadeIn = getLeaveBehindItem(conv);
287                if (hasFadeLeaveBehinds()) {
288                    // Avoid the fade in and just show the text.
289                    fadeIn.showTextImmediately();
290                } else {
291                    fadeIn.startFadeInAnimation();
292                }
293                return fadeIn;
294            }
295        }
296        if (convertView != null && !(convertView instanceof SwipeableConversationItemView)) {
297            LogUtils.w(LOG_TAG, "Incorrect convert view received; nulling it out");
298            convertView = newView(mContext, cursor, parent);
299        } else if (convertView != null) {
300            ((SwipeableConversationItemView) convertView).reset();
301        }
302        return createConversationItemView((SwipeableConversationItemView) convertView, mContext,
303                conv);
304    }
305
306    private boolean hasLeaveBehinds() {
307        return mLeaveBehindItem != null;
308    }
309
310    private boolean hasFadeLeaveBehinds() {
311        return !mFadeLeaveBehindItems.isEmpty();
312    }
313
314    public LeaveBehindItem setupLeaveBehind(Conversation target, ToastBarOperation undoOp,
315            int deletedRow) {
316        fadeOutLeaveBehindItems();
317        boolean isWide = ConversationItemViewCoordinates.isWideMode(ConversationItemViewCoordinates
318                .getMode(mContext, mActivity.getViewMode()));
319        LeaveBehindItem leaveBehind = (LeaveBehindItem) LayoutInflater.from(mContext).inflate(
320                isWide? R.layout.swipe_leavebehind_wide : R.layout.swipe_leavebehind, null);
321        leaveBehind.bindOperations(deletedRow, mAccount, this, undoOp, target, mFolder);
322        mLeaveBehindItem = leaveBehind;
323        mLastDeletingItems.add(target.id);
324        return leaveBehind;
325    }
326
327    public void fadeOutLeaveBehindItems() {
328        // Remove any previously existing leave behind item.
329        final int startPosition = mListView.getFirstVisiblePosition();
330        final int endPosition = mListView.getLastVisiblePosition();
331
332        if (hasLeaveBehinds()) {
333            // If the item is visible, fade it out. Otherwise, just remove
334            // it.
335            Conversation conv = mLeaveBehindItem.getData();
336            if (conv.position >= startPosition && conv.position <= endPosition) {
337                mFadeLeaveBehindItems.put(conv.id, mLeaveBehindItem);
338            } else {
339                mLeaveBehindItem.commit();
340            }
341            clearLeaveBehind(conv.id);
342        }
343        if (!mLastDeletingItems.isEmpty()) {
344            mLastDeletingItems.clear();
345        }
346        notifyDataSetChanged();
347    }
348
349    public SwipeableListView getListView() {
350        return mListView;
351    }
352
353    public void commitLeaveBehindItems(boolean animate) {
354        // Remove any previously existing leave behinds.
355        boolean changed = false;
356        if (hasLeaveBehinds()) {
357            if (animate) {
358                mLeaveBehindItem.dismiss();
359            } else {
360                mLeaveBehindItem.commit();
361            }
362            changed = true;
363        }
364        if (hasFadeLeaveBehinds() && !animate) {
365            // Find any fading leave behind items and commit them all, too.
366            for (LeaveBehindItem item : mFadeLeaveBehindItems.values()) {
367                item.commit();
368            }
369            mFadeLeaveBehindItems.clear();
370            changed = true;
371        }
372        if (!mLastDeletingItems.isEmpty()) {
373            mLastDeletingItems.clear();
374            changed = true;
375        }
376        if (changed) {
377            notifyDataSetChanged();
378        }
379    }
380
381    private LeaveBehindItem getLeaveBehindItem(Conversation target) {
382        return mLeaveBehindItem;
383    }
384
385    private LeaveBehindItem getFadeLeaveBehindItem(int position, Conversation target) {
386        return mFadeLeaveBehindItems.get(target.id);
387    }
388
389    @Override
390    public long getItemId(int position) {
391        if (mShowFooter && position == super.getCount()) {
392            return -1;
393        }
394        return super.getItemId(position);
395    }
396
397    private View getDeletingView(int position, Conversation conversation, ViewGroup parent,
398            boolean swipe) {
399        conversation.position = position;
400        SwipeableConversationItemView deletingView = mAnimatingViews.get(conversation.id);
401        if (deletingView == null) {
402            // The undo animation consists of fading in the conversation that
403            // had been destroyed.
404            deletingView = newConversationItemView(position, parent, conversation);
405            deletingView.startDeleteAnimation(this, swipe);
406        }
407        return deletingView;
408    }
409
410    private View getUndoingView(int position, Conversation conv, ViewGroup parent, boolean swipe) {
411        conv.position = position;
412        SwipeableConversationItemView undoView = mAnimatingViews.get(conv.id);
413        if (undoView == null) {
414            // The undo animation consists of fading in the conversation that
415            // had been destroyed.
416            undoView = newConversationItemView(position, parent, conv);
417            undoView.startUndoAnimation(mActivity.getViewMode(), this, swipe);
418        }
419        return undoView;
420    }
421
422    @Override
423    public View newView(Context context, Cursor cursor, ViewGroup parent) {
424        SwipeableConversationItemView view = new SwipeableConversationItemView(context,
425                mAccount.name);
426        return view;
427    }
428
429    @Override
430    public void bindView(View view, Context context, Cursor cursor) {
431        if (! (view instanceof SwipeableConversationItemView)) {
432            return;
433        }
434        ((SwipeableConversationItemView) view).bind(cursor, mActivity, mBatchConversations, mFolder,
435                mAccount != null ? mAccount.settings.hideCheckboxes : false,
436                        mSwipeEnabled, mPriorityMarkersEnabled, this);
437    }
438
439    private SwipeableConversationItemView newConversationItemView(int position, ViewGroup parent,
440            Conversation conversation) {
441        SwipeableConversationItemView view = (SwipeableConversationItemView) super.getView(
442                position, null, parent);
443        view.reset();
444        view.bind(conversation, mActivity, mBatchConversations, mFolder,
445                mAccount != null ? mAccount.settings.hideCheckboxes : false, mSwipeEnabled,
446                mPriorityMarkersEnabled, this);
447        mAnimatingViews.put(conversation.id, view);
448        return view;
449    }
450
451    @Override
452    public Object getItem(int position) {
453        if (mShowFooter && position == super.getCount()) {
454            return mFooter;
455        }
456        return super.getItem(position);
457    }
458
459    private boolean isPositionDeleting(long id) {
460        return mDeletingItems.contains(id);
461    }
462
463    private boolean isPositionSwipeDeleting(long id) {
464        return mSwipeDeletingItems.contains(id);
465    }
466
467    private boolean isPositionUndoing(long id) {
468        return mUndoingItems.contains(id);
469    }
470
471    private boolean isPositionUndoingSwipe(long id) {
472        return mSwipeUndoingItems.contains(id);
473    }
474
475    private boolean isPositionUndoingType(long id) {
476        return isPositionUndoing(id) || isPositionUndoingSwipe(id);
477    }
478
479    private boolean isPositionLeaveBehind(Conversation conv) {
480        return hasLeaveBehinds()
481                && mLeaveBehindItem.getConversationId() == conv.id
482                && conv.isMostlyDead();
483    }
484
485    private boolean isPositionFadeLeaveBehind(Conversation conv) {
486        return hasFadeLeaveBehinds()
487                && mFadeLeaveBehindItems.containsKey(conv.id)
488                && conv.isMostlyDead();
489    }
490
491    private boolean isPositionTypeLeaveBehind(Conversation conv) {
492        if (hasLeaveBehinds()) {
493            return isPositionLeaveBehind(conv) || isPositionFadeLeaveBehind(conv);
494        }
495        return false;
496    }
497
498    @Override
499    public void onAnimationStart(Animator animation) {
500        if (!mUndoingItems.isEmpty()) {
501            mDeletingItems.clear();
502            mLastDeletingItems.clear();
503            mSwipeDeletingItems.clear();
504        } else {
505            mUndoingItems.clear();
506        }
507    }
508
509    /**
510     * Performs the pending destruction, if any and assigns the next pending action.
511     * @param next The next action that is to be performed, possibly null (if no next action is
512     * needed).
513     */
514    private final void performAndSetNextAction(ListItemsRemovedListener next) {
515        if (mPendingDestruction != null) {
516            mPendingDestruction.onListItemsRemoved();
517        }
518        mPendingDestruction = next;
519    }
520
521    @Override
522    public void onAnimationEnd(Animator animation) {
523        Object obj;
524        if (animation instanceof AnimatorSet) {
525            AnimatorSet set = (AnimatorSet) animation;
526            obj = ((ObjectAnimator) set.getChildAnimations().get(0)).getTarget();
527        } else {
528            obj = ((ObjectAnimator) animation).getTarget();
529        }
530        updateAnimatingConversationItems(obj, mSwipeDeletingItems);
531        updateAnimatingConversationItems(obj, mDeletingItems);
532        updateAnimatingConversationItems(obj, mSwipeUndoingItems);
533        updateAnimatingConversationItems(obj, mUndoingItems);
534        if (hasFadeLeaveBehinds() && obj instanceof LeaveBehindItem) {
535            LeaveBehindItem objItem = (LeaveBehindItem) obj;
536            clearLeaveBehind(objItem.getConversationId());
537            objItem.commit();
538            // The view types have changed, since the animating views are gone.
539            notifyDataSetChanged();
540        }
541
542        if (!isAnimating()) {
543            mActivity.onAnimationEnd(this);
544        }
545    }
546
547    private void updateAnimatingConversationItems(Object obj, HashSet<Long> items) {
548        if (!items.isEmpty()) {
549            if (obj instanceof ConversationItemView) {
550                final ConversationItemView target = (ConversationItemView) obj;
551                final long id = target.getConversation().id;
552                items.remove(id);
553                mAnimatingViews.remove(id);
554                if (items.isEmpty()) {
555                    performAndSetNextAction(null);
556                    notifyDataSetChanged();
557                }
558            }
559        }
560    }
561
562    @Override
563    public boolean areAllItemsEnabled() {
564        // The animating positions are not enabled.
565        return false;
566    }
567
568    @Override
569    public boolean isEnabled(int position) {
570        return !isPositionDeleting(position) && !isPositionUndoing(position);
571    }
572
573    @Override
574    public void onAnimationCancel(Animator animation) {
575        onAnimationEnd(animation);
576    }
577
578    @Override
579    public void onAnimationRepeat(Animator animation) {
580    }
581
582    public void showFooter() {
583        setFooterVisibility(true);
584    }
585
586    public void hideFooter() {
587        setFooterVisibility(false);
588    }
589
590    public void setFooterVisibility(boolean show) {
591        if (mShowFooter != show) {
592            mShowFooter = show;
593            notifyDataSetChanged();
594        }
595    }
596
597    public void addFooter(View footerView) {
598        mFooter = footerView;
599    }
600
601    public void setFolder(Folder folder) {
602        mFolder = folder;
603    }
604
605    public void clearLeaveBehind(long itemId) {
606        if (hasLeaveBehinds() && mLeaveBehindItem.getConversationId() == itemId) {
607            mLeaveBehindItem = null;
608        } else if (hasFadeLeaveBehinds()) {
609            mFadeLeaveBehindItems.remove(itemId);
610        } else {
611            LogUtils.d(LOG_TAG, "Trying to clear a non-existant leave behind");
612        }
613    }
614
615    public void onSaveInstanceState(Bundle outState) {
616        long[] lastDeleting = new long[mLastDeletingItems.size()];
617        for (int i = 0; i < lastDeleting.length; i++) {
618            lastDeleting[i] = mLastDeletingItems.get(i);
619        }
620        outState.putLongArray(LAST_DELETING_ITEMS, lastDeleting);
621        if (hasLeaveBehinds()) {
622            outState.putParcelable(LEAVE_BEHIND_ITEM, mLeaveBehindItem.getLeaveBehindData());
623        }
624    }
625
626    public void onRestoreInstanceState(Bundle outState) {
627        if (outState.containsKey(LAST_DELETING_ITEMS)) {
628            final long[] lastDeleting = outState.getLongArray(LAST_DELETING_ITEMS);
629            for (int i = 0; i < lastDeleting.length;i++) {
630                mLastDeletingItems.add(lastDeleting[i]);
631            }
632        }
633        if (outState.containsKey(LEAVE_BEHIND_ITEM)) {
634            LeaveBehindData left = outState.getParcelable(LEAVE_BEHIND_ITEM);
635            LeaveBehindItem item = setupLeaveBehind(left.data, left.op, left.data.position);
636            mLeaveBehindItem = item;
637        }
638    }
639
640    /**
641     * Return if the adapter is in the process of animating anything.
642     */
643    public boolean isAnimating() {
644        return !mUndoingItems.isEmpty()
645                || !mSwipeUndoingItems.isEmpty()
646                || hasFadeLeaveBehinds()
647                || !mDeletingItems.isEmpty()
648                || !mSwipeDeletingItems.isEmpty();
649    }
650
651    /**
652     * Get the ConversationCursor associated with this adapter.
653     */
654    public ConversationCursor getConversationCursor() {
655        return (ConversationCursor) getCursor();
656    }
657}
658