Home > android, ui > A styleable preference for a list of integers

A styleable preference for a list of integers

Android provides a set of classes for creating nice looking and functional preference screens. One of these is a ListPreference, which lets the user pick a value from a predefined list (supplied in resources).

ListPreference does have one drawback – it only deals with String values. If you (like me) have a set of numeric values, you have to convert them to and from strings. While this is not a big problem, I wanted to avoid conversions, and implemented my own class, IntegerListPreference, which deals with a list of integer values.

This time I’m going to show the code first, and then explain what it does.

One: styleable declaration

We’re going to use styleable attributes to specify values for IntegerListPreference in XML.

<?xml version="1.0" encoding="utf-8"?>
<!-- This goes into res/values/prefs_attrs.xml -->
<resources>
    
     <declare-styleable name="IntegerListPreference">
        <attr name="entryList" format="reference" />
        <attr name="valueList" format="reference" />
    </declare-styleable>    
   
</resources>

Styleables are a powerful mechanism for declaring your own attributes, which then can be used in XML when creating views (and, as I discovered, preferences).

The XML block above declares two attributes, entryList and valueList, whose values are references. You can use other resource value types (color, integer, boolean, etc.) and even specify that one of several types is accepted (e.g. “color|reference”, useful for backgrounds).

The purpose of these two attributes is to let us specify two lists of values for our custom preference. One, valueList, will refer to a list of actual integer values we’re picking from. The other, entryList, will be used to present the list to the user.

The name of the stylable, IntegerListPreference, is used to generate code in your application’s R.stylable class, so you can access the attributes from code. It does not establish any type of connection to the Java class that will be using this stylable, although it’s a good idea to use the same name.

As with all value-type resources, the filename can be arbitrary, as long as it it’s inside res/values.

Two: accessing the resources

At runtime, you get attribute values from an AttributeSet object, using constants automatically generated in R.stylable. Each view (and preference) that is inflated from XML needs to have a constructor that takes a Context and an AttributeSet. Android runtime parses attributes specified in the XML and makes then available in the AttributeSet object.

/*
 * Assumed to be in package "my.sample.app.prefs"
 */
public class IntegerListPreference extends DialogPreference {
	private CharSequence[] mEntries;
	private int[] mValues;
	private int mValue;
	private int mClickedDialogEntryIndex;

	public IntegerListPreference(Context context, AttributeSet attrs) {
		super(context, attrs);

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

		mEntries = a.getTextArray(R.styleable.IntegerListPreference_entryList);
		if (mEntries == null) {
			throw new IllegalArgumentException(
					"IntegerListPreference: error - entryList is not specified");
		}

		int valuesResId = a.getResourceId(R.styleable.IntegerListPreference_valueList, 0);
		if (valuesResId == 0) {
			throw new IllegalArgumentException(
					"IntegerListPreference: error - valueList is not specified");
		}

		mValues = a.getResources().getIntArray(valuesResId);
		a.recycle();
	}
.... more code here...
}

The code above uses constants, R.styleable.IntegerListPreference_entryList and R.styleable.IntegerListPreference_valueList to obtain an array of strings and an array of integers from resources.

The Java code comes from taking Android’s ListPreference code and replacing String with int or Integer for every field of method parameter that deals with the preference’s value.

Three: using the prefrence in XML

Now that we have the stylable declaration and the Java class, let’s put them together.

First, we need value arrays:

<?xml version="1.0" encoding="utf-8"?>
<!-- Goes into res/values/prefs_strings.xml -->
<resources>
    <string name="prefs_int_list_prompt">Select a time period</string>

    <integer-array name="prefs_int_list_values">
        <item>0</item>
        <item>1</item>
        <item>5</item>
        <item>15</item>
        <item>60</item>
    </integer-array>
    
    <string-array name="prefs_int_list_entries">
        <item>Never</item>
        <item>Every minute</item>
        <item>Every 5 minutes</item>
        <item>Every 15 minutes</item>
        <item>Once an hour</item>
    </string-array>

</resources>

The code above provides two lists: the list of integers contains values used by the application code, while the list of strings is what will be shown to the user.

The file can have any name, as long as it’s in res/values. I’ve chosen to keep preference-related values separate, so the file is prefs_strings.xml

Now we’re ready to use IntegerListPreference in our preference screen:

<?xml version="1.0" encoding="utf-8"?>
<!-- Goes into res/xml/prefs.xml -->
<PreferenceScreen
	xmlns:android="http://schemas.android.com/apk/res/android"
	xmlns:sample="http://schemas.android.com/apk/res/my.sample.app">

	<my.sample.app.prefs.IntegerListPreference
		android:title="@string/prefs_int_list_prompt"
		android:key="prefsIntList"
		sample:valueList="@array/prefs_int_list_values"
		sample:entryList="@array/prefs_int_list_entries"
		android:defaultValue="5" />	
</PreferenceScreen>

As you can see, looks pretty simple. We used the arrays defined above, in res/values/prefs_values.xml, to populate the list, and Android’s built in attributes to specify the title, key, and the default value.

The most important thing in the preference declaration above is the namespace, xmlns:sample, and the use of this namespace with our custom attributes, sample:valueList and sample:entryList.

The namespace ties our custom attribute values in the XML to our styleable declaration, and then to R.stylable in the code. I really can’t say enough about how important that namespace is: without it, our custom attributes won’t work. It’s the “magic dust” that makes the whole thing work.

The list preference is instantiated by its full class name, my.sample.app.prefs.IntegerListPreference. I am deliberately using a sub-package here (not just my.sample.app) to show that the namespace should refer to the application’s package name, not the package where your custom attributes are used by code.

Bonus: full source code for IntegerListPreference

Here is the full source code of our custom preference class.

package my.sample.app;

import my.sample.app.R;

import android.app.AlertDialog.Builder;
import android.content.Context;
import android.content.DialogInterface;
import android.content.res.TypedArray;
import android.os.Parcel;
import android.os.Parcelable;
import android.preference.DialogPreference;
import android.util.AttributeSet;

public class IntegerListPreference extends DialogPreference {
	private CharSequence[] mEntries;
	private int[] mValues;
	private int mValue;
	private int mClickedDialogEntryIndex;

	public IntegerListPreference(Context context, AttributeSet attrs) {
		super(context, attrs);

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

		mEntries = a.getTextArray(R.styleable.IntegerListPreference_entryList);
		if (mEntries == null) {
			throw new IllegalArgumentException(
					"IntegerListPreference: error - entryList is not specified");
		}

		int valuesResId = a.getResourceId(R.styleable.IntegerListPreference_valueList, 0);
		if (valuesResId == 0) {
			throw new IllegalArgumentException(
					"IntegerListPreference: error - valueList is not specified");
		}

		mValues = a.getResources().getIntArray(valuesResId);
		a.recycle();
	}

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

	/**
	 * Sets the human-readable entries to be shown in the list. This will be shown in
	 * subsequent dialogs.
	 * <p>
	 * Each entry must have a corresponding index in
	 * {@link #setEntryValues(CharSequence[])}.
	 * 
	 * @param entries
	 *            The entries.
	 * @see #setEntryValues(CharSequence[])
	 */
	public void setEntries(CharSequence[] entries) {
		mEntries = entries;
	}

	/**
	 * @see #setEntries(CharSequence[])
	 * @param entriesResId
	 *            The entries array as a resource.
	 */
	public void setEntries(int entriesResId) {
		setEntries(getContext().getResources().getTextArray(entriesResId));
	}

	/**
	 * The list of entries to be shown in the list in subsequent dialogs.
	 * 
	 * @return The list as an array.
	 */
	public CharSequence[] getEntries() {
		return mEntries;
	}

	/**
	 * The array to find the value to save for a preference when an entry from entries is
	 * selected. If a user clicks on the second item in entries, the second item in this
	 * array will be saved to the preference.
	 * 
	 * @param entryValues
	 *            The array to be used as values to save for the preference.
	 */
	public void setEntryValues(int[] entryValues) {
		mValues = entryValues;
	}

	/**
	 * @see #setEntryValues(CharSequence[])
	 * @param entryValuesResId
	 *            The entry values array as a resource.
	 */
	public void setEntryValues(int entryValuesResId) {
		setEntryValues(getContext().getResources().getIntArray(entryValuesResId));
	}

	/**
	 * Returns the array of values to be saved for the preference.
	 * 
	 * @return The array of values.
	 */
	public int[] getEntryValues() {
		return mValues;
	}

	/**
	 * Sets the value of the key. This should be one of the entries in
	 * {@link #getEntryValues()}.
	 * 
	 * @param value
	 *            The value to set for the key.
	 */
	public void setValue(int value) {
		mValue = value;

		persistInt(value);
	}

	/**
	 * Sets the value to the given index from the entry values.
	 * 
	 * @param index
	 *            The index of the value to set.
	 */
	public void setValueIndex(int index) {
		if (mValues != null) {
			setValue(mValues[index]);
		}
	}

	/**
	 * Returns the value of the key. This should be one of the entries in
	 * {@link #getEntryValues()}.
	 * 
	 * @return The value of the key.
	 */
	public int getValue() {
		return mValue;
	}

	/**
	 * Returns the entry corresponding to the current value.
	 * 
	 * @return The entry corresponding to the current value, or null.
	 */
	public CharSequence getEntry() {
		int index = getValueIndex();
		return index >= 0 && mEntries != null ? mEntries[index] : null;
	}

	/**
	 * Returns the index of the given value (in the entry values array).
	 * 
	 * @param value
	 *            The value whose index should be returned.
	 * @return The index of the value, or -1 if not found.
	 */
	public int findIndexOfValue(int value) {
		for (int i = mValues.length - 1; i >= 0; i--) {
			if (mValues[i] == value) {
				return i;
			}
		}
		return -1;
	}

	private int getValueIndex() {
		return findIndexOfValue(mValue);
	}

	@Override
	protected void onPrepareDialogBuilder(Builder builder) {
		super.onPrepareDialogBuilder(builder);

		if (mEntries == null || mValues == null) {
			throw new IllegalStateException(
					"IntegerListPreference requires an entryList and a valueList.");
		}

		mClickedDialogEntryIndex = getValueIndex();
		builder.setSingleChoiceItems(mEntries, mClickedDialogEntryIndex,
				new DialogInterface.OnClickListener() {
					public void onClick(DialogInterface dialog, int which) {
						mClickedDialogEntryIndex = which;
					}
				});
	}

	@Override
	protected void onDialogClosed(boolean positiveResult) {
		super.onDialogClosed(positiveResult);

		if (positiveResult && mClickedDialogEntryIndex >= 0 && mValues != null) {
			int value = mValues[mClickedDialogEntryIndex];
			if (callChangeListener(value)) {
				setValue(value);
			}
		}
	}

	@Override
	protected Object onGetDefaultValue(TypedArray a, int index) {
		return a.getInt(index, 0);
	}

	@Override
	protected void onSetInitialValue(boolean restoreValue, Object defaultValue) {
		setValue(restoreValue ? getPersistedInt(mValue) : (Integer) defaultValue);
	}

	@Override
	protected Parcelable onSaveInstanceState() {
		final Parcelable superState = super.onSaveInstanceState();
		if (isPersistent()) {
			// No need to save instance state since it's persistent
			return superState;
		}

		final SavedState myState = new SavedState(superState);
		myState.value = getValue();
		return myState;
	}

	@Override
	protected void onRestoreInstanceState(Parcelable state) {
		if (state == null || !state.getClass().equals(SavedState.class)) {
			// Didn't save state for us in onSaveInstanceState
			super.onRestoreInstanceState(state);
			return;
		}

		SavedState myState = (SavedState) state;
		super.onRestoreInstanceState(myState.getSuperState());
		setValue(myState.value);
	}

	private static class SavedState extends BaseSavedState {
		int value;

		public SavedState(Parcel source) {
			super(source);
			value = source.readInt();
		}

		@Override
		public void writeToParcel(Parcel dest, int flags) {
			super.writeToParcel(dest, flags);
			dest.writeInt(value);
		}

		public SavedState(Parcelable superState) {
			super(superState);
		}

		@SuppressWarnings("unused")
		public static final Parcelable.Creator<SavedState> CREATOR = new Parcelable.Creator<SavedState>() {
			public SavedState createFromParcel(Parcel in) {
				return new SavedState(in);
			}

			public SavedState[] newArray(int size) {
				return new SavedState[size];
			}
		};
	}
}

Enjoy, and have a good year!

Advertisements
Categories: android, ui
  1. December 9, 2011 at 10:24 am

    Wow, thank you very much sir – it’s been really helpful for one of my current (and probably future) ones too. 🙂

  1. No trackbacks yet.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s