SwipeableListView.java revision 042a530b2296487fa5899a3e871214ac4a47e3d8
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().sendEvent("list_swipe",
246                AnalyticsUtils.getMenuItemString(mSwipeAction), null, 0);
247
248        if (mSwipeAction == R.id.remove_folder) {
249            FolderOperation folderOp = new FolderOperation(mFolder, false);
250            HashMap<Uri, Folder> targetFolders = Folder
251                    .hashMapForFolders(conv.getRawFolders());
252            targetFolders.remove(folderOp.mFolder.folderUri.fullUri);
253            final FolderList folders = FolderList.copyOf(targetFolders.values());
254            conv.setRawFolders(folders);
255            final ContentValues values = new ContentValues();
256            folderUris = new ArrayList<Uri>();
257            folderUris.add(mFolder.folderUri.fullUri);
258            adds = new ArrayList<Boolean>();
259            adds.add(Boolean.FALSE);
260            ConversationCursor.addFolderUpdates(folderUris, adds, values);
261            ConversationCursor.addTargetFolders(targetFolders.values(), values);
262            cc.mostlyDestructiveUpdate(Conversation.listOf(conv), values);
263        } else if (mSwipeAction == R.id.archive) {
264            cc.mostlyArchive(convList);
265        } else if (mSwipeAction == R.id.delete) {
266            cc.mostlyDelete(convList);
267        }
268        if (mSwipedListener != null) {
269            mSwipedListener.onListItemSwiped(convList);
270        }
271        adapter.notifyDataSetChanged();
272        if (mConvSelectionSet != null && !mConvSelectionSet.isEmpty()
273                && mConvSelectionSet.contains(conv)) {
274            mConvSelectionSet.toggle(conv);
275            // Don't commit destructive actions if the item we just removed from
276            // the selection set is the item we just destroyed!
277            if (!conv.isMostlyDead() && mConvSelectionSet.isEmpty()) {
278                commitDestructiveActions(true);
279            }
280        }
281    }
282
283    @Override
284    public void onBeginDrag(View v) {
285        // We do this so the underlying ScrollView knows that it won't get
286        // the chance to intercept events anymore
287        requestDisallowInterceptTouchEvent(true);
288        cancelDismissCounter();
289
290        // Notifies {@link ConversationListView} to disable pull to refresh since once
291        // an item in the list view has been picked up, we don't want any vertical movement
292        // to also trigger refresh.
293        if (mSwipeListener != null) {
294            mSwipeListener.onBeginSwipe();
295        }
296    }
297
298    @Override
299    public void onDragCancelled(SwipeableItemView v) {
300        final AnimatedAdapter adapter = getAnimatedAdapter();
301        if (adapter != null) {
302            adapter.startDismissCounter();
303            adapter.cancelFadeOutLastLeaveBehindItemText();
304        }
305    }
306
307    /**
308     * Archive items using the swipe away animation before shrinking them away.
309     */
310    public boolean destroyItems(Collection<Conversation> convs,
311            final ListItemsRemovedListener listener) {
312        if (convs == null) {
313            LogUtils.e(LOG_TAG, "SwipeableListView.destroyItems: null conversations.");
314            return false;
315        }
316        final AnimatedAdapter adapter = getAnimatedAdapter();
317        if (adapter == null) {
318            LogUtils.e(LOG_TAG, "SwipeableListView.destroyItems: Cannot destroy: adapter is null.");
319            return false;
320        }
321        adapter.swipeDelete(convs, listener);
322        return true;
323    }
324
325    public int findConversation(ConversationItemView view, Conversation conv) {
326        int position = INVALID_POSITION;
327        long convId = conv.id;
328        try {
329            position = getPositionForView(view);
330        } catch (Exception e) {
331            position = INVALID_POSITION;
332            LogUtils.w(LOG_TAG, e, "Exception finding position; using alternate strategy");
333        }
334        if (position == INVALID_POSITION) {
335            // Try the other way!
336            Conversation foundConv;
337            long foundId;
338            for (int i = 0; i < getChildCount(); i++) {
339                View child = getChildAt(i);
340                if (child instanceof SwipeableConversationItemView) {
341                    foundConv = ((SwipeableConversationItemView) child).getSwipeableItemView()
342                            .getConversation();
343                    foundId = foundConv.id;
344                    if (foundId == convId) {
345                        position = i + getFirstVisiblePosition();
346                        break;
347                    }
348                }
349            }
350        }
351        return position;
352    }
353
354    private AnimatedAdapter getAnimatedAdapter() {
355        return (AnimatedAdapter) getAdapter();
356    }
357
358    @Override
359    public boolean performItemClick(View view, int pos, long id) {
360        final int previousPosition = getCheckedItemPosition();
361        final boolean selectionSetEmpty = mConvSelectionSet.isEmpty();
362
363        // Superclass method modifies the selection set
364        final boolean handled = super.performItemClick(view, pos, id);
365
366        // If we are in CAB mode then a click shouldn't
367        // activate the new item, it should only add it to the selection set
368        if (!selectionSetEmpty && previousPosition != -1) {
369            setItemChecked(previousPosition, true);
370        }
371        // Commit any existing destructive actions when the user selects a
372        // conversation to view.
373        commitDestructiveActions(true);
374        return handled;
375    }
376
377    @Override
378    public void onScroll() {
379        commitDestructiveActions(true);
380    }
381
382    public interface ListItemsRemovedListener {
383        public void onListItemsRemoved();
384    }
385
386    public interface ListItemSwipedListener {
387        public void onListItemSwiped(Collection<Conversation> conversations);
388    }
389
390    @Override
391    public final void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount,
392            int totalItemCount) {
393        if (ENABLE_ATTACHMENT_PARALLAX) {
394            for (int i = 0, len = getChildCount(); i < len; i++) {
395                final View child = getChildAt(i);
396                if (child instanceof OnScrollListener) {
397                    ((OnScrollListener) child).onScroll(view, firstVisibleItem, visibleItemCount,
398                            totalItemCount);
399                }
400            }
401        }
402    }
403
404    @Override
405    public void onScrollStateChanged(final AbsListView view, final int scrollState) {
406        mScrolling = scrollState != OnScrollListener.SCROLL_STATE_IDLE;
407
408        if (!mScrolling) {
409            final Context c = getContext();
410            if (c instanceof ControllableActivity) {
411                final ControllableActivity activity = (ControllableActivity) c;
412                activity.onAnimationEnd(null /* adapter */);
413            } else {
414                LogUtils.wtf(LOG_TAG, "unexpected context=%s", c);
415            }
416        }
417
418        if (SCROLL_PAUSE_ENABLE) {
419            AnimatedAdapter adapter = getAnimatedAdapter();
420            if (adapter != null) {
421                adapter.onScrollStateChanged(scrollState);
422            }
423            ConversationItemView.setScrollStateChanged(scrollState);
424        }
425    }
426
427    public boolean isScrolling() {
428        return mScrolling;
429    }
430
431    @Override
432    public void cancelDismissCounter() {
433        AnimatedAdapter adapter = getAnimatedAdapter();
434        if (adapter != null) {
435            adapter.cancelDismissCounter();
436        }
437    }
438
439    @Override
440    public LeaveBehindItem getLastSwipedItem() {
441        AnimatedAdapter adapter = getAnimatedAdapter();
442        if (adapter != null) {
443            return adapter.getLastLeaveBehindItem();
444        }
445        return null;
446    }
447
448    public void setSwipeListener(SwipeListener swipeListener) {
449        mSwipeListener = swipeListener;
450    }
451
452    public interface SwipeListener {
453        public void onBeginSwipe();
454    }
455}
456