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