ExpandingEntryCardView.java revision f5cc63f9f25f8d75a524202d2bd4f7d388d787a8
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 com.android.contacts.R; 19 20import android.animation.Animator; 21import android.animation.AnimatorListenerAdapter; 22import android.animation.ValueAnimator; 23import android.content.Context; 24import android.content.Intent; 25import android.content.res.Resources; 26import android.graphics.ColorFilter; 27import android.graphics.Rect; 28import android.graphics.drawable.Drawable; 29import android.text.TextUtils; 30import android.util.AttributeSet; 31import android.util.Log; 32import android.view.LayoutInflater; 33import android.view.TouchDelegate; 34import android.view.View; 35import android.view.ViewGroup; 36import android.view.ViewTreeObserver; 37import android.view.ViewTreeObserver.OnPreDrawListener; 38import android.widget.ImageView; 39import android.widget.LinearLayout; 40import android.widget.TextView; 41 42import java.util.ArrayList; 43import java.util.List; 44 45/** 46 * Display entries in a LinearLayout that can be expanded to show all entries. 47 */ 48public class ExpandingEntryCardView extends LinearLayout { 49 50 private static final String TAG = "ExpandingEntryCardView"; 51 52 /** 53 * Entry data. 54 */ 55 public static final class Entry { 56 57 private final int mViewId; 58 private final Drawable mIcon; 59 private final String mHeader; 60 private final String mSubHeader; 61 private final Drawable mSubHeaderIcon; 62 private final String mText; 63 private final Drawable mTextIcon; 64 private final Intent mIntent; 65 private final Drawable mAlternateIcon; 66 private final Intent mAlternateIntent; 67 private final String mAlternateContentDescription; 68 private final boolean mShouldApplyColor; 69 private final boolean mIsEditable; 70 71 public Entry(int viewId, Drawable icon, String header, String subHeader, String text, 72 Intent intent, Drawable alternateIcon, Intent alternateIntent, 73 String alternateContentDescription, boolean shouldApplyColor, 74 boolean isEditable) { 75 this(viewId, icon, header, subHeader, null, text, null, intent, alternateIcon, 76 alternateIntent, alternateContentDescription, shouldApplyColor, isEditable); 77 } 78 79 public Entry(int viewId, Drawable mainIcon, String header, String subHeader, 80 Drawable subHeaderIcon, String text, Drawable textIcon, Intent intent, 81 Drawable alternateIcon, Intent alternateIntent, String alternateContentDescription, 82 boolean shouldApplyColor, boolean isEditable) { 83 mViewId = viewId; 84 mIcon = mainIcon; 85 mHeader = header; 86 mSubHeader = subHeader; 87 mSubHeaderIcon = subHeaderIcon; 88 mText = text; 89 mTextIcon = textIcon; 90 mIntent = intent; 91 mAlternateIcon = alternateIcon; 92 mAlternateIntent = alternateIntent; 93 mAlternateContentDescription = alternateContentDescription; 94 mShouldApplyColor = shouldApplyColor; 95 mIsEditable = isEditable; 96 } 97 98 Drawable getIcon() { 99 return mIcon; 100 } 101 102 String getHeader() { 103 return mHeader; 104 } 105 106 String getSubHeader() { 107 return mSubHeader; 108 } 109 110 Drawable getSubHeaderIcon() { 111 return mSubHeaderIcon; 112 } 113 114 public String getText() { 115 return mText; 116 } 117 118 Drawable getTextIcon() { 119 return mTextIcon; 120 } 121 122 Intent getIntent() { 123 return mIntent; 124 } 125 126 Drawable getAlternateIcon() { 127 return mAlternateIcon; 128 } 129 130 Intent getAlternateIntent() { 131 return mAlternateIntent; 132 } 133 134 String getAlternateContentDescription() { 135 return mAlternateContentDescription; 136 } 137 138 boolean shouldApplyColor() { 139 return mShouldApplyColor; 140 } 141 142 boolean isEditable() { 143 return mIsEditable; 144 } 145 146 int getViewId() { 147 return mViewId; 148 } 149 } 150 151 public interface ExpandingEntryCardViewListener { 152 void onCollapse(int heightDelta); 153 } 154 155 private View mExpandCollapseButton; 156 private TextView mExpandCollapseTextView; 157 private TextView mTitleTextView; 158 private CharSequence mExpandButtonText; 159 private CharSequence mCollapseButtonText; 160 private OnClickListener mOnClickListener; 161 private boolean mIsExpanded = false; 162 private int mCollapsedEntriesCount; 163 private ExpandingEntryCardViewListener mListener; 164 private List<List<Entry>> mEntries; 165 private int mNumEntries = 0; 166 private boolean mAllEntriesInflated = false; 167 private List<List<View>> mEntryViews; 168 private LinearLayout mEntriesViewGroup; 169 private final Drawable mCollapseArrowDrawable; 170 private final Drawable mExpandArrowDrawable; 171 private int mThemeColor; 172 private ColorFilter mThemeColorFilter; 173 174 private final OnClickListener mExpandCollapseButtonListener = new OnClickListener() { 175 @Override 176 public void onClick(View v) { 177 if (mIsExpanded) { 178 collapse(); 179 } else { 180 expand(); 181 } 182 } 183 }; 184 185 public ExpandingEntryCardView(Context context) { 186 this(context, null); 187 } 188 189 public ExpandingEntryCardView(Context context, AttributeSet attrs) { 190 super(context, attrs); 191 LayoutInflater inflater = LayoutInflater.from(context); 192 View expandingEntryCardView = inflater.inflate(R.layout.expanding_entry_card_view, this); 193 mEntriesViewGroup = (LinearLayout) 194 expandingEntryCardView.findViewById(R.id.content_area_linear_layout); 195 mTitleTextView = (TextView) expandingEntryCardView.findViewById(R.id.title); 196 mCollapseArrowDrawable = 197 getResources().getDrawable(R.drawable.expanding_entry_card_collapse_white_24); 198 mExpandArrowDrawable = 199 getResources().getDrawable(R.drawable.expanding_entry_card_expand_white_24); 200 201 mExpandCollapseButton = inflater.inflate( 202 R.layout.quickcontact_expanding_entry_card_button, this, false); 203 mExpandCollapseTextView = (TextView) mExpandCollapseButton.findViewById(R.id.text); 204 mExpandCollapseButton.setOnClickListener(mExpandCollapseButtonListener); 205 206 207 } 208 209 /** 210 * Sets the Entry list to display. 211 * 212 * @param entries The Entry list to display. 213 */ 214 public void initialize(List<List<Entry>> entries, int numInitialVisibleEntries, 215 boolean isExpanded, ExpandingEntryCardViewListener listener) { 216 LayoutInflater layoutInflater = LayoutInflater.from(getContext()); 217 mIsExpanded = isExpanded; 218 mEntryViews = new ArrayList<List<View>>(entries.size()); 219 mEntries = entries; 220 mNumEntries = 0; 221 mAllEntriesInflated = false; 222 for (List<Entry> entryList : mEntries) { 223 mNumEntries += entryList.size(); 224 mEntryViews.add(new ArrayList<View>()); 225 } 226 mCollapsedEntriesCount = Math.min(numInitialVisibleEntries, mNumEntries); 227 // Only show the head of each entry list if the initial visible number falls between the 228 // number of lists and the total number of entries 229 if (mCollapsedEntriesCount > mEntries.size()) { 230 mCollapsedEntriesCount = mEntries.size(); 231 } 232 mListener = listener; 233 234 if (mIsExpanded) { 235 updateExpandCollapseButton(getCollapseButtonText()); 236 inflateAllEntries(layoutInflater); 237 } else { 238 updateExpandCollapseButton(getExpandButtonText()); 239 inflateInitialEntries(layoutInflater); 240 } 241 insertEntriesIntoViewGroup(); 242 applyColor(); 243 } 244 245 /** 246 * Sets the text for the expand button. 247 * 248 * @param expandButtonText The expand button text. 249 */ 250 public void setExpandButtonText(CharSequence expandButtonText) { 251 mExpandButtonText = expandButtonText; 252 if (mExpandCollapseTextView != null && !mIsExpanded) { 253 mExpandCollapseTextView.setText(expandButtonText); 254 } 255 } 256 257 /** 258 * Sets the text for the expand button. 259 * 260 * @param expandButtonText The expand button text. 261 */ 262 public void setCollapseButtonText(CharSequence expandButtonText) { 263 mCollapseButtonText = expandButtonText; 264 if (mExpandCollapseTextView != null && mIsExpanded) { 265 mExpandCollapseTextView.setText(mCollapseButtonText); 266 } 267 } 268 269 @Override 270 public void setOnClickListener(OnClickListener listener) { 271 mOnClickListener = listener; 272 } 273 274 private void insertEntriesIntoViewGroup() { 275 mEntriesViewGroup.removeAllViews(); 276 277 if (mIsExpanded) { 278 for (List<View> viewList : mEntryViews) { 279 for (View view : viewList) { 280 addEntry(view); 281 } 282 } 283 } else { 284 for (int i = 0; i < mCollapsedEntriesCount; i++) { 285 addEntry(mEntryViews.get(i).get(0)); 286 } 287 } 288 289 removeView(mExpandCollapseButton); 290 if (mCollapsedEntriesCount < mNumEntries 291 && mExpandCollapseButton.getParent() == null) { 292 addView(mExpandCollapseButton, -1); 293 } 294 } 295 296 private void addEntry(View entry) { 297 if (mEntriesViewGroup.getChildCount() > 0) { 298 View separator = new View(getContext()); 299 separator.setBackgroundColor(getResources().getColor( 300 R.color.expanding_entry_card_item_separator_color)); 301 LayoutParams layoutParams = generateDefaultLayoutParams(); 302 Resources resources = getResources(); 303 layoutParams.height = resources.getDimensionPixelSize( 304 R.dimen.expanding_entry_card_item_separator_height); 305 // The separator is aligned with the text in the entry. This is offset by a default 306 // margin. If there is an icon present, the icon's width and margin are added 307 int marginStart = resources.getDimensionPixelSize( 308 R.dimen.expanding_entry_card_item_padding_start); 309 ImageView entryIcon = (ImageView) entry.findViewById(R.id.icon); 310 if (entryIcon.getDrawable() != null) { 311 int imageWidthAndMargin = 312 resources.getDimensionPixelSize( 313 R.dimen.expanding_entry_card_item_icon_width) + 314 resources.getDimensionPixelSize( 315 R.dimen.expanding_entry_card_item_image_spacing); 316 marginStart += imageWidthAndMargin; 317 } 318 if (getLayoutDirection() == View.LAYOUT_DIRECTION_RTL) { 319 layoutParams.rightMargin = marginStart; 320 } else { 321 layoutParams.leftMargin = marginStart; 322 } 323 separator.setLayoutParams(layoutParams); 324 mEntriesViewGroup.addView(separator); 325 } 326 mEntriesViewGroup.addView(entry); 327 } 328 329 private CharSequence getExpandButtonText() { 330 if (!TextUtils.isEmpty(mExpandButtonText)) { 331 return mExpandButtonText; 332 } else { 333 // Default to "See more". 334 return getResources().getText(R.string.expanding_entry_card_view_see_more); 335 } 336 } 337 338 private CharSequence getCollapseButtonText() { 339 if (!TextUtils.isEmpty(mCollapseButtonText)) { 340 return mCollapseButtonText; 341 } else { 342 // Default to "See less". 343 return getResources().getText(R.string.expanding_entry_card_view_see_less); 344 } 345 } 346 347 /** 348 * Inflates the initial entries to be shown. 349 */ 350 private void inflateInitialEntries(LayoutInflater layoutInflater) { 351 // If the number of collapsed entries equals total entries, inflate all 352 if (mCollapsedEntriesCount == mNumEntries) { 353 inflateAllEntries(layoutInflater); 354 } else { 355 // Otherwise inflate the top entry from each list 356 for (int i = 0; i < mCollapsedEntriesCount; i++) { 357 mEntryViews.get(i).add(createEntryView(layoutInflater, mEntries.get(i).get(0))); 358 } 359 } 360 } 361 362 /** 363 * Inflates all entries. 364 */ 365 private void inflateAllEntries(LayoutInflater layoutInflater) { 366 if (mAllEntriesInflated) { 367 return; 368 } 369 for (int i = 0; i < mEntries.size(); i++) { 370 List<Entry> entryList = mEntries.get(i); 371 List<View> viewList = mEntryViews.get(i); 372 for (int j = viewList.size(); j < entryList.size(); j++) { 373 viewList.add(createEntryView(layoutInflater, entryList.get(j))); 374 } 375 } 376 mAllEntriesInflated = true; 377 } 378 379 public void setColorAndFilter(int color, ColorFilter colorFilter) { 380 mThemeColor = color; 381 mThemeColorFilter = colorFilter; 382 applyColor(); 383 } 384 385 public void setEntryHeaderColor(int color) { 386 if (mEntries != null) { 387 for (List<View> entryList : mEntryViews) { 388 for (View entryView : entryList) { 389 TextView header = (TextView) entryView.findViewById(R.id.header); 390 if (header != null) { 391 header.setTextColor(color); 392 } 393 } 394 } 395 } 396 } 397 398 /** 399 * The ColorFilter is passed in along with the color so that a new one only needs to be created 400 * once for the entire activity. 401 * 1. Title 402 * 2. Entry icons 403 * 3. Expand/Collapse Text 404 * 4. Expand/Collapse Button 405 */ 406 public void applyColor() { 407 if (mThemeColor != 0 && mThemeColorFilter != null) { 408 // Title 409 if (mTitleTextView != null) { 410 mTitleTextView.setTextColor(mThemeColor); 411 } 412 413 // Entry icons 414 if (mEntries != null) { 415 for (List<Entry> entryList : mEntries) { 416 for (Entry entry : entryList) { 417 if (entry.shouldApplyColor()) { 418 Drawable icon = entry.getIcon(); 419 if (icon != null) { 420 icon.setColorFilter(mThemeColorFilter); 421 } 422 } 423 Drawable alternateIcon = entry.getAlternateIcon(); 424 if (alternateIcon != null) { 425 alternateIcon.setColorFilter(mThemeColorFilter); 426 } 427 } 428 } 429 } 430 431 // Expand/Collapse 432 mExpandCollapseTextView.setTextColor(mThemeColor); 433 mCollapseArrowDrawable.setColorFilter(mThemeColorFilter); 434 mExpandArrowDrawable.setColorFilter(mThemeColorFilter); 435 } 436 } 437 438 // TODO add accessibility content descriptions 439 private View createEntryView(LayoutInflater layoutInflater, Entry entry) { 440 final View view = layoutInflater.inflate( 441 R.layout.expanding_entry_card_item, this, false); 442 443 view.setId(entry.getViewId()); 444 445 final ImageView icon = (ImageView) view.findViewById(R.id.icon); 446 if (entry.getIcon() != null) { 447 icon.setImageDrawable(entry.getIcon()); 448 } else { 449 icon.setVisibility(View.GONE); 450 } 451 452 final TextView header = (TextView) view.findViewById(R.id.header); 453 if (entry.getHeader() != null) { 454 header.setText(entry.getHeader()); 455 } else { 456 header.setVisibility(View.GONE); 457 } 458 459 final TextView subHeader = (TextView) view.findViewById(R.id.sub_header); 460 if (entry.getSubHeader() != null) { 461 subHeader.setText(entry.getSubHeader()); 462 } else { 463 subHeader.setVisibility(View.GONE); 464 } 465 466 final ImageView subHeaderIcon = (ImageView) view.findViewById(R.id.icon_sub_header); 467 if (entry.getSubHeaderIcon() != null) { 468 subHeaderIcon.setImageDrawable(entry.getSubHeaderIcon()); 469 } else { 470 subHeaderIcon.setVisibility(View.GONE); 471 } 472 473 final TextView text = (TextView) view.findViewById(R.id.text); 474 if (entry.getText() != null) { 475 text.setText(entry.getText()); 476 } else { 477 text.setVisibility(View.GONE); 478 } 479 480 final ImageView textIcon = (ImageView) view.findViewById(R.id.icon_text); 481 if (entry.getTextIcon() != null) { 482 textIcon.setImageDrawable(entry.getTextIcon()); 483 } else { 484 textIcon.setVisibility(View.GONE); 485 } 486 487 if (entry.getIntent() != null) { 488 view.setOnClickListener(mOnClickListener); 489 view.setTag(entry.getIntent()); 490 } 491 492 final ImageView alternateIcon = (ImageView) view.findViewById(R.id.icon_alternate); 493 if (entry.getAlternateIcon() != null && entry.getAlternateIntent() != null) { 494 alternateIcon.setImageDrawable(entry.getAlternateIcon()); 495 alternateIcon.setOnClickListener(mOnClickListener); 496 alternateIcon.setTag(entry.getAlternateIntent()); 497 alternateIcon.setId(entry.getViewId()); 498 alternateIcon.setVisibility(View.VISIBLE); 499 alternateIcon.setContentDescription(entry.getAlternateContentDescription()); 500 501 // Expand the clickable area for alternate icon to be top to bottom and to right edge 502 // of the entry view 503 view.post(new Runnable() { 504 @Override 505 public void run() { 506 final Rect entryRect = new Rect(); 507 view.getHitRect(entryRect); 508 509 final Rect alternateIconRect = new Rect(); 510 alternateIcon.getHitRect(alternateIconRect); 511 alternateIconRect.bottom = entryRect.bottom; 512 alternateIconRect.top = entryRect.top; 513 if (getLayoutDirection() == View.LAYOUT_DIRECTION_RTL) { 514 alternateIconRect.left = entryRect.left; 515 } else { 516 alternateIconRect.right = entryRect.right; 517 } 518 final TouchDelegate touchDelegate = 519 new TouchDelegate(alternateIconRect, alternateIcon); 520 view.setTouchDelegate(touchDelegate); 521 } 522 }); 523 } 524 525 return view; 526 } 527 528 private void updateExpandCollapseButton(CharSequence buttonText) { 529 final Drawable arrow = mIsExpanded ? mCollapseArrowDrawable : mExpandArrowDrawable; 530 if (getLayoutDirection() == View.LAYOUT_DIRECTION_RTL) { 531 mExpandCollapseTextView.setCompoundDrawablesWithIntrinsicBounds(null, null, arrow, 532 null); 533 } else { 534 mExpandCollapseTextView.setCompoundDrawablesWithIntrinsicBounds(arrow, null, null, 535 null); 536 } 537 mExpandCollapseTextView.setText(buttonText); 538 } 539 540 private void expand() { 541 final int startingHeight = mEntriesViewGroup.getHeight(); 542 543 mIsExpanded = true; 544 // In order to insert new entries, we may need to inflate them for the first time 545 inflateAllEntries(LayoutInflater.from(getContext())); 546 insertEntriesIntoViewGroup(); 547 updateExpandCollapseButton(getCollapseButtonText()); 548 549 // When expanding, all the TextViews haven't been laid out yet. Therefore, 550 // calling measure() would return an incorrect result. Therefore, we need a pre draw 551 // listener. 552 final ViewTreeObserver observer = mEntriesViewGroup.getViewTreeObserver(); 553 observer.addOnPreDrawListener(new OnPreDrawListener() { 554 @Override 555 public boolean onPreDraw() { 556 if (observer.isAlive()) { 557 mEntriesViewGroup.getViewTreeObserver().removeOnPreDrawListener(this); 558 } 559 createExpandAnimator(startingHeight, mEntriesViewGroup.getHeight()).start(); 560 // Do not draw the final frame of the animation immediately. 561 return false; 562 } 563 }); 564 } 565 566 private void collapse() { 567 int startingHeight = mEntriesViewGroup.getHeight(); 568 int finishHeight = measureCollapsedViewGroupHeight(); 569 mListener.onCollapse(startingHeight - finishHeight); 570 571 mIsExpanded = false; 572 updateExpandCollapseButton(getExpandButtonText()); 573 createExpandAnimator(startingHeight, finishHeight).start(); 574 } 575 576 private int measureCollapsedViewGroupHeight() { 577 if (mCollapsedEntriesCount == 0) { 578 return 0; 579 } 580 final View bottomCollapsedView = mEntryViews.get(mCollapsedEntriesCount - 1).get(0); 581 return bottomCollapsedView.getTop() + bottomCollapsedView.getHeight(); 582 } 583 584 /** 585 * Create ValueAnimator that performs an expand animation on the content LinearLayout. 586 * 587 * The animation needs to be performed manually using a ValueAnimator, since LinearLayout 588 * doesn't have a single set-able height property (ie, no setHeight()). 589 */ 590 private ValueAnimator createExpandAnimator(int start, int end) { 591 ValueAnimator animator = ValueAnimator.ofInt(start, end); 592 animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { 593 @Override 594 public void onAnimationUpdate(ValueAnimator valueAnimator) { 595 int value = (Integer) valueAnimator.getAnimatedValue(); 596 ViewGroup.LayoutParams layoutParams = mEntriesViewGroup.getLayoutParams(); 597 layoutParams.height = value; 598 mEntriesViewGroup.setLayoutParams(layoutParams); 599 } 600 }); 601 animator.addListener(new AnimatorListenerAdapter() { 602 @Override 603 public void onAnimationEnd(Animator animation) { 604 insertEntriesIntoViewGroup(); 605 // Now that the animation is done, stop using a fixed height. 606 ViewGroup.LayoutParams layoutParams = mEntriesViewGroup.getLayoutParams(); 607 layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT; 608 mEntriesViewGroup.setLayoutParams(layoutParams); 609 } 610 }); 611 return animator; 612 } 613 614 /** 615 * Returns whether the view is currently in its expanded state. 616 */ 617 public boolean isExpanded() { 618 return mIsExpanded; 619 } 620 621 /** 622 * Sets the title text of this ExpandingEntryCardView. 623 * @param title The title to set. A null title will result in the title being removed. 624 */ 625 public void setTitle(String title) { 626 if (mTitleTextView == null) { 627 Log.e(TAG, "mTitleTextView is null"); 628 } 629 if (title == null) { 630 mTitleTextView.setVisibility(View.GONE); 631 findViewById(R.id.title_separator).setVisibility(View.GONE); 632 } 633 mTitleTextView.setText(title); 634 mTitleTextView.setVisibility(View.VISIBLE); 635 findViewById(R.id.title_separator).setVisibility(View.VISIBLE); 636 } 637 638 public boolean shouldShow() { 639 return mEntries != null && mEntries.size() > 0; 640 } 641} 642