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 androidx.annotation.DrawableRes; 13 import androidx.annotation.IdRes; 14 import androidx.annotation.LayoutRes; 15 import androidx.annotation.Nullable; 16 import androidx.core.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 { onChipDelete()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 */ onPermissionRequestDismissed()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 DropdownChipLayouter(LayoutInflater inflater, Context context)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 setQuery(Query query)77 public void setQuery(Query query) { 78 mQuery = query; 79 } 80 setDeleteListener(ChipDeleteListener listener)81 public void setDeleteListener(ChipDeleteListener listener) { 82 mDeleteListener = listener; 83 } 84 setPermissionRequestDismissedListener(PermissionRequestDismissedListener listener)85 public void setPermissionRequestDismissedListener(PermissionRequestDismissedListener listener) { 86 mPermissionRequestDismissedListener = listener; 87 } 88 setAutocompleteDividerMarginStart(int autocompleteDividerMarginStart)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 */ bindView(View convertView, ViewGroup parent, RecipientEntry entry, int position, AdapterType type, String constraint)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 */ bindView(View convertView, ViewGroup parent, RecipientEntry entry, int position, AdapterType type, String constraint, StateListDrawable deleteDrawable)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 */ newView(AdapterType type)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 */ reuseOrInflateView(View convertView, ViewGroup parent, AdapterType type)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 */ bindTextToView(CharSequence text, TextView view)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 */ bindIconToView(boolean showImage, RecipientEntry entry, ImageView view, AdapterType type)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 bindDrawableToDeleteView(final StateListDrawable drawable, String recipient, ImageView view)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 bindIndicatorToView( @rawableRes int indicatorIconId, String indicatorText, TextView view)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 bindPermissionRequestDismissView(ImageView view)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 setViewVisibility(View view, int visibility)340 protected void setViewVisibility(View view, int visibility) { 341 if (view != null) { 342 view.setVisibility(visibility); 343 } 344 } 345 getDestinationType(RecipientEntry entry)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 */ getItemLayoutResId(AdapterType type)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 */ getAlternateItemLayoutResId(AdapterType type)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 */ getDefaultPhotoResId()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 */ getPersonGroupResId()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 */ getDisplayNameResId()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 */ getDestinationResId()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 */ getDestinationTypeResId()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 */ getPhotoResId()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 */ getDeleteResId()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 */ getPermissionGroupResId()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 */ getPermissionRequestDismissResId()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 */ getStyledResults(@ullable String constraint, RecipientEntry entry)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 */ getStyledResults(@ullable String constraint, String... results)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 isAllWhitespace(@ullable String string)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 ViewHolder(View view)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