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