1/*
2 * Copyright (C) 2007 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
17package android.support.v7.internal.widget;
18
19import android.app.AlertDialog;
20import android.content.Context;
21import android.content.DialogInterface;
22import android.content.DialogInterface.OnClickListener;
23import android.content.res.TypedArray;
24import android.database.DataSetObserver;
25import android.graphics.Rect;
26import android.graphics.drawable.Drawable;
27import android.support.v7.appcompat.R;
28import android.util.AttributeSet;
29import android.view.Gravity;
30import android.view.View;
31import android.view.ViewGroup;
32import android.widget.AdapterView;
33import android.widget.ListAdapter;
34import android.widget.ListView;
35import android.widget.SpinnerAdapter;
36
37
38/**
39 * A view that displays one child at a time and lets the user pick among them.
40 * The items in the Spinner come from the {@link android.widget.Adapter} associated with
41 * this view.
42 *
43 * <p>See the <a href="{@docRoot}resources/tutorials/views/hello-spinner.html">Spinner
44 * tutorial</a>.</p>
45 *
46 * @attr ref android.support.v7.appcompat.R.styleable#Spinner_prompt
47 */
48class SpinnerICS extends AbsSpinnerICS implements OnClickListener {
49    private static final String TAG = "Spinner";
50
51    // Only measure this many items to get a decent max width.
52    private static final int MAX_ITEMS_MEASURED = 15;
53
54    /**
55     * Use a dialog window for selecting spinner options.
56     */
57    static final int MODE_DIALOG = 0;
58
59    /**
60     * Use a dropdown anchored to the Spinner for selecting spinner options.
61     */
62    static final int MODE_DROPDOWN = 1;
63
64    /**
65     * Use the theme-supplied value to select the dropdown mode.
66     */
67    private static final int MODE_THEME = -1;
68
69    private SpinnerPopup mPopup;
70    private DropDownAdapter mTempAdapter;
71    int mDropDownWidth;
72
73    private int mGravity;
74
75    private Rect mTempRect = new Rect();
76
77    /**
78     * Construct a new spinner with the given context's theme.
79     *
80     * @param context The Context the view is running in, through which it can
81     *        access the current theme, resources, etc.
82     */
83    SpinnerICS(Context context) {
84        this(context, null);
85    }
86
87    /**
88     * Construct a new spinner with the given context's theme and the supplied
89     * mode of displaying choices. <code>mode</code> may be one of
90     * {@link #MODE_DIALOG} or {@link #MODE_DROPDOWN}.
91     *
92     * @param context The Context the view is running in, through which it can
93     *        access the current theme, resources, etc.
94     * @param mode Constant describing how the user will select choices from the spinner.
95     *
96     * @see #MODE_DIALOG
97     * @see #MODE_DROPDOWN
98     */
99    SpinnerICS(Context context, int mode) {
100        this(context, null, R.attr.spinnerStyle, mode);
101    }
102
103    /**
104     * Construct a new spinner with the given context's theme and the supplied attribute set.
105     *
106     * @param context The Context the view is running in, through which it can
107     *        access the current theme, resources, etc.
108     * @param attrs The attributes of the XML tag that is inflating the view.
109     */
110    SpinnerICS(Context context, AttributeSet attrs) {
111        this(context, attrs, R.attr.spinnerStyle);
112    }
113
114    /**
115     * Construct a new spinner with the given context's theme, the supplied attribute set,
116     * and default style.
117     *
118     * @param context The Context the view is running in, through which it can
119     *        access the current theme, resources, etc.
120     * @param attrs The attributes of the XML tag that is inflating the view.
121     * @param defStyle The default style to apply to this view. If 0, no style
122     *        will be applied (beyond what is included in the theme). This may
123     *        either be an attribute resource, whose value will be retrieved
124     *        from the current theme, or an explicit style resource.
125     */
126    SpinnerICS(Context context, AttributeSet attrs, int defStyle) {
127        this(context, attrs, defStyle, MODE_THEME);
128    }
129
130    /**
131     * Construct a new spinner with the given context's theme, the supplied attribute set,
132     * and default style. <code>mode</code> may be one of {@link #MODE_DIALOG} or
133     * {@link #MODE_DROPDOWN} and determines how the user will select choices from the spinner.
134     *
135     * @param context The Context the view is running in, through which it can
136     *        access the current theme, resources, etc.
137     * @param attrs The attributes of the XML tag that is inflating the view.
138     * @param defStyle The default style to apply to this view. If 0, no style
139     *        will be applied (beyond what is included in the theme). This may
140     *        either be an attribute resource, whose value will be retrieved
141     *        from the current theme, or an explicit style resource.
142     * @param mode Constant describing how the user will select choices from the spinner.
143     *
144     * @see #MODE_DIALOG
145     * @see #MODE_DROPDOWN
146     */
147    SpinnerICS(Context context, AttributeSet attrs, int defStyle, int mode) {
148        super(context, attrs, defStyle);
149
150        TypedArray a = context.obtainStyledAttributes(attrs,
151                R.styleable.Spinner, defStyle, 0);
152
153        if (mode == MODE_THEME) {
154            mode = a.getInt(R.styleable.Spinner_spinnerMode, MODE_DIALOG);
155        }
156
157        switch (mode) {
158            case MODE_DIALOG: {
159                mPopup = new DialogPopup();
160                break;
161            }
162
163            case MODE_DROPDOWN: {
164                DropdownPopup popup = new DropdownPopup(context, attrs, defStyle);
165
166                mDropDownWidth = a.getLayoutDimension(R.styleable.Spinner_android_dropDownWidth,
167                        ViewGroup.LayoutParams.WRAP_CONTENT);
168
169                popup.setBackgroundDrawable(
170                        a.getDrawable(R.styleable.Spinner_android_popupBackground));
171
172                final int verticalOffset = a.getDimensionPixelOffset(
173                        R.styleable.Spinner_android_dropDownVerticalOffset, 0);
174                if (verticalOffset != 0) {
175                    popup.setVerticalOffset(verticalOffset);
176                }
177
178                final int horizontalOffset = a.getDimensionPixelOffset(
179                        R.styleable.Spinner_android_dropDownHorizontalOffset, 0);
180                if (horizontalOffset != 0) {
181                    popup.setHorizontalOffset(horizontalOffset);
182                }
183
184                mPopup = popup;
185                break;
186            }
187        }
188
189        mGravity = a.getInt(R.styleable.Spinner_android_gravity, Gravity.CENTER);
190
191        mPopup.setPromptText(a.getString(R.styleable.Spinner_prompt));
192
193        a.recycle();
194
195        // Base constructor can call setAdapter before we initialize mPopup.
196        // Finish setting things up if this happened.
197        if (mTempAdapter != null) {
198            mPopup.setAdapter(mTempAdapter);
199            mTempAdapter = null;
200        }
201    }
202
203    /**
204     * Describes how the selected item view is positioned. Currently only the horizontal component
205     * is used. The default is determined by the current theme.
206     *
207     * @param gravity See {@link android.view.Gravity}
208     *
209     * @attr ref android.R.styleable#Spinner_gravity
210     */
211    public void setGravity(int gravity) {
212        if (mGravity != gravity) {
213            if ((gravity & Gravity.HORIZONTAL_GRAVITY_MASK) == 0) {
214                gravity |= Gravity.LEFT;
215            }
216            mGravity = gravity;
217            requestLayout();
218        }
219    }
220
221    @Override
222    public void setAdapter(SpinnerAdapter adapter) {
223        super.setAdapter(adapter);
224
225        if (mPopup != null) {
226            mPopup.setAdapter(new DropDownAdapter(adapter));
227        } else {
228            mTempAdapter = new DropDownAdapter(adapter);
229        }
230    }
231
232    @Override
233    public int getBaseline() {
234        View child = null;
235
236        if (getChildCount() > 0) {
237            child = getChildAt(0);
238        } else if (mAdapter != null && mAdapter.getCount() > 0) {
239            child = makeAndAddView(0);
240            mRecycler.put(0, child);
241            removeAllViewsInLayout();
242        }
243
244        if (child != null) {
245            final int childBaseline = child.getBaseline();
246            return childBaseline >= 0 ? child.getTop() + childBaseline : -1;
247        } else {
248            return -1;
249        }
250    }
251
252    @Override
253    protected void onDetachedFromWindow() {
254        super.onDetachedFromWindow();
255
256        if (mPopup != null && mPopup.isShowing()) {
257            mPopup.dismiss();
258        }
259    }
260
261    /**
262     * <p>A spinner does not support item click events. Calling this method
263     * will raise an exception.</p>
264     *
265     * @param l this listener will be ignored
266     */
267    @Override
268    public void setOnItemClickListener(OnItemClickListener l) {
269        throw new RuntimeException("setOnItemClickListener cannot be used with a spinner.");
270    }
271
272    void setOnItemClickListenerInt(OnItemClickListener l) {
273        super.setOnItemClickListener(l);
274    }
275
276    @Override
277    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
278        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
279        if (mPopup != null && MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.AT_MOST) {
280            final int measuredWidth = getMeasuredWidth();
281            setMeasuredDimension(Math.min(Math.max(measuredWidth,
282                    measureContentWidth(getAdapter(), getBackground())),
283                    MeasureSpec.getSize(widthMeasureSpec)),
284                    getMeasuredHeight());
285        }
286    }
287
288    /**
289     * @see android.view.View#onLayout(boolean,int,int,int,int)
290     *
291     * Creates and positions all views
292     *
293     */
294    @Override
295    protected void onLayout(boolean changed, int l, int t, int r, int b) {
296        super.onLayout(changed, l, t, r, b);
297        mInLayout = true;
298        layout(0, false);
299        mInLayout = false;
300    }
301
302    /**
303     * Creates and positions all views for this Spinner.
304     *
305     * @param delta Change in the selected position. +1 moves selection is moving to the right,
306     * so views are scrolling to the left. -1 means selection is moving to the left.
307     */
308    @Override
309    void layout(int delta, boolean animate) {
310        int childrenLeft = mSpinnerPadding.left;
311        int childrenWidth = getRight() - getLeft() - mSpinnerPadding.left - mSpinnerPadding.right;
312
313        if (mDataChanged) {
314            handleDataChanged();
315        }
316
317        // Handle the empty set by removing all views
318        if (mItemCount == 0) {
319            resetList();
320            return;
321        }
322
323        if (mNextSelectedPosition >= 0) {
324            setSelectedPositionInt(mNextSelectedPosition);
325        }
326
327        recycleAllViews();
328
329        // Clear out old views
330        removeAllViewsInLayout();
331
332        // Make selected view and position it
333        mFirstPosition = mSelectedPosition;
334        View sel = makeAndAddView(mSelectedPosition);
335        int width = sel.getMeasuredWidth();
336        int selectedOffset = childrenLeft;
337        switch (mGravity & Gravity.HORIZONTAL_GRAVITY_MASK) {
338            case Gravity.CENTER_HORIZONTAL:
339                selectedOffset = childrenLeft + (childrenWidth / 2) - (width / 2);
340                break;
341            case Gravity.RIGHT:
342                selectedOffset = childrenLeft + childrenWidth - width;
343                break;
344        }
345        sel.offsetLeftAndRight(selectedOffset);
346
347        // Flush any cached views that did not get reused above
348        mRecycler.clear();
349
350        invalidate();
351
352        checkSelectionChanged();
353
354        mDataChanged = false;
355        mNeedSync = false;
356        setNextSelectedPositionInt(mSelectedPosition);
357    }
358
359    /**
360     * Obtain a view, either by pulling an existing view from the recycler or
361     * by getting a new one from the adapter. If we are animating, make sure
362     * there is enough information in the view's layout parameters to animate
363     * from the old to new positions.
364     *
365     * @param position Position in the spinner for the view to obtain
366     * @return A view that has been added to the spinner
367     */
368    private View makeAndAddView(int position) {
369
370        View child;
371
372        if (!mDataChanged) {
373            child = mRecycler.get(position);
374            if (child != null) {
375                // Position the view
376                setUpChild(child);
377
378                return child;
379            }
380        }
381
382        // Nothing found in the recycler -- ask the adapter for a view
383        child = mAdapter.getView(position, null, this);
384
385        // Position the view
386        setUpChild(child);
387
388        return child;
389    }
390
391    /**
392     * Helper for makeAndAddView to set the position of a view
393     * and fill out its layout paramters.
394     *
395     * @param child The view to position
396     */
397    private void setUpChild(View child) {
398
399        // Respect layout params that are already in the view. Otherwise
400        // make some up...
401        ViewGroup.LayoutParams lp = child.getLayoutParams();
402        if (lp == null) {
403            lp = generateDefaultLayoutParams();
404        }
405
406        addViewInLayout(child, 0, lp);
407
408        child.setSelected(hasFocus());
409
410        // Get measure specs
411        int childHeightSpec = ViewGroup.getChildMeasureSpec(mHeightMeasureSpec,
412                mSpinnerPadding.top + mSpinnerPadding.bottom, lp.height);
413        int childWidthSpec = ViewGroup.getChildMeasureSpec(mWidthMeasureSpec,
414                mSpinnerPadding.left + mSpinnerPadding.right, lp.width);
415
416        // Measure child
417        child.measure(childWidthSpec, childHeightSpec);
418
419        int childLeft;
420        int childRight;
421
422        // Position vertically based on gravity setting
423        int childTop = mSpinnerPadding.top
424                + ((getMeasuredHeight() - mSpinnerPadding.bottom -
425                mSpinnerPadding.top - child.getMeasuredHeight()) / 2);
426        int childBottom = childTop + child.getMeasuredHeight();
427
428        int width = child.getMeasuredWidth();
429        childLeft = 0;
430        childRight = childLeft + width;
431
432        child.layout(childLeft, childTop, childRight, childBottom);
433    }
434
435    @Override
436    public boolean performClick() {
437        boolean handled = super.performClick();
438
439        if (!handled) {
440            handled = true;
441
442            if (!mPopup.isShowing()) {
443                mPopup.show();
444            }
445        }
446
447        return handled;
448    }
449
450    public void onClick(DialogInterface dialog, int which) {
451        setSelection(which);
452        dialog.dismiss();
453    }
454
455    /**
456     * Sets the prompt to display when the dialog is shown.
457     * @param prompt the prompt to set
458     */
459    public void setPrompt(CharSequence prompt) {
460        mPopup.setPromptText(prompt);
461    }
462
463    /**
464     * Sets the prompt to display when the dialog is shown.
465     * @param promptId the resource ID of the prompt to display when the dialog is shown
466     */
467    public void setPromptId(int promptId) {
468        setPrompt(getContext().getText(promptId));
469    }
470
471    /**
472     * @return The prompt to display when the dialog is shown
473     */
474    public CharSequence getPrompt() {
475        return mPopup.getHintText();
476    }
477
478    int measureContentWidth(SpinnerAdapter adapter, Drawable background) {
479        if (adapter == null) {
480            return 0;
481        }
482
483        int width = 0;
484        View itemView = null;
485        int itemType = 0;
486        final int widthMeasureSpec =
487                MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
488        final int heightMeasureSpec =
489                MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
490
491        // Make sure the number of items we'll measure is capped. If it's a huge data set
492        // with wildly varying sizes, oh well.
493        int start = Math.max(0, getSelectedItemPosition());
494        final int end = Math.min(adapter.getCount(), start + MAX_ITEMS_MEASURED);
495        final int count = end - start;
496        start = Math.max(0, start - (MAX_ITEMS_MEASURED - count));
497        for (int i = start; i < end; i++) {
498            final int positionType = adapter.getItemViewType(i);
499            if (positionType != itemType) {
500                itemType = positionType;
501                itemView = null;
502            }
503            itemView = adapter.getView(i, itemView, this);
504            if (itemView.getLayoutParams() == null) {
505                itemView.setLayoutParams(new ViewGroup.LayoutParams(
506                        ViewGroup.LayoutParams.WRAP_CONTENT,
507                        ViewGroup.LayoutParams.WRAP_CONTENT));
508            }
509            itemView.measure(widthMeasureSpec, heightMeasureSpec);
510            width = Math.max(width, itemView.getMeasuredWidth());
511        }
512
513        // Add background padding to measured width
514        if (background != null) {
515            background.getPadding(mTempRect);
516            width += mTempRect.left + mTempRect.right;
517        }
518
519        return width;
520    }
521
522    /**
523     * <p>Wrapper class for an Adapter. Transforms the embedded Adapter instance
524     * into a ListAdapter.</p>
525     */
526    private static class DropDownAdapter implements ListAdapter, SpinnerAdapter {
527        private SpinnerAdapter mAdapter;
528        private ListAdapter mListAdapter;
529
530        /**
531         * <p>Creates a new ListAdapter wrapper for the specified adapter.</p>
532         *
533         * @param adapter the Adapter to transform into a ListAdapter
534         */
535        public DropDownAdapter(SpinnerAdapter adapter) {
536            this.mAdapter = adapter;
537            if (adapter instanceof ListAdapter) {
538                this.mListAdapter = (ListAdapter) adapter;
539            }
540        }
541
542        public int getCount() {
543            return mAdapter == null ? 0 : mAdapter.getCount();
544        }
545
546        public Object getItem(int position) {
547            return mAdapter == null ? null : mAdapter.getItem(position);
548        }
549
550        public long getItemId(int position) {
551            return mAdapter == null ? -1 : mAdapter.getItemId(position);
552        }
553
554        public View getView(int position, View convertView, ViewGroup parent) {
555            return getDropDownView(position, convertView, parent);
556        }
557
558        public View getDropDownView(int position, View convertView, ViewGroup parent) {
559            return mAdapter == null ? null :
560                    mAdapter.getDropDownView(position, convertView, parent);
561        }
562
563        public boolean hasStableIds() {
564            return mAdapter != null && mAdapter.hasStableIds();
565        }
566
567        public void registerDataSetObserver(DataSetObserver observer) {
568            if (mAdapter != null) {
569                mAdapter.registerDataSetObserver(observer);
570            }
571        }
572
573        public void unregisterDataSetObserver(DataSetObserver observer) {
574            if (mAdapter != null) {
575                mAdapter.unregisterDataSetObserver(observer);
576            }
577        }
578
579        /**
580         * If the wrapped SpinnerAdapter is also a ListAdapter, delegate this call.
581         * Otherwise, return true.
582         */
583        public boolean areAllItemsEnabled() {
584            final ListAdapter adapter = mListAdapter;
585            if (adapter != null) {
586                return adapter.areAllItemsEnabled();
587            } else {
588                return true;
589            }
590        }
591
592        /**
593         * If the wrapped SpinnerAdapter is also a ListAdapter, delegate this call.
594         * Otherwise, return true.
595         */
596        public boolean isEnabled(int position) {
597            final ListAdapter adapter = mListAdapter;
598            if (adapter != null) {
599                return adapter.isEnabled(position);
600            } else {
601                return true;
602            }
603        }
604
605        public int getItemViewType(int position) {
606            return 0;
607        }
608
609        public int getViewTypeCount() {
610            return 1;
611        }
612
613        public boolean isEmpty() {
614            return getCount() == 0;
615        }
616    }
617
618    /**
619     * Implements some sort of popup selection interface for selecting a spinner option.
620     * Allows for different spinner modes.
621     */
622    private interface SpinnerPopup {
623        public void setAdapter(ListAdapter adapter);
624
625        /**
626         * Show the popup
627         */
628        public void show();
629
630        /**
631         * Dismiss the popup
632         */
633        public void dismiss();
634
635        /**
636         * @return true if the popup is showing, false otherwise.
637         */
638        public boolean isShowing();
639
640        /**
641         * Set hint text to be displayed to the user. This should provide
642         * a description of the choice being made.
643         * @param hintText Hint text to set.
644         */
645        public void setPromptText(CharSequence hintText);
646        public CharSequence getHintText();
647    }
648
649    private class DialogPopup implements SpinnerPopup, DialogInterface.OnClickListener {
650        private AlertDialog mPopup;
651        private ListAdapter mListAdapter;
652        private CharSequence mPrompt;
653
654        public void dismiss() {
655            mPopup.dismiss();
656            mPopup = null;
657        }
658
659        public boolean isShowing() {
660            return mPopup != null ? mPopup.isShowing() : false;
661        }
662
663        public void setAdapter(ListAdapter adapter) {
664            mListAdapter = adapter;
665        }
666
667        public void setPromptText(CharSequence hintText) {
668            mPrompt = hintText;
669        }
670
671        public CharSequence getHintText() {
672            return mPrompt;
673        }
674
675        public void show() {
676            AlertDialog.Builder builder = new AlertDialog.Builder(getContext());
677            if (mPrompt != null) {
678                builder.setTitle(mPrompt);
679            }
680            mPopup = builder.setSingleChoiceItems(mListAdapter,
681                    getSelectedItemPosition(), this).show();
682        }
683
684        public void onClick(DialogInterface dialog, int which) {
685            setSelection(which);
686            if (mOnItemClickListener != null) {
687                performItemClick(null, which, mListAdapter.getItemId(which));
688            }
689            dismiss();
690        }
691    }
692
693    private class DropdownPopup extends android.support.v7.internal.widget.ListPopupWindow
694            implements SpinnerPopup {
695        private CharSequence mHintText;
696        private ListAdapter mAdapter;
697
698        public DropdownPopup(Context context, AttributeSet attrs, int defStyleRes) {
699            super(context, attrs, defStyleRes);
700
701            setAnchorView(SpinnerICS.this);
702            setModal(true);
703            setPromptPosition(POSITION_PROMPT_ABOVE);
704
705            AdapterView.OnItemClickListener listener = new OnItemClickListenerWrapper(
706                    new OnItemClickListener() {
707                public void onItemClick(AdapterViewICS parent, View v, int position, long id) {
708                    SpinnerICS.this.setSelection(position);
709                    if (mOnItemClickListener != null) {
710                        SpinnerICS.this.performItemClick(v, position, mAdapter.getItemId(position));
711                    }
712                    dismiss();
713                }
714            });
715
716            setOnItemClickListener(listener);
717        }
718
719        @Override
720        public void setAdapter(ListAdapter adapter) {
721            super.setAdapter(adapter);
722            mAdapter = adapter;
723        }
724
725        public CharSequence getHintText() {
726            return mHintText;
727        }
728
729        public void setPromptText(CharSequence hintText) {
730            // Hint text is ignored for dropdowns, but maintain it here.
731            mHintText = hintText;
732        }
733
734        @Override
735        public void show() {
736            final int spinnerPaddingLeft = SpinnerICS.this.getPaddingLeft();
737            if (mDropDownWidth == WRAP_CONTENT) {
738                final int spinnerWidth = SpinnerICS.this.getWidth();
739                final int spinnerPaddingRight = SpinnerICS.this.getPaddingRight();
740                setContentWidth(Math.max(
741                        measureContentWidth((SpinnerAdapter) mAdapter, getBackground()),
742                        spinnerWidth - spinnerPaddingLeft - spinnerPaddingRight));
743            } else if (mDropDownWidth == FILL_PARENT) {
744                final int spinnerWidth = SpinnerICS.this.getWidth();
745                final int spinnerPaddingRight = SpinnerICS.this.getPaddingRight();
746                setContentWidth(spinnerWidth - spinnerPaddingLeft - spinnerPaddingRight);
747            } else {
748                setContentWidth(mDropDownWidth);
749            }
750            final Drawable background = getBackground();
751            int bgOffset = 0;
752            if (background != null) {
753                background.getPadding(mTempRect);
754                bgOffset = -mTempRect.left;
755            }
756            setHorizontalOffset(bgOffset + spinnerPaddingLeft);
757            setInputMethodMode(ListPopupWindow.INPUT_METHOD_NOT_NEEDED);
758            super.show();
759            getListView().setChoiceMode(ListView.CHOICE_MODE_SINGLE);
760            setSelection(SpinnerICS.this.getSelectedItemPosition());
761        }
762    }
763}
764