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