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