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