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