1 /* 2 * Copyright (C) 2013 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 package com.android.dialer.list; 17 18 import static android.Manifest.permission.READ_CONTACTS; 19 20 import android.animation.Animator; 21 import android.animation.AnimatorSet; 22 import android.animation.ObjectAnimator; 23 import android.app.Activity; 24 import android.app.Fragment; 25 import android.app.LoaderManager; 26 import android.content.CursorLoader; 27 import android.content.Loader; 28 import android.content.pm.PackageManager; 29 import android.database.Cursor; 30 import android.graphics.Rect; 31 import android.net.Uri; 32 import android.os.Bundle; 33 import android.os.Trace; 34 import android.util.Log; 35 import android.view.LayoutInflater; 36 import android.view.View; 37 import android.view.ViewGroup; 38 import android.view.ViewTreeObserver; 39 import android.view.animation.AnimationUtils; 40 import android.view.animation.LayoutAnimationController; 41 import android.widget.AbsListView; 42 import android.widget.AdapterView; 43 import android.widget.AdapterView.OnItemClickListener; 44 import android.widget.FrameLayout; 45 import android.widget.FrameLayout.LayoutParams; 46 import android.widget.ImageView; 47 import android.widget.ListView; 48 49 import com.android.contacts.common.ContactPhotoManager; 50 import com.android.contacts.common.ContactTileLoaderFactory; 51 import com.android.contacts.common.list.ContactTileView; 52 import com.android.contacts.common.list.OnPhoneNumberPickerActionListener; 53 import com.android.contacts.common.util.PermissionsUtil; 54 import com.android.dialer.R; 55 import com.android.dialer.util.DialerUtils; 56 import com.android.dialer.widget.EmptyContentView; 57 58 import java.util.ArrayList; 59 import java.util.HashMap; 60 61 /** 62 * This fragment displays the user's favorite/frequent contacts in a grid. 63 */ 64 public class SpeedDialFragment extends Fragment implements OnItemClickListener, 65 PhoneFavoritesTileAdapter.OnDataSetChangedForAnimationListener, 66 EmptyContentView.OnEmptyViewActionButtonClickedListener { 67 68 private static final int READ_CONTACTS_PERMISSION_REQUEST_CODE = 1; 69 70 /** 71 * By default, the animation code assumes that all items in a list view are of the same height 72 * when animating new list items into view (e.g. from the bottom of the screen into view). 73 * This can cause incorrect translation offsets when a item that is larger or smaller than 74 * other list item is removed from the list. This key is used to provide the actual height 75 * of the removed object so that the actual translation appears correct to the user. 76 */ 77 private static final long KEY_REMOVED_ITEM_HEIGHT = Long.MAX_VALUE; 78 79 private static final String TAG = "SpeedDialFragment"; 80 private static final boolean DEBUG = false; 81 82 private int mAnimationDuration; 83 84 /** 85 * Used with LoaderManager. 86 */ 87 private static int LOADER_ID_CONTACT_TILE = 1; 88 89 public interface HostInterface { 90 public void setDragDropController(DragDropController controller); 91 public void showAllContactsTab(); 92 } 93 94 private class ContactTileLoaderListener implements LoaderManager.LoaderCallbacks<Cursor> { 95 @Override 96 public CursorLoader onCreateLoader(int id, Bundle args) { 97 if (DEBUG) Log.d(TAG, "ContactTileLoaderListener#onCreateLoader."); 98 return ContactTileLoaderFactory.createStrequentPhoneOnlyLoader(getActivity()); 99 } 100 101 @Override 102 public void onLoadFinished(Loader<Cursor> loader, Cursor data) { 103 if (DEBUG) Log.d(TAG, "ContactTileLoaderListener#onLoadFinished"); 104 mContactTileAdapter.setContactCursor(data); 105 setEmptyViewVisibility(mContactTileAdapter.getCount() == 0); 106 } 107 108 @Override 109 public void onLoaderReset(Loader<Cursor> loader) { 110 if (DEBUG) Log.d(TAG, "ContactTileLoaderListener#onLoaderReset. "); 111 } 112 } 113 114 private class ContactTileAdapterListener implements ContactTileView.Listener { 115 @Override 116 public void onContactSelected(Uri contactUri, Rect targetRect) { 117 if (mPhoneNumberPickerActionListener != null) { 118 mPhoneNumberPickerActionListener.onPickPhoneNumberAction(contactUri); 119 } 120 } 121 122 @Override 123 public void onCallNumberDirectly(String phoneNumber) { 124 if (mPhoneNumberPickerActionListener != null) { 125 mPhoneNumberPickerActionListener.onCallNumberDirectly(phoneNumber); 126 } 127 } 128 129 @Override 130 public int getApproximateTileWidth() { 131 return getView().getWidth(); 132 } 133 } 134 135 private class ScrollListener implements ListView.OnScrollListener { 136 @Override 137 public void onScroll(AbsListView view, 138 int firstVisibleItem, int visibleItemCount, int totalItemCount) { 139 if (mActivityScrollListener != null) { 140 mActivityScrollListener.onListFragmentScroll(firstVisibleItem, visibleItemCount, 141 totalItemCount); 142 } 143 } 144 145 @Override 146 public void onScrollStateChanged(AbsListView view, int scrollState) { 147 mActivityScrollListener.onListFragmentScrollStateChange(scrollState); 148 } 149 } 150 151 private OnPhoneNumberPickerActionListener mPhoneNumberPickerActionListener; 152 153 private OnListFragmentScrolledListener mActivityScrollListener; 154 private PhoneFavoritesTileAdapter mContactTileAdapter; 155 156 private View mParentView; 157 158 private PhoneFavoriteListView mListView; 159 160 private View mContactTileFrame; 161 162 private final HashMap<Long, Integer> mItemIdTopMap = new HashMap<Long, Integer>(); 163 private final HashMap<Long, Integer> mItemIdLeftMap = new HashMap<Long, Integer>(); 164 165 /** 166 * Layout used when there are no favorites. 167 */ 168 private EmptyContentView mEmptyView; 169 170 private final ContactTileView.Listener mContactTileAdapterListener = 171 new ContactTileAdapterListener(); 172 private final LoaderManager.LoaderCallbacks<Cursor> mContactTileLoaderListener = 173 new ContactTileLoaderListener(); 174 private final ScrollListener mScrollListener = new ScrollListener(); 175 176 @Override 177 public void onAttach(Activity activity) { 178 if (DEBUG) Log.d(TAG, "onAttach()"); 179 super.onAttach(activity); 180 181 // Construct two base adapters which will become part of PhoneFavoriteMergedAdapter. 182 // We don't construct the resultant adapter at this moment since it requires LayoutInflater 183 // that will be available on onCreateView(). 184 mContactTileAdapter = new PhoneFavoritesTileAdapter(activity, mContactTileAdapterListener, 185 this); 186 mContactTileAdapter.setPhotoLoader(ContactPhotoManager.getInstance(activity)); 187 } 188 189 @Override 190 public void onCreate(Bundle savedState) { 191 if (DEBUG) Log.d(TAG, "onCreate()"); 192 Trace.beginSection(TAG + " onCreate"); 193 super.onCreate(savedState); 194 195 mAnimationDuration = getResources().getInteger(R.integer.fade_duration); 196 Trace.endSection(); 197 } 198 199 @Override 200 public void onResume() { 201 Trace.beginSection(TAG + " onResume"); 202 super.onResume(); 203 204 if (PermissionsUtil.hasContactsPermissions(getActivity())) { 205 if (getLoaderManager().getLoader(LOADER_ID_CONTACT_TILE) == null) { 206 getLoaderManager().initLoader(LOADER_ID_CONTACT_TILE, null, 207 mContactTileLoaderListener); 208 209 } else { 210 getLoaderManager().getLoader(LOADER_ID_CONTACT_TILE).forceLoad(); 211 } 212 213 mEmptyView.setDescription(R.string.speed_dial_empty); 214 mEmptyView.setActionLabel(R.string.speed_dial_empty_add_favorite_action); 215 } else { 216 mEmptyView.setDescription(R.string.permission_no_speeddial); 217 mEmptyView.setActionLabel(R.string.permission_single_turn_on); 218 } 219 Trace.endSection(); 220 } 221 222 @Override 223 public View onCreateView(LayoutInflater inflater, ViewGroup container, 224 Bundle savedInstanceState) { 225 Trace.beginSection(TAG + " onCreateView"); 226 mParentView = inflater.inflate(R.layout.speed_dial_fragment, container, false); 227 228 mListView = (PhoneFavoriteListView) mParentView.findViewById(R.id.contact_tile_list); 229 mListView.setOnItemClickListener(this); 230 mListView.setVerticalScrollBarEnabled(false); 231 mListView.setVerticalScrollbarPosition(View.SCROLLBAR_POSITION_RIGHT); 232 mListView.setScrollBarStyle(ListView.SCROLLBARS_OUTSIDE_OVERLAY); 233 mListView.getDragDropController().addOnDragDropListener(mContactTileAdapter); 234 235 final ImageView dragShadowOverlay = 236 (ImageView) getActivity().findViewById(R.id.contact_tile_drag_shadow_overlay); 237 mListView.setDragShadowOverlay(dragShadowOverlay); 238 239 mEmptyView = (EmptyContentView) mParentView.findViewById(R.id.empty_list_view); 240 mEmptyView.setImage(R.drawable.empty_speed_dial); 241 mEmptyView.setActionClickedListener(this); 242 243 mContactTileFrame = mParentView.findViewById(R.id.contact_tile_frame); 244 245 final LayoutAnimationController controller = new LayoutAnimationController( 246 AnimationUtils.loadAnimation(getActivity(), android.R.anim.fade_in)); 247 controller.setDelay(0); 248 mListView.setLayoutAnimation(controller); 249 mListView.setAdapter(mContactTileAdapter); 250 251 mListView.setOnScrollListener(mScrollListener); 252 mListView.setFastScrollEnabled(false); 253 mListView.setFastScrollAlwaysVisible(false); 254 Trace.endSection(); 255 return mParentView; 256 } 257 258 public boolean hasFrequents() { 259 if (mContactTileAdapter == null) return false; 260 return mContactTileAdapter.getNumFrequents() > 0; 261 } 262 263 /* package */ void setEmptyViewVisibility(final boolean visible) { 264 final int previousVisibility = mEmptyView.getVisibility(); 265 final int emptyViewVisibility = visible ? View.VISIBLE : View.GONE; 266 final int listViewVisibility = visible ? View.GONE : View.VISIBLE; 267 268 if (previousVisibility != emptyViewVisibility) { 269 final FrameLayout.LayoutParams params = (LayoutParams) mContactTileFrame 270 .getLayoutParams(); 271 params.height = visible ? LayoutParams.WRAP_CONTENT : LayoutParams.MATCH_PARENT; 272 mContactTileFrame.setLayoutParams(params); 273 mEmptyView.setVisibility(emptyViewVisibility); 274 mListView.setVisibility(listViewVisibility); 275 } 276 } 277 278 @Override 279 public void onStart() { 280 super.onStart(); 281 282 final Activity activity = getActivity(); 283 284 try { 285 mActivityScrollListener = (OnListFragmentScrolledListener) activity; 286 } catch (ClassCastException e) { 287 throw new ClassCastException(activity.toString() 288 + " must implement OnListFragmentScrolledListener"); 289 } 290 291 try { 292 OnDragDropListener listener = (OnDragDropListener) activity; 293 mListView.getDragDropController().addOnDragDropListener(listener); 294 ((HostInterface) activity).setDragDropController(mListView.getDragDropController()); 295 } catch (ClassCastException e) { 296 throw new ClassCastException(activity.toString() 297 + " must implement OnDragDropListener and HostInterface"); 298 } 299 300 try { 301 mPhoneNumberPickerActionListener = (OnPhoneNumberPickerActionListener) activity; 302 } catch (ClassCastException e) { 303 throw new ClassCastException(activity.toString() 304 + " must implement PhoneFavoritesFragment.listener"); 305 } 306 307 // Use initLoader() instead of restartLoader() to refraining unnecessary reload. 308 // This method call implicitly assures ContactTileLoaderListener's onLoadFinished() will 309 // be called, on which we'll check if "all" contacts should be reloaded again or not. 310 if (PermissionsUtil.hasContactsPermissions(activity)) { 311 getLoaderManager().initLoader(LOADER_ID_CONTACT_TILE, null, mContactTileLoaderListener); 312 } else { 313 setEmptyViewVisibility(true); 314 } 315 } 316 317 /** 318 * {@inheritDoc} 319 * 320 * This is only effective for elements provided by {@link #mContactTileAdapter}. 321 * {@link #mContactTileAdapter} has its own logic for click events. 322 */ 323 @Override 324 public void onItemClick(AdapterView<?> parent, View view, int position, long id) { 325 final int contactTileAdapterCount = mContactTileAdapter.getCount(); 326 if (position <= contactTileAdapterCount) { 327 Log.e(TAG, "onItemClick() event for unexpected position. " 328 + "The position " + position + " is before \"all\" section. Ignored."); 329 } 330 } 331 332 /** 333 * Cache the current view offsets into memory. Once a relayout of views in the ListView 334 * has happened due to a dataset change, the cached offsets are used to create animations 335 * that slide views from their previous positions to their new ones, to give the appearance 336 * that the views are sliding into their new positions. 337 */ 338 private void saveOffsets(int removedItemHeight) { 339 final int firstVisiblePosition = mListView.getFirstVisiblePosition(); 340 if (DEBUG) { 341 Log.d(TAG, "Child count : " + mListView.getChildCount()); 342 } 343 for (int i = 0; i < mListView.getChildCount(); i++) { 344 final View child = mListView.getChildAt(i); 345 final int position = firstVisiblePosition + i; 346 // Since we are getting the position from mListView and then querying 347 // mContactTileAdapter, its very possible that things are out of sync 348 // and we might index out of bounds. Let's make sure that this doesn't happen. 349 if (!mContactTileAdapter.isIndexInBound(position)) { 350 continue; 351 } 352 final long itemId = mContactTileAdapter.getItemId(position); 353 if (DEBUG) { 354 Log.d(TAG, "Saving itemId: " + itemId + " for listview child " + i + " Top: " 355 + child.getTop()); 356 } 357 mItemIdTopMap.put(itemId, child.getTop()); 358 mItemIdLeftMap.put(itemId, child.getLeft()); 359 } 360 mItemIdTopMap.put(KEY_REMOVED_ITEM_HEIGHT, removedItemHeight); 361 } 362 363 /* 364 * Performs animations for the gridView 365 */ 366 private void animateGridView(final long... idsInPlace) { 367 if (mItemIdTopMap.isEmpty()) { 368 // Don't do animations if the database is being queried for the first time and 369 // the previous item offsets have not been cached, or the user hasn't done anything 370 // (dragging, swiping etc) that requires an animation. 371 return; 372 } 373 374 final ViewTreeObserver observer = mListView.getViewTreeObserver(); 375 observer.addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() { 376 @SuppressWarnings("unchecked") 377 @Override 378 public boolean onPreDraw() { 379 observer.removeOnPreDrawListener(this); 380 final int firstVisiblePosition = mListView.getFirstVisiblePosition(); 381 final AnimatorSet animSet = new AnimatorSet(); 382 final ArrayList<Animator> animators = new ArrayList<Animator>(); 383 for (int i = 0; i < mListView.getChildCount(); i++) { 384 final View child = mListView.getChildAt(i); 385 int position = firstVisiblePosition + i; 386 387 // Since we are getting the position from mListView and then querying 388 // mContactTileAdapter, its very possible that things are out of sync 389 // and we might index out of bounds. Let's make sure that this doesn't happen. 390 if (!mContactTileAdapter.isIndexInBound(position)) { 391 continue; 392 } 393 394 final long itemId = mContactTileAdapter.getItemId(position); 395 396 if (containsId(idsInPlace, itemId)) { 397 animators.add(ObjectAnimator.ofFloat( 398 child, "alpha", 0.0f, 1.0f)); 399 break; 400 } else { 401 Integer startTop = mItemIdTopMap.get(itemId); 402 Integer startLeft = mItemIdLeftMap.get(itemId); 403 final int top = child.getTop(); 404 final int left = child.getLeft(); 405 int deltaX = 0; 406 int deltaY = 0; 407 408 if (startLeft != null) { 409 if (startLeft != left) { 410 deltaX = startLeft - left; 411 animators.add(ObjectAnimator.ofFloat( 412 child, "translationX", deltaX, 0.0f)); 413 } 414 } 415 416 if (startTop != null) { 417 if (startTop != top) { 418 deltaY = startTop - top; 419 animators.add(ObjectAnimator.ofFloat( 420 child, "translationY", deltaY, 0.0f)); 421 } 422 } 423 424 if (DEBUG) { 425 Log.d(TAG, "Found itemId: " + itemId + " for listview child " + i + 426 " Top: " + top + 427 " Delta: " + deltaY); 428 } 429 } 430 } 431 432 if (animators.size() > 0) { 433 animSet.setDuration(mAnimationDuration).playTogether(animators); 434 animSet.start(); 435 } 436 437 mItemIdTopMap.clear(); 438 mItemIdLeftMap.clear(); 439 return true; 440 } 441 }); 442 } 443 444 private boolean containsId(long[] ids, long target) { 445 // Linear search on array is fine because this is typically only 0-1 elements long 446 for (int i = 0; i < ids.length; i++) { 447 if (ids[i] == target) { 448 return true; 449 } 450 } 451 return false; 452 } 453 454 @Override 455 public void onDataSetChangedForAnimation(long... idsInPlace) { 456 animateGridView(idsInPlace); 457 } 458 459 @Override 460 public void cacheOffsetsForDatasetChange() { 461 saveOffsets(0); 462 } 463 464 public AbsListView getListView() { 465 return mListView; 466 } 467 468 @Override 469 public void onEmptyViewActionButtonClicked() { 470 final Activity activity = getActivity(); 471 if (activity == null) { 472 return; 473 } 474 475 if (!PermissionsUtil.hasPermission(activity, READ_CONTACTS)) { 476 requestPermissions(new String[] {READ_CONTACTS}, READ_CONTACTS_PERMISSION_REQUEST_CODE); 477 } else { 478 // Switch tabs 479 ((HostInterface) activity).showAllContactsTab(); 480 } 481 } 482 483 @Override 484 public void onRequestPermissionsResult(int requestCode, String[] permissions, 485 int[] grantResults) { 486 if (requestCode == READ_CONTACTS_PERMISSION_REQUEST_CODE) { 487 if (grantResults.length == 1 && PackageManager.PERMISSION_GRANTED == grantResults[0]) { 488 PermissionsUtil.notifyPermissionGranted(getActivity(), READ_CONTACTS); 489 } 490 } 491 } 492 } 493