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