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.view.MotionEvent; 27 import android.view.View; 28 import android.view.ViewConfiguration; 29 import android.widget.AbsListView; 30 import android.widget.AbsListView.OnScrollListener; 31 import android.widget.ListView; 32 33 import com.android.mail.R; 34 import com.android.mail.analytics.Analytics; 35 import com.android.mail.browse.ConversationCursor; 36 import com.android.mail.browse.ConversationItemView; 37 import com.android.mail.browse.SwipeableConversationItemView; 38 import com.android.mail.providers.Account; 39 import com.android.mail.providers.Conversation; 40 import com.android.mail.providers.Folder; 41 import com.android.mail.providers.FolderList; 42 import com.android.mail.ui.SwipeHelper.Callback; 43 import com.android.mail.utils.LogTag; 44 import com.android.mail.utils.LogUtils; 45 import com.android.mail.utils.Utils; 46 47 import java.util.ArrayList; 48 import java.util.Collection; 49 import java.util.HashMap; 50 51 public class SwipeableListView extends ListView implements Callback, OnScrollListener { 52 private static final long INVALID_CONVERSATION_ID = -1; 53 54 private final SwipeHelper mSwipeHelper; 55 /** 56 * Are swipes enabled on all items? (Each individual item can still prevent swiping.)<br> 57 * When swiping is disabled, the UI still reacts to the gesture to acknowledge it. 58 */ 59 private boolean mEnableSwipe = false; 60 /** 61 * When set, we prevent the SwipeHelper from kicking in at all. This 62 * short-circuits {@link #mEnableSwipe}. 63 */ 64 private boolean mPreventSwipesEntirely = false; 65 66 public static final String LOG_TAG = LogTag.getLogTag(); 67 68 private ConversationCheckedSet mConvCheckedSet; 69 private int mSwipeAction; 70 private Account mAccount; 71 private Folder mFolder; 72 private ListItemSwipedListener mSwipedListener; 73 private boolean mScrolling; 74 75 private SwipeListener mSwipeListener; 76 77 private long mSelectedConversationId = INVALID_CONVERSATION_ID; 78 79 // Instantiated through view inflation 80 @SuppressWarnings("unused") 81 public SwipeableListView(Context context) { 82 this(context, null); 83 } 84 85 public SwipeableListView(Context context, AttributeSet attrs) { 86 this(context, attrs, -1); 87 } 88 89 public SwipeableListView(Context context, AttributeSet attrs, int defStyle) { 90 super(context, attrs, defStyle); 91 float densityScale = getResources().getDisplayMetrics().density; 92 float pagingTouchSlop = ViewConfiguration.get(context).getScaledPagingTouchSlop(); 93 mSwipeHelper = new SwipeHelper(context, SwipeHelper.X, this, densityScale, 94 pagingTouchSlop); 95 mScrolling = false; 96 } 97 98 @Override 99 protected void onConfigurationChanged(Configuration newConfig) { 100 super.onConfigurationChanged(newConfig); 101 float densityScale = getResources().getDisplayMetrics().density; 102 mSwipeHelper.setDensityScale(densityScale); 103 float pagingTouchSlop = ViewConfiguration.get(getContext()).getScaledPagingTouchSlop(); 104 mSwipeHelper.setPagingTouchSlop(pagingTouchSlop); 105 } 106 107 @Override 108 protected void onFocusChanged(boolean gainFocus, int direction, Rect previouslyFocusedRect) { 109 LogUtils.d(Utils.VIEW_DEBUGGING_TAG, 110 "START CLF-ListView.onFocusChanged layoutRequested=%s root.layoutRequested=%s", 111 isLayoutRequested(), getRootView().isLayoutRequested()); 112 super.onFocusChanged(gainFocus, direction, previouslyFocusedRect); 113 LogUtils.d(Utils.VIEW_DEBUGGING_TAG, new Error(), 114 "FINISH CLF-ListView.onFocusChanged layoutRequested=%s root.layoutRequested=%s", 115 isLayoutRequested(), getRootView().isLayoutRequested()); 116 } 117 118 /** 119 * Enable swipe gestures. 120 */ 121 public void enableSwipe(boolean enable) { 122 mEnableSwipe = enable; 123 } 124 125 /** 126 * Completely ignore any horizontal swiping gestures. 127 */ 128 public void preventSwipesEntirely() { 129 mPreventSwipesEntirely = true; 130 } 131 132 /** 133 * Reverses a prior call to {@link #preventSwipesEntirely()}. 134 */ 135 public void stopPreventingSwipes() { 136 mPreventSwipesEntirely = false; 137 } 138 139 public void setSwipeAction(int action) { 140 mSwipeAction = action; 141 } 142 143 public void setListItemSwipedListener(ListItemSwipedListener listener) { 144 mSwipedListener = listener; 145 } 146 147 public int getSwipeAction() { 148 return mSwipeAction; 149 } 150 151 public void setCheckedSet(ConversationCheckedSet set) { 152 mConvCheckedSet = set; 153 } 154 155 public void setCurrentAccount(Account account) { 156 mAccount = account; 157 } 158 159 public void setCurrentFolder(Folder folder) { 160 mFolder = folder; 161 } 162 163 @Override 164 public ConversationCheckedSet getCheckedSet() { 165 return mConvCheckedSet; 166 } 167 168 @Override 169 public boolean onInterceptTouchEvent(MotionEvent ev) { 170 if (mScrolling) { 171 return super.onInterceptTouchEvent(ev); 172 } else { 173 return (!mPreventSwipesEntirely && mSwipeHelper.onInterceptTouchEvent(ev)) 174 || super.onInterceptTouchEvent(ev); 175 } 176 } 177 178 @Override 179 public boolean onTouchEvent(MotionEvent ev) { 180 return (!mPreventSwipesEntirely && mSwipeHelper.onTouchEvent(ev)) || super.onTouchEvent(ev); 181 } 182 183 @Override 184 public View getChildAtPosition(MotionEvent ev) { 185 // find the view under the pointer, accounting for GONE views 186 final int count = getChildCount(); 187 final int touchY = (int) ev.getY(); 188 int childIdx = 0; 189 View slidingChild; 190 for (; childIdx < count; childIdx++) { 191 slidingChild = getChildAt(childIdx); 192 if (slidingChild.getVisibility() == GONE) { 193 continue; 194 } 195 if (touchY >= slidingChild.getTop() && touchY <= slidingChild.getBottom()) { 196 if (slidingChild instanceof SwipeableConversationItemView) { 197 return ((SwipeableConversationItemView) slidingChild).getSwipeableItemView(); 198 } 199 return slidingChild; 200 } 201 } 202 return null; 203 } 204 205 @Override 206 public boolean canChildBeDismissed(SwipeableItemView v) { 207 return mEnableSwipe && v.canChildBeDismissed(); 208 } 209 210 @Override 211 public void onChildDismissed(SwipeableItemView v) { 212 if (v != null) { 213 v.dismiss(); 214 } 215 } 216 217 // Call this whenever a new action is taken; this forces a commit of any 218 // existing destructive actions. 219 public void commitDestructiveActions(boolean animate) { 220 final AnimatedAdapter adapter = getAnimatedAdapter(); 221 if (adapter != null) { 222 adapter.commitLeaveBehindItems(animate); 223 } 224 } 225 226 public void dismissChild(final ConversationItemView target) { 227 // Notifies the SwipeListener that a swipe has ended. 228 if (mSwipeListener != null) { 229 mSwipeListener.onEndSwipe(); 230 } 231 232 final ToastBarOperation undoOp; 233 234 undoOp = new ToastBarOperation(1, mSwipeAction, ToastBarOperation.UNDO, false /* batch */, 235 mFolder); 236 Conversation conv = target.getConversation(); 237 target.getConversation().position = findConversation(target, conv); 238 final AnimatedAdapter adapter = getAnimatedAdapter(); 239 if (adapter == null) { 240 return; 241 } 242 adapter.setupLeaveBehind(conv, undoOp, conv.position, target.getHeight()); 243 ConversationCursor cc = (ConversationCursor) adapter.getCursor(); 244 Collection<Conversation> convList = Conversation.listOf(conv); 245 ArrayList<Uri> folderUris; 246 ArrayList<Boolean> adds; 247 248 Analytics.getInstance().sendMenuItemEvent("list_swipe", mSwipeAction, null, 0); 249 250 if (mSwipeAction == R.id.remove_folder) { 251 FolderOperation folderOp = new FolderOperation(mFolder, false); 252 HashMap<Uri, Folder> targetFolders = Folder 253 .hashMapForFolders(conv.getRawFolders()); 254 targetFolders.remove(folderOp.mFolder.folderUri.fullUri); 255 final FolderList folders = FolderList.copyOf(targetFolders.values()); 256 conv.setRawFolders(folders); 257 final ContentValues values = new ContentValues(); 258 folderUris = new ArrayList<Uri>(); 259 folderUris.add(mFolder.folderUri.fullUri); 260 adds = new ArrayList<Boolean>(); 261 adds.add(Boolean.FALSE); 262 ConversationCursor.addFolderUpdates(folderUris, adds, values); 263 ConversationCursor.addTargetFolders(targetFolders.values(), values); 264 cc.mostlyDestructiveUpdate(Conversation.listOf(conv), values); 265 } else if (mSwipeAction == R.id.archive) { 266 cc.mostlyArchive(convList); 267 } else if (mSwipeAction == R.id.delete) { 268 cc.mostlyDelete(convList); 269 } else if (mSwipeAction == R.id.discard_outbox) { 270 cc.moveFailedIntoDrafts(convList); 271 } 272 if (mSwipedListener != null) { 273 mSwipedListener.onListItemSwiped(convList); 274 } 275 adapter.notifyDataSetChanged(); 276 if (mConvCheckedSet != null && !mConvCheckedSet.isEmpty() 277 && mConvCheckedSet.contains(conv)) { 278 mConvCheckedSet.toggle(conv); 279 // Don't commit destructive actions if the item we just removed from 280 // the selection set is the item we just destroyed! 281 if (!conv.isMostlyDead() && mConvCheckedSet.isEmpty()) { 282 commitDestructiveActions(true); 283 } 284 } 285 } 286 287 @Override 288 public void onBeginDrag(View v) { 289 // We do this so the underlying ScrollView knows that it won't get 290 // the chance to intercept events anymore 291 requestDisallowInterceptTouchEvent(true); 292 cancelDismissCounter(); 293 294 // Notifies the SwipeListener that a swipe has begun. 295 if (mSwipeListener != null) { 296 mSwipeListener.onBeginSwipe(); 297 } 298 } 299 300 @Override 301 public void onDragCancelled(SwipeableItemView v) { 302 final AnimatedAdapter adapter = getAnimatedAdapter(); 303 if (adapter != null) { 304 adapter.startDismissCounter(); 305 adapter.cancelFadeOutLastLeaveBehindItemText(); 306 } 307 308 // Notifies the SwipeListener that a swipe has ended. 309 if (mSwipeListener != null) { 310 mSwipeListener.onEndSwipe(); 311 } 312 } 313 314 /** 315 * Archive items using the swipe away animation before shrinking them away. 316 */ 317 public boolean destroyItems(Collection<Conversation> convs, 318 final ListItemsRemovedListener listener) { 319 if (convs == null) { 320 LogUtils.e(LOG_TAG, "SwipeableListView.destroyItems: null conversations."); 321 return false; 322 } 323 final AnimatedAdapter adapter = getAnimatedAdapter(); 324 if (adapter == null) { 325 LogUtils.e(LOG_TAG, "SwipeableListView.destroyItems: Cannot destroy: adapter is null."); 326 return false; 327 } 328 adapter.swipeDelete(convs, listener); 329 return true; 330 } 331 332 public int findConversation(ConversationItemView view, Conversation conv) { 333 int position = INVALID_POSITION; 334 long convId = conv.id; 335 try { 336 position = getPositionForView(view); 337 } catch (Exception e) { 338 position = INVALID_POSITION; 339 LogUtils.w(LOG_TAG, e, "Exception finding position; using alternate strategy"); 340 } 341 if (position == INVALID_POSITION) { 342 // Try the other way! 343 Conversation foundConv; 344 long foundId; 345 for (int i = 0; i < getChildCount(); i++) { 346 View child = getChildAt(i); 347 if (child instanceof SwipeableConversationItemView) { 348 foundConv = ((SwipeableConversationItemView) child).getSwipeableItemView() 349 .getConversation(); 350 foundId = foundConv.id; 351 if (foundId == convId) { 352 position = i + getFirstVisiblePosition(); 353 break; 354 } 355 } 356 } 357 } 358 return position; 359 } 360 361 private AnimatedAdapter getAnimatedAdapter() { 362 return (AnimatedAdapter) getAdapter(); 363 } 364 365 @Override 366 public boolean performItemClick(View view, int pos, long id) { 367 // Superclass method modifies the selection set 368 final boolean handled = super.performItemClick(view, pos, id); 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 } 393 394 @Override 395 public void onScrollStateChanged(final AbsListView view, final int scrollState) { 396 mScrolling = scrollState != OnScrollListener.SCROLL_STATE_IDLE; 397 398 if (!mScrolling) { 399 final Context c = getContext(); 400 if (c instanceof ControllableActivity) { 401 final ControllableActivity activity = (ControllableActivity) c; 402 activity.onAnimationEnd(null /* adapter */); 403 } else { 404 LogUtils.wtf(LOG_TAG, "unexpected context=%s", c); 405 } 406 } 407 } 408 409 public boolean isScrolling() { 410 return mScrolling; 411 } 412 413 /** 414 * Set the currently selected (focused by the list view) position. 415 */ 416 public void setSelectedConversation(Conversation conv) { 417 if (conv == null) { 418 return; 419 } 420 421 mSelectedConversationId = conv.id; 422 } 423 424 public boolean isConversationSelected(Conversation conv) { 425 return mSelectedConversationId != INVALID_CONVERSATION_ID && conv != null 426 && mSelectedConversationId == conv.id; 427 } 428 429 /** 430 * This is only used for debugging/logging purposes. DO NOT call this function to try to get 431 * the currently selected position. Use {@link #mSelectedConversationId} instead. 432 */ 433 public int getSelectedConversationPosDebug() { 434 for (int i = getFirstVisiblePosition(); i < getLastVisiblePosition(); i++) { 435 final Object item = getItemAtPosition(i); 436 if (item instanceof ConversationCursor) { 437 final Conversation c = ((ConversationCursor) item).getConversation(); 438 if (c.id == mSelectedConversationId) { 439 return i; 440 } 441 } 442 } 443 return ListView.INVALID_POSITION; 444 } 445 446 @Override 447 public void onTouchModeChanged(boolean isInTouchMode) { 448 super.onTouchModeChanged(isInTouchMode); 449 if (!isInTouchMode) { 450 // We need to invalidate going from touch mode -> keyboard mode because the currently 451 // selected item might have changed in touch mode. However, since from the framework's 452 // perspective the selected position doesn't matter in touch mode, when we enter 453 // keyboard mode via up/down arrow, the list view will ONLY invalidate the newly 454 // selected item and not the currently selected item. As a result, we might get an 455 // inconsistent UI where it looks like both the old and new selected items are focused. 456 final int index = getSelectedItemPosition(); 457 if (index != ListView.INVALID_POSITION) { 458 final View child = getChildAt(index - getFirstVisiblePosition()); 459 if (child != null) { 460 child.invalidate(); 461 } 462 } 463 } 464 } 465 466 @Override 467 public void cancelDismissCounter() { 468 AnimatedAdapter adapter = getAnimatedAdapter(); 469 if (adapter != null) { 470 adapter.cancelDismissCounter(); 471 } 472 } 473 474 @Override 475 public LeaveBehindItem getLastSwipedItem() { 476 AnimatedAdapter adapter = getAnimatedAdapter(); 477 if (adapter != null) { 478 return adapter.getLastLeaveBehindItem(); 479 } 480 return null; 481 } 482 483 public void setSwipeListener(SwipeListener swipeListener) { 484 mSwipeListener = swipeListener; 485 } 486 487 public interface SwipeListener { 488 public void onBeginSwipe(); 489 public void onEndSwipe(); 490 } 491 } 492