/* * Copyright (C) 2012 Google Inc. * Licensed to The Android Open Source Project. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.mail.ui; import android.content.ContentValues; import android.content.Context; import android.content.res.Configuration; import android.graphics.Rect; import android.net.Uri; import android.util.AttributeSet; import android.view.MotionEvent; import android.view.View; import android.view.ViewConfiguration; import android.widget.AbsListView; import android.widget.AbsListView.OnScrollListener; import android.widget.ListView; import com.android.mail.R; import com.android.mail.analytics.Analytics; import com.android.mail.browse.ConversationCursor; import com.android.mail.browse.ConversationItemView; import com.android.mail.browse.SwipeableConversationItemView; import com.android.mail.providers.Account; import com.android.mail.providers.Conversation; import com.android.mail.providers.Folder; import com.android.mail.providers.FolderList; import com.android.mail.ui.SwipeHelper.Callback; import com.android.mail.utils.LogTag; import com.android.mail.utils.LogUtils; import com.android.mail.utils.Utils; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; public class SwipeableListView extends ListView implements Callback, OnScrollListener { private static final long INVALID_CONVERSATION_ID = -1; private final SwipeHelper mSwipeHelper; /** * Are swipes enabled on all items? (Each individual item can still prevent swiping.)
* When swiping is disabled, the UI still reacts to the gesture to acknowledge it. */ private boolean mEnableSwipe = false; /** * When set, we prevent the SwipeHelper from kicking in at all. This * short-circuits {@link #mEnableSwipe}. */ private boolean mPreventSwipesEntirely = false; public static final String LOG_TAG = LogTag.getLogTag(); private ConversationCheckedSet mConvCheckedSet; private int mSwipeAction; private Account mAccount; private Folder mFolder; private ListItemSwipedListener mSwipedListener; private boolean mScrolling; private SwipeListener mSwipeListener; private long mSelectedConversationId = INVALID_CONVERSATION_ID; // Instantiated through view inflation @SuppressWarnings("unused") public SwipeableListView(Context context) { this(context, null); } public SwipeableListView(Context context, AttributeSet attrs) { this(context, attrs, -1); } public SwipeableListView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); float densityScale = getResources().getDisplayMetrics().density; float pagingTouchSlop = ViewConfiguration.get(context).getScaledPagingTouchSlop(); mSwipeHelper = new SwipeHelper(context, SwipeHelper.X, this, densityScale, pagingTouchSlop); mScrolling = false; } @Override protected void onConfigurationChanged(Configuration newConfig) { super.onConfigurationChanged(newConfig); float densityScale = getResources().getDisplayMetrics().density; mSwipeHelper.setDensityScale(densityScale); float pagingTouchSlop = ViewConfiguration.get(getContext()).getScaledPagingTouchSlop(); mSwipeHelper.setPagingTouchSlop(pagingTouchSlop); } @Override protected void onFocusChanged(boolean gainFocus, int direction, Rect previouslyFocusedRect) { LogUtils.d(Utils.VIEW_DEBUGGING_TAG, "START CLF-ListView.onFocusChanged layoutRequested=%s root.layoutRequested=%s", isLayoutRequested(), getRootView().isLayoutRequested()); super.onFocusChanged(gainFocus, direction, previouslyFocusedRect); LogUtils.d(Utils.VIEW_DEBUGGING_TAG, new Error(), "FINISH CLF-ListView.onFocusChanged layoutRequested=%s root.layoutRequested=%s", isLayoutRequested(), getRootView().isLayoutRequested()); } /** * Enable swipe gestures. */ public void enableSwipe(boolean enable) { mEnableSwipe = enable; } /** * Completely ignore any horizontal swiping gestures. */ public void preventSwipesEntirely() { mPreventSwipesEntirely = true; } /** * Reverses a prior call to {@link #preventSwipesEntirely()}. */ public void stopPreventingSwipes() { mPreventSwipesEntirely = false; } public void setSwipeAction(int action) { mSwipeAction = action; } public void setListItemSwipedListener(ListItemSwipedListener listener) { mSwipedListener = listener; } public int getSwipeAction() { return mSwipeAction; } public void setCheckedSet(ConversationCheckedSet set) { mConvCheckedSet = set; } public void setCurrentAccount(Account account) { mAccount = account; } public void setCurrentFolder(Folder folder) { mFolder = folder; } @Override public ConversationCheckedSet getCheckedSet() { return mConvCheckedSet; } @Override public boolean onInterceptTouchEvent(MotionEvent ev) { if (mScrolling) { return super.onInterceptTouchEvent(ev); } else { return (!mPreventSwipesEntirely && mSwipeHelper.onInterceptTouchEvent(ev)) || super.onInterceptTouchEvent(ev); } } @Override public boolean onTouchEvent(MotionEvent ev) { return (!mPreventSwipesEntirely && mSwipeHelper.onTouchEvent(ev)) || super.onTouchEvent(ev); } @Override public View getChildAtPosition(MotionEvent ev) { // find the view under the pointer, accounting for GONE views final int count = getChildCount(); final int touchY = (int) ev.getY(); int childIdx = 0; View slidingChild; for (; childIdx < count; childIdx++) { slidingChild = getChildAt(childIdx); if (slidingChild.getVisibility() == GONE) { continue; } if (touchY >= slidingChild.getTop() && touchY <= slidingChild.getBottom()) { if (slidingChild instanceof SwipeableConversationItemView) { return ((SwipeableConversationItemView) slidingChild).getSwipeableItemView(); } return slidingChild; } } return null; } @Override public boolean canChildBeDismissed(SwipeableItemView v) { return mEnableSwipe && v.canChildBeDismissed(); } @Override public void onChildDismissed(SwipeableItemView v) { if (v != null) { v.dismiss(); } } // Call this whenever a new action is taken; this forces a commit of any // existing destructive actions. public void commitDestructiveActions(boolean animate) { final AnimatedAdapter adapter = getAnimatedAdapter(); if (adapter != null) { adapter.commitLeaveBehindItems(animate); } } public void dismissChild(final ConversationItemView target) { // Notifies the SwipeListener that a swipe has ended. if (mSwipeListener != null) { mSwipeListener.onEndSwipe(); } final ToastBarOperation undoOp; undoOp = new ToastBarOperation(1, mSwipeAction, ToastBarOperation.UNDO, false /* batch */, mFolder); Conversation conv = target.getConversation(); target.getConversation().position = findConversation(target, conv); final AnimatedAdapter adapter = getAnimatedAdapter(); if (adapter == null) { return; } adapter.setupLeaveBehind(conv, undoOp, conv.position, target.getHeight()); ConversationCursor cc = (ConversationCursor) adapter.getCursor(); Collection convList = Conversation.listOf(conv); ArrayList folderUris; ArrayList adds; Analytics.getInstance().sendMenuItemEvent("list_swipe", mSwipeAction, null, 0); if (mSwipeAction == R.id.remove_folder) { FolderOperation folderOp = new FolderOperation(mFolder, false); HashMap targetFolders = Folder .hashMapForFolders(conv.getRawFolders()); targetFolders.remove(folderOp.mFolder.folderUri.fullUri); final FolderList folders = FolderList.copyOf(targetFolders.values()); conv.setRawFolders(folders); final ContentValues values = new ContentValues(); folderUris = new ArrayList(); folderUris.add(mFolder.folderUri.fullUri); adds = new ArrayList(); adds.add(Boolean.FALSE); ConversationCursor.addFolderUpdates(folderUris, adds, values); ConversationCursor.addTargetFolders(targetFolders.values(), values); cc.mostlyDestructiveUpdate(Conversation.listOf(conv), values); } else if (mSwipeAction == R.id.archive) { cc.mostlyArchive(convList); } else if (mSwipeAction == R.id.delete) { cc.mostlyDelete(convList); } else if (mSwipeAction == R.id.discard_outbox) { cc.moveFailedIntoDrafts(convList); } if (mSwipedListener != null) { mSwipedListener.onListItemSwiped(convList); } adapter.notifyDataSetChanged(); if (mConvCheckedSet != null && !mConvCheckedSet.isEmpty() && mConvCheckedSet.contains(conv)) { mConvCheckedSet.toggle(conv); // Don't commit destructive actions if the item we just removed from // the selection set is the item we just destroyed! if (!conv.isMostlyDead() && mConvCheckedSet.isEmpty()) { commitDestructiveActions(true); } } } @Override public void onBeginDrag(View v) { // We do this so the underlying ScrollView knows that it won't get // the chance to intercept events anymore requestDisallowInterceptTouchEvent(true); cancelDismissCounter(); // Notifies the SwipeListener that a swipe has begun. if (mSwipeListener != null) { mSwipeListener.onBeginSwipe(); } } @Override public void onDragCancelled(SwipeableItemView v) { final AnimatedAdapter adapter = getAnimatedAdapter(); if (adapter != null) { adapter.startDismissCounter(); adapter.cancelFadeOutLastLeaveBehindItemText(); } // Notifies the SwipeListener that a swipe has ended. if (mSwipeListener != null) { mSwipeListener.onEndSwipe(); } } /** * Archive items using the swipe away animation before shrinking them away. */ public boolean destroyItems(Collection convs, final ListItemsRemovedListener listener) { if (convs == null) { LogUtils.e(LOG_TAG, "SwipeableListView.destroyItems: null conversations."); return false; } final AnimatedAdapter adapter = getAnimatedAdapter(); if (adapter == null) { LogUtils.e(LOG_TAG, "SwipeableListView.destroyItems: Cannot destroy: adapter is null."); return false; } adapter.swipeDelete(convs, listener); return true; } public int findConversation(ConversationItemView view, Conversation conv) { int position = INVALID_POSITION; long convId = conv.id; try { position = getPositionForView(view); } catch (Exception e) { position = INVALID_POSITION; LogUtils.w(LOG_TAG, e, "Exception finding position; using alternate strategy"); } if (position == INVALID_POSITION) { // Try the other way! Conversation foundConv; long foundId; for (int i = 0; i < getChildCount(); i++) { View child = getChildAt(i); if (child instanceof SwipeableConversationItemView) { foundConv = ((SwipeableConversationItemView) child).getSwipeableItemView() .getConversation(); foundId = foundConv.id; if (foundId == convId) { position = i + getFirstVisiblePosition(); break; } } } } return position; } private AnimatedAdapter getAnimatedAdapter() { return (AnimatedAdapter) getAdapter(); } @Override public boolean performItemClick(View view, int pos, long id) { // Superclass method modifies the selection set final boolean handled = super.performItemClick(view, pos, id); // Commit any existing destructive actions when the user selects a // conversation to view. commitDestructiveActions(true); return handled; } @Override public void onScroll() { commitDestructiveActions(true); } public interface ListItemsRemovedListener { public void onListItemsRemoved(); } public interface ListItemSwipedListener { public void onListItemSwiped(Collection conversations); } @Override public final void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) { } @Override public void onScrollStateChanged(final AbsListView view, final int scrollState) { mScrolling = scrollState != OnScrollListener.SCROLL_STATE_IDLE; if (!mScrolling) { final Context c = getContext(); if (c instanceof ControllableActivity) { final ControllableActivity activity = (ControllableActivity) c; activity.onAnimationEnd(null /* adapter */); } else { LogUtils.wtf(LOG_TAG, "unexpected context=%s", c); } } } public boolean isScrolling() { return mScrolling; } /** * Set the currently selected (focused by the list view) position. */ public void setSelectedConversation(Conversation conv) { if (conv == null) { return; } mSelectedConversationId = conv.id; } public boolean isConversationSelected(Conversation conv) { return mSelectedConversationId != INVALID_CONVERSATION_ID && conv != null && mSelectedConversationId == conv.id; } /** * This is only used for debugging/logging purposes. DO NOT call this function to try to get * the currently selected position. Use {@link #mSelectedConversationId} instead. */ public int getSelectedConversationPosDebug() { for (int i = getFirstVisiblePosition(); i < getLastVisiblePosition(); i++) { final Object item = getItemAtPosition(i); if (item instanceof ConversationCursor) { final Conversation c = ((ConversationCursor) item).getConversation(); if (c.id == mSelectedConversationId) { return i; } } } return ListView.INVALID_POSITION; } @Override public void onTouchModeChanged(boolean isInTouchMode) { super.onTouchModeChanged(isInTouchMode); if (!isInTouchMode) { // We need to invalidate going from touch mode -> keyboard mode because the currently // selected item might have changed in touch mode. However, since from the framework's // perspective the selected position doesn't matter in touch mode, when we enter // keyboard mode via up/down arrow, the list view will ONLY invalidate the newly // selected item and not the currently selected item. As a result, we might get an // inconsistent UI where it looks like both the old and new selected items are focused. final int index = getSelectedItemPosition(); if (index != ListView.INVALID_POSITION) { final View child = getChildAt(index - getFirstVisiblePosition()); if (child != null) { child.invalidate(); } } } } @Override public void cancelDismissCounter() { AnimatedAdapter adapter = getAnimatedAdapter(); if (adapter != null) { adapter.cancelDismissCounter(); } } @Override public LeaveBehindItem getLastSwipedItem() { AnimatedAdapter adapter = getAnimatedAdapter(); if (adapter != null) { return adapter.getLastLeaveBehindItem(); } return null; } public void setSwipeListener(SwipeListener swipeListener) { mSwipeListener = swipeListener; } public interface SwipeListener { public void onBeginSwipe(); public void onEndSwipe(); } }