1 /* 2 * Copyright (C) 2014 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package androidx.appcompat.widget; 18 19 import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX; 20 21 import android.annotation.SuppressLint; 22 import android.content.Context; 23 import android.content.DialogInterface; 24 import android.content.res.ColorStateList; 25 import android.content.res.Resources; 26 import android.content.res.TypedArray; 27 import android.database.DataSetObserver; 28 import android.graphics.PorterDuff; 29 import android.graphics.Rect; 30 import android.graphics.drawable.Drawable; 31 import android.os.Build; 32 import android.os.Parcel; 33 import android.os.Parcelable; 34 import android.util.AttributeSet; 35 import android.util.Log; 36 import android.view.MotionEvent; 37 import android.view.View; 38 import android.view.ViewGroup; 39 import android.view.ViewTreeObserver; 40 import android.view.ViewTreeObserver.OnGlobalLayoutListener; 41 import android.widget.AdapterView; 42 import android.widget.ArrayAdapter; 43 import android.widget.ListAdapter; 44 import android.widget.ListView; 45 import android.widget.PopupWindow; 46 import android.widget.Spinner; 47 import android.widget.SpinnerAdapter; 48 49 import androidx.annotation.DrawableRes; 50 import androidx.annotation.RequiresApi; 51 import androidx.annotation.RestrictTo; 52 import androidx.annotation.StyleableRes; 53 import androidx.annotation.VisibleForTesting; 54 import androidx.appcompat.R; 55 import androidx.appcompat.app.AlertDialog; 56 import androidx.appcompat.content.res.AppCompatResources; 57 import androidx.appcompat.view.ContextThemeWrapper; 58 import androidx.appcompat.view.menu.ShowableListMenu; 59 import androidx.core.util.ObjectsCompat; 60 import androidx.core.view.TintableBackgroundView; 61 import androidx.resourceinspection.annotation.AppCompatShadowedAttributes; 62 63 import org.jspecify.annotations.NonNull; 64 import org.jspecify.annotations.Nullable; 65 66 /** 67 * A {@link Spinner} which supports compatible features on older versions of the platform, 68 * including: 69 * <ul> 70 * <li>Allows dynamic tint of its background via the background tint methods in 71 * {@link androidx.core.view.ViewCompat}.</li> 72 * <li>Allows setting of the background tint using {@link R.attr#buttonTint} and 73 * {@link R.attr#buttonTintMode}.</li> 74 * <li>Setting the popup theme using {@link R.attr#popupTheme}.</li> 75 * </ul> 76 * 77 * <p>This will automatically be used when you use {@link Spinner} in your layouts. 78 * You should only need to manually use this class when writing custom views.</p> 79 */ 80 @AppCompatShadowedAttributes 81 public class AppCompatSpinner extends Spinner implements TintableBackgroundView { 82 83 @SuppressLint("ResourceType") 84 @StyleableRes 85 private static final int[] ATTRS_ANDROID_SPINNERMODE = {android.R.attr.spinnerMode}; 86 87 private static final int MAX_ITEMS_MEASURED = 15; 88 89 private static final String TAG = "AppCompatSpinner"; 90 91 private static final int MODE_DIALOG = 0; 92 private static final int MODE_DROPDOWN = 1; 93 private static final int MODE_THEME = -1; 94 95 private final AppCompatBackgroundHelper mBackgroundTintHelper; 96 97 /** Context used to inflate the popup window or dialog. */ 98 private final Context mPopupContext; 99 100 /** Forwarding listener used to implement drag-to-open. */ 101 private ForwardingListener mForwardingListener; 102 103 /** Temporary holder for setAdapter() calls from the super constructor. */ 104 private SpinnerAdapter mTempAdapter; 105 106 private final boolean mPopupSet; 107 108 private SpinnerPopup mPopup; 109 110 int mDropDownWidth; 111 112 final Rect mTempRect = new Rect(); 113 114 /** 115 * Construct a new spinner with the given context's theme. 116 * 117 * @param context The Context the view is running in, through which it can 118 * access the current theme, resources, etc. 119 */ AppCompatSpinner( @onNull Context context)120 public AppCompatSpinner( 121 @NonNull Context context) { 122 this(context, null); 123 } 124 125 /** 126 * Construct a new spinner with the given context's theme and the supplied 127 * mode of displaying choices. <code>mode</code> may be one of 128 * {@link #MODE_DIALOG} or {@link #MODE_DROPDOWN}. 129 * 130 * @param context The Context the view is running in, through which it can 131 * access the current theme, resources, etc. 132 * @param mode Constant describing how the user will select choices from the spinner. 133 * @see #MODE_DIALOG 134 * @see #MODE_DROPDOWN 135 */ AppCompatSpinner( @onNull Context context, int mode)136 public AppCompatSpinner( 137 @NonNull Context context, int mode) { 138 this(context, null, R.attr.spinnerStyle, mode); 139 } 140 141 /** 142 * Construct a new spinner with the given context's theme and the supplied attribute set. 143 * 144 * @param context The Context the view is running in, through which it can 145 * access the current theme, resources, etc. 146 * @param attrs The attributes of the XML tag that is inflating the view. 147 */ AppCompatSpinner( @onNull Context context, @Nullable AttributeSet attrs)148 public AppCompatSpinner( 149 @NonNull Context context, @Nullable AttributeSet attrs) { 150 this(context, attrs, R.attr.spinnerStyle); 151 } 152 153 /** 154 * Construct a new spinner with the given context's theme, the supplied attribute set, 155 * and default style attribute. 156 * 157 * @param context The Context the view is running in, through which it can 158 * access the current theme, resources, etc. 159 * @param attrs The attributes of the XML tag that is inflating the view. 160 * @param defStyleAttr An attribute in the current theme that contains a 161 * reference to a style resource that supplies default values for 162 * the view. Can be 0 to not look for defaults. 163 */ AppCompatSpinner( @onNull Context context, @Nullable AttributeSet attrs, int defStyleAttr)164 public AppCompatSpinner( 165 @NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { 166 this(context, attrs, defStyleAttr, MODE_THEME); 167 } 168 169 /** 170 * Construct a new spinner with the given context's theme, the supplied attribute set, 171 * and default style. <code>mode</code> may be one of {@link #MODE_DIALOG} or 172 * {@link #MODE_DROPDOWN} and determines how the user will select choices from the spinner. 173 * 174 * @param context The Context the view is running in, through which it can 175 * access the current theme, resources, etc. 176 * @param attrs The attributes of the XML tag that is inflating the view. 177 * @param defStyleAttr An attribute in the current theme that contains a 178 * reference to a style resource that supplies default values for 179 * the view. Can be 0 to not look for defaults. 180 * @param mode Constant describing how the user will select choices from the spinner. 181 * @see #MODE_DIALOG 182 * @see #MODE_DROPDOWN 183 */ AppCompatSpinner( @onNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, int mode)184 public AppCompatSpinner( 185 @NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, int mode) { 186 this(context, attrs, defStyleAttr, mode, null); 187 } 188 189 190 /** 191 * Constructs a new spinner with the given context's theme, the supplied 192 * attribute set, default styles, popup mode (one of {@link #MODE_DIALOG} 193 * or {@link #MODE_DROPDOWN}), and the context against which the popup 194 * should be inflated. 195 * 196 * @param context The context against which the view is inflated, which 197 * provides access to the current theme, resources, etc. 198 * @param attrs The attributes of the XML tag that is inflating the view. 199 * @param defStyleAttr An attribute in the current theme that contains a 200 * reference to a style resource that supplies default 201 * values for the view. Can be 0 to not look for 202 * defaults. 203 * @param mode Constant describing how the user will select choices from 204 * the spinner. 205 * @param popupTheme The theme against which the dialog or dropdown popup 206 * should be inflated. May be {@code null} to use the 207 * view theme. If set, this will override any value 208 * specified by 209 * {@link R.styleable#Spinner_popupTheme}. 210 * @see #MODE_DIALOG 211 * @see #MODE_DROPDOWN 212 */ AppCompatSpinner(@onNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, int mode, Resources.Theme popupTheme)213 public AppCompatSpinner(@NonNull Context context, @Nullable AttributeSet attrs, 214 int defStyleAttr, int mode, Resources.Theme popupTheme) { 215 super(context, attrs, defStyleAttr); 216 217 ThemeUtils.checkAppCompatTheme(this, getContext()); 218 219 TintTypedArray a = TintTypedArray.obtainStyledAttributes(context, attrs, 220 R.styleable.Spinner, defStyleAttr, 0); 221 222 mBackgroundTintHelper = new AppCompatBackgroundHelper(this); 223 224 if (popupTheme != null) { 225 mPopupContext = new ContextThemeWrapper(context, popupTheme); 226 } else { 227 final int popupThemeResId = a.getResourceId(R.styleable.Spinner_popupTheme, 0); 228 if (popupThemeResId != 0) { 229 mPopupContext = new ContextThemeWrapper(context, popupThemeResId); 230 } else { 231 mPopupContext = context; 232 } 233 } 234 235 if (mode == MODE_THEME) { 236 TypedArray aa = null; 237 try { 238 aa = context.obtainStyledAttributes(attrs, ATTRS_ANDROID_SPINNERMODE, 239 defStyleAttr, 0); 240 if (aa.hasValue(0)) { 241 mode = aa.getInt(0, MODE_DIALOG); 242 } 243 } catch (Exception e) { 244 Log.i(TAG, "Could not read android:spinnerMode", e); 245 } finally { 246 if (aa != null) { 247 aa.recycle(); 248 } 249 } 250 } 251 252 switch (mode) { 253 case MODE_DIALOG: { 254 mPopup = new AppCompatSpinner.DialogPopup(); 255 mPopup.setPromptText(a.getString(R.styleable.Spinner_android_prompt)); 256 break; 257 } 258 case MODE_DROPDOWN: { 259 final DropdownPopup popup = new DropdownPopup(mPopupContext, attrs, defStyleAttr); 260 final TintTypedArray pa = TintTypedArray.obtainStyledAttributes( 261 mPopupContext, attrs, R.styleable.Spinner, defStyleAttr, 0); 262 mDropDownWidth = pa.getLayoutDimension(R.styleable.Spinner_android_dropDownWidth, 263 LayoutParams.WRAP_CONTENT); 264 popup.setBackgroundDrawable( 265 pa.getDrawable(R.styleable.Spinner_android_popupBackground)); 266 popup.setPromptText(a.getString(R.styleable.Spinner_android_prompt)); 267 pa.recycle(); 268 269 mPopup = popup; 270 mForwardingListener = new ForwardingListener(this) { 271 @Override 272 public ShowableListMenu getPopup() { 273 return popup; 274 } 275 276 @Override 277 public boolean onForwardingStarted() { 278 if (!getInternalPopup().isShowing()) { 279 showPopup(); 280 } 281 return true; 282 } 283 }; 284 } 285 } 286 287 final CharSequence[] entries = a.getTextArray(R.styleable.Spinner_android_entries); 288 if (entries != null) { 289 final ArrayAdapter<CharSequence> adapter = new ArrayAdapter<>( 290 context, android.R.layout.simple_spinner_item, entries); 291 adapter.setDropDownViewResource(R.layout.support_simple_spinner_dropdown_item); 292 setAdapter(adapter); 293 } 294 295 a.recycle(); 296 297 mPopupSet = true; 298 299 // Base constructors can call setAdapter before we initialize mPopup. 300 // Finish setting things up if this happened. 301 if (mTempAdapter != null) { 302 setAdapter(mTempAdapter); 303 mTempAdapter = null; 304 } 305 306 mBackgroundTintHelper.loadFromAttributes(attrs, defStyleAttr); 307 } 308 309 /** 310 * @return the context used to inflate the Spinner's popup or dialog window 311 */ 312 @Override getPopupContext()313 public Context getPopupContext() { 314 return mPopupContext; 315 } 316 317 @Override setPopupBackgroundDrawable(Drawable background)318 public void setPopupBackgroundDrawable(Drawable background) { 319 if (mPopup != null) { 320 mPopup.setBackgroundDrawable(background); 321 } else { 322 super.setPopupBackgroundDrawable(background); 323 } 324 } 325 326 @Override setPopupBackgroundResource(@rawableRes int resId)327 public void setPopupBackgroundResource(@DrawableRes int resId) { 328 setPopupBackgroundDrawable(AppCompatResources.getDrawable(getPopupContext(), resId)); 329 } 330 331 @Override getPopupBackground()332 public Drawable getPopupBackground() { 333 if (mPopup != null) { 334 return mPopup.getBackground(); 335 } else { 336 return super.getPopupBackground(); 337 } 338 } 339 340 @Override setDropDownVerticalOffset(int pixels)341 public void setDropDownVerticalOffset(int pixels) { 342 if (mPopup != null) { 343 mPopup.setVerticalOffset(pixels); 344 } else { 345 super.setDropDownVerticalOffset(pixels); 346 } 347 } 348 349 @Override getDropDownVerticalOffset()350 public int getDropDownVerticalOffset() { 351 if (mPopup != null) { 352 return mPopup.getVerticalOffset(); 353 } else { 354 return super.getDropDownVerticalOffset(); 355 } 356 } 357 358 @Override setDropDownHorizontalOffset(int pixels)359 public void setDropDownHorizontalOffset(int pixels) { 360 if (mPopup != null) { 361 mPopup.setHorizontalOriginalOffset(pixels); 362 mPopup.setHorizontalOffset(pixels); 363 } else { 364 super.setDropDownHorizontalOffset(pixels); 365 } 366 } 367 368 /** 369 * Get the configured horizontal offset in pixels for the spinner's popup window of choices. 370 * Only valid in {@link #MODE_DROPDOWN}; other modes will return 0. 371 * 372 * @return Horizontal offset in pixels 373 */ 374 @Override getDropDownHorizontalOffset()375 public int getDropDownHorizontalOffset() { 376 if (mPopup != null) { 377 return mPopup.getHorizontalOffset(); 378 } else { 379 return super.getDropDownHorizontalOffset(); 380 } 381 } 382 383 @Override setDropDownWidth(int pixels)384 public void setDropDownWidth(int pixels) { 385 if (mPopup != null) { 386 mDropDownWidth = pixels; 387 } else { 388 super.setDropDownWidth(pixels); 389 } 390 } 391 392 @Override getDropDownWidth()393 public int getDropDownWidth() { 394 if (mPopup != null) { 395 return mDropDownWidth; 396 } else { 397 return super.getDropDownWidth(); 398 } 399 } 400 401 @Override setAdapter(SpinnerAdapter adapter)402 public void setAdapter(SpinnerAdapter adapter) { 403 // The super constructor may call setAdapter before we're prepared. 404 // Postpone doing anything until we've finished construction. 405 if (!mPopupSet) { 406 mTempAdapter = adapter; 407 return; 408 } 409 410 super.setAdapter(adapter); 411 412 if (mPopup != null) { 413 final Context popupContext = mPopupContext == null ? getContext() : mPopupContext; 414 mPopup.setAdapter(new DropDownAdapter(adapter, popupContext.getTheme())); 415 } 416 } 417 418 @Override onDetachedFromWindow()419 protected void onDetachedFromWindow() { 420 super.onDetachedFromWindow(); 421 422 if (mPopup != null && mPopup.isShowing()) { 423 mPopup.dismiss(); 424 } 425 } 426 427 @Override onTouchEvent(MotionEvent event)428 public boolean onTouchEvent(MotionEvent event) { 429 if (mForwardingListener != null && mForwardingListener.onTouch(this, event)) { 430 return true; 431 } 432 return super.onTouchEvent(event); 433 } 434 435 @Override onMeasure(int widthMeasureSpec, int heightMeasureSpec)436 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 437 super.onMeasure(widthMeasureSpec, heightMeasureSpec); 438 439 if (mPopup != null && MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.AT_MOST) { 440 final int measuredWidth = getMeasuredWidth(); 441 setMeasuredDimension(Math.min(Math.max(measuredWidth, 442 compatMeasureContentWidth(getAdapter(), getBackground())), 443 MeasureSpec.getSize(widthMeasureSpec)), 444 getMeasuredHeight()); 445 } 446 } 447 448 @Override performClick()449 public boolean performClick() { 450 if (mPopup != null) { 451 // If we have a popup, show it if needed, or just consume the click... 452 if (!mPopup.isShowing()) { 453 showPopup(); 454 } 455 return true; 456 } 457 458 // Else let the platform handle the click 459 return super.performClick(); 460 } 461 462 @Override setPrompt(CharSequence prompt)463 public void setPrompt(CharSequence prompt) { 464 if (mPopup != null) { 465 mPopup.setPromptText(prompt); 466 } else { 467 super.setPrompt(prompt); 468 } 469 } 470 471 @Override getPrompt()472 public CharSequence getPrompt() { 473 return mPopup != null ? mPopup.getHintText() : super.getPrompt(); 474 } 475 476 @Override setBackgroundResource(@rawableRes int resId)477 public void setBackgroundResource(@DrawableRes int resId) { 478 super.setBackgroundResource(resId); 479 if (mBackgroundTintHelper != null) { 480 mBackgroundTintHelper.onSetBackgroundResource(resId); 481 } 482 } 483 484 @Override setBackgroundDrawable(@ullable Drawable background)485 public void setBackgroundDrawable(@Nullable Drawable background) { 486 super.setBackgroundDrawable(background); 487 if (mBackgroundTintHelper != null) { 488 mBackgroundTintHelper.onSetBackgroundDrawable(background); 489 } 490 } 491 492 /** 493 * This should be accessed via 494 * {@link androidx.core.view.ViewCompat#setBackgroundTintList(android.view.View, 495 * ColorStateList)} 496 * 497 */ 498 @RestrictTo(LIBRARY_GROUP_PREFIX) 499 @Override setSupportBackgroundTintList(@ullable ColorStateList tint)500 public void setSupportBackgroundTintList(@Nullable ColorStateList tint) { 501 if (mBackgroundTintHelper != null) { 502 mBackgroundTintHelper.setSupportBackgroundTintList(tint); 503 } 504 } 505 506 /** 507 * This should be accessed via 508 * {@link androidx.core.view.ViewCompat#getBackgroundTintList(android.view.View)} 509 * 510 */ 511 @RestrictTo(LIBRARY_GROUP_PREFIX) 512 @Override getSupportBackgroundTintList()513 public @Nullable ColorStateList getSupportBackgroundTintList() { 514 return mBackgroundTintHelper != null 515 ? mBackgroundTintHelper.getSupportBackgroundTintList() : null; 516 } 517 518 /** 519 * This should be accessed via 520 * {@link androidx.core.view.ViewCompat#setBackgroundTintMode(android.view.View, 521 * PorterDuff.Mode)} 522 * 523 */ 524 @RestrictTo(LIBRARY_GROUP_PREFIX) 525 @Override setSupportBackgroundTintMode(PorterDuff.@ullable Mode tintMode)526 public void setSupportBackgroundTintMode(PorterDuff.@Nullable Mode tintMode) { 527 if (mBackgroundTintHelper != null) { 528 mBackgroundTintHelper.setSupportBackgroundTintMode(tintMode); 529 } 530 } 531 532 /** 533 * This should be accessed via 534 * {@link androidx.core.view.ViewCompat#getBackgroundTintMode(android.view.View)} 535 * 536 */ 537 @RestrictTo(LIBRARY_GROUP_PREFIX) 538 @Override getSupportBackgroundTintMode()539 public PorterDuff.@Nullable Mode getSupportBackgroundTintMode() { 540 return mBackgroundTintHelper != null 541 ? mBackgroundTintHelper.getSupportBackgroundTintMode() : null; 542 } 543 544 @Override drawableStateChanged()545 protected void drawableStateChanged() { 546 super.drawableStateChanged(); 547 if (mBackgroundTintHelper != null) { 548 mBackgroundTintHelper.applySupportBackgroundTint(); 549 } 550 } 551 compatMeasureContentWidth(SpinnerAdapter adapter, Drawable background)552 int compatMeasureContentWidth(SpinnerAdapter adapter, Drawable background) { 553 if (adapter == null) { 554 return 0; 555 } 556 557 int width = 0; 558 View itemView = null; 559 int itemType = 0; 560 final int widthMeasureSpec = 561 MeasureSpec.makeMeasureSpec(getMeasuredWidth(), MeasureSpec.UNSPECIFIED); 562 final int heightMeasureSpec = 563 MeasureSpec.makeMeasureSpec(getMeasuredHeight(), MeasureSpec.UNSPECIFIED); 564 565 // Make sure the number of items we'll measure is capped. If it's a huge data set 566 // with wildly varying sizes, oh well. 567 int start = Math.max(0, getSelectedItemPosition()); 568 final int end = Math.min(adapter.getCount(), start + MAX_ITEMS_MEASURED); 569 final int count = end - start; 570 start = Math.max(0, start - (MAX_ITEMS_MEASURED - count)); 571 for (int i = start; i < end; i++) { 572 final int positionType = adapter.getItemViewType(i); 573 if (positionType != itemType) { 574 itemType = positionType; 575 itemView = null; 576 } 577 itemView = adapter.getView(i, itemView, this); 578 if (itemView.getLayoutParams() == null) { 579 itemView.setLayoutParams(new LayoutParams( 580 LayoutParams.WRAP_CONTENT, 581 LayoutParams.WRAP_CONTENT)); 582 } 583 itemView.measure(widthMeasureSpec, heightMeasureSpec); 584 width = Math.max(width, itemView.getMeasuredWidth()); 585 } 586 587 // Add background padding to measured width 588 if (background != null) { 589 background.getPadding(mTempRect); 590 width += mTempRect.left + mTempRect.right; 591 } 592 593 return width; 594 } 595 596 @VisibleForTesting getInternalPopup()597 final SpinnerPopup getInternalPopup() { 598 return mPopup; 599 } 600 showPopup()601 void showPopup() { 602 mPopup.show(getTextDirection(), getTextAlignment()); 603 } 604 605 606 @Override onSaveInstanceState()607 public Parcelable onSaveInstanceState() { 608 final AppCompatSpinner.SavedState ss = 609 new AppCompatSpinner.SavedState(super.onSaveInstanceState()); 610 ss.mShowDropdown = mPopup != null && mPopup.isShowing(); 611 return ss; 612 } 613 614 @Override onRestoreInstanceState(Parcelable state)615 public void onRestoreInstanceState(Parcelable state) { 616 AppCompatSpinner.SavedState ss = (AppCompatSpinner.SavedState) state; 617 618 super.onRestoreInstanceState(ss.getSuperState()); 619 620 if (ss.mShowDropdown) { 621 ViewTreeObserver vto = getViewTreeObserver(); 622 if (vto != null) { 623 final OnGlobalLayoutListener listener = new OnGlobalLayoutListener() { 624 @Override 625 public void onGlobalLayout() { 626 if (!getInternalPopup().isShowing()) { 627 showPopup(); 628 } 629 final ViewTreeObserver vto = getViewTreeObserver(); 630 if (vto != null) { 631 vto.removeOnGlobalLayoutListener(this); 632 } 633 } 634 }; 635 vto.addOnGlobalLayoutListener(listener); 636 } 637 } 638 } 639 640 static class SavedState extends BaseSavedState { 641 boolean mShowDropdown; 642 SavedState(Parcelable superState)643 SavedState(Parcelable superState) { 644 super(superState); 645 } 646 SavedState(Parcel in)647 SavedState(Parcel in) { 648 super(in); 649 mShowDropdown = in.readByte() != 0; 650 } 651 652 @Override writeToParcel(Parcel out, int flags)653 public void writeToParcel(Parcel out, int flags) { 654 super.writeToParcel(out, flags); 655 out.writeByte((byte) (mShowDropdown ? 1 : 0)); 656 } 657 658 public static final Parcelable.Creator<SavedState> CREATOR = 659 new Parcelable.Creator<SavedState>() { 660 @Override 661 public SavedState createFromParcel(Parcel in) { 662 return new SavedState(in); 663 } 664 665 @Override 666 public SavedState[] newArray(int size) { 667 return new SavedState[size]; 668 } 669 }; 670 } 671 672 /** 673 * <p>Wrapper class for an Adapter. Transforms the embedded Adapter instance 674 * into a ListAdapter.</p> 675 */ 676 private static class DropDownAdapter implements ListAdapter, SpinnerAdapter { 677 678 private SpinnerAdapter mAdapter; 679 680 private ListAdapter mListAdapter; 681 682 /** 683 * Creates a new ListAdapter wrapper for the specified adapter. 684 * 685 * @param adapter the SpinnerAdapter to transform into a ListAdapter 686 * @param dropDownTheme the theme against which to inflate drop-down 687 * views, may be {@null} to use default theme 688 */ DropDownAdapter(@ullable SpinnerAdapter adapter, Resources.@Nullable Theme dropDownTheme)689 public DropDownAdapter(@Nullable SpinnerAdapter adapter, 690 Resources.@Nullable Theme dropDownTheme) { 691 mAdapter = adapter; 692 693 if (adapter instanceof ListAdapter) { 694 mListAdapter = (ListAdapter) adapter; 695 } 696 697 if (dropDownTheme != null) { 698 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M 699 && adapter instanceof android.widget.ThemedSpinnerAdapter) { 700 final android.widget.ThemedSpinnerAdapter themedAdapter = 701 (android.widget.ThemedSpinnerAdapter) adapter; 702 Api23Impl.setDropDownViewTheme(themedAdapter, dropDownTheme); 703 } else if (adapter instanceof ThemedSpinnerAdapter) { 704 final ThemedSpinnerAdapter themedAdapter = (ThemedSpinnerAdapter) adapter; 705 if (themedAdapter.getDropDownViewTheme() == null) { 706 themedAdapter.setDropDownViewTheme(dropDownTheme); 707 } 708 } 709 } 710 } 711 712 @Override getCount()713 public int getCount() { 714 return mAdapter == null ? 0 : mAdapter.getCount(); 715 } 716 717 @Override getItem(int position)718 public Object getItem(int position) { 719 return mAdapter == null ? null : mAdapter.getItem(position); 720 } 721 722 @Override getItemId(int position)723 public long getItemId(int position) { 724 return mAdapter == null ? -1 : mAdapter.getItemId(position); 725 } 726 727 @Override getView(int position, View convertView, ViewGroup parent)728 public View getView(int position, View convertView, ViewGroup parent) { 729 return getDropDownView(position, convertView, parent); 730 } 731 732 @Override getDropDownView(int position, View convertView, ViewGroup parent)733 public View getDropDownView(int position, View convertView, ViewGroup parent) { 734 return (mAdapter == null) ? null 735 : mAdapter.getDropDownView(position, convertView, parent); 736 } 737 738 @Override hasStableIds()739 public boolean hasStableIds() { 740 return mAdapter != null && mAdapter.hasStableIds(); 741 } 742 743 @Override registerDataSetObserver(DataSetObserver observer)744 public void registerDataSetObserver(DataSetObserver observer) { 745 if (mAdapter != null) { 746 mAdapter.registerDataSetObserver(observer); 747 } 748 } 749 750 @Override unregisterDataSetObserver(DataSetObserver observer)751 public void unregisterDataSetObserver(DataSetObserver observer) { 752 if (mAdapter != null) { 753 mAdapter.unregisterDataSetObserver(observer); 754 } 755 } 756 757 /** 758 * If the wrapped SpinnerAdapter is also a ListAdapter, delegate this call. 759 * Otherwise, return true. 760 */ 761 @Override areAllItemsEnabled()762 public boolean areAllItemsEnabled() { 763 final ListAdapter adapter = mListAdapter; 764 if (adapter != null) { 765 return adapter.areAllItemsEnabled(); 766 } else { 767 return true; 768 } 769 } 770 771 /** 772 * If the wrapped SpinnerAdapter is also a ListAdapter, delegate this call. 773 * Otherwise, return true. 774 */ 775 @Override isEnabled(int position)776 public boolean isEnabled(int position) { 777 final ListAdapter adapter = mListAdapter; 778 if (adapter != null) { 779 return adapter.isEnabled(position); 780 } else { 781 return true; 782 } 783 } 784 785 @Override getItemViewType(int position)786 public int getItemViewType(int position) { 787 return 0; 788 } 789 790 @Override getViewTypeCount()791 public int getViewTypeCount() { 792 return 1; 793 } 794 795 @Override isEmpty()796 public boolean isEmpty() { 797 return getCount() == 0; 798 } 799 } 800 801 /** 802 * Implements some sort of popup selection interface for selecting a spinner option. 803 * Allows for different spinner modes. 804 */ 805 @VisibleForTesting 806 interface SpinnerPopup { setAdapter(ListAdapter adapter)807 void setAdapter(ListAdapter adapter); 808 809 /** 810 * Show the popup 811 */ show(int textDirection, int textAlignment)812 void show(int textDirection, int textAlignment); 813 814 /** 815 * Dismiss the popup 816 */ dismiss()817 void dismiss(); 818 819 /** 820 * @return true if the popup is showing, false otherwise. 821 */ isShowing()822 boolean isShowing(); 823 824 /** 825 * Set hint text to be displayed to the user. This should provide 826 * a description of the choice being made. 827 * @param hintText Hint text to set. 828 */ setPromptText(CharSequence hintText)829 void setPromptText(CharSequence hintText); getHintText()830 CharSequence getHintText(); 831 setBackgroundDrawable(Drawable bg)832 void setBackgroundDrawable(Drawable bg); setVerticalOffset(int px)833 void setVerticalOffset(int px); setHorizontalOffset(int px)834 void setHorizontalOffset(int px); setHorizontalOriginalOffset(int px)835 void setHorizontalOriginalOffset(int px); getHorizontalOriginalOffset()836 int getHorizontalOriginalOffset(); getBackground()837 Drawable getBackground(); getVerticalOffset()838 int getVerticalOffset(); getHorizontalOffset()839 int getHorizontalOffset(); 840 } 841 842 @VisibleForTesting 843 class DialogPopup implements SpinnerPopup, DialogInterface.OnClickListener { 844 @VisibleForTesting 845 AlertDialog mPopup; 846 private ListAdapter mListAdapter; 847 private CharSequence mPrompt; 848 849 @Override dismiss()850 public void dismiss() { 851 if (mPopup != null) { 852 mPopup.dismiss(); 853 mPopup = null; 854 } 855 } 856 857 @Override isShowing()858 public boolean isShowing() { 859 return mPopup != null ? mPopup.isShowing() : false; 860 } 861 862 @Override setAdapter(ListAdapter adapter)863 public void setAdapter(ListAdapter adapter) { 864 mListAdapter = adapter; 865 } 866 867 @Override setPromptText(CharSequence hintText)868 public void setPromptText(CharSequence hintText) { 869 mPrompt = hintText; 870 } 871 872 @Override getHintText()873 public CharSequence getHintText() { 874 return mPrompt; 875 } 876 877 @Override show(int textDirection, int textAlignment)878 public void show(int textDirection, int textAlignment) { 879 if (mListAdapter == null) { 880 return; 881 } 882 AlertDialog.Builder builder = new AlertDialog.Builder(getPopupContext()); 883 if (mPrompt != null) { 884 builder.setTitle(mPrompt); 885 } 886 mPopup = builder.setSingleChoiceItems(mListAdapter, 887 getSelectedItemPosition(), this).create(); 888 final ListView listView = mPopup.getListView(); 889 listView.setTextDirection(textDirection); 890 listView.setTextAlignment(textAlignment); 891 mPopup.show(); 892 } 893 894 @Override onClick(DialogInterface dialog, int which)895 public void onClick(DialogInterface dialog, int which) { 896 setSelection(which); 897 if (getOnItemClickListener() != null) { 898 performItemClick(null, which, mListAdapter.getItemId(which)); 899 } 900 dismiss(); 901 } 902 903 @Override setBackgroundDrawable(Drawable bg)904 public void setBackgroundDrawable(Drawable bg) { 905 Log.e(TAG, "Cannot set popup background for MODE_DIALOG, ignoring"); 906 } 907 908 @Override setVerticalOffset(int px)909 public void setVerticalOffset(int px) { 910 Log.e(TAG, "Cannot set vertical offset for MODE_DIALOG, ignoring"); 911 } 912 913 @Override setHorizontalOffset(int px)914 public void setHorizontalOffset(int px) { 915 Log.e(TAG, "Cannot set horizontal offset for MODE_DIALOG, ignoring"); 916 } 917 918 @Override getBackground()919 public Drawable getBackground() { 920 return null; 921 } 922 923 @Override getVerticalOffset()924 public int getVerticalOffset() { 925 return 0; 926 } 927 928 @Override getHorizontalOffset()929 public int getHorizontalOffset() { 930 return 0; 931 } 932 933 @Override setHorizontalOriginalOffset(int px)934 public void setHorizontalOriginalOffset(int px) { 935 Log.e(TAG, "Cannot set horizontal (original) offset for MODE_DIALOG, ignoring"); 936 } 937 938 @Override getHorizontalOriginalOffset()939 public int getHorizontalOriginalOffset() { 940 return 0; 941 } 942 } 943 944 @VisibleForTesting 945 class DropdownPopup extends ListPopupWindow implements SpinnerPopup { 946 private CharSequence mHintText; 947 ListAdapter mAdapter; 948 private final Rect mVisibleRect = new Rect(); 949 private int mOriginalHorizontalOffset; 950 DropdownPopup(Context context, AttributeSet attrs, int defStyleAttr)951 public DropdownPopup(Context context, AttributeSet attrs, int defStyleAttr) { 952 super(context, attrs, defStyleAttr); 953 954 setAnchorView(AppCompatSpinner.this); 955 setModal(true); 956 setPromptPosition(POSITION_PROMPT_ABOVE); 957 958 setOnItemClickListener(new AdapterView.OnItemClickListener() { 959 @Override 960 public void onItemClick(AdapterView<?> parent, View v, int position, long id) { 961 AppCompatSpinner.this.setSelection(position); 962 if (getOnItemClickListener() != null) { 963 AppCompatSpinner.this 964 .performItemClick(v, position, mAdapter.getItemId(position)); 965 } 966 dismiss(); 967 } 968 }); 969 } 970 971 @Override setAdapter(ListAdapter adapter)972 public void setAdapter(ListAdapter adapter) { 973 super.setAdapter(adapter); 974 mAdapter = adapter; 975 } 976 977 @Override getHintText()978 public CharSequence getHintText() { 979 return mHintText; 980 } 981 982 @Override setPromptText(CharSequence hintText)983 public void setPromptText(CharSequence hintText) { 984 // Hint text is ignored for dropdowns, but maintain it here. 985 mHintText = hintText; 986 } 987 computeContentWidth()988 void computeContentWidth() { 989 final Drawable background = getBackground(); 990 int hOffset = 0; 991 if (background != null) { 992 background.getPadding(mTempRect); 993 hOffset = ViewUtils.isLayoutRtl(AppCompatSpinner.this) ? mTempRect.right 994 : -mTempRect.left; 995 } else { 996 mTempRect.left = mTempRect.right = 0; 997 } 998 999 final int spinnerPaddingLeft = AppCompatSpinner.this.getPaddingLeft(); 1000 final int spinnerPaddingRight = AppCompatSpinner.this.getPaddingRight(); 1001 final int spinnerWidth = AppCompatSpinner.this.getWidth(); 1002 if (mDropDownWidth == WRAP_CONTENT) { 1003 int contentWidth = compatMeasureContentWidth( 1004 (SpinnerAdapter) mAdapter, getBackground()); 1005 final int contentWidthLimit = getContext().getResources() 1006 .getDisplayMetrics().widthPixels - mTempRect.left - mTempRect.right; 1007 if (contentWidth > contentWidthLimit) { 1008 contentWidth = contentWidthLimit; 1009 } 1010 setContentWidth(Math.max( 1011 contentWidth, spinnerWidth - spinnerPaddingLeft - spinnerPaddingRight)); 1012 } else if (mDropDownWidth == MATCH_PARENT) { 1013 setContentWidth(spinnerWidth - spinnerPaddingLeft - spinnerPaddingRight); 1014 } else { 1015 setContentWidth(mDropDownWidth); 1016 } 1017 if (ViewUtils.isLayoutRtl(AppCompatSpinner.this)) { 1018 hOffset += spinnerWidth - spinnerPaddingRight - getWidth() 1019 - getHorizontalOriginalOffset(); 1020 } else { 1021 hOffset += spinnerPaddingLeft + getHorizontalOriginalOffset(); 1022 } 1023 setHorizontalOffset(hOffset); 1024 } 1025 1026 @Override show(int textDirection, int textAlignment)1027 public void show(int textDirection, int textAlignment) { 1028 final boolean wasShowing = isShowing(); 1029 1030 computeContentWidth(); 1031 1032 setInputMethodMode(ListPopupWindow.INPUT_METHOD_NOT_NEEDED); 1033 super.show(); 1034 final ListView listView = getListView(); 1035 listView.setChoiceMode(ListView.CHOICE_MODE_SINGLE); 1036 listView.setTextDirection(textDirection); 1037 listView.setTextAlignment(textAlignment); 1038 setSelection(AppCompatSpinner.this.getSelectedItemPosition()); 1039 1040 if (wasShowing) { 1041 // Skip setting up the layout/dismiss listener below. If we were previously 1042 // showing it will still stick around. 1043 return; 1044 } 1045 1046 // Make sure we hide if our anchor goes away. 1047 // TODO: This might be appropriate to push all the way down to PopupWindow, 1048 // but it may have other side effects to investigate first. (Text editing handles, etc.) 1049 final ViewTreeObserver vto = getViewTreeObserver(); 1050 if (vto != null) { 1051 final ViewTreeObserver.OnGlobalLayoutListener layoutListener 1052 = new ViewTreeObserver.OnGlobalLayoutListener() { 1053 @Override 1054 public void onGlobalLayout() { 1055 if (!isVisibleToUser(AppCompatSpinner.this)) { 1056 dismiss(); 1057 } else { 1058 computeContentWidth(); 1059 1060 // Use super.show here to update; we don't want to move the selected 1061 // position or adjust other things that would be reset otherwise. 1062 DropdownPopup.super.show(); 1063 } 1064 } 1065 }; 1066 vto.addOnGlobalLayoutListener(layoutListener); 1067 setOnDismissListener(new PopupWindow.OnDismissListener() { 1068 @Override 1069 public void onDismiss() { 1070 final ViewTreeObserver vto = getViewTreeObserver(); 1071 if (vto != null) { 1072 vto.removeGlobalOnLayoutListener(layoutListener); 1073 } 1074 } 1075 }); 1076 } 1077 } 1078 1079 /** 1080 * Simplified version of the the hidden View.isVisibleToUser() 1081 */ isVisibleToUser(View view)1082 boolean isVisibleToUser(View view) { 1083 return view.isAttachedToWindow() && view.getGlobalVisibleRect(mVisibleRect); 1084 } 1085 1086 @Override setHorizontalOriginalOffset(int px)1087 public void setHorizontalOriginalOffset(int px) { 1088 mOriginalHorizontalOffset = px; 1089 } 1090 1091 @Override getHorizontalOriginalOffset()1092 public int getHorizontalOriginalOffset() { 1093 return mOriginalHorizontalOffset; 1094 } 1095 } 1096 1097 @RequiresApi(23) 1098 private static final class Api23Impl { Api23Impl()1099 private Api23Impl() { 1100 // This class is not instantiable. 1101 } 1102 setDropDownViewTheme( android.widget.@onNull ThemedSpinnerAdapter themedSpinnerAdapter, Resources.@Nullable Theme theme )1103 static void setDropDownViewTheme( 1104 android.widget.@NonNull ThemedSpinnerAdapter themedSpinnerAdapter, 1105 Resources.@Nullable Theme theme 1106 ) { 1107 if (!ObjectsCompat.equals(themedSpinnerAdapter.getDropDownViewTheme(), theme)) { 1108 themedSpinnerAdapter.setDropDownViewTheme(theme); 1109 } 1110 } 1111 } 1112 } 1113