DropdownChipLayouter.java revision 0d4d806606fa8210ad09430fab3a230a1c4e3681
1package com.android.ex.chips;
2
3import android.content.Context;
4import android.content.res.Resources;
5import android.graphics.Bitmap;
6import android.graphics.BitmapFactory;
7import android.graphics.Color;
8import android.graphics.PorterDuff;
9import android.graphics.drawable.Drawable;
10import android.graphics.drawable.StateListDrawable;
11import android.net.Uri;
12import android.support.annotation.DrawableRes;
13import android.support.annotation.IdRes;
14import android.support.annotation.LayoutRes;
15import android.support.annotation.Nullable;
16import android.support.v4.view.MarginLayoutParamsCompat;
17import android.text.SpannableStringBuilder;
18import android.text.Spanned;
19import android.text.TextUtils;
20import android.text.style.ForegroundColorSpan;
21import android.text.util.Rfc822Tokenizer;
22import android.view.LayoutInflater;
23import android.view.View;
24import android.view.View.OnClickListener;
25import android.view.ViewGroup;
26import android.view.ViewGroup.MarginLayoutParams;
27import android.widget.ImageView;
28import android.widget.TextView;
29
30import com.android.ex.chips.Queries.Query;
31
32/**
33 * A class that inflates and binds the views in the dropdown list from
34 * RecipientEditTextView.
35 */
36public class DropdownChipLayouter {
37    /**
38     * The type of adapter that is requesting a chip layout.
39     */
40    public enum AdapterType {
41        BASE_RECIPIENT,
42        RECIPIENT_ALTERNATES,
43        SINGLE_RECIPIENT
44    }
45
46    public interface ChipDeleteListener {
47        void onChipDelete();
48    }
49
50    /**
51     * Listener that handles the dismisses of the entries of the
52     * {@link RecipientEntry#ENTRY_TYPE_PERMISSION_REQUEST} type.
53     */
54    public interface PermissionRequestDismissedListener {
55
56        /**
57         * Callback that occurs when user dismisses the item that asks user to grant permissions to
58         * the app.
59         */
60        void onPermissionRequestDismissed();
61    }
62
63    private final LayoutInflater mInflater;
64    private final Context mContext;
65    private ChipDeleteListener mDeleteListener;
66    private PermissionRequestDismissedListener mPermissionRequestDismissedListener;
67    private Query mQuery;
68    private int mAutocompleteDividerMarginStart;
69
70    public DropdownChipLayouter(LayoutInflater inflater, Context context) {
71        mInflater = inflater;
72        mContext = context;
73        mAutocompleteDividerMarginStart =
74                context.getResources().getDimensionPixelOffset(R.dimen.chip_wrapper_start_padding);
75    }
76
77    public void setQuery(Query query) {
78        mQuery = query;
79    }
80
81    public void setDeleteListener(ChipDeleteListener listener) {
82        mDeleteListener = listener;
83    }
84
85    public void setPermissionRequestDismissedListener(PermissionRequestDismissedListener listener) {
86        mPermissionRequestDismissedListener = listener;
87    }
88
89    public void setAutocompleteDividerMarginStart(int autocompleteDividerMarginStart) {
90        mAutocompleteDividerMarginStart = autocompleteDividerMarginStart;
91    }
92
93    /**
94     * Layouts and binds recipient information to the view. If convertView is null, inflates a new
95     * view with getItemLaytout().
96     *
97     * @param convertView The view to bind information to.
98     * @param parent The parent to bind the view to if we inflate a new view.
99     * @param entry The recipient entry to get information from.
100     * @param position The position in the list.
101     * @param type The adapter type that is requesting the bind.
102     * @param constraint The constraint typed in the auto complete view.
103     *
104     * @return A view ready to be shown in the drop down list.
105     */
106    public View bindView(View convertView, ViewGroup parent, RecipientEntry entry, int position,
107        AdapterType type, String constraint) {
108        return bindView(convertView, parent, entry, position, type, constraint, null);
109    }
110
111    /**
112     * See {@link #bindView(View, ViewGroup, RecipientEntry, int, AdapterType, String)}
113     * @param deleteDrawable a {@link android.graphics.drawable.StateListDrawable} representing
114     *     the delete icon. android.R.attr.state_activated should map to the delete icon, and the
115     *     default state can map to a drawable of your choice (or null for no drawable).
116     */
117    public View bindView(View convertView, ViewGroup parent, RecipientEntry entry, int position,
118            AdapterType type, String constraint, StateListDrawable deleteDrawable) {
119        // Default to show all the information
120        CharSequence[] styledResults =
121                getStyledResults(constraint, entry.getDisplayName(), entry.getDestination());
122        CharSequence displayName = styledResults[0];
123        CharSequence destination = styledResults[1];
124        boolean showImage = true;
125        CharSequence destinationType = getDestinationType(entry);
126
127        final View itemView = reuseOrInflateView(convertView, parent, type);
128
129        final ViewHolder viewHolder = new ViewHolder(itemView);
130
131        // Hide some information depending on the adapter type.
132        switch (type) {
133            case BASE_RECIPIENT:
134                if (TextUtils.isEmpty(displayName) || TextUtils.equals(displayName, destination)) {
135                    displayName = destination;
136
137                    // We only show the destination for secondary entries, so clear it only for the
138                    // first level.
139                    if (entry.isFirstLevel()) {
140                        destination = null;
141                    }
142                }
143
144                if (!entry.isFirstLevel()) {
145                    displayName = null;
146                    showImage = false;
147                }
148
149                // For BASE_RECIPIENT set all top dividers except for the first one to be GONE.
150                if (viewHolder.topDivider != null) {
151                    viewHolder.topDivider.setVisibility(position == 0 ? View.VISIBLE : View.GONE);
152                    MarginLayoutParamsCompat.setMarginStart(
153                            (MarginLayoutParams) viewHolder.topDivider.getLayoutParams(),
154                            mAutocompleteDividerMarginStart);
155                }
156                if (viewHolder.bottomDivider != null) {
157                    MarginLayoutParamsCompat.setMarginStart(
158                            (MarginLayoutParams) viewHolder.bottomDivider.getLayoutParams(),
159                            mAutocompleteDividerMarginStart);
160                }
161                break;
162            case RECIPIENT_ALTERNATES:
163                if (position != 0) {
164                    displayName = null;
165                    showImage = false;
166                }
167                break;
168            case SINGLE_RECIPIENT:
169                destination = Rfc822Tokenizer.tokenize(entry.getDestination())[0].getAddress();
170                destinationType = null;
171        }
172
173        // Bind the information to the view
174        bindTextToView(displayName, viewHolder.displayNameView);
175        bindTextToView(destination, viewHolder.destinationView);
176        bindTextToView(destinationType, viewHolder.destinationTypeView);
177        bindIconToView(showImage, entry, viewHolder.imageView, type);
178        bindDrawableToDeleteView(deleteDrawable, entry.getDisplayName(), viewHolder.deleteView);
179        bindIndicatorToView(
180                entry.getIndicatorIconId(), entry.getIndicatorText(), viewHolder.indicatorView);
181        bindPermissionRequestDismissView(viewHolder.permissionRequestDismissView);
182
183        // Hide some view groups depending on the entry type
184        final int entryType = entry.getEntryType();
185        if (entryType == RecipientEntry.ENTRY_TYPE_PERSON) {
186            setViewVisibility(viewHolder.personViewGroup, View.VISIBLE);
187            setViewVisibility(viewHolder.permissionViewGroup, View.GONE);
188        } else if (entryType == RecipientEntry.ENTRY_TYPE_PERMISSION_REQUEST) {
189            setViewVisibility(viewHolder.personViewGroup, View.GONE);
190            setViewVisibility(viewHolder.permissionViewGroup, View.VISIBLE);
191        }
192
193        return itemView;
194    }
195
196    /**
197     * Returns a new view with {@link #getItemLayoutResId(AdapterType)}.
198     */
199    public View newView(AdapterType type) {
200        return mInflater.inflate(getItemLayoutResId(type), null);
201    }
202
203    /**
204     * Returns the same view, or inflates a new one if the given view was null.
205     */
206    protected View reuseOrInflateView(View convertView, ViewGroup parent, AdapterType type) {
207        int itemLayout = getItemLayoutResId(type);
208        switch (type) {
209            case BASE_RECIPIENT:
210            case RECIPIENT_ALTERNATES:
211                break;
212            case SINGLE_RECIPIENT:
213                itemLayout = getAlternateItemLayoutResId(type);
214                break;
215        }
216        return convertView != null ? convertView : mInflater.inflate(itemLayout, parent, false);
217    }
218
219    /**
220     * Binds the text to the given text view. If the text was null, hides the text view.
221     */
222    protected void bindTextToView(CharSequence text, TextView view) {
223        if (view == null) {
224            return;
225        }
226
227        if (text != null) {
228            view.setText(text);
229            view.setVisibility(View.VISIBLE);
230        } else {
231            view.setVisibility(View.GONE);
232        }
233    }
234
235    /**
236     * Binds the avatar icon to the image view. If we don't want to show the image, hides the
237     * image view.
238     */
239    protected void bindIconToView(boolean showImage, RecipientEntry entry, ImageView view,
240        AdapterType type) {
241        if (view == null) {
242            return;
243        }
244
245        if (showImage) {
246            switch (type) {
247                case BASE_RECIPIENT:
248                    byte[] photoBytes = entry.getPhotoBytes();
249                    if (photoBytes != null && photoBytes.length > 0) {
250                        final Bitmap photo = BitmapFactory.decodeByteArray(photoBytes, 0,
251                            photoBytes.length);
252                        view.setImageBitmap(photo);
253                    } else {
254                        view.setImageResource(getDefaultPhotoResId());
255                    }
256                    break;
257                case RECIPIENT_ALTERNATES:
258                    Uri thumbnailUri = entry.getPhotoThumbnailUri();
259                    if (thumbnailUri != null) {
260                        // TODO: see if this needs to be done outside the main thread
261                        // as it may be too slow to get immediately.
262                        view.setImageURI(thumbnailUri);
263                    } else {
264                        view.setImageResource(getDefaultPhotoResId());
265                    }
266                    break;
267                case SINGLE_RECIPIENT:
268                default:
269                    break;
270            }
271            view.setVisibility(View.VISIBLE);
272        } else {
273            view.setVisibility(View.GONE);
274        }
275    }
276
277    protected void bindDrawableToDeleteView(final StateListDrawable drawable, String recipient,
278            ImageView view) {
279        if (view == null) {
280            return;
281        }
282        if (drawable == null) {
283            view.setVisibility(View.GONE);
284        } else {
285            final Resources res = mContext.getResources();
286            view.setImageDrawable(drawable);
287            view.setContentDescription(
288                    res.getString(R.string.dropdown_delete_button_desc, recipient));
289            if (mDeleteListener != null) {
290                view.setOnClickListener(new View.OnClickListener() {
291                    @Override
292                    public void onClick(View view) {
293                        if (drawable.getCurrent() != null) {
294                            mDeleteListener.onChipDelete();
295                        }
296                    }
297                });
298            }
299        }
300    }
301
302    protected void bindIndicatorToView(
303            @DrawableRes int indicatorIconId, String indicatorText, TextView view) {
304        if (view != null) {
305            if (indicatorText != null || indicatorIconId != 0) {
306                view.setText(indicatorText);
307                view.setVisibility(View.VISIBLE);
308                final Drawable indicatorIcon;
309                if (indicatorIconId != 0) {
310                    indicatorIcon = mContext.getDrawable(indicatorIconId).mutate();
311                    indicatorIcon.setColorFilter(Color.WHITE, PorterDuff.Mode.SRC_IN);
312                } else {
313                    indicatorIcon = null;
314                }
315                view.setCompoundDrawablesRelativeWithIntrinsicBounds(
316                        indicatorIcon, null, null, null);
317            } else {
318                view.setVisibility(View.GONE);
319            }
320        }
321    }
322
323    protected void bindPermissionRequestDismissView(ImageView view) {
324        if (view == null) {
325            return;
326        }
327        view.setOnClickListener(new OnClickListener() {
328            @Override
329            public void onClick(View v) {
330                if (mPermissionRequestDismissedListener != null) {
331                    mPermissionRequestDismissedListener.onPermissionRequestDismissed();
332                }
333            }
334        });
335    }
336
337    protected void setViewVisibility(View view, int visibility) {
338        if (view != null) {
339            view.setVisibility(visibility);
340        }
341    }
342
343    protected CharSequence getDestinationType(RecipientEntry entry) {
344        return mQuery.getTypeLabel(mContext.getResources(), entry.getDestinationType(),
345            entry.getDestinationLabel()).toString().toUpperCase();
346    }
347
348    /**
349     * Returns a layout id for each item inside auto-complete list.
350     *
351     * Each View must contain two TextViews (for display name and destination) and one ImageView
352     * (for photo). Ids for those should be available via {@link #getDisplayNameResId()},
353     * {@link #getDestinationResId()}, and {@link #getPhotoResId()}.
354     */
355    protected @LayoutRes int getItemLayoutResId(AdapterType type) {
356        switch (type) {
357            case BASE_RECIPIENT:
358                return R.layout.chips_autocomplete_recipient_dropdown_item;
359            case RECIPIENT_ALTERNATES:
360                return R.layout.chips_recipient_dropdown_item;
361            default:
362                return R.layout.chips_recipient_dropdown_item;
363        }
364    }
365
366    /**
367     * Returns a layout id for each item inside alternate auto-complete list.
368     *
369     * Each View must contain two TextViews (for display name and destination) and one ImageView
370     * (for photo). Ids for those should be available via {@link #getDisplayNameResId()},
371     * {@link #getDestinationResId()}, and {@link #getPhotoResId()}.
372     */
373    protected @LayoutRes int getAlternateItemLayoutResId(AdapterType type) {
374        switch (type) {
375            case BASE_RECIPIENT:
376                return R.layout.chips_autocomplete_recipient_dropdown_item;
377            case RECIPIENT_ALTERNATES:
378                return R.layout.chips_recipient_dropdown_item;
379            default:
380                return R.layout.chips_recipient_dropdown_item;
381        }
382    }
383
384    /**
385     * Returns a resource ID representing an image which should be shown when ther's no relevant
386     * photo is available.
387     */
388    protected @DrawableRes int getDefaultPhotoResId() {
389        return R.drawable.ic_contact_picture;
390    }
391
392    /**
393     * Returns an id for the ViewGroup in an item View that contains the person ui elements.
394     */
395    protected @IdRes int getPersonGroupResId() {
396        return R.id.chip_person_wrapper;
397    }
398
399    /**
400     * Returns an id for TextView in an item View for showing a display name. By default
401     * {@link android.R.id#title} is returned.
402     */
403    protected @IdRes int getDisplayNameResId() {
404        return android.R.id.title;
405    }
406
407    /**
408     * Returns an id for TextView in an item View for showing a destination
409     * (an email address or a phone number).
410     * By default {@link android.R.id#text1} is returned.
411     */
412    protected @IdRes int getDestinationResId() {
413        return android.R.id.text1;
414    }
415
416    /**
417     * Returns an id for TextView in an item View for showing the type of the destination.
418     * By default {@link android.R.id#text2} is returned.
419     */
420    protected @IdRes int getDestinationTypeResId() {
421        return android.R.id.text2;
422    }
423
424    /**
425     * Returns an id for ImageView in an item View for showing photo image for a person. In default
426     * {@link android.R.id#icon} is returned.
427     */
428    protected @IdRes int getPhotoResId() {
429        return android.R.id.icon;
430    }
431
432    /**
433     * Returns an id for ImageView in an item View for showing the delete button. In default
434     * {@link android.R.id#icon1} is returned.
435     */
436    protected @IdRes int getDeleteResId() { return android.R.id.icon1; }
437
438    /**
439     * Returns an id for the ViewGroup in an item View that contains the request permission ui
440     * elements.
441     */
442    protected @IdRes int getPermissionGroupResId() {
443        return R.id.chip_permission_wrapper;
444    }
445
446    /**
447     * Returns an id for ImageView in an item View for dismissing the permission request. In default
448     * {@link android.R.id#icon2} is returned.
449     */
450    protected @IdRes int getPermissionRequestDismissResId() {
451        return android.R.id.icon2;
452    }
453
454    /**
455     * Given a constraint and results, tries to find the constraint in those results, one at a time.
456     * A foreground font color style will be applied to the section that matches the constraint. As
457     * soon as a match has been found, no further matches are attempted.
458     *
459     * @param constraint A string that we will attempt to find within the results.
460     * @param results Strings that may contain the constraint. The order given is the order used to
461     *     search for the constraint.
462     *
463     * @return An array of CharSequences, the length determined by the length of results. Each
464     *     CharSequence will either be a styled SpannableString or just the input String.
465     */
466    protected CharSequence[] getStyledResults(@Nullable String constraint, String... results) {
467        if (isAllWhitespace(constraint)) {
468            return results;
469        }
470
471        CharSequence[] styledResults = new CharSequence[results.length];
472        boolean foundMatch = false;
473        for (int i = 0; i < results.length; i++) {
474            String result = results[i];
475            if (result == null) {
476                continue;
477            }
478
479            if (!foundMatch) {
480                int index = result.toLowerCase().indexOf(constraint.toLowerCase());
481                if (index != -1) {
482                    SpannableStringBuilder styled = SpannableStringBuilder.valueOf(result);
483                    ForegroundColorSpan highlightSpan =
484                            new ForegroundColorSpan(mContext.getResources().getColor(
485                                    R.color.chips_dropdown_text_highlighted));
486                    styled.setSpan(highlightSpan,
487                            index, index + constraint.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
488                    styledResults[i] = styled;
489                    foundMatch = true;
490                    continue;
491                }
492            }
493            styledResults[i] = result;
494        }
495        return styledResults;
496    }
497
498    private static boolean isAllWhitespace(@Nullable String string) {
499        if (TextUtils.isEmpty(string)) {
500            return true;
501        }
502
503        for (int i = 0; i < string.length(); ++i) {
504            if (!Character.isWhitespace(string.charAt(i))) {
505                return false;
506            }
507        }
508
509        return true;
510    }
511
512    /**
513     * A holder class the view. Uses the getters in DropdownChipLayouter to find the id of the
514     * corresponding views.
515     */
516    protected class ViewHolder {
517        public final ViewGroup personViewGroup;
518        public final TextView displayNameView;
519        public final TextView destinationView;
520        public final TextView destinationTypeView;
521        public final TextView indicatorView;
522        public final ImageView imageView;
523        public final ImageView deleteView;
524        public final View topDivider;
525        public final View bottomDivider;
526
527        public final ViewGroup permissionViewGroup;
528        public final ImageView permissionRequestDismissView;
529
530        public ViewHolder(View view) {
531            personViewGroup = (ViewGroup) view.findViewById(getPersonGroupResId());
532            displayNameView = (TextView) view.findViewById(getDisplayNameResId());
533            destinationView = (TextView) view.findViewById(getDestinationResId());
534            destinationTypeView = (TextView) view.findViewById(getDestinationTypeResId());
535            imageView = (ImageView) view.findViewById(getPhotoResId());
536            deleteView = (ImageView) view.findViewById(getDeleteResId());
537            topDivider = view.findViewById(R.id.chip_autocomplete_top_divider);
538            bottomDivider = view.findViewById(R.id.chip_autocomplete_bottom_divider);
539            indicatorView = (TextView) view.findViewById(R.id.chip_indicator_text);
540
541            permissionViewGroup = (ViewGroup) view.findViewById(getPermissionGroupResId());
542            permissionRequestDismissView =
543                    (ImageView) view.findViewById(getPermissionRequestDismissResId());
544        }
545    }
546}
547