1 /* 2 * Copyright (C) 2015 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.messaging.ui.conversationlist; 17 18 import android.app.Activity; 19 import android.app.Fragment; 20 import android.content.Context; 21 import android.database.Cursor; 22 import android.graphics.Rect; 23 import android.net.Uri; 24 import android.os.Bundle; 25 import android.os.Parcelable; 26 import androidx.core.view.ViewCompat; 27 import androidx.core.view.ViewGroupCompat; 28 import androidx.recyclerview.widget.LinearLayoutManager; 29 import androidx.recyclerview.widget.RecyclerView; 30 import android.view.LayoutInflater; 31 import android.view.Menu; 32 import android.view.MenuInflater; 33 import android.view.MenuItem; 34 import android.view.View; 35 import android.view.View.OnClickListener; 36 import android.view.ViewGroup; 37 import android.view.ViewGroup.MarginLayoutParams; 38 import android.view.ViewPropertyAnimator; 39 import android.view.accessibility.AccessibilityManager; 40 import android.widget.AbsListView; 41 import android.widget.ImageView; 42 43 import com.android.messaging.R; 44 import com.android.messaging.annotation.VisibleForAnimation; 45 import com.android.messaging.datamodel.DataModel; 46 import com.android.messaging.datamodel.binding.Binding; 47 import com.android.messaging.datamodel.binding.BindingBase; 48 import com.android.messaging.datamodel.data.ConversationListData; 49 import com.android.messaging.datamodel.data.ConversationListData.ConversationListDataListener; 50 import com.android.messaging.datamodel.data.ConversationListItemData; 51 import com.android.messaging.ui.BugleAnimationTags; 52 import com.android.messaging.ui.ListEmptyView; 53 import com.android.messaging.ui.SnackBarInteraction; 54 import com.android.messaging.ui.UIIntents; 55 import com.android.messaging.util.AccessibilityUtil; 56 import com.android.messaging.util.Assert; 57 import com.android.messaging.util.ImeUtil; 58 import com.android.messaging.util.LogUtil; 59 import com.android.messaging.util.UiUtils; 60 import com.google.common.annotations.VisibleForTesting; 61 62 import java.util.ArrayList; 63 import java.util.List; 64 65 /** 66 * Shows a list of conversations. 67 */ 68 public class ConversationListFragment extends Fragment implements ConversationListDataListener, 69 ConversationListItemView.HostInterface { 70 private static final String BUNDLE_ARCHIVED_MODE = "archived_mode"; 71 private static final String BUNDLE_FORWARD_MESSAGE_MODE = "forward_message_mode"; 72 private static final boolean VERBOSE = false; 73 74 private MenuItem mShowBlockedMenuItem; 75 private boolean mArchiveMode; 76 private boolean mBlockedAvailable; 77 private boolean mForwardMessageMode; 78 79 public interface ConversationListFragmentHost { onConversationClick(final ConversationListData listData, final ConversationListItemData conversationListItemData, final boolean isLongClick, final ConversationListItemView conversationView)80 public void onConversationClick(final ConversationListData listData, 81 final ConversationListItemData conversationListItemData, 82 final boolean isLongClick, 83 final ConversationListItemView conversationView); onCreateConversationClick()84 public void onCreateConversationClick(); isConversationSelected(final String conversationId)85 public boolean isConversationSelected(final String conversationId); isSwipeAnimatable()86 public boolean isSwipeAnimatable(); isSelectionMode()87 public boolean isSelectionMode(); hasWindowFocus()88 public boolean hasWindowFocus(); 89 } 90 91 private ConversationListFragmentHost mHost; 92 private RecyclerView mRecyclerView; 93 private ImageView mStartNewConversationButton; 94 private ListEmptyView mEmptyListMessageView; 95 private ConversationListAdapter mAdapter; 96 97 // Saved Instance State Data - only for temporal data which is nice to maintain but not 98 // critical for correctness. 99 private static final String SAVED_INSTANCE_STATE_LIST_VIEW_STATE_KEY = 100 "conversationListViewState"; 101 private Parcelable mListState; 102 103 @VisibleForTesting 104 final Binding<ConversationListData> mListBinding = BindingBase.createBinding(this); 105 createArchivedConversationListFragment()106 public static ConversationListFragment createArchivedConversationListFragment() { 107 return createConversationListFragment(BUNDLE_ARCHIVED_MODE); 108 } 109 createForwardMessageConversationListFragment()110 public static ConversationListFragment createForwardMessageConversationListFragment() { 111 return createConversationListFragment(BUNDLE_FORWARD_MESSAGE_MODE); 112 } 113 createConversationListFragment(String modeKeyName)114 public static ConversationListFragment createConversationListFragment(String modeKeyName) { 115 final ConversationListFragment fragment = new ConversationListFragment(); 116 final Bundle bundle = new Bundle(); 117 bundle.putBoolean(modeKeyName, true); 118 fragment.setArguments(bundle); 119 return fragment; 120 } 121 122 /** 123 * {@inheritDoc} from Fragment 124 */ 125 @Override onCreate(final Bundle bundle)126 public void onCreate(final Bundle bundle) { 127 super.onCreate(bundle); 128 mListBinding.getData().init(getLoaderManager(), mListBinding); 129 mAdapter = new ConversationListAdapter(getActivity(), null, this); 130 } 131 132 @Override onResume()133 public void onResume() { 134 super.onResume(); 135 136 Assert.notNull(mHost); 137 setScrolledToNewestConversationIfNeeded(); 138 139 updateUi(); 140 } 141 setScrolledToNewestConversationIfNeeded()142 public void setScrolledToNewestConversationIfNeeded() { 143 if (!mArchiveMode 144 && !mForwardMessageMode 145 && isScrolledToFirstConversation() 146 && mHost.hasWindowFocus()) { 147 mListBinding.getData().setScrolledToNewestConversation(true); 148 } 149 } 150 isScrolledToFirstConversation()151 private boolean isScrolledToFirstConversation() { 152 int firstItemPosition = ((LinearLayoutManager) mRecyclerView.getLayoutManager()) 153 .findFirstCompletelyVisibleItemPosition(); 154 return firstItemPosition == 0; 155 } 156 157 /** 158 * {@inheritDoc} from Fragment 159 */ 160 @Override onDestroy()161 public void onDestroy() { 162 super.onDestroy(); 163 mListBinding.unbind(); 164 mHost = null; 165 } 166 167 /** 168 * {@inheritDoc} from Fragment 169 */ 170 @Override onCreateView(final LayoutInflater inflater, final ViewGroup container, final Bundle savedInstanceState)171 public View onCreateView(final LayoutInflater inflater, final ViewGroup container, 172 final Bundle savedInstanceState) { 173 final ViewGroup rootView = (ViewGroup) inflater.inflate(R.layout.conversation_list_fragment, 174 container, false); 175 mRecyclerView = (RecyclerView) rootView.findViewById(android.R.id.list); 176 mEmptyListMessageView = (ListEmptyView) rootView.findViewById(R.id.no_conversations_view); 177 mEmptyListMessageView.setImageHint(R.drawable.ic_oobe_conv_list); 178 // The default behavior for default layout param generation by LinearLayoutManager is to 179 // provide width and height of WRAP_CONTENT, but this is not desirable for 180 // ConversationListFragment; the view in each row should be a width of MATCH_PARENT so that 181 // the entire row is tappable. 182 final Activity activity = getActivity(); 183 final LinearLayoutManager manager = new LinearLayoutManager(activity) { 184 @Override 185 public RecyclerView.LayoutParams generateDefaultLayoutParams() { 186 return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 187 ViewGroup.LayoutParams.WRAP_CONTENT); 188 } 189 }; 190 mRecyclerView.setLayoutManager(manager); 191 mRecyclerView.setHasFixedSize(true); 192 mRecyclerView.setAdapter(mAdapter); 193 mRecyclerView.setOnScrollListener(new RecyclerView.OnScrollListener() { 194 int mCurrentState = AbsListView.OnScrollListener.SCROLL_STATE_IDLE; 195 196 @Override 197 public void onScrolled(final RecyclerView recyclerView, final int dx, final int dy) { 198 if (mCurrentState == AbsListView.OnScrollListener.SCROLL_STATE_TOUCH_SCROLL 199 || mCurrentState == AbsListView.OnScrollListener.SCROLL_STATE_FLING) { 200 ImeUtil.get().hideImeKeyboard(getActivity(), mRecyclerView); 201 } 202 203 if (isScrolledToFirstConversation()) { 204 setScrolledToNewestConversationIfNeeded(); 205 } else { 206 mListBinding.getData().setScrolledToNewestConversation(false); 207 } 208 } 209 210 @Override 211 public void onScrollStateChanged(final RecyclerView recyclerView, final int newState) { 212 mCurrentState = newState; 213 } 214 }); 215 mRecyclerView.addOnItemTouchListener(new ConversationListSwipeHelper(mRecyclerView)); 216 217 if (savedInstanceState != null) { 218 mListState = savedInstanceState.getParcelable(SAVED_INSTANCE_STATE_LIST_VIEW_STATE_KEY); 219 } 220 221 mStartNewConversationButton = (ImageView) rootView.findViewById( 222 R.id.start_new_conversation_button); 223 if (mArchiveMode) { 224 mStartNewConversationButton.setVisibility(View.GONE); 225 } else { 226 mStartNewConversationButton.setVisibility(View.VISIBLE); 227 mStartNewConversationButton.setOnClickListener(new OnClickListener() { 228 @Override 229 public void onClick(final View clickView) { 230 mHost.onCreateConversationClick(); 231 } 232 }); 233 } 234 ViewCompat.setTransitionName(mStartNewConversationButton, BugleAnimationTags.TAG_FABICON); 235 236 // The root view has a non-null background, which by default is deemed by the framework 237 // to be a "transition group," where all child views are animated together during an 238 // activity transition. However, we want each individual items in the recycler view to 239 // show explode animation themselves, so we explicitly tag the root view to be a non-group. 240 ViewGroupCompat.setTransitionGroup(rootView, false); 241 242 setHasOptionsMenu(true); 243 return rootView; 244 } 245 246 @Override onAttach(final Activity activity)247 public void onAttach(final Activity activity) { 248 super.onAttach(activity); 249 if (VERBOSE) { 250 LogUtil.v(LogUtil.BUGLE_TAG, "Attaching List"); 251 } 252 final Bundle arguments = getArguments(); 253 if (arguments != null) { 254 mArchiveMode = arguments.getBoolean(BUNDLE_ARCHIVED_MODE, false); 255 mForwardMessageMode = arguments.getBoolean(BUNDLE_FORWARD_MESSAGE_MODE, false); 256 } 257 mListBinding.bind(DataModel.get().createConversationListData(activity, this, mArchiveMode)); 258 } 259 260 261 @Override onSaveInstanceState(final Bundle outState)262 public void onSaveInstanceState(final Bundle outState) { 263 super.onSaveInstanceState(outState); 264 if (mListState != null) { 265 outState.putParcelable(SAVED_INSTANCE_STATE_LIST_VIEW_STATE_KEY, mListState); 266 } 267 } 268 269 @Override onPause()270 public void onPause() { 271 super.onPause(); 272 mListState = mRecyclerView.getLayoutManager().onSaveInstanceState(); 273 mListBinding.getData().setScrolledToNewestConversation(false); 274 } 275 276 /** 277 * Call this immediately after attaching the fragment 278 */ setHost(final ConversationListFragmentHost host)279 public void setHost(final ConversationListFragmentHost host) { 280 Assert.isNull(mHost); 281 mHost = host; 282 } 283 284 @Override onConversationListCursorUpdated(final ConversationListData data, final Cursor cursor)285 public void onConversationListCursorUpdated(final ConversationListData data, 286 final Cursor cursor) { 287 mListBinding.ensureBound(data); 288 final Cursor oldCursor = mAdapter.swapCursor(cursor); 289 updateEmptyListUi(cursor == null || cursor.getCount() == 0); 290 if (mListState != null && cursor != null && oldCursor == null) { 291 mRecyclerView.getLayoutManager().onRestoreInstanceState(mListState); 292 } 293 } 294 295 @Override setBlockedParticipantsAvailable(final boolean blockedAvailable)296 public void setBlockedParticipantsAvailable(final boolean blockedAvailable) { 297 mBlockedAvailable = blockedAvailable; 298 if (mShowBlockedMenuItem != null) { 299 mShowBlockedMenuItem.setVisible(blockedAvailable); 300 } 301 } 302 updateUi()303 public void updateUi() { 304 mAdapter.notifyDataSetChanged(); 305 } 306 307 @Override onPrepareOptionsMenu(final Menu menu)308 public void onPrepareOptionsMenu(final Menu menu) { 309 super.onPrepareOptionsMenu(menu); 310 final MenuItem startNewConversationMenuItem = 311 menu.findItem(R.id.action_start_new_conversation); 312 if (startNewConversationMenuItem != null) { 313 // It is recommended for the Floating Action button functionality to be duplicated as a 314 // menu 315 AccessibilityManager accessibilityManager = (AccessibilityManager) 316 getActivity().getSystemService(Context.ACCESSIBILITY_SERVICE); 317 startNewConversationMenuItem.setVisible(accessibilityManager 318 .isTouchExplorationEnabled()); 319 } 320 321 final MenuItem archive = menu.findItem(R.id.action_show_archived); 322 if (archive != null) { 323 archive.setVisible(true); 324 } 325 } 326 327 @Override onCreateOptionsMenu(final Menu menu, final MenuInflater inflater)328 public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { 329 if (!isAdded()) { 330 // Guard against being called before we're added to the activity 331 return; 332 } 333 334 mShowBlockedMenuItem = menu.findItem(R.id.action_show_blocked_contacts); 335 if (mShowBlockedMenuItem != null) { 336 mShowBlockedMenuItem.setVisible(mBlockedAvailable); 337 } 338 } 339 340 /** 341 * {@inheritDoc} from ConversationListItemView.HostInterface 342 */ 343 @Override onConversationClicked(final ConversationListItemData conversationListItemData, final boolean isLongClick, final ConversationListItemView conversationView)344 public void onConversationClicked(final ConversationListItemData conversationListItemData, 345 final boolean isLongClick, final ConversationListItemView conversationView) { 346 final ConversationListData listData = mListBinding.getData(); 347 mHost.onConversationClick(listData, conversationListItemData, isLongClick, 348 conversationView); 349 } 350 351 /** 352 * {@inheritDoc} from ConversationListItemView.HostInterface 353 */ 354 @Override isConversationSelected(final String conversationId)355 public boolean isConversationSelected(final String conversationId) { 356 return mHost.isConversationSelected(conversationId); 357 } 358 359 @Override isSwipeAnimatable()360 public boolean isSwipeAnimatable() { 361 return mHost.isSwipeAnimatable(); 362 } 363 364 // Show and hide empty list UI as needed with appropriate text based on view specifics updateEmptyListUi(final boolean isEmpty)365 private void updateEmptyListUi(final boolean isEmpty) { 366 if (isEmpty) { 367 int emptyListText; 368 if (!mListBinding.getData().getHasFirstSyncCompleted()) { 369 emptyListText = R.string.conversation_list_first_sync_text; 370 } else if (mArchiveMode) { 371 emptyListText = R.string.archived_conversation_list_empty_text; 372 } else { 373 emptyListText = R.string.conversation_list_empty_text; 374 } 375 mEmptyListMessageView.setTextHint(emptyListText); 376 mEmptyListMessageView.setVisibility(View.VISIBLE); 377 mEmptyListMessageView.setIsImageVisible(true); 378 mEmptyListMessageView.setIsVerticallyCentered(true); 379 } else { 380 mEmptyListMessageView.setVisibility(View.GONE); 381 } 382 } 383 384 @Override getSnackBarInteractions()385 public List<SnackBarInteraction> getSnackBarInteractions() { 386 final List<SnackBarInteraction> interactions = new ArrayList<SnackBarInteraction>(1); 387 final SnackBarInteraction fabInteraction = 388 new SnackBarInteraction.BasicSnackBarInteraction(mStartNewConversationButton); 389 interactions.add(fabInteraction); 390 return interactions; 391 } 392 getNormalizedFabAnimator()393 private ViewPropertyAnimator getNormalizedFabAnimator() { 394 return mStartNewConversationButton.animate() 395 .setInterpolator(UiUtils.DEFAULT_INTERPOLATOR) 396 .setDuration(getActivity().getResources().getInteger( 397 R.integer.fab_animation_duration_ms)); 398 } 399 dismissFab()400 public ViewPropertyAnimator dismissFab() { 401 // To prevent clicking while animating. 402 mStartNewConversationButton.setEnabled(false); 403 final MarginLayoutParams lp = 404 (MarginLayoutParams) mStartNewConversationButton.getLayoutParams(); 405 final float fabWidthWithLeftRightMargin = mStartNewConversationButton.getWidth() 406 + lp.leftMargin + lp.rightMargin; 407 final int direction = AccessibilityUtil.isLayoutRtl(mStartNewConversationButton) ? -1 : 1; 408 return getNormalizedFabAnimator().translationX(direction * fabWidthWithLeftRightMargin); 409 } 410 showFab()411 public ViewPropertyAnimator showFab() { 412 return getNormalizedFabAnimator().translationX(0).withEndAction(new Runnable() { 413 @Override 414 public void run() { 415 // Re-enable clicks after the animation. 416 mStartNewConversationButton.setEnabled(true); 417 } 418 }); 419 } 420 421 public View getHeroElementForTransition() { 422 return mArchiveMode ? null : mStartNewConversationButton; 423 } 424 425 @VisibleForAnimation 426 public RecyclerView getRecyclerView() { 427 return mRecyclerView; 428 } 429 430 @Override 431 public void startFullScreenPhotoViewer( 432 final Uri initialPhoto, final Rect initialPhotoBounds, final Uri photosUri) { 433 UIIntents.get().launchFullScreenPhotoViewer( 434 getActivity(), initialPhoto, initialPhotoBounds, photosUri); 435 } 436 437 @Override 438 public void startFullScreenVideoViewer(final Uri videoUri) { 439 UIIntents.get().launchFullScreenVideoViewer(getActivity(), videoUri); 440 } 441 442 @Override 443 public boolean isSelectionMode() { 444 return mHost != null && mHost.isSelectionMode(); 445 } 446 } 447