DropdownChipLayouter.java revision e78ad406f0b2ee0d031b71b33e6094a6961f7c79
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            setViewVisibility(viewHolder.permissionBottomDivider, View.GONE);
189        } else if (entryType == RecipientEntry.ENTRY_TYPE_PERMISSION_REQUEST) {
190            setViewVisibility(viewHolder.personViewGroup, View.GONE);
191            setViewVisibility(viewHolder.permissionViewGroup, View.VISIBLE);
192            setViewVisibility(viewHolder.permissionBottomDivider, View.VISIBLE);
193        }
194
195        return itemView;
196    }
197
198    /**
199     * Returns a new view with {@link #getItemLayoutResId(AdapterType)}.
200     */
201    public View newView(AdapterType type) {
202        return mInflater.inflate(getItemLayoutResId(type), null);
203    }
204
205    /**
206     * Returns the same view, or inflates a new one if the given view was null.
207     */
208    protected View reuseOrInflateView(View convertView, ViewGroup parent, AdapterType type) {
209        int itemLayout = getItemLayoutResId(type);
210        switch (type) {
211            case BASE_RECIPIENT:
212            case RECIPIENT_ALTERNATES:
213                break;
214            case SINGLE_RECIPIENT:
215                itemLayout = getAlternateItemLayoutResId(type);
216                break;
217        }
218        return convertView != null ? convertView : mInflater.inflate(itemLayout, parent, false);
219    }
220
221    /**
222     * Binds the text to the given text view. If the text was null, hides the text view.
223     */
224    protected void bindTextToView(CharSequence text, TextView view) {
225        if (view == null) {
226            return;
227        }
228
229        if (text != null) {
230            view.setText(text);
231            view.setVisibility(View.VISIBLE);
232        } else {
233            view.setVisibility(View.GONE);
234        }
235    }
236
237    /**
238     * Binds the avatar icon to the image view. If we don't want to show the image, hides the
239     * image view.
240     */
241    protected void bindIconToView(boolean showImage, RecipientEntry entry, ImageView view,
242        AdapterType type) {
243        if (view == null) {
244            return;
245        }
246
247        if (showImage) {
248            switch (type) {
249                case BASE_RECIPIENT:
250                    byte[] photoBytes = entry.getPhotoBytes();
251                    if (photoBytes != null && photoBytes.length > 0) {
252                        final Bitmap photo = BitmapFactory.decodeByteArray(photoBytes, 0,
253                            photoBytes.length);
254                        view.setImageBitmap(photo);
255                    } else {
256                        view.setImageResource(getDefaultPhotoResId());
257                    }
258                    break;
259                case RECIPIENT_ALTERNATES:
260                    Uri thumbnailUri = entry.getPhotoThumbnailUri();
261                    if (thumbnailUri != null) {
262                        // TODO: see if this needs to be done outside the main thread
263                        // as it may be too slow to get immediately.
264                        view.setImageURI(thumbnailUri);
265                    } else {
266                        view.setImageResource(getDefaultPhotoResId());
267                    }
268                    break;
269                case SINGLE_RECIPIENT:
270                default:
271                    break;
272            }
273            view.setVisibility(View.VISIBLE);
274        } else {
275            view.setVisibility(View.GONE);
276        }
277    }
278
279    protected void bindDrawableToDeleteView(final StateListDrawable drawable, String recipient,
280            ImageView view) {
281        if (view == null) {
282            return;
283        }
284        if (drawable == null) {
285            view.setVisibility(View.GONE);
286        } else {
287            final Resources res = mContext.getResources();
288            view.setImageDrawable(drawable);
289            view.setContentDescription(
290                    res.getString(R.string.dropdown_delete_button_desc, recipient));
291            if (mDeleteListener != null) {
292                view.setOnClickListener(new View.OnClickListener() {
293                    @Override
294                    public void onClick(View view) {
295                        if (drawable.getCurrent() != null) {
296                            mDeleteListener.onChipDelete();
297                        }
298                    }
299                });
300            }
301        }
302    }
303
304    protected void bindIndicatorToView(
305            @DrawableRes int indicatorIconId, String indicatorText, TextView view) {
306        if (view != null) {
307            if (indicatorText != null || indicatorIconId != 0) {
308                view.setText(indicatorText);
309                view.setVisibility(View.VISIBLE);
310                final Drawable indicatorIcon;
311                if (indicatorIconId != 0) {
312                    indicatorIcon = mContext.getDrawable(indicatorIconId).mutate();
313                    indicatorIcon.setColorFilter(Color.WHITE, PorterDuff.Mode.SRC_IN);
314                } else {
315                    indicatorIcon = null;
316                }
317                view.setCompoundDrawablesRelativeWithIntrinsicBounds(
318                        indicatorIcon, null, null, null);
319            } else {
320                view.setVisibility(View.GONE);
321            }
322        }
323    }
324
325    protected void bindPermissionRequestDismissView(ImageView view) {
326        if (view == null) {
327            return;
328        }
329        view.setOnClickListener(new OnClickListener() {
330            @Override
331            public void onClick(View v) {
332                if (mPermissionRequestDismissedListener != null) {
333                    mPermissionRequestDismissedListener.onPermissionRequestDismissed();
334                }
335            }
336        });
337    }
338
339    protected void setViewVisibility(View view, int visibility) {
340        if (view != null) {
341            view.setVisibility(visibility);
342        }
343    }
344
345    protected CharSequence getDestinationType(RecipientEntry entry) {
346        return mQuery.getTypeLabel(mContext.getResources(), entry.getDestinationType(),
347            entry.getDestinationLabel()).toString().toUpperCase();
348    }
349
350    /**
351     * Returns a layout id for each item inside auto-complete list.
352     *
353     * Each View must contain two TextViews (for display name and destination) and one ImageView
354     * (for photo). Ids for those should be available via {@link #getDisplayNameResId()},
355     * {@link #getDestinationResId()}, and {@link #getPhotoResId()}.
356     */
357    protected @LayoutRes int getItemLayoutResId(AdapterType type) {
358        switch (type) {
359            case BASE_RECIPIENT:
360                return R.layout.chips_autocomplete_recipient_dropdown_item;
361            case RECIPIENT_ALTERNATES:
362                return R.layout.chips_recipient_dropdown_item;
363            default:
364                return R.layout.chips_recipient_dropdown_item;
365        }
366    }
367
368    /**
369     * Returns a layout id for each item inside alternate auto-complete list.
370     *
371     * Each View must contain two TextViews (for display name and destination) and one ImageView
372     * (for photo). Ids for those should be available via {@link #getDisplayNameResId()},
373     * {@link #getDestinationResId()}, and {@link #getPhotoResId()}.
374     */
375    protected @LayoutRes int getAlternateItemLayoutResId(AdapterType type) {
376        switch (type) {
377            case BASE_RECIPIENT:
378                return R.layout.chips_autocomplete_recipient_dropdown_item;
379            case RECIPIENT_ALTERNATES:
380                return R.layout.chips_recipient_dropdown_item;
381            default:
382                return R.layout.chips_recipient_dropdown_item;
383        }
384    }
385
386    /**
387     * Returns a resource ID representing an image which should be shown when ther's no relevant
388     * photo is available.
389     */
390    protected @DrawableRes int getDefaultPhotoResId() {
391        return R.drawable.ic_contact_picture;
392    }
393
394    /**
395     * Returns an id for the ViewGroup in an item View that contains the person ui elements.
396     */
397    protected @IdRes int getPersonGroupResId() {
398        return R.id.chip_person_wrapper;
399    }
400
401    /**
402     * Returns an id for TextView in an item View for showing a display name. By default
403     * {@link android.R.id#title} is returned.
404     */
405    protected @IdRes int getDisplayNameResId() {
406        return android.R.id.title;
407    }
408
409    /**
410     * Returns an id for TextView in an item View for showing a destination
411     * (an email address or a phone number).
412     * By default {@link android.R.id#text1} is returned.
413     */
414    protected @IdRes int getDestinationResId() {
415        return android.R.id.text1;
416    }
417
418    /**
419     * Returns an id for TextView in an item View for showing the type of the destination.
420     * By default {@link android.R.id#text2} is returned.
421     */
422    protected @IdRes int getDestinationTypeResId() {
423        return android.R.id.text2;
424    }
425
426    /**
427     * Returns an id for ImageView in an item View for showing photo image for a person. In default
428     * {@link android.R.id#icon} is returned.
429     */
430    protected @IdRes int getPhotoResId() {
431        return android.R.id.icon;
432    }
433
434    /**
435     * Returns an id for ImageView in an item View for showing the delete button. In default
436     * {@link android.R.id#icon1} is returned.
437     */
438    protected @IdRes int getDeleteResId() { return android.R.id.icon1; }
439
440    /**
441     * Returns an id for the ViewGroup in an item View that contains the request permission ui
442     * elements.
443     */
444    protected @IdRes int getPermissionGroupResId() {
445        return R.id.chip_permission_wrapper;
446    }
447
448    /**
449     * Returns an id for ImageView in an item View for dismissing the permission request. In default
450     * {@link android.R.id#icon2} is returned.
451     */
452    protected @IdRes int getPermissionRequestDismissResId() {
453        return android.R.id.icon2;
454    }
455
456    /**
457     * Given a constraint and results, tries to find the constraint in those results, one at a time.
458     * A foreground font color style will be applied to the section that matches the constraint. As
459     * soon as a match has been found, no further matches are attempted.
460     *
461     * @param constraint A string that we will attempt to find within the results.
462     * @param results Strings that may contain the constraint. The order given is the order used to
463     *     search for the constraint.
464     *
465     * @return An array of CharSequences, the length determined by the length of results. Each
466     *     CharSequence will either be a styled SpannableString or just the input String.
467     */
468    protected CharSequence[] getStyledResults(@Nullable String constraint, String... results) {
469        if (isAllWhitespace(constraint)) {
470            return results;
471        }
472
473        CharSequence[] styledResults = new CharSequence[results.length];
474        boolean foundMatch = false;
475        for (int i = 0; i < results.length; i++) {
476            String result = results[i];
477            if (result == null) {
478                continue;
479            }
480
481            if (!foundMatch) {
482                int index = result.toLowerCase().indexOf(constraint.toLowerCase());
483                if (index != -1) {
484                    SpannableStringBuilder styled = SpannableStringBuilder.valueOf(result);
485                    ForegroundColorSpan highlightSpan =
486                            new ForegroundColorSpan(mContext.getResources().getColor(
487                                    R.color.chips_dropdown_text_highlighted));
488                    styled.setSpan(highlightSpan,
489                            index, index + constraint.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
490                    styledResults[i] = styled;
491                    foundMatch = true;
492                    continue;
493                }
494            }
495            styledResults[i] = result;
496        }
497        return styledResults;
498    }
499
500    private static boolean isAllWhitespace(@Nullable String string) {
501        if (TextUtils.isEmpty(string)) {
502            return true;
503        }
504
505        for (int i = 0; i < string.length(); ++i) {
506            if (!Character.isWhitespace(string.charAt(i))) {
507                return false;
508            }
509        }
510
511        return true;
512    }
513
514    /**
515     * A holder class the view. Uses the getters in DropdownChipLayouter to find the id of the
516     * corresponding views.
517     */
518    protected class ViewHolder {
519        public final ViewGroup personViewGroup;
520        public final TextView displayNameView;
521        public final TextView destinationView;
522        public final TextView destinationTypeView;
523        public final TextView indicatorView;
524        public final ImageView imageView;
525        public final ImageView deleteView;
526        public final View topDivider;
527        public final View bottomDivider;
528        public final View permissionBottomDivider;
529
530        public final ViewGroup permissionViewGroup;
531        public final ImageView permissionRequestDismissView;
532
533        public ViewHolder(View view) {
534            personViewGroup = (ViewGroup) view.findViewById(getPersonGroupResId());
535            displayNameView = (TextView) view.findViewById(getDisplayNameResId());
536            destinationView = (TextView) view.findViewById(getDestinationResId());
537            destinationTypeView = (TextView) view.findViewById(getDestinationTypeResId());
538            imageView = (ImageView) view.findViewById(getPhotoResId());
539            deleteView = (ImageView) view.findViewById(getDeleteResId());
540            topDivider = view.findViewById(R.id.chip_autocomplete_top_divider);
541
542            bottomDivider = view.findViewById(R.id.chip_autocomplete_bottom_divider);
543            permissionBottomDivider = view.findViewById(R.id.chip_permission_bottom_divider);
544
545            indicatorView = (TextView) view.findViewById(R.id.chip_indicator_text);
546
547            permissionViewGroup = (ViewGroup) view.findViewById(getPermissionGroupResId());
548            permissionRequestDismissView =
549                    (ImageView) view.findViewById(getPermissionRequestDismissResId());
550        }
551    }
552}
553