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