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