DropdownChipLayouter.java revision 174917b0eae33cf51af7fab947c49738028ed018
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 adapterType, 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 int entryType = entry.getEntryType();
128        final View itemView =
129                reuseOrInflateView(convertView, parent, adapterType, entryType);
130
131        final ViewHolder viewHolder = new ViewHolder(itemView);
132
133        // Hide some information depending on the adapter type.
134        switch (adapterType) {
135            case BASE_RECIPIENT:
136                if (TextUtils.isEmpty(displayName) || TextUtils.equals(displayName, destination)) {
137                    displayName = destination;
138
139                    // We only show the destination for secondary entries, so clear it only for the
140                    // first level.
141                    if (entry.isFirstLevel()) {
142                        destination = null;
143                    }
144                }
145
146                if (!entry.isFirstLevel()) {
147                    displayName = null;
148                    showImage = false;
149                }
150
151                // For BASE_RECIPIENT set all top dividers except for the first one to be GONE.
152                if (viewHolder.topDivider != null) {
153                    viewHolder.topDivider.setVisibility(position == 0 ? View.VISIBLE : View.GONE);
154                    MarginLayoutParamsCompat.setMarginStart(
155                            (MarginLayoutParams) viewHolder.topDivider.getLayoutParams(),
156                            mAutocompleteDividerMarginStart);
157                }
158                if ((viewHolder.bottomDivider != null)
159                        && (entryType == RecipientEntry.ENTRY_TYPE_PERSON)) {
160                    MarginLayoutParamsCompat.setMarginStart(
161                            (MarginLayoutParams) viewHolder.bottomDivider.getLayoutParams(),
162                            mAutocompleteDividerMarginStart);
163                }
164                break;
165            case RECIPIENT_ALTERNATES:
166                if (position != 0) {
167                    displayName = null;
168                    showImage = false;
169                }
170                break;
171            case SINGLE_RECIPIENT:
172                destination = Rfc822Tokenizer.tokenize(entry.getDestination())[0].getAddress();
173                destinationType = null;
174        }
175
176        // Bind the information to the view
177        bindTextToView(displayName, viewHolder.displayNameView);
178        bindTextToView(destination, viewHolder.destinationView);
179        bindTextToView(destinationType, viewHolder.destinationTypeView);
180        bindIconToView(showImage, entry, viewHolder.imageView, adapterType);
181        bindDrawableToDeleteView(deleteDrawable, entry.getDisplayName(), viewHolder.deleteView);
182        bindIndicatorToView(
183                entry.getIndicatorIconId(), entry.getIndicatorText(), viewHolder.indicatorView);
184        bindPermissionRequestDismissView(viewHolder.permissionRequestDismissView);
185
186        return itemView;
187    }
188
189    /**
190     * Returns a new view with {@link #getItemLayoutResId(AdapterType)}.
191     */
192    public View newView(AdapterType type) {
193        return mInflater.inflate(getItemLayoutResId(type), null);
194    }
195
196    /**
197     * Returns the same view, or inflates a new one if the given view was null.
198     */
199    protected View reuseOrInflateView(
200            View convertView, ViewGroup parent, AdapterType adapterType, int entryType) {
201        @LayoutRes int itemLayout = getItemLayoutResId(adapterType, entryType);
202        switch (adapterType) {
203            case BASE_RECIPIENT:
204            case RECIPIENT_ALTERNATES:
205                break;
206            case SINGLE_RECIPIENT:
207                itemLayout = getAlternateItemLayoutResId(adapterType);
208                break;
209        }
210        return convertView != null ? convertView : mInflater.inflate(itemLayout, parent, false);
211    }
212
213    /**
214     * Binds the text to the given text view. If the text was null, hides the text view.
215     */
216    protected void bindTextToView(CharSequence text, TextView view) {
217        if (view == null) {
218            return;
219        }
220
221        if (text != null) {
222            view.setText(text);
223            view.setVisibility(View.VISIBLE);
224        } else {
225            view.setVisibility(View.GONE);
226        }
227    }
228
229    /**
230     * Binds the avatar icon to the image view. If we don't want to show the image, hides the
231     * image view.
232     */
233    protected void bindIconToView(boolean showImage, RecipientEntry entry, ImageView view,
234        AdapterType type) {
235        if (view == null) {
236            return;
237        }
238
239        if (showImage) {
240            switch (type) {
241                case BASE_RECIPIENT:
242                    byte[] photoBytes = entry.getPhotoBytes();
243                    if (photoBytes != null && photoBytes.length > 0) {
244                        final Bitmap photo = BitmapFactory.decodeByteArray(photoBytes, 0,
245                            photoBytes.length);
246                        view.setImageBitmap(photo);
247                    } else {
248                        view.setImageResource(getDefaultPhotoResId());
249                    }
250                    break;
251                case RECIPIENT_ALTERNATES:
252                    Uri thumbnailUri = entry.getPhotoThumbnailUri();
253                    if (thumbnailUri != null) {
254                        // TODO: see if this needs to be done outside the main thread
255                        // as it may be too slow to get immediately.
256                        view.setImageURI(thumbnailUri);
257                    } else {
258                        view.setImageResource(getDefaultPhotoResId());
259                    }
260                    break;
261                case SINGLE_RECIPIENT:
262                default:
263                    break;
264            }
265            view.setVisibility(View.VISIBLE);
266        } else {
267            view.setVisibility(View.GONE);
268        }
269    }
270
271    protected void bindDrawableToDeleteView(final StateListDrawable drawable, String recipient,
272            ImageView view) {
273        if (view == null) {
274            return;
275        }
276        if (drawable == null) {
277            view.setVisibility(View.GONE);
278        } else {
279            final Resources res = mContext.getResources();
280            view.setImageDrawable(drawable);
281            view.setContentDescription(
282                    res.getString(R.string.dropdown_delete_button_desc, recipient));
283            if (mDeleteListener != null) {
284                view.setOnClickListener(new View.OnClickListener() {
285                    @Override
286                    public void onClick(View view) {
287                        if (drawable.getCurrent() != null) {
288                            mDeleteListener.onChipDelete();
289                        }
290                    }
291                });
292            }
293        }
294    }
295
296    protected void bindIndicatorToView(
297            @DrawableRes int indicatorIconId, String indicatorText, TextView view) {
298        if (view != null) {
299            if (indicatorText != null || indicatorIconId != 0) {
300                view.setText(indicatorText);
301                view.setVisibility(View.VISIBLE);
302                final Drawable indicatorIcon;
303                if (indicatorIconId != 0) {
304                    indicatorIcon = mContext.getDrawable(indicatorIconId).mutate();
305                    indicatorIcon.setColorFilter(Color.WHITE, PorterDuff.Mode.SRC_IN);
306                } else {
307                    indicatorIcon = null;
308                }
309                view.setCompoundDrawablesRelativeWithIntrinsicBounds(
310                        indicatorIcon, null, null, null);
311            } else {
312                view.setVisibility(View.GONE);
313            }
314        }
315    }
316
317    protected void bindPermissionRequestDismissView(ImageView view) {
318        if (view == null) {
319            return;
320        }
321        view.setOnClickListener(new OnClickListener() {
322            @Override
323            public void onClick(View v) {
324                if (mPermissionRequestDismissedListener != null) {
325                    mPermissionRequestDismissedListener.onPermissionRequestDismissed();
326                }
327            }
328        });
329    }
330
331    protected void setViewVisibility(View view, int visibility) {
332        if (view != null) {
333            view.setVisibility(visibility);
334        }
335    }
336
337    protected CharSequence getDestinationType(RecipientEntry entry) {
338        return mQuery.getTypeLabel(mContext.getResources(), entry.getDestinationType(),
339            entry.getDestinationLabel()).toString().toUpperCase();
340    }
341
342    /**
343     * Returns a layout id for each item inside auto-complete list.
344     *
345     * Each View must contain two TextViews (for display name and destination) and one ImageView
346     * (for photo). Ids for those should be available via {@link #getDisplayNameResId()},
347     * {@link #getDestinationResId()}, and {@link #getPhotoResId()}.
348     */
349    protected @LayoutRes int getItemLayoutResId(
350            AdapterType adapterType, @Nullable Integer entryType) {
351        if (entryType == RecipientEntry.ENTRY_TYPE_PERMISSION_REQUEST) {
352            return R.layout.chips_autocomplete_permission_dropdown_item;
353        }
354        switch (adapterType) {
355            case BASE_RECIPIENT:
356                return R.layout.chips_autocomplete_recipient_dropdown_item;
357            case RECIPIENT_ALTERNATES:
358                return R.layout.chips_recipient_dropdown_item;
359            default:
360                return R.layout.chips_recipient_dropdown_item;
361        }
362    }
363
364    protected @LayoutRes int getItemLayoutResId(AdapterType adapterType) {
365        return getItemLayoutResId(adapterType, null);
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 TextView displayNameView;
520        public final TextView destinationView;
521        public final TextView destinationTypeView;
522        public final TextView indicatorView;
523        public final ImageView imageView;
524        public final ImageView deleteView;
525        public final View topDivider;
526        public final View bottomDivider;
527
528        public final ImageView permissionRequestDismissView;
529
530        public ViewHolder(View view) {
531            displayNameView = (TextView) view.findViewById(getDisplayNameResId());
532            destinationView = (TextView) view.findViewById(getDestinationResId());
533            destinationTypeView = (TextView) view.findViewById(getDestinationTypeResId());
534            imageView = (ImageView) view.findViewById(getPhotoResId());
535            deleteView = (ImageView) view.findViewById(getDeleteResId());
536            topDivider = view.findViewById(R.id.chip_autocomplete_top_divider);
537            bottomDivider = view.findViewById(R.id.chip_autocomplete_bottom_divider);
538            indicatorView = (TextView) view.findViewById(R.id.chip_indicator_text);
539
540            permissionRequestDismissView =
541                    (ImageView) view.findViewById(getPermissionRequestDismissResId());
542        }
543    }
544}
545