1 /* 2 * Copyright (C) 2014 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.contacts.quickcontact; 17 18 import android.animation.Animator; 19 import android.animation.Animator.AnimatorListener; 20 import android.animation.AnimatorSet; 21 import android.animation.ObjectAnimator; 22 import android.app.Activity; 23 import android.content.Context; 24 import android.content.Intent; 25 import android.content.res.Resources; 26 import android.graphics.ColorFilter; 27 import android.graphics.Rect; 28 import android.graphics.drawable.Drawable; 29 import android.os.Bundle; 30 import android.support.v7.widget.CardView; 31 import android.text.Spannable; 32 import android.text.TextUtils; 33 import android.transition.ChangeBounds; 34 import android.transition.Fade; 35 import android.transition.Transition; 36 import android.transition.Transition.TransitionListener; 37 import android.transition.TransitionManager; 38 import android.transition.TransitionSet; 39 import android.util.AttributeSet; 40 import android.util.Log; 41 import android.util.Property; 42 import android.view.ContextMenu.ContextMenuInfo; 43 import android.view.LayoutInflater; 44 import android.view.MotionEvent; 45 import android.view.View; 46 import android.view.ViewConfiguration; 47 import android.view.ViewGroup; 48 import android.widget.ImageView; 49 import android.widget.LinearLayout; 50 import android.widget.LinearLayout.LayoutParams; 51 import android.widget.RelativeLayout; 52 import android.widget.TextView; 53 54 import com.android.contacts.R; 55 import com.android.contacts.common.dialog.CallSubjectDialog; 56 57 import java.util.ArrayList; 58 import java.util.List; 59 60 /** 61 * Display entries in a LinearLayout that can be expanded to show all entries. 62 */ 63 public class ExpandingEntryCardView extends CardView { 64 65 private static final String TAG = "ExpandingEntryCardView"; 66 private static final int DURATION_EXPAND_ANIMATION_FADE_IN = 200; 67 private static final int DURATION_COLLAPSE_ANIMATION_FADE_OUT = 75; 68 private static final int DELAY_EXPAND_ANIMATION_FADE_IN = 100; 69 70 public static final int DURATION_EXPAND_ANIMATION_CHANGE_BOUNDS = 300; 71 public static final int DURATION_COLLAPSE_ANIMATION_CHANGE_BOUNDS = 300; 72 73 private static final Property<View, Integer> VIEW_LAYOUT_HEIGHT_PROPERTY = 74 new Property<View, Integer>(Integer.class, "height") { 75 @Override 76 public void set(View view, Integer height) { 77 LinearLayout.LayoutParams params = (LinearLayout.LayoutParams) 78 view.getLayoutParams(); 79 params.height = height; 80 view.setLayoutParams(params); 81 } 82 83 @Override 84 public Integer get(View view) { 85 return view.getLayoutParams().height; 86 } 87 }; 88 89 /** 90 * Entry data. 91 */ 92 public static final class Entry { 93 // No action when clicking a button is specified. 94 public static final int ACTION_NONE = 1; 95 // Button action is an intent. 96 public static final int ACTION_INTENT = 2; 97 // Button action will open the call with subject dialog. 98 public static final int ACTION_CALL_WITH_SUBJECT = 3; 99 100 private final int mId; 101 private final Drawable mIcon; 102 private final String mHeader; 103 private final String mSubHeader; 104 private final Drawable mSubHeaderIcon; 105 private final String mText; 106 private final Drawable mTextIcon; 107 private Spannable mPrimaryContentDescription; 108 private final Intent mIntent; 109 private final Drawable mAlternateIcon; 110 private final Intent mAlternateIntent; 111 private Spannable mAlternateContentDescription; 112 private final boolean mShouldApplyColor; 113 private final boolean mIsEditable; 114 private final EntryContextMenuInfo mEntryContextMenuInfo; 115 private final Drawable mThirdIcon; 116 private final Intent mThirdIntent; 117 private final String mThirdContentDescription; 118 private final int mIconResourceId; 119 private final int mThirdAction; 120 private final Bundle mThirdExtras; 121 122 public Entry(int id, Drawable mainIcon, String header, String subHeader, 123 Drawable subHeaderIcon, String text, Drawable textIcon, 124 Spannable primaryContentDescription, Intent intent, 125 Drawable alternateIcon, Intent alternateIntent, 126 Spannable alternateContentDescription, boolean shouldApplyColor, boolean isEditable, 127 EntryContextMenuInfo entryContextMenuInfo, Drawable thirdIcon, Intent thirdIntent, 128 String thirdContentDescription, int thirdAction, Bundle thirdExtras, 129 int iconResourceId) { 130 mId = id; 131 mIcon = mainIcon; 132 mHeader = header; 133 mSubHeader = subHeader; 134 mSubHeaderIcon = subHeaderIcon; 135 mText = text; 136 mTextIcon = textIcon; 137 mPrimaryContentDescription = primaryContentDescription; 138 mIntent = intent; 139 mAlternateIcon = alternateIcon; 140 mAlternateIntent = alternateIntent; 141 mAlternateContentDescription = alternateContentDescription; 142 mShouldApplyColor = shouldApplyColor; 143 mIsEditable = isEditable; 144 mEntryContextMenuInfo = entryContextMenuInfo; 145 mThirdIcon = thirdIcon; 146 mThirdIntent = thirdIntent; 147 mThirdContentDescription = thirdContentDescription; 148 mThirdAction = thirdAction; 149 mThirdExtras = thirdExtras; 150 mIconResourceId = iconResourceId; 151 } 152 153 Drawable getIcon() { 154 return mIcon; 155 } 156 157 String getHeader() { 158 return mHeader; 159 } 160 161 String getSubHeader() { 162 return mSubHeader; 163 } 164 165 Drawable getSubHeaderIcon() { 166 return mSubHeaderIcon; 167 } 168 169 public String getText() { 170 return mText; 171 } 172 173 Drawable getTextIcon() { 174 return mTextIcon; 175 } 176 177 Spannable getPrimaryContentDescription() { 178 return mPrimaryContentDescription; 179 } 180 181 Intent getIntent() { 182 return mIntent; 183 } 184 185 Drawable getAlternateIcon() { 186 return mAlternateIcon; 187 } 188 189 Intent getAlternateIntent() { 190 return mAlternateIntent; 191 } 192 193 Spannable getAlternateContentDescription() { 194 return mAlternateContentDescription; 195 } 196 197 boolean shouldApplyColor() { 198 return mShouldApplyColor; 199 } 200 201 boolean isEditable() { 202 return mIsEditable; 203 } 204 205 int getId() { 206 return mId; 207 } 208 209 EntryContextMenuInfo getEntryContextMenuInfo() { 210 return mEntryContextMenuInfo; 211 } 212 213 Drawable getThirdIcon() { 214 return mThirdIcon; 215 } 216 217 Intent getThirdIntent() { 218 return mThirdIntent; 219 } 220 221 String getThirdContentDescription() { 222 return mThirdContentDescription; 223 } 224 225 int getIconResourceId() { 226 return mIconResourceId; 227 } 228 229 public int getThirdAction() { 230 return mThirdAction; 231 } 232 233 public Bundle getThirdExtras() { 234 return mThirdExtras; 235 } 236 } 237 238 public interface ExpandingEntryCardViewListener { 239 void onCollapse(int heightDelta); 240 void onExpand(); 241 void onExpandDone(); 242 } 243 244 private View mExpandCollapseButton; 245 private TextView mExpandCollapseTextView; 246 private TextView mTitleTextView; 247 private CharSequence mExpandButtonText; 248 private CharSequence mCollapseButtonText; 249 private OnClickListener mOnClickListener; 250 private OnCreateContextMenuListener mOnCreateContextMenuListener; 251 private boolean mIsExpanded = false; 252 /** 253 * The max number of entries to show in a collapsed card. If there are less entries passed in, 254 * then they are all shown. 255 */ 256 private int mCollapsedEntriesCount; 257 private ExpandingEntryCardViewListener mListener; 258 private List<List<Entry>> mEntries; 259 private int mNumEntries = 0; 260 private boolean mAllEntriesInflated = false; 261 private List<List<View>> mEntryViews; 262 private LinearLayout mEntriesViewGroup; 263 private final ImageView mExpandCollapseArrow; 264 private int mThemeColor; 265 private ColorFilter mThemeColorFilter; 266 /** 267 * Whether to prioritize the first entry type. If prioritized, we should show at least two 268 * of this entry type. 269 */ 270 private boolean mShowFirstEntryTypeTwice; 271 private boolean mIsAlwaysExpanded; 272 /** The ViewGroup to run the expand/collapse animation on */ 273 private ViewGroup mAnimationViewGroup; 274 private LinearLayout mBadgeContainer; 275 private final List<ImageView> mBadges; 276 private final List<Integer> mBadgeIds; 277 private final int mDividerLineHeightPixels; 278 /** 279 * List to hold the separators. This saves us from reconstructing every expand/collapse and 280 * provides a smoother animation. 281 */ 282 private List<View> mSeparators; 283 private LinearLayout mContainer; 284 285 private final OnClickListener mExpandCollapseButtonListener = new OnClickListener() { 286 @Override 287 public void onClick(View v) { 288 if (mIsExpanded) { 289 collapse(); 290 } else { 291 expand(); 292 } 293 } 294 }; 295 296 public ExpandingEntryCardView(Context context) { 297 this(context, null); 298 } 299 300 public ExpandingEntryCardView(Context context, AttributeSet attrs) { 301 super(context, attrs); 302 LayoutInflater inflater = LayoutInflater.from(context); 303 View expandingEntryCardView = inflater.inflate(R.layout.expanding_entry_card_view, this); 304 mEntriesViewGroup = (LinearLayout) 305 expandingEntryCardView.findViewById(R.id.content_area_linear_layout); 306 mTitleTextView = (TextView) expandingEntryCardView.findViewById(R.id.title); 307 mContainer = (LinearLayout) expandingEntryCardView.findViewById(R.id.container); 308 309 mExpandCollapseButton = inflater.inflate( 310 R.layout.quickcontact_expanding_entry_card_button, this, false); 311 mExpandCollapseTextView = (TextView) mExpandCollapseButton.findViewById(R.id.text); 312 mExpandCollapseArrow = (ImageView) mExpandCollapseButton.findViewById(R.id.arrow); 313 mExpandCollapseButton.setOnClickListener(mExpandCollapseButtonListener); 314 mBadgeContainer = (LinearLayout) mExpandCollapseButton.findViewById(R.id.badge_container); 315 mDividerLineHeightPixels = getResources() 316 .getDimensionPixelSize(R.dimen.divider_line_height); 317 318 mBadges = new ArrayList<ImageView>(); 319 mBadgeIds = new ArrayList<Integer>(); 320 } 321 322 public void initialize(List<List<Entry>> entries, int numInitialVisibleEntries, 323 boolean isExpanded, boolean isAlwaysExpanded, ExpandingEntryCardViewListener listener, 324 ViewGroup animationViewGroup) { 325 initialize(entries, numInitialVisibleEntries, isExpanded, isAlwaysExpanded, 326 listener, animationViewGroup, /* showFirstEntryTypeTwice = */ false); 327 } 328 329 /** 330 * Sets the Entry list to display. 331 * 332 * @param entries The Entry list to display. 333 */ 334 public void initialize(List<List<Entry>> entries, int numInitialVisibleEntries, 335 boolean isExpanded, boolean isAlwaysExpanded, 336 ExpandingEntryCardViewListener listener, ViewGroup animationViewGroup, 337 boolean showFirstEntryTypeTwice) { 338 LayoutInflater layoutInflater = LayoutInflater.from(getContext()); 339 mIsExpanded = isExpanded; 340 mIsAlwaysExpanded = isAlwaysExpanded; 341 // If isAlwaysExpanded is true, mIsExpanded should be true 342 mIsExpanded |= mIsAlwaysExpanded; 343 mEntryViews = new ArrayList<List<View>>(entries.size()); 344 mEntries = entries; 345 mNumEntries = 0; 346 mAllEntriesInflated = false; 347 mShowFirstEntryTypeTwice = showFirstEntryTypeTwice; 348 for (List<Entry> entryList : mEntries) { 349 mNumEntries += entryList.size(); 350 mEntryViews.add(new ArrayList<View>()); 351 } 352 mCollapsedEntriesCount = Math.min(numInitialVisibleEntries, mNumEntries); 353 // We need a separator between each list, but not after the last one 354 if (entries.size() > 1) { 355 mSeparators = new ArrayList<>(entries.size() - 1); 356 } 357 mListener = listener; 358 mAnimationViewGroup = animationViewGroup; 359 360 if (mIsExpanded) { 361 updateExpandCollapseButton(getCollapseButtonText(), /* duration = */ 0); 362 inflateAllEntries(layoutInflater); 363 } else { 364 updateExpandCollapseButton(getExpandButtonText(), /* duration = */ 0); 365 inflateInitialEntries(layoutInflater); 366 } 367 insertEntriesIntoViewGroup(); 368 applyColor(); 369 } 370 371 /** 372 * Sets the text for the expand button. 373 * 374 * @param expandButtonText The expand button text. 375 */ 376 public void setExpandButtonText(CharSequence expandButtonText) { 377 mExpandButtonText = expandButtonText; 378 if (mExpandCollapseTextView != null && !mIsExpanded) { 379 mExpandCollapseTextView.setText(expandButtonText); 380 } 381 } 382 383 /** 384 * Sets the text for the expand button. 385 * 386 * @param expandButtonText The expand button text. 387 */ 388 public void setCollapseButtonText(CharSequence expandButtonText) { 389 mCollapseButtonText = expandButtonText; 390 if (mExpandCollapseTextView != null && mIsExpanded) { 391 mExpandCollapseTextView.setText(mCollapseButtonText); 392 } 393 } 394 395 @Override 396 public void setOnClickListener(OnClickListener listener) { 397 mOnClickListener = listener; 398 } 399 400 @Override 401 public void setOnCreateContextMenuListener (OnCreateContextMenuListener listener) { 402 mOnCreateContextMenuListener = listener; 403 } 404 405 private List<View> calculateEntriesToRemoveDuringCollapse() { 406 final List<View> viewsToRemove = getViewsToDisplay(true); 407 final List<View> viewsCollapsed = getViewsToDisplay(false); 408 viewsToRemove.removeAll(viewsCollapsed); 409 return viewsToRemove; 410 } 411 412 private void insertEntriesIntoViewGroup() { 413 mEntriesViewGroup.removeAllViews(); 414 415 for (View view : getViewsToDisplay(mIsExpanded)) { 416 mEntriesViewGroup.addView(view); 417 } 418 419 removeView(mExpandCollapseButton); 420 if (mCollapsedEntriesCount < mNumEntries 421 && mExpandCollapseButton.getParent() == null && !mIsAlwaysExpanded) { 422 mContainer.addView(mExpandCollapseButton, -1); 423 } 424 } 425 426 /** 427 * Returns the list of views that should be displayed. This changes depending on whether 428 * the card is expanded or collapsed. 429 */ 430 private List<View> getViewsToDisplay(boolean isExpanded) { 431 final List<View> viewsToDisplay = new ArrayList<View>(); 432 if (isExpanded) { 433 for (int i = 0; i < mEntryViews.size(); i++) { 434 List<View> viewList = mEntryViews.get(i); 435 if (i > 0) { 436 View separator; 437 if (mSeparators.size() <= i - 1) { 438 separator = generateSeparator(viewList.get(0)); 439 mSeparators.add(separator); 440 } else { 441 separator = mSeparators.get(i - 1); 442 } 443 viewsToDisplay.add(separator); 444 } 445 for (View view : viewList) { 446 viewsToDisplay.add(view); 447 } 448 } 449 } else { 450 // We want to insert mCollapsedEntriesCount entries into the group. extraEntries is the 451 // number of entries that need to be added that are not the head element of a list 452 // to reach mCollapsedEntriesCount. 453 int numInViewGroup = 0; 454 int extraEntries = mCollapsedEntriesCount - mEntryViews.size(); 455 for (int i = 0; i < mEntryViews.size() && numInViewGroup < mCollapsedEntriesCount; 456 i++) { 457 List<View> entryViewList = mEntryViews.get(i); 458 if (i > 0) { 459 View separator; 460 if (mSeparators.size() <= i - 1) { 461 separator = generateSeparator(entryViewList.get(0)); 462 mSeparators.add(separator); 463 } else { 464 separator = mSeparators.get(i - 1); 465 } 466 viewsToDisplay.add(separator); 467 } 468 viewsToDisplay.add(entryViewList.get(0)); 469 numInViewGroup++; 470 471 int indexInEntryViewList = 1; 472 if (mShowFirstEntryTypeTwice && i == 0 && entryViewList.size() > 1) { 473 viewsToDisplay.add(entryViewList.get(1)); 474 numInViewGroup++; 475 extraEntries--; 476 indexInEntryViewList++; 477 } 478 479 // Insert entries in this list to hit mCollapsedEntriesCount. 480 for (int j = indexInEntryViewList; 481 j < entryViewList.size() && numInViewGroup < mCollapsedEntriesCount && 482 extraEntries > 0; 483 j++) { 484 viewsToDisplay.add(entryViewList.get(j)); 485 numInViewGroup++; 486 extraEntries--; 487 } 488 } 489 } 490 491 formatEntryIfFirst(viewsToDisplay); 492 return viewsToDisplay; 493 } 494 495 private void formatEntryIfFirst(List<View> entriesViewGroup) { 496 // If no title and the first entry in the group, add extra padding 497 if (TextUtils.isEmpty(mTitleTextView.getText()) && 498 entriesViewGroup.size() > 0) { 499 final View entry = entriesViewGroup.get(0); 500 entry.setPadding(entry.getPaddingLeft(), 501 getResources().getDimensionPixelSize( 502 R.dimen.expanding_entry_card_item_padding_top) + 503 getResources().getDimensionPixelSize( 504 R.dimen.expanding_entry_card_null_title_top_extra_padding), 505 entry.getPaddingRight(), 506 entry.getPaddingBottom()); 507 } 508 } 509 510 private View generateSeparator(View entry) { 511 View separator = new View(getContext()); 512 Resources res = getResources(); 513 514 separator.setBackgroundColor(res.getColor( 515 R.color.divider_line_color_light)); 516 LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams( 517 ViewGroup.LayoutParams.MATCH_PARENT, mDividerLineHeightPixels); 518 // The separator is aligned with the text in the entry. This is offset by a default 519 // margin. If there is an icon present, the icon's width and margin are added 520 int marginStart = res.getDimensionPixelSize( 521 R.dimen.expanding_entry_card_item_padding_start); 522 ImageView entryIcon = (ImageView) entry.findViewById(R.id.icon); 523 if (entryIcon.getVisibility() == View.VISIBLE) { 524 int imageWidthAndMargin = 525 res.getDimensionPixelSize(R.dimen.expanding_entry_card_item_icon_width) + 526 res.getDimensionPixelSize(R.dimen.expanding_entry_card_item_image_spacing); 527 marginStart += imageWidthAndMargin; 528 } 529 layoutParams.setMarginStart(marginStart); 530 separator.setLayoutParams(layoutParams); 531 return separator; 532 } 533 534 private CharSequence getExpandButtonText() { 535 if (!TextUtils.isEmpty(mExpandButtonText)) { 536 return mExpandButtonText; 537 } else { 538 // Default to "See more". 539 return getResources().getText(R.string.expanding_entry_card_view_see_more); 540 } 541 } 542 543 private CharSequence getCollapseButtonText() { 544 if (!TextUtils.isEmpty(mCollapseButtonText)) { 545 return mCollapseButtonText; 546 } else { 547 // Default to "See less". 548 return getResources().getText(R.string.expanding_entry_card_view_see_less); 549 } 550 } 551 552 /** 553 * Inflates the initial entries to be shown. 554 */ 555 private void inflateInitialEntries(LayoutInflater layoutInflater) { 556 // If the number of collapsed entries equals total entries, inflate all 557 if (mCollapsedEntriesCount == mNumEntries) { 558 inflateAllEntries(layoutInflater); 559 } else { 560 // Otherwise inflate the top entry from each list 561 // extraEntries is used to add extra entries until mCollapsedEntriesCount is reached. 562 int numInflated = 0; 563 int extraEntries = mCollapsedEntriesCount - mEntries.size(); 564 for (int i = 0; i < mEntries.size() && numInflated < mCollapsedEntriesCount; i++) { 565 List<Entry> entryList = mEntries.get(i); 566 List<View> entryViewList = mEntryViews.get(i); 567 568 entryViewList.add(createEntryView(layoutInflater, entryList.get(0), 569 /* showIcon = */ View.VISIBLE)); 570 numInflated++; 571 572 int indexInEntryViewList = 1; 573 if (mShowFirstEntryTypeTwice && i == 0 && entryList.size() > 1) { 574 entryViewList.add(createEntryView(layoutInflater, entryList.get(1), 575 /* showIcon = */ View.INVISIBLE)); 576 numInflated++; 577 extraEntries--; 578 indexInEntryViewList++; 579 } 580 581 // Inflate entries in this list to hit mCollapsedEntriesCount. 582 for (int j = indexInEntryViewList; j < entryList.size() 583 && numInflated < mCollapsedEntriesCount 584 && extraEntries > 0; j++) { 585 entryViewList.add(createEntryView(layoutInflater, entryList.get(j), 586 /* showIcon = */ View.INVISIBLE)); 587 numInflated++; 588 extraEntries--; 589 } 590 } 591 } 592 } 593 594 /** 595 * Inflates all entries. 596 */ 597 private void inflateAllEntries(LayoutInflater layoutInflater) { 598 if (mAllEntriesInflated) { 599 return; 600 } 601 for (int i = 0; i < mEntries.size(); i++) { 602 List<Entry> entryList = mEntries.get(i); 603 List<View> viewList = mEntryViews.get(i); 604 for (int j = viewList.size(); j < entryList.size(); j++) { 605 final int iconVisibility; 606 final Entry entry = entryList.get(j); 607 // If the entry does not have an icon, mark gone. Else if it has an icon, show 608 // for the first Entry in the list only 609 if (entry.getIcon() == null) { 610 iconVisibility = View.GONE; 611 } else if (j == 0) { 612 iconVisibility = View.VISIBLE; 613 } else { 614 iconVisibility = View.INVISIBLE; 615 } 616 viewList.add(createEntryView(layoutInflater, entry, iconVisibility)); 617 } 618 } 619 mAllEntriesInflated = true; 620 } 621 622 public void setColorAndFilter(int color, ColorFilter colorFilter) { 623 mThemeColor = color; 624 mThemeColorFilter = colorFilter; 625 applyColor(); 626 } 627 628 public void setEntryHeaderColor(int color) { 629 if (mEntries != null) { 630 for (List<View> entryList : mEntryViews) { 631 for (View entryView : entryList) { 632 TextView header = (TextView) entryView.findViewById(R.id.header); 633 if (header != null) { 634 header.setTextColor(color); 635 } 636 } 637 } 638 } 639 } 640 641 /** 642 * The ColorFilter is passed in along with the color so that a new one only needs to be created 643 * once for the entire activity. 644 * 1. Title 645 * 2. Entry icons 646 * 3. Expand/Collapse Text 647 * 4. Expand/Collapse Button 648 */ 649 public void applyColor() { 650 if (mThemeColor != 0 && mThemeColorFilter != null) { 651 // Title 652 if (mTitleTextView != null) { 653 mTitleTextView.setTextColor(mThemeColor); 654 } 655 656 // Entry icons 657 if (mEntries != null) { 658 for (List<Entry> entryList : mEntries) { 659 for (Entry entry : entryList) { 660 if (entry.shouldApplyColor()) { 661 Drawable icon = entry.getIcon(); 662 if (icon != null) { 663 icon.mutate(); 664 icon.setColorFilter(mThemeColorFilter); 665 } 666 } 667 Drawable alternateIcon = entry.getAlternateIcon(); 668 if (alternateIcon != null) { 669 alternateIcon.mutate(); 670 alternateIcon.setColorFilter(mThemeColorFilter); 671 } 672 Drawable thirdIcon = entry.getThirdIcon(); 673 if (thirdIcon != null) { 674 thirdIcon.mutate(); 675 thirdIcon.setColorFilter(mThemeColorFilter); 676 } 677 } 678 } 679 } 680 681 // Expand/Collapse 682 mExpandCollapseTextView.setTextColor(mThemeColor); 683 mExpandCollapseArrow.setColorFilter(mThemeColorFilter); 684 } 685 } 686 687 private View createEntryView(LayoutInflater layoutInflater, final Entry entry, 688 int iconVisibility) { 689 final EntryView view = (EntryView) layoutInflater.inflate( 690 R.layout.expanding_entry_card_item, this, false); 691 692 view.setContextMenuInfo(entry.getEntryContextMenuInfo()); 693 if (!TextUtils.isEmpty(entry.getPrimaryContentDescription())) { 694 view.setContentDescription(entry.getPrimaryContentDescription()); 695 } 696 697 final ImageView icon = (ImageView) view.findViewById(R.id.icon); 698 icon.setVisibility(iconVisibility); 699 if (entry.getIcon() != null) { 700 icon.setImageDrawable(entry.getIcon()); 701 } 702 final TextView header = (TextView) view.findViewById(R.id.header); 703 if (!TextUtils.isEmpty(entry.getHeader())) { 704 header.setText(entry.getHeader()); 705 } else { 706 header.setVisibility(View.GONE); 707 } 708 709 final TextView subHeader = (TextView) view.findViewById(R.id.sub_header); 710 if (!TextUtils.isEmpty(entry.getSubHeader())) { 711 subHeader.setText(entry.getSubHeader()); 712 } else { 713 subHeader.setVisibility(View.GONE); 714 } 715 716 final ImageView subHeaderIcon = (ImageView) view.findViewById(R.id.icon_sub_header); 717 if (entry.getSubHeaderIcon() != null) { 718 subHeaderIcon.setImageDrawable(entry.getSubHeaderIcon()); 719 } else { 720 subHeaderIcon.setVisibility(View.GONE); 721 } 722 723 final TextView text = (TextView) view.findViewById(R.id.text); 724 if (!TextUtils.isEmpty(entry.getText())) { 725 text.setText(entry.getText()); 726 } else { 727 text.setVisibility(View.GONE); 728 } 729 730 final ImageView textIcon = (ImageView) view.findViewById(R.id.icon_text); 731 if (entry.getTextIcon() != null) { 732 textIcon.setImageDrawable(entry.getTextIcon()); 733 } else { 734 textIcon.setVisibility(View.GONE); 735 } 736 737 if (entry.getIntent() != null) { 738 view.setOnClickListener(mOnClickListener); 739 view.setTag(new EntryTag(entry.getId(), entry.getIntent())); 740 } 741 742 if (entry.getIntent() == null && entry.getEntryContextMenuInfo() == null) { 743 // Remove the click effect 744 view.setBackground(null); 745 } 746 747 // If only the header is visible, add a top margin to match icon's top margin. 748 // Also increase the space below the header for visual comfort. 749 if (header.getVisibility() == View.VISIBLE && subHeader.getVisibility() == View.GONE && 750 text.getVisibility() == View.GONE) { 751 RelativeLayout.LayoutParams headerLayoutParams = 752 (RelativeLayout.LayoutParams) header.getLayoutParams(); 753 headerLayoutParams.topMargin = (int) (getResources().getDimension( 754 R.dimen.expanding_entry_card_item_header_only_margin_top)); 755 headerLayoutParams.bottomMargin += (int) (getResources().getDimension( 756 R.dimen.expanding_entry_card_item_header_only_margin_bottom)); 757 header.setLayoutParams(headerLayoutParams); 758 } 759 760 // Adjust the top padding size for entries with an invisible icon. The padding depends on 761 // if there is a sub header or text section 762 if (iconVisibility == View.INVISIBLE && 763 (!TextUtils.isEmpty(entry.getSubHeader()) || !TextUtils.isEmpty(entry.getText()))) { 764 view.setPaddingRelative(view.getPaddingStart(), 765 getResources().getDimensionPixelSize( 766 R.dimen.expanding_entry_card_item_no_icon_margin_top), 767 view.getPaddingEnd(), 768 view.getPaddingBottom()); 769 } else if (iconVisibility == View.INVISIBLE && TextUtils.isEmpty(entry.getSubHeader()) 770 && TextUtils.isEmpty(entry.getText())) { 771 view.setPaddingRelative(view.getPaddingStart(), 0, view.getPaddingEnd(), 772 view.getPaddingBottom()); 773 } 774 775 final ImageView alternateIcon = (ImageView) view.findViewById(R.id.icon_alternate); 776 final ImageView thirdIcon = (ImageView) view.findViewById(R.id.third_icon); 777 778 if (entry.getAlternateIcon() != null && entry.getAlternateIntent() != null) { 779 alternateIcon.setImageDrawable(entry.getAlternateIcon()); 780 alternateIcon.setOnClickListener(mOnClickListener); 781 alternateIcon.setTag(new EntryTag(entry.getId(), entry.getAlternateIntent())); 782 alternateIcon.setVisibility(View.VISIBLE); 783 alternateIcon.setContentDescription(entry.getAlternateContentDescription()); 784 } 785 786 if (entry.getThirdIcon() != null && entry.getThirdAction() != Entry.ACTION_NONE) { 787 thirdIcon.setImageDrawable(entry.getThirdIcon()); 788 if (entry.getThirdAction() == Entry.ACTION_INTENT) { 789 thirdIcon.setOnClickListener(mOnClickListener); 790 thirdIcon.setTag(new EntryTag(entry.getId(), entry.getThirdIntent())); 791 } else if (entry.getThirdAction() == Entry.ACTION_CALL_WITH_SUBJECT) { 792 thirdIcon.setOnClickListener(new View.OnClickListener() { 793 @Override 794 public void onClick(View v) { 795 Object tag = v.getTag(); 796 if (!(tag instanceof Bundle)) { 797 return; 798 } 799 800 Context context = getContext(); 801 if (context instanceof Activity) { 802 CallSubjectDialog.start((Activity) context, entry.getThirdExtras()); 803 } 804 } 805 }); 806 thirdIcon.setTag(entry.getThirdExtras()); 807 } 808 thirdIcon.setVisibility(View.VISIBLE); 809 thirdIcon.setContentDescription(entry.getThirdContentDescription()); 810 } 811 812 // Set a custom touch listener for expanding the extra icon touch areas 813 view.setOnTouchListener(new EntryTouchListener(view, alternateIcon, thirdIcon)); 814 view.setOnCreateContextMenuListener(mOnCreateContextMenuListener); 815 816 return view; 817 } 818 819 private void updateExpandCollapseButton(CharSequence buttonText, long duration) { 820 if (mIsExpanded) { 821 final ObjectAnimator animator = ObjectAnimator.ofFloat(mExpandCollapseArrow, 822 "rotation", 180); 823 animator.setDuration(duration); 824 animator.start(); 825 } else { 826 final ObjectAnimator animator = ObjectAnimator.ofFloat(mExpandCollapseArrow, 827 "rotation", 0); 828 animator.setDuration(duration); 829 animator.start(); 830 } 831 updateBadges(); 832 833 mExpandCollapseTextView.setText(buttonText); 834 } 835 836 private void updateBadges() { 837 if (mIsExpanded) { 838 mBadgeContainer.removeAllViews(); 839 } else { 840 int numberOfMimeTypesShown = mCollapsedEntriesCount; 841 if (mShowFirstEntryTypeTwice && mEntries.size() > 0 842 && mEntries.get(0).size() > 1) { 843 numberOfMimeTypesShown--; 844 } 845 // Inflate badges if not yet created 846 if (mBadges.size() < mEntries.size() - numberOfMimeTypesShown) { 847 for (int i = numberOfMimeTypesShown; i < mEntries.size(); i++) { 848 Drawable badgeDrawable = mEntries.get(i).get(0).getIcon(); 849 int badgeResourceId = mEntries.get(i).get(0).getIconResourceId(); 850 // Do not add the same badge twice 851 if (badgeResourceId != 0 && mBadgeIds.contains(badgeResourceId)) { 852 continue; 853 } 854 if (badgeDrawable != null) { 855 ImageView badgeView = new ImageView(getContext()); 856 LinearLayout.LayoutParams badgeViewParams = new LinearLayout.LayoutParams( 857 (int) getResources().getDimension( 858 R.dimen.expanding_entry_card_item_icon_width), 859 (int) getResources().getDimension( 860 R.dimen.expanding_entry_card_item_icon_height)); 861 badgeViewParams.setMarginEnd((int) getResources().getDimension( 862 R.dimen.expanding_entry_card_badge_separator_margin)); 863 badgeView.setLayoutParams(badgeViewParams); 864 badgeView.setImageDrawable(badgeDrawable); 865 mBadges.add(badgeView); 866 mBadgeIds.add(badgeResourceId); 867 } 868 } 869 } 870 mBadgeContainer.removeAllViews(); 871 for (ImageView badge : mBadges) { 872 mBadgeContainer.addView(badge); 873 } 874 } 875 } 876 877 private void expand() { 878 ChangeBounds boundsTransition = new ChangeBounds(); 879 boundsTransition.setDuration(DURATION_EXPAND_ANIMATION_CHANGE_BOUNDS); 880 881 Fade fadeIn = new Fade(Fade.IN); 882 fadeIn.setDuration(DURATION_EXPAND_ANIMATION_FADE_IN); 883 fadeIn.setStartDelay(DELAY_EXPAND_ANIMATION_FADE_IN); 884 885 TransitionSet transitionSet = new TransitionSet(); 886 transitionSet.addTransition(boundsTransition); 887 transitionSet.addTransition(fadeIn); 888 889 transitionSet.excludeTarget(R.id.text, /* exclude = */ true); 890 891 final ViewGroup transitionViewContainer = mAnimationViewGroup == null ? 892 this : mAnimationViewGroup; 893 894 transitionSet.addListener(new TransitionListener() { 895 @Override 896 public void onTransitionStart(Transition transition) { 897 mListener.onExpand(); 898 } 899 900 @Override 901 public void onTransitionEnd(Transition transition) { 902 mListener.onExpandDone(); 903 } 904 905 @Override 906 public void onTransitionCancel(Transition transition) { 907 } 908 909 @Override 910 public void onTransitionPause(Transition transition) { 911 } 912 913 @Override 914 public void onTransitionResume(Transition transition) { 915 } 916 }); 917 918 TransitionManager.beginDelayedTransition(transitionViewContainer, transitionSet); 919 920 mIsExpanded = true; 921 // In order to insert new entries, we may need to inflate them for the first time 922 inflateAllEntries(LayoutInflater.from(getContext())); 923 insertEntriesIntoViewGroup(); 924 updateExpandCollapseButton(getCollapseButtonText(), 925 DURATION_EXPAND_ANIMATION_CHANGE_BOUNDS); 926 } 927 928 private void collapse() { 929 final List<View> views = calculateEntriesToRemoveDuringCollapse(); 930 931 // This animation requires layout changes, unlike the expand() animation: the action bar 932 // might get scrolled open in order to fill empty space. As a result, we can't use 933 // ChangeBounds here. Instead manually animate view height and alpha. This isn't as 934 // efficient as the bounds and translation changes performed by ChangeBounds. Nonetheless, a 935 // reasonable frame-rate is achieved collapsing a dozen elements on a user Svelte N4. So the 936 // performance hit doesn't justify writing a less maintainable animation. 937 final AnimatorSet set = new AnimatorSet(); 938 final List<Animator> animators = new ArrayList<Animator>(views.size()); 939 int totalSizeChange = 0; 940 for (View viewToRemove : views) { 941 final ObjectAnimator animator = ObjectAnimator.ofObject(viewToRemove, 942 VIEW_LAYOUT_HEIGHT_PROPERTY, null, viewToRemove.getHeight(), 0); 943 totalSizeChange += viewToRemove.getHeight(); 944 animator.setDuration(DURATION_COLLAPSE_ANIMATION_CHANGE_BOUNDS); 945 animators.add(animator); 946 viewToRemove.animate().alpha(0).setDuration(DURATION_COLLAPSE_ANIMATION_FADE_OUT); 947 } 948 set.playTogether(animators); 949 set.start(); 950 set.addListener(new AnimatorListener() { 951 @Override 952 public void onAnimationStart(Animator animation) { 953 } 954 955 @Override 956 public void onAnimationEnd(Animator animation) { 957 // Now that the views have been animated away, actually remove them from the view 958 // hierarchy. Reset their appearance so that they look appropriate when they 959 // get added back later. 960 insertEntriesIntoViewGroup(); 961 for (View view : views) { 962 if (view instanceof EntryView) { 963 VIEW_LAYOUT_HEIGHT_PROPERTY.set(view, LayoutParams.WRAP_CONTENT); 964 } else { 965 VIEW_LAYOUT_HEIGHT_PROPERTY.set(view, mDividerLineHeightPixels); 966 } 967 view.animate().cancel(); 968 view.setAlpha(1); 969 } 970 } 971 972 @Override 973 public void onAnimationCancel(Animator animation) { 974 } 975 976 @Override 977 public void onAnimationRepeat(Animator animation) { 978 } 979 }); 980 981 mListener.onCollapse(totalSizeChange); 982 mIsExpanded = false; 983 updateExpandCollapseButton(getExpandButtonText(), 984 DURATION_COLLAPSE_ANIMATION_CHANGE_BOUNDS); 985 } 986 987 /** 988 * Returns whether the view is currently in its expanded state. 989 */ 990 public boolean isExpanded() { 991 return mIsExpanded; 992 } 993 994 /** 995 * Sets the title text of this ExpandingEntryCardView. 996 * @param title The title to set. A null title will result in the title being removed. 997 */ 998 public void setTitle(String title) { 999 if (mTitleTextView == null) { 1000 Log.e(TAG, "mTitleTextView is null"); 1001 } 1002 mTitleTextView.setText(title); 1003 mTitleTextView.setVisibility(TextUtils.isEmpty(title) ? View.GONE : View.VISIBLE); 1004 findViewById(R.id.title_separator).setVisibility(TextUtils.isEmpty(title) ? 1005 View.GONE : View.VISIBLE); 1006 // If the title is set after children have been added, reset the top entry's padding to 1007 // the default. Else if the title is cleared after children have been added, set 1008 // the extra top padding 1009 if (!TextUtils.isEmpty(title) && mEntriesViewGroup.getChildCount() > 0) { 1010 View firstEntry = mEntriesViewGroup.getChildAt(0); 1011 firstEntry.setPadding(firstEntry.getPaddingLeft(), 1012 getResources().getDimensionPixelSize( 1013 R.dimen.expanding_entry_card_item_padding_top), 1014 firstEntry.getPaddingRight(), 1015 firstEntry.getPaddingBottom()); 1016 } else if (!TextUtils.isEmpty(title) && mEntriesViewGroup.getChildCount() > 0) { 1017 View firstEntry = mEntriesViewGroup.getChildAt(0); 1018 firstEntry.setPadding(firstEntry.getPaddingLeft(), 1019 getResources().getDimensionPixelSize( 1020 R.dimen.expanding_entry_card_item_padding_top) + 1021 getResources().getDimensionPixelSize( 1022 R.dimen.expanding_entry_card_null_title_top_extra_padding), 1023 firstEntry.getPaddingRight(), 1024 firstEntry.getPaddingBottom()); 1025 } 1026 } 1027 1028 public boolean shouldShow() { 1029 return mEntries != null && mEntries.size() > 0; 1030 } 1031 1032 public static final class EntryView extends RelativeLayout { 1033 private EntryContextMenuInfo mEntryContextMenuInfo; 1034 1035 public EntryView(Context context) { 1036 super(context); 1037 } 1038 1039 public EntryView(Context context, AttributeSet attrs) { 1040 super(context, attrs); 1041 } 1042 1043 public void setContextMenuInfo(EntryContextMenuInfo info) { 1044 mEntryContextMenuInfo = info; 1045 } 1046 1047 @Override 1048 protected ContextMenuInfo getContextMenuInfo() { 1049 return mEntryContextMenuInfo; 1050 } 1051 } 1052 1053 public static final class EntryContextMenuInfo implements ContextMenuInfo { 1054 private final String mCopyText; 1055 private final String mCopyLabel; 1056 private final String mMimeType; 1057 private final long mId; 1058 private final boolean mIsSuperPrimary; 1059 1060 public EntryContextMenuInfo(String copyText, String copyLabel, String mimeType, long id, 1061 boolean isSuperPrimary) { 1062 mCopyText = copyText; 1063 mCopyLabel = copyLabel; 1064 mMimeType = mimeType; 1065 mId = id; 1066 mIsSuperPrimary = isSuperPrimary; 1067 } 1068 1069 public String getCopyText() { 1070 return mCopyText; 1071 } 1072 1073 public String getCopyLabel() { 1074 return mCopyLabel; 1075 } 1076 1077 public String getMimeType() { 1078 return mMimeType; 1079 } 1080 1081 public long getId() { 1082 return mId; 1083 } 1084 1085 public boolean isSuperPrimary() { 1086 return mIsSuperPrimary; 1087 } 1088 } 1089 1090 static final class EntryTag { 1091 private final int mId; 1092 private final Intent mIntent; 1093 1094 public EntryTag(int id, Intent intent) { 1095 mId = id; 1096 mIntent = intent; 1097 } 1098 1099 public int getId() { 1100 return mId; 1101 } 1102 1103 public Intent getIntent() { 1104 return mIntent; 1105 } 1106 } 1107 1108 /** 1109 * This custom touch listener increases the touch area for the second and third icons, if 1110 * they are present. This is necessary to maintain other properties on an entry view, like 1111 * using a top padding on entry. Based off of {@link android.view.TouchDelegate} 1112 */ 1113 private static final class EntryTouchListener implements View.OnTouchListener { 1114 private final View mEntry; 1115 private final ImageView mAlternateIcon; 1116 private final ImageView mThirdIcon; 1117 /** mTouchedView locks in a view on touch down */ 1118 private View mTouchedView; 1119 /** mSlop adds some space to account for touches that are just outside the hit area */ 1120 private int mSlop; 1121 1122 public EntryTouchListener(View entry, ImageView alternateIcon, ImageView thirdIcon) { 1123 mEntry = entry; 1124 mAlternateIcon = alternateIcon; 1125 mThirdIcon = thirdIcon; 1126 mSlop = ViewConfiguration.get(entry.getContext()).getScaledTouchSlop(); 1127 } 1128 1129 @Override 1130 public boolean onTouch(View v, MotionEvent event) { 1131 View touchedView = mTouchedView; 1132 boolean sendToTouched = false; 1133 boolean hit = true; 1134 boolean handled = false; 1135 1136 switch (event.getAction()) { 1137 case MotionEvent.ACTION_DOWN: 1138 if (hitThirdIcon(event)) { 1139 mTouchedView = mThirdIcon; 1140 sendToTouched = true; 1141 } else if (hitAlternateIcon(event)) { 1142 mTouchedView = mAlternateIcon; 1143 sendToTouched = true; 1144 } else { 1145 mTouchedView = mEntry; 1146 sendToTouched = false; 1147 } 1148 touchedView = mTouchedView; 1149 break; 1150 case MotionEvent.ACTION_UP: 1151 case MotionEvent.ACTION_MOVE: 1152 sendToTouched = mTouchedView != null && mTouchedView != mEntry; 1153 if (sendToTouched) { 1154 final Rect slopBounds = new Rect(); 1155 touchedView.getHitRect(slopBounds); 1156 slopBounds.inset(-mSlop, -mSlop); 1157 if (!slopBounds.contains((int) event.getX(), (int) event.getY())) { 1158 hit = false; 1159 } 1160 } 1161 break; 1162 case MotionEvent.ACTION_CANCEL: 1163 sendToTouched = mTouchedView != null && mTouchedView != mEntry; 1164 mTouchedView = null; 1165 break; 1166 } 1167 if (sendToTouched) { 1168 if (hit) { 1169 event.setLocation(touchedView.getWidth() / 2, touchedView.getHeight() / 2); 1170 } else { 1171 // Offset event coordinates to be outside the target view (in case it does 1172 // something like tracking pressed state) 1173 event.setLocation(-(mSlop * 2), -(mSlop * 2)); 1174 } 1175 handled = touchedView.dispatchTouchEvent(event); 1176 } 1177 return handled; 1178 } 1179 1180 private boolean hitThirdIcon(MotionEvent event) { 1181 if (mEntry.getLayoutDirection() == View.LAYOUT_DIRECTION_RTL) { 1182 return mThirdIcon.getVisibility() == View.VISIBLE && 1183 event.getX() < mThirdIcon.getRight(); 1184 } else { 1185 return mThirdIcon.getVisibility() == View.VISIBLE && 1186 event.getX() > mThirdIcon.getLeft(); 1187 } 1188 } 1189 1190 /** 1191 * Should be used after checking if third icon was hit 1192 */ 1193 private boolean hitAlternateIcon(MotionEvent event) { 1194 // LayoutParams used to add the start margin to the touch area 1195 final RelativeLayout.LayoutParams alternateIconParams = 1196 (RelativeLayout.LayoutParams) mAlternateIcon.getLayoutParams(); 1197 if (mEntry.getLayoutDirection() == View.LAYOUT_DIRECTION_RTL) { 1198 return mAlternateIcon.getVisibility() == View.VISIBLE && 1199 event.getX() < mAlternateIcon.getRight() + alternateIconParams.rightMargin; 1200 } else { 1201 return mAlternateIcon.getVisibility() == View.VISIBLE && 1202 event.getX() > mAlternateIcon.getLeft() - alternateIconParams.leftMargin; 1203 } 1204 } 1205 } 1206 } 1207