AnimatedAdapter.java revision 4f0814f1957b2607a6243db587aa8d6e7f1fd982
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.SwipeableConversationItemView;
35import com.android.mail.providers.Account;
36import com.android.mail.providers.AccountObserver;
37import com.android.mail.providers.Conversation;
38import com.android.mail.providers.Folder;
39import com.android.mail.providers.UIProvider;
40import com.android.mail.utils.LogTag;
41import com.android.mail.utils.LogUtils;
42
43import java.util.ArrayList;
44import java.util.Collection;
45import java.util.HashMap;
46import java.util.HashSet;
47
48public class AnimatedAdapter extends SimpleCursorAdapter implements
49        android.animation.Animator.AnimatorListener {
50    private static final String LAST_DELETING_ITEMS = "last_deleting_items";
51    private static final String LEAVE_BEHIND_ITEM = "leave_behind_item";
52    private final static int TYPE_VIEW_CONVERSATION = 0;
53    private final static int TYPE_VIEW_DELETING = 1;
54    private final static int TYPE_VIEW_UNDOING = 2;
55    private final static int TYPE_VIEW_FOOTER = 3;
56    private final static int TYPE_VIEW_LEAVEBEHIND = 4;
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 DestructiveAction mPendingDestruction;
76    /**
77     * A destructive action that refreshes the list and performs no other action.
78     */
79    private final DestructiveAction mRefreshAction = new DestructiveAction() {
80        @Override
81        public void performAction() {
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    @Override
163    public View newView(Context context, Cursor cursor, ViewGroup parent) {
164        SwipeableConversationItemView view = new SwipeableConversationItemView(context,
165                mAccount.name);
166        return view;
167    }
168
169    @Override
170    public void bindView(View view, Context context, Cursor cursor) {
171        if (! (view instanceof SwipeableConversationItemView)) {
172            return;
173        }
174        ((SwipeableConversationItemView) view).bind(cursor, mActivity, mBatchConversations, mFolder,
175                mAccount != null ? mAccount.settings.hideCheckboxes : false,
176                        mSwipeEnabled, mPriorityMarkersEnabled, this);
177    }
178
179    @Override
180    public boolean hasStableIds() {
181        return true;
182    }
183
184    @Override
185    public int getViewTypeCount() {
186        // TYPE_VIEW_CONVERSATION, TYPE_VIEW_DELETING, TYPE_VIEW_UNDOING, and
187        // TYPE_VIEW_FOOTER, TYPE_VIEW_LEAVEBEHIND.
188        return 5;
189    }
190
191    @Override
192    public int getItemViewType(int position) {
193        // Try to recycle views.
194        if (mShowFooter && position == super.getCount()) {
195            return TYPE_VIEW_FOOTER;
196        }
197        return TYPE_VIEW_CONVERSATION;
198    }
199
200    /**
201     * Deletes the selected conversations from the conversation list view with a
202     * translation and then a shrink. These conversations <b>must</b> have their
203     * {@link Conversation#position} set to the position of these conversations
204     * among the list. This will only remove the element from the list. The job
205     * of deleting the actual element is left to the the listener. This listener
206     * will be called when the animations are complete and is required to delete
207     * the conversation.
208     * @param conversations
209     * @param listener
210     */
211    public void swipeDelete(Collection<Conversation> conversations, DestructiveAction listener) {
212        delete(conversations, listener, mSwipeDeletingItems);
213    }
214
215
216    /**
217     * Deletes the selected conversations from the conversation list view by
218     * shrinking them away. These conversations <b>must</b> have their
219     * {@link Conversation#position} set to the position of these conversations
220     * among the list. This will only remove the element from the list. The job
221     * of deleting the actual element is left to the the listener. This listener
222     * will be called when the animations are complete and is required to delete
223     * the conversation.
224     * @param conversations
225     * @param listener
226     */
227    public void delete(Collection<Conversation> conversations, DestructiveAction listener) {
228        delete(conversations, listener, mDeletingItems);
229    }
230
231    private void delete(Collection<Conversation> conversations, DestructiveAction action,
232            HashSet<Long> list) {
233        // Clear out any remaining items and add the new ones
234        mLastDeletingItems.clear();
235
236        final int startPosition = mListView.getFirstVisiblePosition();
237        final int endPosition = mListView.getLastVisiblePosition();
238
239        // Only animate visible items
240        for (Conversation c: conversations) {
241            if (c.position >= startPosition && c.position <= endPosition) {
242                mLastDeletingItems.add(c.id);
243                list.add(c.id);
244            }
245        }
246
247        if (list.isEmpty()) {
248            // If we have no deleted items on screen, skip the animation
249            action.performAction();
250        } else {
251            performAndSetNextAction(action);
252        }
253
254        // TODO(viki): Rather than notifying for a full data set change,
255        // perhaps we can mark
256        // only the affected conversations?
257        notifyDataSetChanged();
258    }
259
260    @Override
261    public View getView(int position, View convertView, ViewGroup parent) {
262        if (mShowFooter && position == super.getCount()) {
263            return mFooter;
264        }
265        Conversation conv = new Conversation((ConversationCursor) getItem(position));
266        if (isPositionUndoing(conv.id)) {
267            return getUndoingView(position, conv, parent, false /* don't show swipe background */);
268        } if (isPositionUndoingSwipe(conv.id)) {
269            return getUndoingView(position, conv, parent, true /* show swipe background */);
270        } else if (isPositionDeleting(conv.id)) {
271            return getDeletingView(position, conv, parent, false);
272        } else if (isPositionSwipeDeleting(conv.id)) {
273            return getDeletingView(position, conv, parent, true);
274        }
275        if (hasFadeLeaveBehinds()) {
276            if(isPositionFadeLeaveBehind(conv)) {
277                LeaveBehindItem fade  = getFadeLeaveBehindItem(position, conv);
278                fade.startAnimation(mActivity.getViewMode(), this);
279                return fade;
280            }
281        }
282        if (hasLeaveBehinds()) {
283            if(isPositionLeaveBehind(conv)) {
284                LeaveBehindItem fadeIn = getLeaveBehindItem(conv);
285                if (hasFadeLeaveBehinds()) {
286                    // Avoid the fade in and just show the text.
287                    fadeIn.showTextImmediately();
288                } else {
289                    fadeIn.startFadeInAnimation();
290                }
291                return fadeIn;
292            }
293        }
294        if (convertView != null && !(convertView instanceof SwipeableConversationItemView)) {
295            LogUtils.w(LOG_TAG, "Incorrect convert view received; nulling it out");
296            convertView = null;
297        } else if (convertView != null) {
298            ((SwipeableConversationItemView) convertView).reset();
299        }
300        return super.getView(position, convertView, parent);
301    }
302
303    private boolean hasLeaveBehinds() {
304        return mLeaveBehindItem != null;
305    }
306
307    private boolean hasFadeLeaveBehinds() {
308        return !mFadeLeaveBehindItems.isEmpty();
309    }
310
311    public LeaveBehindItem setupLeaveBehind(Conversation target, ToastBarOperation undoOp,
312            int deletedRow) {
313        fadeOutLeaveBehindItems();
314        LeaveBehindItem leaveBehind = (LeaveBehindItem) LayoutInflater.from(mContext).inflate(
315                R.layout.swipe_leavebehind, null);
316        leaveBehind.bindOperations(deletedRow, mAccount, this, undoOp, target, mFolder);
317        mLeaveBehindItem = leaveBehind;
318        mLastDeletingItems.add(target.id);
319        return leaveBehind;
320    }
321
322    public void fadeOutLeaveBehindItems() {
323        // Remove any previously existing leave behind item.
324        final int startPosition = mListView.getFirstVisiblePosition();
325        final int endPosition = mListView.getLastVisiblePosition();
326
327        if (hasLeaveBehinds()) {
328            // If the item is visible, fade it out. Otherwise, just remove
329            // it.
330            Conversation conv = mLeaveBehindItem.getData();
331            if (conv.position >= startPosition && conv.position <= endPosition) {
332                mFadeLeaveBehindItems.put(conv.id, mLeaveBehindItem);
333            } else {
334                mLeaveBehindItem.commit();
335            }
336            clearLeaveBehind(conv.id);
337        }
338        if (!mLastDeletingItems.isEmpty()) {
339            mLastDeletingItems.clear();
340        }
341        notifyDataSetChanged();
342    }
343
344    public SwipeableListView getListView() {
345        return mListView;
346    }
347
348    public void commitLeaveBehindItems(boolean animate) {
349        // Remove any previously existing leave behinds.
350        boolean changed = false;
351        if (hasLeaveBehinds()) {
352            if (animate) {
353                mLeaveBehindItem.dismiss();
354            } else {
355                mLeaveBehindItem.commit();
356            }
357            changed = true;
358        }
359        if (hasFadeLeaveBehinds() && !animate) {
360            // Find any fading leave behind items and commit them all, too.
361            for (LeaveBehindItem item : mFadeLeaveBehindItems.values()) {
362                item.commit();
363            }
364            mFadeLeaveBehindItems.clear();
365            changed = true;
366        }
367        if (!mLastDeletingItems.isEmpty()) {
368            mLastDeletingItems.clear();
369            changed = true;
370        }
371        if (changed) {
372            notifyDataSetChanged();
373        }
374    }
375
376    private LeaveBehindItem getLeaveBehindItem(Conversation target) {
377        return mLeaveBehindItem;
378    }
379
380    private LeaveBehindItem getFadeLeaveBehindItem(int position, Conversation target) {
381        return mFadeLeaveBehindItems.get(target.id);
382    }
383
384    @Override
385    public long getItemId(int position) {
386        if (mShowFooter && position == super.getCount()) {
387            return -1;
388        }
389        return super.getItemId(position);
390    }
391
392    private View getDeletingView(int position, Conversation conversation, ViewGroup parent,
393            boolean swipe) {
394        conversation.position = position;
395        SwipeableConversationItemView deletingView = mAnimatingViews.get(conversation.id);
396        if (deletingView == null) {
397            // The undo animation consists of fading in the conversation that
398            // had been destroyed.
399            deletingView = newConversationItemView(position, parent, conversation);
400            deletingView.startDeleteAnimation(this, swipe);
401        }
402        return deletingView;
403    }
404
405    private View getUndoingView(int position, Conversation conv, ViewGroup parent, boolean swipe) {
406        conv.position = position;
407        SwipeableConversationItemView undoView = mAnimatingViews.get(conv.id);
408        if (undoView == null) {
409            // The undo animation consists of fading in the conversation that
410            // had been destroyed.
411            undoView = newConversationItemView(position, parent, conv);
412            undoView.startUndoAnimation(mActivity.getViewMode(), this, swipe);
413        }
414        return undoView;
415    }
416
417    private SwipeableConversationItemView newConversationItemView(int position, ViewGroup parent,
418            Conversation conversation) {
419        SwipeableConversationItemView view = (SwipeableConversationItemView) super.getView(
420                position, null, parent);
421        view.bind(conversation, mActivity, mBatchConversations, mFolder,
422                mAccount != null ? mAccount.settings.hideCheckboxes : false, mSwipeEnabled,
423                mPriorityMarkersEnabled, this);
424        mAnimatingViews.put(conversation.id, view);
425        return view;
426    }
427
428    @Override
429    public Object getItem(int position) {
430        if (mShowFooter && position == super.getCount()) {
431            return mFooter;
432        }
433        return super.getItem(position);
434    }
435
436    private boolean isPositionDeleting(long id) {
437        return mDeletingItems.contains(id);
438    }
439
440    private boolean isPositionSwipeDeleting(long id) {
441        return mSwipeDeletingItems.contains(id);
442    }
443
444    private boolean isPositionUndoing(long id) {
445        return mUndoingItems.contains(id);
446    }
447
448    private boolean isPositionUndoingSwipe(long id) {
449        return mSwipeUndoingItems.contains(id);
450    }
451
452    private boolean isPositionUndoingType(long id) {
453        return isPositionUndoing(id) || isPositionUndoingSwipe(id);
454    }
455
456    private boolean isPositionLeaveBehind(Conversation conv) {
457        return hasLeaveBehinds()
458                && mLeaveBehindItem.getConversationId() == conv.id
459                && conv.isMostlyDead();
460    }
461
462    private boolean isPositionFadeLeaveBehind(Conversation conv) {
463        return hasFadeLeaveBehinds()
464                && mFadeLeaveBehindItems.containsKey(conv.id)
465                && conv.isMostlyDead();
466    }
467
468    private boolean isPositionTypeLeaveBehind(Conversation conv) {
469        if (hasLeaveBehinds()) {
470            return isPositionLeaveBehind(conv) || isPositionFadeLeaveBehind(conv);
471        }
472        return false;
473    }
474
475    @Override
476    public void onAnimationStart(Animator animation) {
477        if (!mUndoingItems.isEmpty()) {
478            mDeletingItems.clear();
479            mLastDeletingItems.clear();
480            mSwipeDeletingItems.clear();
481        } else {
482            mUndoingItems.clear();
483        }
484    }
485
486    /**
487     * Performs the pending destruction, if any and assigns the next pending action.
488     * @param next The next action that is to be performed, possibly null (if no next action is
489     * needed).
490     */
491    private final void performAndSetNextAction(DestructiveAction next) {
492        if (mPendingDestruction != null) {
493            mPendingDestruction.performAction();
494        }
495        mPendingDestruction = next;
496    }
497
498    @Override
499    public void onAnimationEnd(Animator animation) {
500        Object obj;
501        if (animation instanceof AnimatorSet) {
502            AnimatorSet set = (AnimatorSet) animation;
503            obj = ((ObjectAnimator) set.getChildAnimations().get(0)).getTarget();
504        } else {
505            obj = ((ObjectAnimator) animation).getTarget();
506        }
507        updateAnimatingConversationItems(obj, mSwipeDeletingItems);
508        updateAnimatingConversationItems(obj, mDeletingItems);
509        updateAnimatingConversationItems(obj, mSwipeUndoingItems);
510        updateAnimatingConversationItems(obj, mUndoingItems);
511        if (hasFadeLeaveBehinds() && obj instanceof LeaveBehindItem) {
512            LeaveBehindItem objItem = (LeaveBehindItem) obj;
513            clearLeaveBehind(objItem.getConversationId());
514            objItem.commit();
515            // The view types have changed, since the animating views are gone.
516            notifyDataSetChanged();
517        }
518
519        if (!isAnimating()) {
520            mActivity.onAnimationEnd(this);
521        }
522    }
523
524    private void updateAnimatingConversationItems(Object obj, HashSet<Long> items) {
525        if (!items.isEmpty()) {
526            if (obj instanceof ConversationItemView) {
527                final ConversationItemView target = (ConversationItemView) obj;
528                final long id = target.getConversation().id;
529                items.remove(id);
530                mAnimatingViews.remove(id);
531                if (items.isEmpty()) {
532                    performAndSetNextAction(null);
533                    notifyDataSetChanged();
534                }
535            }
536        }
537    }
538
539    @Override
540    public boolean areAllItemsEnabled() {
541        // The animating positions are not enabled.
542        return false;
543    }
544
545    @Override
546    public boolean isEnabled(int position) {
547        return !isPositionDeleting(position) && !isPositionUndoing(position);
548    }
549
550    @Override
551    public void onAnimationCancel(Animator animation) {
552        onAnimationEnd(animation);
553    }
554
555    @Override
556    public void onAnimationRepeat(Animator animation) {
557    }
558
559    public void showFooter() {
560        setFooterVisibility(true);
561    }
562
563    public void hideFooter() {
564        setFooterVisibility(false);
565    }
566
567    public void setFooterVisibility(boolean show) {
568        if (mShowFooter != show) {
569            mShowFooter = show;
570            notifyDataSetChanged();
571        }
572    }
573
574    public void addFooter(View footerView) {
575        mFooter = footerView;
576    }
577
578    public void setFolder(Folder folder) {
579        mFolder = folder;
580    }
581
582    public void clearLeaveBehind(long itemId) {
583        if (hasLeaveBehinds() && mLeaveBehindItem.getConversationId() == itemId) {
584            mLeaveBehindItem = null;
585        } else if (hasFadeLeaveBehinds()) {
586            mFadeLeaveBehindItems.remove(itemId);
587        } else {
588            LogUtils.d(LOG_TAG, "Trying to clear a non-existant leave behind");
589        }
590    }
591
592    public void onSaveInstanceState(Bundle outState) {
593        long[] lastDeleting = new long[mLastDeletingItems.size()];
594        for (int i = 0; i < lastDeleting.length; i++) {
595            lastDeleting[i] = mLastDeletingItems.get(i);
596        }
597        outState.putLongArray(LAST_DELETING_ITEMS, lastDeleting);
598        if (hasLeaveBehinds()) {
599            outState.putParcelable(LEAVE_BEHIND_ITEM, mLeaveBehindItem.getLeaveBehindData());
600        }
601    }
602
603    public void onRestoreInstanceState(Bundle outState) {
604        if (outState.containsKey(LAST_DELETING_ITEMS)) {
605            final long[] lastDeleting = outState.getLongArray(LAST_DELETING_ITEMS);
606            for (int i = 0; i < lastDeleting.length;i++) {
607                mLastDeletingItems.add(lastDeleting[i]);
608            }
609        }
610        if (outState.containsKey(LEAVE_BEHIND_ITEM)) {
611            LeaveBehindItem.LeaveBehindData left = outState.getParcelable(LEAVE_BEHIND_ITEM);
612            LeaveBehindItem item = setupLeaveBehind(left.data, left.op, left.data.position);
613            mLeaveBehindItem = item;
614        }
615    }
616
617    /**
618     * Return if the adapter is in the process of animating anything.
619     */
620    public boolean isAnimating() {
621        return !mUndoingItems.isEmpty()
622                || !mSwipeUndoingItems.isEmpty()
623                || hasFadeLeaveBehinds()
624                || !mDeletingItems.isEmpty()
625                || !mSwipeDeletingItems.isEmpty();
626    }
627
628    /**
629     * Get the ConversationCursor associated with this adapter.
630     */
631    public ConversationCursor getConversationCursor() {
632        return (ConversationCursor) getCursor();
633    }
634}
635