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