com.satsuware.usefulviews.LabelledSpinner.java Source code

Java tutorial

Introduction

Here is the source code for com.satsuware.usefulviews.LabelledSpinner.java

Source

/*
 * Copyright 2015-2016 Farbod Salamat-Zadeh
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.satsuware.usefulviews;

import android.annotation.TargetApi;
import android.content.Context;
import android.content.res.TypedArray;
import android.os.Build;
import android.support.annotation.ArrayRes;
import android.support.annotation.ColorRes;
import android.support.annotation.LayoutRes;
import android.support.annotation.StringRes;
import android.support.v4.content.ContextCompat;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.LinearLayout;
import android.widget.Spinner;
import android.widget.SpinnerAdapter;
import android.widget.TextView;

import java.util.List;

/**
 * A Spinner widget with a 'floating label' above it.
 *
 * <p>
 * This is actually a compound view consisting of a
 * {@link android.widget.Spinner}, a {@link android.widget.TextView}
 * (the floating label), and another {@link android.view.View} (a 1dp thin
 * line underneath the Spinner). The result is a Spinner widget looking
 * similar to a {@code android.support.design.widget.TextInputLayout}
 * and complying with the
 * <a href="www.google.com/design/spec/components/text-fields.html#text-fields-labels">
 * Material Design Guidelines for labels on text fields and spinners</a>.
 * </p>
 *
 * @attr ref R.styleable#LabelledSpinner_labelText
 * @attr ref R.styleable#LabelledSpinner_widgetColor
 * @attr ref R.styleable#LabelledSpinner_spinnerEntries
 * @attr ref R.styleable#LabelledSpinner_defaultErrorEnabled
 */
public class LabelledSpinner extends LinearLayout implements AdapterView.OnItemSelectedListener {

    /**
     * The label positioned above the Spinner, similar to the floating
     * label from a {@code android.support.design.widget.TextInputLayout}.
     */
    private TextView mLabel;

    /**
     * The Spinner widget used in this layout.
     */
    private Spinner mSpinner;

    /**
     * A thin (1dp thick) divider line positioned below the Spinner,
     * similar to the bottom line in an {@link android.widget.EditText}.
     */
    private View mDivider;

    /**
     * A label positioned below the Spinner and thin divider line,
     * which displays error texts. This is similar to a
     * {@code android.support.design.widget.TextInputLayout}).
     */
    private TextView mErrorLabel;

    /**
     * The listener that receives notifications when an item in the
     * AdapterView is selected.
     */
    private OnItemChosenListener mOnItemChosenListener;

    /**
     * The main color used in the widget (the label color and divider
     * color). This may be updated when XML attributes are obtained and
     * again if the color is set programmatically.
     */
    private int mWidgetColor;

    /**
     * The error text that is displayed for when the first item (e.g.
     * a prompt) is selected.
     */
    private CharSequence mDefaultErrorText;

    /**
     * Whether or not the 'default error' is shown. This is the error
     * when the first item, such as a prompt, is selected.
     */
    private boolean mDefaultErrorEnabled;

    public LabelledSpinner(Context context) {
        this(context, null);
    }

    public LabelledSpinner(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public LabelledSpinner(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        initializeLayout(context, attrs);
    }

    @TargetApi(Build.VERSION_CODES.LOLLIPOP)
    public LabelledSpinner(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
        initializeLayout(context, attrs);
    }

    /**
     * Sets up views and widget attributes
     *
     * @param context Context passed from constructor
     * @param attrs AttributeSet passed from constructor
     */
    private void initializeLayout(Context context, AttributeSet attrs) {
        prepareLayout(context);

        mLabel = (TextView) getChildAt(0);
        mSpinner = (Spinner) getChildAt(1);
        mDivider = getChildAt(2);
        mErrorLabel = (TextView) getChildAt(3);

        final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.LabelledSpinner, 0, 0);

        String labelText = a.getString(R.styleable.LabelledSpinner_labelText);
        mWidgetColor = a.getColor(R.styleable.LabelledSpinner_widgetColor,
                ContextCompat.getColor(context, R.color.widget_labelled_spinner_default));

        mLabel.setText(labelText);
        mLabel.setPadding(0, dpToPixels(16), 0, 0);
        mSpinner.setPadding(0, dpToPixels(8), 0, dpToPixels(8));
        mSpinner.setOnItemSelectedListener(this);

        MarginLayoutParams dividerParams = (MarginLayoutParams) mDivider.getLayoutParams();
        dividerParams.rightMargin = dpToPixels(4);
        dividerParams.bottomMargin = dpToPixels(8);
        mDivider.setLayoutParams(dividerParams);

        mLabel.setTextColor(mWidgetColor);
        mDivider.setBackgroundColor(mWidgetColor);

        alignLabelWithSpinnerItem(4);

        final CharSequence[] entries = a.getTextArray(R.styleable.LabelledSpinner_spinnerEntries);
        if (entries != null) {
            setItemsArray(entries);
        }

        mDefaultErrorEnabled = a.getBoolean(R.styleable.LabelledSpinner_defaultErrorEnabled, false);
        mDefaultErrorText = getResources().getString(R.string.widget_labelled_spinner_errorText);

        a.recycle();
    }

    /**
     * Inflates the layout and sets layout parameters
     */
    private void prepareLayout(Context context) {
        LayoutInflater inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
        inflater.inflate(R.layout.widget_labelled_spinner, this, true);

        setOrientation(LinearLayout.VERTICAL);
        setLayoutParams(new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
    }

    /**
     * @return the label (a {@link android.widget.TextView}) from this
     * compound view
     */
    public TextView getLabel() {
        return mLabel;
    }

    /**
     * @return the {@link android.widget.Spinner} widget from this compound
     * view
     */
    public Spinner getSpinner() {
        return mSpinner;
    }

    /**
     * @return the divider line {@link android.view.View} underneath the
     * Spinner
     */
    public View getDivider() {
        return mDivider;
    }

    /**
     * @return the error label (a {@link android.widget.TextView}) from this
     * compound view, which displays error texts
     */
    public TextView getErrorLabel() {
        return mErrorLabel;
    }

    /**
     * Sets the text the label is to display.
     *
     * @param labelText The CharSequence value to be displayed on the label.
     *
     * @see #setLabelText(int)
     * @see #getLabelText()
     * @attr ref R.styleable#LabelledSpinner_labelText
     */
    public void setLabelText(CharSequence labelText) {
        mLabel.setText(labelText);
    }

    /**
     * Sets the text the label is to display.
     *
     * @param labelTextId The string resource identifier which refers to
     *                    the string value which is to be displayed on
     *                    the label.
     *
     * @see #setLabelText(CharSequence)
     * @see #getLabelText()
     * @attr ref R.styleable#LabelledSpinner_labelText
     */
    public void setLabelText(@StringRes int labelTextId) {
        mLabel.setText(getResources().getString(labelTextId));
    }

    /**
     * @return the text shown on the floating label
     */
    public CharSequence getLabelText() {
        return mLabel.getText();
    }

    /**
     * Sets the color to use for the label text and the divider line
     * underneath.
     *
     * @param colorRes The color resource identifier which refers to the
     *                 color that is to be displayed on the widget.
     *
     * @see #getColor()
     * @attr ref R.styleable#LabelledSpinner_widgetColor
     */
    public void setColor(@ColorRes int colorRes) {
        mWidgetColor = ContextCompat.getColor(getContext(), colorRes);
        mLabel.setTextColor(mWidgetColor);
        mDivider.setBackgroundColor(mWidgetColor);
    }

    /**
     * @return the color used as the label and divider line
     */
    public int getColor() {
        return mWidgetColor;
    }

    /**
     * Sets the error text that is displayed when the first item of the
     * Spinner (e.g. a prompt such as 'Select an item...') is selected.
     *
     * This will only have an effect if the default error option is
     * enabled.
     *
     * @param error The error text to be displayed.
     * @see #getDefaultErrorText()
     * @see #setDefaultErrorEnabled(boolean)
     */
    public void setDefaultErrorText(CharSequence error) {
        mDefaultErrorText = error;
    }

    /**
     * @return the error text that is displayed when the first item of
     * the Spinner (e.g. a prompt) is selected
     */
    public CharSequence getDefaultErrorText() {
        return mDefaultErrorText;
    }

    /**
     * Sets a boolean deciding whether the default error can be
     * displayed (this is where an error is shown if the first item of
     * the Spinner, such as a prompt, is selected).
     *
     * @param enabled Whether or not the default error is enabled.
     *
     * @attr ref R.styleable#LabelledSpinner_defaultErrorEnabled
     */
    public void setDefaultErrorEnabled(boolean enabled) {
        mDefaultErrorEnabled = enabled;
    }

    /**
     * Sets the array of items to be used in the Spinner.
     *
     * @param arrayResId The identifier of the array to use as the data
     *                   source (e.g. {@code R.array.myArray})
     *
     * @see #setItemsArray(int, int, int)
     * @see #setItemsArray(CharSequence[])
     * @see #setItemsArray(CharSequence[], int, int)
     * @see #setItemsArray(List)
     * @see #setItemsArray(List, int, int)
     *
     * @attr ref R.styleable#LabelledSpinner_spinnerEntries
     */
    public void setItemsArray(@ArrayRes int arrayResId) {
        setItemsArray(arrayResId, android.R.layout.simple_spinner_item,
                android.R.layout.simple_spinner_dropdown_item);
    }

    /**
     * Sets the array of items to be used in the Spinner.
     *
     * @param arrayResId The identifier of the array to use as the data
     *                   source (e.g. {@code R.array.myArray})
     * @param spinnerItemRes The identifier of the layout used to create
     *                       views (e.g. {@code R.layout.my_item})
     * @param dropdownViewRes The layout resource to create the drop down
     *                        views (e.g. {@code R.layout.my_dropdown})
     *
     * @see #setItemsArray(int)
     * @see #setItemsArray(CharSequence[])
     * @see #setItemsArray(CharSequence[], int, int)
     * @see #setItemsArray(List)
     * @see #setItemsArray(List, int, int)
     *
     * @attr ref R.styleable#LabelledSpinner_spinnerEntries
     */
    public void setItemsArray(@ArrayRes int arrayResId, @LayoutRes int spinnerItemRes,
            @LayoutRes int dropdownViewRes) {
        ArrayAdapter<CharSequence> adapter = ArrayAdapter.createFromResource(getContext(), arrayResId,
                spinnerItemRes);
        adapter.setDropDownViewResource(dropdownViewRes);
        mSpinner.setAdapter(adapter);
    }

    /**
     * Sets the array of items to be used in the Spinner.
     *
     * @param itemsArray The array used as the data source
     *
     * @see #setItemsArray(int)
     * @see #setItemsArray(int, int, int)
     * @see #setItemsArray(CharSequence[], int, int)
     * @see #setItemsArray(List)
     * @see #setItemsArray(List, int, int)
     *
     * @attr ref R.styleable#LabelledSpinner_spinnerEntries
     */
    public void setItemsArray(CharSequence[] itemsArray) {
        setItemsArray(itemsArray, android.R.layout.simple_spinner_item,
                android.R.layout.simple_spinner_dropdown_item);
    }

    /**
     * Sets the array of items to be used in the Spinner.
     *
     * @param itemsArray The array used as the data source
     * @param spinnerItemRes The identifier of the layout used to create
     *                       views (e.g. {@code R.layout.my_item})
     * @param dropdownViewRes The layout resource to create the drop down
     *                        views (e.g. {@code R.layout.my_dropdown})
     *
     * @see #setItemsArray(int)
     * @see #setItemsArray(int, int, int)
     * @see #setItemsArray(CharSequence[])
     * @see #setItemsArray(List)
     * @see #setItemsArray(List, int, int)
     *
     * @attr ref R.styleable#LabelledSpinner_spinnerEntries
     */
    public void setItemsArray(CharSequence[] itemsArray, @LayoutRes int spinnerItemRes,
            @LayoutRes int dropdownViewRes) {
        ArrayAdapter<CharSequence> adapter = new ArrayAdapter<>(getContext(), spinnerItemRes, itemsArray);
        adapter.setDropDownViewResource(dropdownViewRes);
        mSpinner.setAdapter(adapter);
    }

    /**
     * Sets the array of items to be used in the Spinner.
     *
     * @param list The List used as the data source
     *
     * @see #setItemsArray(int)
     * @see #setItemsArray(int, int, int)
     * @see #setItemsArray(CharSequence[])
     * @see #setItemsArray(CharSequence[], int, int)
     * @see #setItemsArray(List, int, int)
     *
     * @attr ref R.styleable#LabelledSpinner_spinnerEntries
     */
    public void setItemsArray(List<?> list) {
        setItemsArray(list, android.R.layout.simple_spinner_item, android.R.layout.simple_spinner_dropdown_item);
    }

    /**
     * Sets the array of items to be used in the Spinner.
     *
     * @param list The list to be used as the data source.
     * @param spinnerItemRes The identifier of the layout used to create
     *                       views (e.g. {@code R.layout.my_item})
     * @param dropdownViewRes The layout resource to create the drop down
     *                        views (e.g. {@code R.layout.my_dropdown})
     *
     * @see #setItemsArray(int)
     * @see #setItemsArray(int, int, int)
     * @see #setItemsArray(CharSequence[])
     * @see #setItemsArray(CharSequence[], int, int)
     * @see #setItemsArray(List)
     *
     * @attr ref R.styleable#LabelledSpinner_spinnerEntries
     */
    public void setItemsArray(List<?> list, @LayoutRes int spinnerItemRes, @LayoutRes int dropdownViewRes) {
        ArrayAdapter<?> adapter = new ArrayAdapter<>(getContext(), spinnerItemRes, list);
        adapter.setDropDownViewResource(dropdownViewRes);
        mSpinner.setAdapter(adapter);
    }

    /**
     * Sets the Adapter used to provide the data for the Spinner.
     * This would be similar to setting an Adapter for a normal Spinner
     * component.
     *
     * @param adapter The Adapter which would provide data for the Spinner
     */
    public void setCustomAdapter(SpinnerAdapter adapter) {
        mSpinner.setAdapter(adapter);
    }

    /**
     * Sets the currently selected item.
     *
     * @param position Index (starting at 0) of the data item to be selected.
     */
    public void setSelection(int position) {
        mSpinner.setSelection(position);
    }

    /**
     * Sets the currently selected item.
     *
     * @param position Index (starting at 0) of the data item to be selected.
     * @param animate Whether or not the transition should be animated
     */
    public void setSelection(int position, boolean animate) {
        mSpinner.setSelection(position, animate);
    }

    /**
     * Interface definition for a callback to be invoked when an item in this
     * LabelledSpinner's Spinner view has been selected.
     */
    public interface OnItemChosenListener {

        /**
         * Callback method to be invoked when an item in this LabelledSpinner's
         * spinner view has been selected. This callback is invoked only when
         * the newly selected position is different from the previously selected
         * position or if there was no selected item.
         *
         * @param labelledSpinner The LabelledSpinner where the selection
         *                        happened. This view contains the AdapterView.
         * @param adapterView The AdapterView where the selection happened. Note
         *                    that this AdapterView is part of the LabelledSpinner
         *                    component.
         * @param itemView The view within the AdapterView that was clicked.
         * @param position The position of the view in the adapter.
         * @param id The row id of the item that is selected.
         */
        void onItemChosen(View labelledSpinner, AdapterView<?> adapterView, View itemView, int position, long id);

        /**
         * Callback method to be invoked when the selection disappears from this
         * view. The selection can disappear for instance when touch is activated
         * or when the adapter becomes empty.
         *
         * @param labelledSpinner The LabelledSpinner view that contains the
         *                        AdapterView.
         * @param adapterView The AdapterView that now contains no selected item.
         */
        void onNothingChosen(View labelledSpinner, AdapterView<?> adapterView);
    }

    /**
     * Register a callback to be invoked when an item in this AdapterView has
     * been selected.
     * This would be similar to setting an OnItemSelectedListener for a normal
     * Spinner component.
     *
     * @param onItemChosenListener The callback that will run
     */
    public void setOnItemChosenListener(OnItemChosenListener onItemChosenListener) {
        mOnItemChosenListener = onItemChosenListener;
    }

    /**
     * @return the interface which handles selection of items in the Spinner
     */
    public OnItemChosenListener getOnItemChosenListener() {
        return mOnItemChosenListener;
    }

    /**
     * Implemented method from {@link android.widget.AdapterView.OnItemSelectedListener}
     */
    @Override
    public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
        if (mOnItemChosenListener != null) {
            if (mDefaultErrorEnabled) {
                if (position == 0) {
                    // If the first item is selected (e.g. a prompt), the error is shown
                    mErrorLabel.setText(mDefaultErrorText);
                    mDivider.setBackgroundColor(
                            ContextCompat.getColor(getContext(), R.color.widget_labelled_spinner_error));
                } else {
                    mErrorLabel.setText(" ");
                    mDivider.setBackgroundColor(mWidgetColor);
                }
            }

            // 'this' refers to this LabelledSpinner component
            mOnItemChosenListener.onItemChosen(this, parent, view, position, id);
        }
    }

    /**
     * Implemented method from {@link android.widget.AdapterView.OnItemSelectedListener}
     */
    @Override
    public void onNothingSelected(AdapterView<?> parent) {
        if (mOnItemChosenListener != null) {
            // 'this' refers to this LabelledSpinner component
            mOnItemChosenListener.onNothingChosen(this, parent);
        }
    }

    /**
     * Adds a 4dp left margin to the label and divider line underneath so that
     * it aligns with the Spinner item text. By default, the additional 4dp
     * margin will not be added.
     * <p>
     * Note: By default, however, a 4dp margin will be added so that the label
     * and divider align correctly with other UI components, such as the label
     * in a {@code android.support.design.widget.TextInputLayout}. This means
     * that if {@code indentLabel} is true, an 8dp left margin will be added
     * (this would be the 4dp margin to align with other UI components with
     * an additional 4dp margin to align the label with the Spinner item text.
     * Also note that if {@code indentLabel} is true, the label and divider
     * will not be aligned with other UI components as they would be 4dp
     * further right from them.
     * </p>
     *
     * @param indentLabel Whether or not the label will be indented
     *
     * @see #alignLabelWithSpinnerItem(int)
     */
    public void alignLabelWithSpinnerItem(boolean indentLabel) {
        if (indentLabel) {
            alignLabelWithSpinnerItem(8);
        } else {
            alignLabelWithSpinnerItem(4);
        }
    }

    /**
     * A helper method responsible for adding left margins to the label and
     * divider line underneath, used to align these to the start of the Spinner
     * item text.
     *
     * @param indentDps The density-independent pixel value for the left margin
     *
     * @see #alignLabelWithSpinnerItem(boolean)
     */
    private void alignLabelWithSpinnerItem(int indentDps) {
        MarginLayoutParams labelParams = (MarginLayoutParams) mLabel.getLayoutParams();
        labelParams.leftMargin = dpToPixels(indentDps);
        mLabel.setLayoutParams(labelParams);
        mErrorLabel.setLayoutParams(labelParams);

        MarginLayoutParams dividerParams = (MarginLayoutParams) mDivider.getLayoutParams();
        dividerParams.leftMargin = dpToPixels(indentDps);
        mDivider.setLayoutParams(dividerParams);
    }

    /**
     * A helper method responsible for the conversion of dp/dip (density-independent
     * pixel) values to pixels, so that they can be used when setting layout
     * parameters such as margins.
     *
     * @param dps The density-independent pixel value
     * @return The pixel value from the conversion
     */
    private int dpToPixels(int dps) {
        if (dps == 0) {
            return 0;
        }
        final float scale = getResources().getDisplayMetrics().density;
        return (int) (dps * scale + 0.5f);
    }
}