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.content.ContentValues;
21import android.content.Context;
22import android.content.res.Configuration;
23import android.graphics.Rect;
24import android.net.Uri;
25import android.util.AttributeSet;
26import android.view.MotionEvent;
27import android.view.View;
28import android.view.ViewConfiguration;
29import android.widget.AbsListView;
30import android.widget.AbsListView.OnScrollListener;
31import android.widget.ListView;
32
33import com.android.mail.R;
34import com.android.mail.analytics.Analytics;
35import com.android.mail.browse.ConversationCursor;
36import com.android.mail.browse.ConversationItemView;
37import com.android.mail.browse.SwipeableConversationItemView;
38import com.android.mail.providers.Account;
39import com.android.mail.providers.Conversation;
40import com.android.mail.providers.Folder;
41import com.android.mail.providers.FolderList;
42import com.android.mail.ui.SwipeHelper.Callback;
43import com.android.mail.utils.LogTag;
44import com.android.mail.utils.LogUtils;
45import com.android.mail.utils.Utils;
46
47import java.util.ArrayList;
48import java.util.Collection;
49import java.util.HashMap;
50
51public class SwipeableListView extends ListView implements Callback, OnScrollListener {
52    private static final long INVALID_CONVERSATION_ID = -1;
53
54    private final SwipeHelper mSwipeHelper;
55    /**
56     * Are swipes enabled on all items? (Each individual item can still prevent swiping.)<br>
57     * When swiping is disabled, the UI still reacts to the gesture to acknowledge it.
58     */
59    private boolean mEnableSwipe = false;
60    /**
61     * When set, we prevent the SwipeHelper from kicking in at all. This
62     * short-circuits {@link #mEnableSwipe}.
63     */
64    private boolean mPreventSwipesEntirely = false;
65
66    public static final String LOG_TAG = LogTag.getLogTag();
67
68    private ConversationCheckedSet mConvCheckedSet;
69    private int mSwipeAction;
70    private Account mAccount;
71    private Folder mFolder;
72    private ListItemSwipedListener mSwipedListener;
73    private boolean mScrolling;
74
75    private SwipeListener mSwipeListener;
76
77    private long mSelectedConversationId = INVALID_CONVERSATION_ID;
78
79    // Instantiated through view inflation
80    @SuppressWarnings("unused")
81    public SwipeableListView(Context context) {
82        this(context, null);
83    }
84
85    public SwipeableListView(Context context, AttributeSet attrs) {
86        this(context, attrs, -1);
87    }
88
89    public SwipeableListView(Context context, AttributeSet attrs, int defStyle) {
90        super(context, attrs, defStyle);
91        float densityScale = getResources().getDisplayMetrics().density;
92        float pagingTouchSlop = ViewConfiguration.get(context).getScaledPagingTouchSlop();
93        mSwipeHelper = new SwipeHelper(context, SwipeHelper.X, this, densityScale,
94                pagingTouchSlop);
95        mScrolling = false;
96    }
97
98    @Override
99    protected void onConfigurationChanged(Configuration newConfig) {
100        super.onConfigurationChanged(newConfig);
101        float densityScale = getResources().getDisplayMetrics().density;
102        mSwipeHelper.setDensityScale(densityScale);
103        float pagingTouchSlop = ViewConfiguration.get(getContext()).getScaledPagingTouchSlop();
104        mSwipeHelper.setPagingTouchSlop(pagingTouchSlop);
105    }
106
107    @Override
108    protected void onFocusChanged(boolean gainFocus, int direction, Rect previouslyFocusedRect) {
109        LogUtils.d(Utils.VIEW_DEBUGGING_TAG,
110                "START CLF-ListView.onFocusChanged layoutRequested=%s root.layoutRequested=%s",
111                isLayoutRequested(), getRootView().isLayoutRequested());
112        super.onFocusChanged(gainFocus, direction, previouslyFocusedRect);
113        LogUtils.d(Utils.VIEW_DEBUGGING_TAG, new Error(),
114                "FINISH CLF-ListView.onFocusChanged layoutRequested=%s root.layoutRequested=%s",
115                isLayoutRequested(), getRootView().isLayoutRequested());
116    }
117
118    /**
119     * Enable swipe gestures.
120     */
121    public void enableSwipe(boolean enable) {
122        mEnableSwipe = enable;
123    }
124
125    /**
126     * Completely ignore any horizontal swiping gestures.
127     */
128    public void preventSwipesEntirely() {
129        mPreventSwipesEntirely = true;
130    }
131
132    /**
133     * Reverses a prior call to {@link #preventSwipesEntirely()}.
134     */
135    public void stopPreventingSwipes() {
136        mPreventSwipesEntirely = false;
137    }
138
139    public void setSwipeAction(int action) {
140        mSwipeAction = action;
141    }
142
143    public void setListItemSwipedListener(ListItemSwipedListener listener) {
144        mSwipedListener = listener;
145    }
146
147    public int getSwipeAction() {
148        return mSwipeAction;
149    }
150
151    public void setCheckedSet(ConversationCheckedSet set) {
152        mConvCheckedSet = set;
153    }
154
155    public void setCurrentAccount(Account account) {
156        mAccount = account;
157    }
158
159    public void setCurrentFolder(Folder folder) {
160        mFolder = folder;
161    }
162
163    @Override
164    public ConversationCheckedSet getCheckedSet() {
165        return mConvCheckedSet;
166    }
167
168    @Override
169    public boolean onInterceptTouchEvent(MotionEvent ev) {
170        if (mScrolling) {
171            return super.onInterceptTouchEvent(ev);
172        } else {
173            return (!mPreventSwipesEntirely && mSwipeHelper.onInterceptTouchEvent(ev))
174                    || super.onInterceptTouchEvent(ev);
175        }
176    }
177
178    @Override
179    public boolean onTouchEvent(MotionEvent ev) {
180        return (!mPreventSwipesEntirely && mSwipeHelper.onTouchEvent(ev)) || super.onTouchEvent(ev);
181    }
182
183    @Override
184    public View getChildAtPosition(MotionEvent ev) {
185        // find the view under the pointer, accounting for GONE views
186        final int count = getChildCount();
187        final int touchY = (int) ev.getY();
188        int childIdx = 0;
189        View slidingChild;
190        for (; childIdx < count; childIdx++) {
191            slidingChild = getChildAt(childIdx);
192            if (slidingChild.getVisibility() == GONE) {
193                continue;
194            }
195            if (touchY >= slidingChild.getTop() && touchY <= slidingChild.getBottom()) {
196                if (slidingChild instanceof SwipeableConversationItemView) {
197                    return ((SwipeableConversationItemView) slidingChild).getSwipeableItemView();
198                }
199                return slidingChild;
200            }
201        }
202        return null;
203    }
204
205    @Override
206    public boolean canChildBeDismissed(SwipeableItemView v) {
207        return mEnableSwipe && v.canChildBeDismissed();
208    }
209
210    @Override
211    public void onChildDismissed(SwipeableItemView v) {
212        if (v != null) {
213            v.dismiss();
214        }
215    }
216
217    // Call this whenever a new action is taken; this forces a commit of any
218    // existing destructive actions.
219    public void commitDestructiveActions(boolean animate) {
220        final AnimatedAdapter adapter = getAnimatedAdapter();
221        if (adapter != null) {
222            adapter.commitLeaveBehindItems(animate);
223        }
224    }
225
226    public void dismissChild(final ConversationItemView target) {
227        // Notifies the SwipeListener that a swipe has ended.
228        if (mSwipeListener != null) {
229            mSwipeListener.onEndSwipe();
230        }
231
232        final ToastBarOperation undoOp;
233
234        undoOp = new ToastBarOperation(1, mSwipeAction, ToastBarOperation.UNDO, false /* batch */,
235                mFolder);
236        Conversation conv = target.getConversation();
237        target.getConversation().position = findConversation(target, conv);
238        final AnimatedAdapter adapter = getAnimatedAdapter();
239        if (adapter == null) {
240            return;
241        }
242        adapter.setupLeaveBehind(conv, undoOp, conv.position, target.getHeight());
243        ConversationCursor cc = (ConversationCursor) adapter.getCursor();
244        Collection<Conversation> convList = Conversation.listOf(conv);
245        ArrayList<Uri> folderUris;
246        ArrayList<Boolean> adds;
247
248        Analytics.getInstance().sendMenuItemEvent("list_swipe", mSwipeAction, null, 0);
249
250        if (mSwipeAction == R.id.remove_folder) {
251            FolderOperation folderOp = new FolderOperation(mFolder, false);
252            HashMap<Uri, Folder> targetFolders = Folder
253                    .hashMapForFolders(conv.getRawFolders());
254            targetFolders.remove(folderOp.mFolder.folderUri.fullUri);
255            final FolderList folders = FolderList.copyOf(targetFolders.values());
256            conv.setRawFolders(folders);
257            final ContentValues values = new ContentValues();
258            folderUris = new ArrayList<Uri>();
259            folderUris.add(mFolder.folderUri.fullUri);
260            adds = new ArrayList<Boolean>();
261            adds.add(Boolean.FALSE);
262            ConversationCursor.addFolderUpdates(folderUris, adds, values);
263            ConversationCursor.addTargetFolders(targetFolders.values(), values);
264            cc.mostlyDestructiveUpdate(Conversation.listOf(conv), values);
265        } else if (mSwipeAction == R.id.archive) {
266            cc.mostlyArchive(convList);
267        } else if (mSwipeAction == R.id.delete) {
268            cc.mostlyDelete(convList);
269        } else if (mSwipeAction == R.id.discard_outbox) {
270            cc.moveFailedIntoDrafts(convList);
271        }
272        if (mSwipedListener != null) {
273            mSwipedListener.onListItemSwiped(convList);
274        }
275        adapter.notifyDataSetChanged();
276        if (mConvCheckedSet != null && !mConvCheckedSet.isEmpty()
277                && mConvCheckedSet.contains(conv)) {
278            mConvCheckedSet.toggle(conv);
279            // Don't commit destructive actions if the item we just removed from
280            // the selection set is the item we just destroyed!
281            if (!conv.isMostlyDead() && mConvCheckedSet.isEmpty()) {
282                commitDestructiveActions(true);
283            }
284        }
285    }
286
287    @Override
288    public void onBeginDrag(View v) {
289        // We do this so the underlying ScrollView knows that it won't get
290        // the chance to intercept events anymore
291        requestDisallowInterceptTouchEvent(true);
292        cancelDismissCounter();
293
294        // Notifies the SwipeListener that a swipe has begun.
295        if (mSwipeListener != null) {
296            mSwipeListener.onBeginSwipe();
297        }
298    }
299
300    @Override
301    public void onDragCancelled(SwipeableItemView v) {
302        final AnimatedAdapter adapter = getAnimatedAdapter();
303        if (adapter != null) {
304            adapter.startDismissCounter();
305            adapter.cancelFadeOutLastLeaveBehindItemText();
306        }
307
308        // Notifies the SwipeListener that a swipe has ended.
309        if (mSwipeListener != null) {
310            mSwipeListener.onEndSwipe();
311        }
312    }
313
314    /**
315     * Archive items using the swipe away animation before shrinking them away.
316     */
317    public boolean destroyItems(Collection<Conversation> convs,
318            final ListItemsRemovedListener listener) {
319        if (convs == null) {
320            LogUtils.e(LOG_TAG, "SwipeableListView.destroyItems: null conversations.");
321            return false;
322        }
323        final AnimatedAdapter adapter = getAnimatedAdapter();
324        if (adapter == null) {
325            LogUtils.e(LOG_TAG, "SwipeableListView.destroyItems: Cannot destroy: adapter is null.");
326            return false;
327        }
328        adapter.swipeDelete(convs, listener);
329        return true;
330    }
331
332    public int findConversation(ConversationItemView view, Conversation conv) {
333        int position = INVALID_POSITION;
334        long convId = conv.id;
335        try {
336            position = getPositionForView(view);
337        } catch (Exception e) {
338            position = INVALID_POSITION;
339            LogUtils.w(LOG_TAG, e, "Exception finding position; using alternate strategy");
340        }
341        if (position == INVALID_POSITION) {
342            // Try the other way!
343            Conversation foundConv;
344            long foundId;
345            for (int i = 0; i < getChildCount(); i++) {
346                View child = getChildAt(i);
347                if (child instanceof SwipeableConversationItemView) {
348                    foundConv = ((SwipeableConversationItemView) child).getSwipeableItemView()
349                            .getConversation();
350                    foundId = foundConv.id;
351                    if (foundId == convId) {
352                        position = i + getFirstVisiblePosition();
353                        break;
354                    }
355                }
356            }
357        }
358        return position;
359    }
360
361    private AnimatedAdapter getAnimatedAdapter() {
362        return (AnimatedAdapter) getAdapter();
363    }
364
365    @Override
366    public boolean performItemClick(View view, int pos, long id) {
367        // Superclass method modifies the selection set
368        final boolean handled = super.performItemClick(view, pos, id);
369
370        // Commit any existing destructive actions when the user selects a
371        // conversation to view.
372        commitDestructiveActions(true);
373        return handled;
374    }
375
376    @Override
377    public void onScroll() {
378        commitDestructiveActions(true);
379    }
380
381    public interface ListItemsRemovedListener {
382        public void onListItemsRemoved();
383    }
384
385    public interface ListItemSwipedListener {
386        public void onListItemSwiped(Collection<Conversation> conversations);
387    }
388
389    @Override
390    public final void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount,
391            int totalItemCount) {
392    }
393
394    @Override
395    public void onScrollStateChanged(final AbsListView view, final int scrollState) {
396        mScrolling = scrollState != OnScrollListener.SCROLL_STATE_IDLE;
397
398        if (!mScrolling) {
399            final Context c = getContext();
400            if (c instanceof ControllableActivity) {
401                final ControllableActivity activity = (ControllableActivity) c;
402                activity.onAnimationEnd(null /* adapter */);
403            } else {
404                LogUtils.wtf(LOG_TAG, "unexpected context=%s", c);
405            }
406        }
407    }
408
409    public boolean isScrolling() {
410        return mScrolling;
411    }
412
413    /**
414     * Set the currently selected (focused by the list view) position.
415     */
416    public void setSelectedConversation(Conversation conv) {
417        if (conv == null) {
418            return;
419        }
420
421        mSelectedConversationId = conv.id;
422    }
423
424    public boolean isConversationSelected(Conversation conv) {
425        return mSelectedConversationId != INVALID_CONVERSATION_ID && conv != null
426                && mSelectedConversationId == conv.id;
427    }
428
429    /**
430     * This is only used for debugging/logging purposes. DO NOT call this function to try to get
431     * the currently selected position. Use {@link #mSelectedConversationId} instead.
432     */
433    public int getSelectedConversationPosDebug() {
434        for (int i = getFirstVisiblePosition(); i < getLastVisiblePosition(); i++) {
435            final Object item = getItemAtPosition(i);
436            if (item instanceof ConversationCursor) {
437                final Conversation c = ((ConversationCursor) item).getConversation();
438                if (c.id == mSelectedConversationId) {
439                    return i;
440                }
441            }
442        }
443        return ListView.INVALID_POSITION;
444    }
445
446    @Override
447    public void onTouchModeChanged(boolean isInTouchMode) {
448        super.onTouchModeChanged(isInTouchMode);
449        if (!isInTouchMode) {
450            // We need to invalidate going from touch mode -> keyboard mode because the currently
451            // selected item might have changed in touch mode. However, since from the framework's
452            // perspective the selected position doesn't matter in touch mode, when we enter
453            // keyboard mode via up/down arrow, the list view will ONLY invalidate the newly
454            // selected item and not the currently selected item. As a result, we might get an
455            // inconsistent UI where it looks like both the old and new selected items are focused.
456            final int index = getSelectedItemPosition();
457            if (index != ListView.INVALID_POSITION) {
458                final View child = getChildAt(index - getFirstVisiblePosition());
459                if (child != null) {
460                    child.invalidate();
461                }
462            }
463        }
464    }
465
466    @Override
467    public void cancelDismissCounter() {
468        AnimatedAdapter adapter = getAnimatedAdapter();
469        if (adapter != null) {
470            adapter.cancelDismissCounter();
471        }
472    }
473
474    @Override
475    public LeaveBehindItem getLastSwipedItem() {
476        AnimatedAdapter adapter = getAnimatedAdapter();
477        if (adapter != null) {
478            return adapter.getLastLeaveBehindItem();
479        }
480        return null;
481    }
482
483    public void setSwipeListener(SwipeListener swipeListener) {
484        mSwipeListener = swipeListener;
485    }
486
487    public interface SwipeListener {
488        public void onBeginSwipe();
489        public void onEndSwipe();
490    }
491}
492