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