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