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