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