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.widget.AbsListView;
27import android.widget.AbsListView.OnScrollListener;
28import android.view.MotionEvent;
29import android.view.View;
30import android.view.ViewConfiguration;
31import android.widget.ListView;
32
33import com.android.mail.R;
34import com.android.mail.analytics.Analytics;
35import com.android.mail.analytics.AnalyticsUtils;
36import com.android.mail.browse.ConversationCursor;
37import com.android.mail.browse.ConversationItemView;
38import com.android.mail.browse.SwipeableConversationItemView;
39import com.android.mail.providers.Account;
40import com.android.mail.providers.Conversation;
41import com.android.mail.providers.Folder;
42import com.android.mail.providers.FolderList;
43import com.android.mail.ui.SwipeHelper.Callback;
44import com.android.mail.utils.LogTag;
45import com.android.mail.utils.LogUtils;
46import com.android.mail.utils.Utils;
47
48import java.util.ArrayList;
49import java.util.Collection;
50import java.util.HashMap;
51
52public class SwipeableListView extends ListView implements Callback, OnScrollListener {
53    private final SwipeHelper mSwipeHelper;
54    private boolean mEnableSwipe = false;
55
56    public static final String LOG_TAG = LogTag.getLogTag();
57    /**
58     * Set to false to prevent the FLING scroll state from pausing the photo manager loaders.
59     */
60    private final static boolean SCROLL_PAUSE_ENABLE = true;
61
62    /**
63     * Set to true to enable parallax effect for attachment previews as the scroll position varies.
64     * This effect triggers invalidations on scroll (!) and requires more memory for attachment
65     * preview bitmaps.
66     */
67    public static final boolean ENABLE_ATTACHMENT_PARALLAX = true;
68
69    /**
70     * Set to true to queue finished decodes in an aggregator so that we display decoded attachment
71     * previews in an ordered fashion. This artificially delays updating the UI with decoded images,
72     * since they may have to wait on another image to finish decoding first.
73     */
74    public static final boolean ENABLE_ATTACHMENT_DECODE_AGGREGATOR = true;
75
76    /**
77     * The amount of extra vertical space to decode in attachment previews so we have image data to
78     * pan within. 1.0 implies no parallax effect.
79     */
80    public static final float ATTACHMENT_PARALLAX_MULTIPLIER_NORMAL = 1.5f;
81    public static final float ATTACHMENT_PARALLAX_MULTIPLIER_ALTERNATIVE = 2.0f;
82
83    private ConversationSelectionSet mConvSelectionSet;
84    private int mSwipeAction;
85    private Account mAccount;
86    private Folder mFolder;
87    private ListItemSwipedListener mSwipedListener;
88    private boolean mScrolling;
89
90    private SwipeListener mSwipeListener;
91
92    // Instantiated through view inflation
93    @SuppressWarnings("unused")
94    public SwipeableListView(Context context) {
95        this(context, null);
96    }
97
98    public SwipeableListView(Context context, AttributeSet attrs) {
99        this(context, attrs, -1);
100    }
101
102    public SwipeableListView(Context context, AttributeSet attrs, int defStyle) {
103        super(context, attrs, defStyle);
104        setOnScrollListener(this);
105        float densityScale = getResources().getDisplayMetrics().density;
106        float pagingTouchSlop = ViewConfiguration.get(context).getScaledPagingTouchSlop();
107        mSwipeHelper = new SwipeHelper(context, SwipeHelper.X, this, densityScale,
108                pagingTouchSlop);
109    }
110
111    @Override
112    protected void onConfigurationChanged(Configuration newConfig) {
113        super.onConfigurationChanged(newConfig);
114        float densityScale = getResources().getDisplayMetrics().density;
115        mSwipeHelper.setDensityScale(densityScale);
116        float pagingTouchSlop = ViewConfiguration.get(getContext()).getScaledPagingTouchSlop();
117        mSwipeHelper.setPagingTouchSlop(pagingTouchSlop);
118    }
119
120    @Override
121    protected void onFocusChanged(boolean gainFocus, int direction, Rect previouslyFocusedRect) {
122        LogUtils.d(Utils.VIEW_DEBUGGING_TAG,
123                "START CLF-ListView.onFocusChanged layoutRequested=%s root.layoutRequested=%s",
124                isLayoutRequested(), getRootView().isLayoutRequested());
125        super.onFocusChanged(gainFocus, direction, previouslyFocusedRect);
126        LogUtils.d(Utils.VIEW_DEBUGGING_TAG, new Error(),
127                "FINISH CLF-ListView.onFocusChanged layoutRequested=%s root.layoutRequested=%s",
128                isLayoutRequested(), getRootView().isLayoutRequested());
129    }
130
131    /**
132     * Enable swipe gestures.
133     */
134    public void enableSwipe(boolean enable) {
135        mEnableSwipe = enable;
136    }
137
138    public void setSwipeAction(int action) {
139        mSwipeAction = action;
140    }
141
142    public void setSwipedListener(ListItemSwipedListener listener) {
143        mSwipedListener = listener;
144    }
145
146    public int getSwipeAction() {
147        return mSwipeAction;
148    }
149
150    public void setSelectionSet(ConversationSelectionSet set) {
151        mConvSelectionSet = set;
152    }
153
154    public void setCurrentAccount(Account account) {
155        mAccount = account;
156    }
157
158    public void setCurrentFolder(Folder folder) {
159        mFolder = folder;
160    }
161
162    @Override
163    public ConversationSelectionSet getSelectionSet() {
164        return mConvSelectionSet;
165    }
166
167    @Override
168    public boolean onInterceptTouchEvent(MotionEvent ev) {
169        if (mScrolling || !mEnableSwipe) {
170            return super.onInterceptTouchEvent(ev);
171        } else {
172            return mSwipeHelper.onInterceptTouchEvent(ev) || super.onInterceptTouchEvent(ev);
173        }
174    }
175
176    @Override
177    public boolean onTouchEvent(MotionEvent ev) {
178        if (mEnableSwipe) {
179            return mSwipeHelper.onTouchEvent(ev) || super.onTouchEvent(ev);
180        } else {
181            return super.onTouchEvent(ev);
182        }
183    }
184
185    @Override
186    public View getChildAtPosition(MotionEvent ev) {
187        // find the view under the pointer, accounting for GONE views
188        final int count = getChildCount();
189        final int touchY = (int) ev.getY();
190        int childIdx = 0;
191        View slidingChild;
192        for (; childIdx < count; childIdx++) {
193            slidingChild = getChildAt(childIdx);
194            if (slidingChild.getVisibility() == GONE) {
195                continue;
196            }
197            if (touchY >= slidingChild.getTop() && touchY <= slidingChild.getBottom()) {
198                if (slidingChild instanceof SwipeableConversationItemView) {
199                    return ((SwipeableConversationItemView) slidingChild).getSwipeableItemView();
200                }
201                return slidingChild;
202            }
203        }
204        return null;
205    }
206
207    @Override
208    public boolean canChildBeDismissed(SwipeableItemView v) {
209        return v.canChildBeDismissed();
210    }
211
212    @Override
213    public void onChildDismissed(SwipeableItemView v) {
214        if (v != null) {
215            v.dismiss();
216        }
217    }
218
219    // Call this whenever a new action is taken; this forces a commit of any
220    // existing destructive actions.
221    public void commitDestructiveActions(boolean animate) {
222        final AnimatedAdapter adapter = getAnimatedAdapter();
223        if (adapter != null) {
224            adapter.commitLeaveBehindItems(animate);
225        }
226    }
227
228    public void dismissChild(final ConversationItemView target) {
229        final ToastBarOperation undoOp;
230
231        undoOp = new ToastBarOperation(1, mSwipeAction, ToastBarOperation.UNDO, false /* batch */,
232                mFolder);
233        Conversation conv = target.getConversation();
234        target.getConversation().position = findConversation(target, conv);
235        final AnimatedAdapter adapter = getAnimatedAdapter();
236        if (adapter == null) {
237            return;
238        }
239        adapter.setupLeaveBehind(conv, undoOp, conv.position, target.getHeight());
240        ConversationCursor cc = (ConversationCursor) adapter.getCursor();
241        Collection<Conversation> convList = Conversation.listOf(conv);
242        ArrayList<Uri> folderUris;
243        ArrayList<Boolean> adds;
244
245        Analytics.getInstance().sendMenuItemEvent("list_swipe", mSwipeAction, null, 0);
246
247        if (mSwipeAction == R.id.remove_folder) {
248            FolderOperation folderOp = new FolderOperation(mFolder, false);
249            HashMap<Uri, Folder> targetFolders = Folder
250                    .hashMapForFolders(conv.getRawFolders());
251            targetFolders.remove(folderOp.mFolder.folderUri.fullUri);
252            final FolderList folders = FolderList.copyOf(targetFolders.values());
253            conv.setRawFolders(folders);
254            final ContentValues values = new ContentValues();
255            folderUris = new ArrayList<Uri>();
256            folderUris.add(mFolder.folderUri.fullUri);
257            adds = new ArrayList<Boolean>();
258            adds.add(Boolean.FALSE);
259            ConversationCursor.addFolderUpdates(folderUris, adds, values);
260            ConversationCursor.addTargetFolders(targetFolders.values(), values);
261            cc.mostlyDestructiveUpdate(Conversation.listOf(conv), values);
262        } else if (mSwipeAction == R.id.archive) {
263            cc.mostlyArchive(convList);
264        } else if (mSwipeAction == R.id.delete) {
265            cc.mostlyDelete(convList);
266        }
267        if (mSwipedListener != null) {
268            mSwipedListener.onListItemSwiped(convList);
269        }
270        adapter.notifyDataSetChanged();
271        if (mConvSelectionSet != null && !mConvSelectionSet.isEmpty()
272                && mConvSelectionSet.contains(conv)) {
273            mConvSelectionSet.toggle(conv);
274            // Don't commit destructive actions if the item we just removed from
275            // the selection set is the item we just destroyed!
276            if (!conv.isMostlyDead() && mConvSelectionSet.isEmpty()) {
277                commitDestructiveActions(true);
278            }
279        }
280    }
281
282    @Override
283    public void onBeginDrag(View v) {
284        // We do this so the underlying ScrollView knows that it won't get
285        // the chance to intercept events anymore
286        requestDisallowInterceptTouchEvent(true);
287        cancelDismissCounter();
288
289        // Notifies {@link ConversationListView} to disable pull to refresh since once
290        // an item in the list view has been picked up, we don't want any vertical movement
291        // to also trigger refresh.
292        if (mSwipeListener != null) {
293            mSwipeListener.onBeginSwipe();
294        }
295    }
296
297    @Override
298    public void onDragCancelled(SwipeableItemView v) {
299        final AnimatedAdapter adapter = getAnimatedAdapter();
300        if (adapter != null) {
301            adapter.startDismissCounter();
302            adapter.cancelFadeOutLastLeaveBehindItemText();
303        }
304    }
305
306    /**
307     * Archive items using the swipe away animation before shrinking them away.
308     */
309    public boolean destroyItems(Collection<Conversation> convs,
310            final ListItemsRemovedListener listener) {
311        if (convs == null) {
312            LogUtils.e(LOG_TAG, "SwipeableListView.destroyItems: null conversations.");
313            return false;
314        }
315        final AnimatedAdapter adapter = getAnimatedAdapter();
316        if (adapter == null) {
317            LogUtils.e(LOG_TAG, "SwipeableListView.destroyItems: Cannot destroy: adapter is null.");
318            return false;
319        }
320        adapter.swipeDelete(convs, listener);
321        return true;
322    }
323
324    public int findConversation(ConversationItemView view, Conversation conv) {
325        int position = INVALID_POSITION;
326        long convId = conv.id;
327        try {
328            position = getPositionForView(view);
329        } catch (Exception e) {
330            position = INVALID_POSITION;
331            LogUtils.w(LOG_TAG, e, "Exception finding position; using alternate strategy");
332        }
333        if (position == INVALID_POSITION) {
334            // Try the other way!
335            Conversation foundConv;
336            long foundId;
337            for (int i = 0; i < getChildCount(); i++) {
338                View child = getChildAt(i);
339                if (child instanceof SwipeableConversationItemView) {
340                    foundConv = ((SwipeableConversationItemView) child).getSwipeableItemView()
341                            .getConversation();
342                    foundId = foundConv.id;
343                    if (foundId == convId) {
344                        position = i + getFirstVisiblePosition();
345                        break;
346                    }
347                }
348            }
349        }
350        return position;
351    }
352
353    private AnimatedAdapter getAnimatedAdapter() {
354        return (AnimatedAdapter) getAdapter();
355    }
356
357    @Override
358    public boolean performItemClick(View view, int pos, long id) {
359        final int previousPosition = getCheckedItemPosition();
360        final boolean selectionSetEmpty = mConvSelectionSet.isEmpty();
361
362        // Superclass method modifies the selection set
363        final boolean handled = super.performItemClick(view, pos, id);
364
365        // If we are in CAB mode then a click shouldn't
366        // activate the new item, it should only add it to the selection set
367        if (!selectionSetEmpty && previousPosition != -1) {
368            setItemChecked(previousPosition, true);
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        if (ENABLE_ATTACHMENT_PARALLAX) {
393            for (int i = 0, len = getChildCount(); i < len; i++) {
394                final View child = getChildAt(i);
395                if (child instanceof OnScrollListener) {
396                    ((OnScrollListener) child).onScroll(view, firstVisibleItem, visibleItemCount,
397                            totalItemCount);
398                }
399            }
400        }
401    }
402
403    @Override
404    public void onScrollStateChanged(final AbsListView view, final int scrollState) {
405        mScrolling = scrollState != OnScrollListener.SCROLL_STATE_IDLE;
406
407        if (!mScrolling) {
408            final Context c = getContext();
409            if (c instanceof ControllableActivity) {
410                final ControllableActivity activity = (ControllableActivity) c;
411                activity.onAnimationEnd(null /* adapter */);
412            } else {
413                LogUtils.wtf(LOG_TAG, "unexpected context=%s", c);
414            }
415        }
416
417        if (SCROLL_PAUSE_ENABLE) {
418            AnimatedAdapter adapter = getAnimatedAdapter();
419            if (adapter != null) {
420                adapter.onScrollStateChanged(scrollState);
421            }
422            ConversationItemView.setScrollStateChanged(scrollState);
423        }
424    }
425
426    public boolean isScrolling() {
427        return mScrolling;
428    }
429
430    @Override
431    public void cancelDismissCounter() {
432        AnimatedAdapter adapter = getAnimatedAdapter();
433        if (adapter != null) {
434            adapter.cancelDismissCounter();
435        }
436    }
437
438    @Override
439    public LeaveBehindItem getLastSwipedItem() {
440        AnimatedAdapter adapter = getAnimatedAdapter();
441        if (adapter != null) {
442            return adapter.getLastLeaveBehindItem();
443        }
444        return null;
445    }
446
447    public void setSwipeListener(SwipeListener swipeListener) {
448        mSwipeListener = swipeListener;
449    }
450
451    public interface SwipeListener {
452        public void onBeginSwipe();
453    }
454}
455