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 18 package com.android.mail.ui; 19 20 import android.content.ContentValues; 21 import android.content.Context; 22 import android.content.res.Configuration; 23 import android.graphics.Rect; 24 import android.net.Uri; 25 import android.util.AttributeSet; 26 import android.widget.AbsListView; 27 import android.widget.AbsListView.OnScrollListener; 28 import android.view.MotionEvent; 29 import android.view.View; 30 import android.view.ViewConfiguration; 31 import android.widget.ListView; 32 33 import com.android.mail.R; 34 import com.android.mail.analytics.Analytics; 35 import com.android.mail.analytics.AnalyticsUtils; 36 import com.android.mail.browse.ConversationCursor; 37 import com.android.mail.browse.ConversationItemView; 38 import com.android.mail.browse.SwipeableConversationItemView; 39 import com.android.mail.providers.Account; 40 import com.android.mail.providers.Conversation; 41 import com.android.mail.providers.Folder; 42 import com.android.mail.providers.FolderList; 43 import com.android.mail.ui.SwipeHelper.Callback; 44 import com.android.mail.utils.LogTag; 45 import com.android.mail.utils.LogUtils; 46 import com.android.mail.utils.Utils; 47 48 import java.util.ArrayList; 49 import java.util.Collection; 50 import java.util.HashMap; 51 52 public 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