Archive

Archive for the ‘ui’ Category

Customizing AlertDialog, an update – removing the title

February 27, 2011 6 comments

A follow-up to an earlier post about customizing AlertDialog.

Jonas Petersson asked whether it’s possible to remove an alert dialog’s title. It is. Add a call to requestWindowFeature in your subclass’s onCreate, make sure it comes before the call to super.onCreate():

	@Override
	protected void onCreate(Bundle savedInstanceState) {

		setView...
		setButton...

		/*** This removes the title ***/
		requestWindowFeature(Window.FEATURE_NO_TITLE);

		super.onCreate(savedInstanceState);
	}

And this is what it looks like:

Advertisements
Categories: android, ui

Customizing AlertDialog

February 19, 2011 8 comments

Android has two ways to create dialogs: for simple ones, there is AlertDialog and its builder, for more complex there is Dialog. AlertDialog looks very nice: the bottom panel (for buttons) is highlighted, the title bar has an icon, the font sizes are just right. But at the same time, AlertDialog can be too restrictive. You can replace the content view and configure the buttons, but you can’t change the logic of how these buttons work, and you can’t get to individual UI components to modify their behavior. Or so I thought. Whenever I needed a dialog with some logic to it (such as disabling buttons until the user provides correct input), I used to use plain Dialog. It did what I needed, but my dialogs looked different than AlertDialog, as you can see in the Android Development Guide. The resources that Android uses for AlertDialog are: 1) private, and can’t be referenced directly from an application using “@android:drawable/imgname” and 2) can be customized by the hardware vendors (my Samsung Galaxy S uses a bluish scheme and a different title bar icon). For those two reasons, I had to find a way to customize AlertDialog without trying to copy those resources into my project and rebuild the way the UI looks with my own code.

Sidebar: I’m really not too happy with how some Andorid resources can’t be referenced from an application. Especially menu icons: for example, ic_menu_close_clear_cancel and ic_menu_add are accessible under “@drawable:android”, but ic_menu_refresh, or ic_menu_attachment are private and have to be copied into the project. The trouble, of course, is that hardware manufacturers can customize those, and your application will look inconsistent).

Ok, so it turns out, you can subclass AlertDialog, and get all the benefits with respect to its appearance, and still implement the behavior logic as needed.

public class MyPrettyDialog extends AlertDialog {

	private ListView mListView;
	private MyAdapter mAdapter;
	private Button mAcceptButton;

	public MyPrettyDialog(Context context) {
		super(context);
	}

	@Override
	protected void onCreate(Bundle savedInstanceState) {
		ListView listView = new ListView(mContext);
		listView.setLayoutParams(new LayoutParams(LayoutParams.FILL_PARENT,
				LayoutParams.WRAP_CONTENT, 1.0f));
		listView.setId(android.R.id.list);

		MyAdapter adapter = new MyAdapter();

		listView.setAdapter(adapter);
		listView.setOnItemClickListener(adapter);

		mListView = listView;
		mAdapter = adapter;

		setInverseBackgroundForced(true);
		setView(listView);
		setTitle(R.string.mytitle);
		setCancelable(false);

		setButton(DialogInterface.BUTTON_POSITIVE, "Positive",
				new OnClickListener() {
					@Override
					public void onClick(DialogInterface dialog, int which) {
						// This is replaced later
					}
				});

		setButton(DialogInterface.BUTTON_NEGATIVE, "Negative",
				new OnClickListener() {
					@Override
					public void onClick(DialogInterface dialog, int which) {
						// Do something
					}
				});

		super.onCreate(savedInstanceState);

		/*
		 * Replace the click listener
		 */
		mAcceptButton = getButton(BUTTON_POSITIVE);
		mAcceptButton.setOnClickListener(new View.OnClickListener() {
			@Override
			public void onClick(View v) {
				// Do something, the dialog is NOT closed automatically
			}
		});
	}
}

The most important part here is the overriden onCreate(). It is called automatically by the framework as nedded (just like for Activities). Creating this dialog is as simple as when using AlertDialog.Builder:

	@Override
	protected Dialog onCreateDialog(int id) {
		if (id == MY_PRETTY_DIALOG) {
			MyPrettyDialog dialog = new MyPrettyDialog(this);
			return dialog;
		}
		return super.onCreateDialog(id);
	}

The real value of the above code is that we can get references to UI widgets and keep them in the dialog (mListView, mAcceptButton). With this, we can for example disable the “Accept” button until the user provides required data. Also, AlertDialog, by default, closes right after the user clicks any of the buttons, and you can’t avoid that. If you would like to do some validation (keeping the “Accept” button enabled, and explaining to the user what he needs to fill in and how), you can do that by replacing the click handler by the button with your own, as the code above does at the end of onCreate. And this is what I actually ended up with – an AlertDialog with pretty involved processing logic, that still has the look the users expect.

Categories: android, ui

Large (tablet) screen impact on Android Application UI

January 29, 2011 Leave a comment

A few days Google released a perview of the next version of Andorid, 3.0 (Honeycomb), targeted at tablet devices.

Honeycomb contains new framework features that make it easier to create UIs that scale to large screen sizes. However, there are already tablets out there, like the Samsung Galaxy Tab, which run earlier versions of Android. Applications running on these earlier Android versions cannot take advantage of those new Honeycomb features (at least not yet). So, what effects does running on a large screen have on the UI of an Android application?

I’ve only done Galaxy Tab tests with the emulator, so my observations are somewhat limited, but still, here they are.

The Galaxy Tab reports itself as an HDPI device, even though its actual screen density is 170 dpi. This is to account for the larger display pixel count, and greater viewing distance.

Still, compared to a typical 4″ 480×800 hdpi phone screen, the Galaxy Tab can fit more on the screen, since it has more pixels: using a 72×72 icon as a measurement unit, a 480×800 screen can fit 6.6 accross and 11.11 down. The Galaxy Tab can fit 8.33 down and 14.22 across.

This makes images look smaller relative to the rest of the screen, even though they are larger in real world units, compared to a 4″ hdpi phone, and have same pixel size.

Screen metrics for a 4″ 480 by 800 screen

Pixels Inches Actual DPI Icon size, pixels Icon size, inches Icons per screen
480 2,06 233,01 72px 0,31″ 6,67
800 3,43 233,01 72px 0,31″ 11,11

Screen metrics for a 7″ 600 by 1024 screen

Pixels Inches Actual DPI Icon size, pixels Icon size, inches Icons per screen
600 3,54 169,49 72px 0,42″ 8,33
1024 6,04 169,49 72px 0,42″ 14,22

What this means is…

I don’t think you can expect an application running on the Tab (or another tablet) to look just like it looks on a phone, only larger. So far, this has been the case for phones of varying screen sizes and densities, but for a tablet device, this is no longer true.

And here are some screenshots of my application running on an HVGA device, on a WVGA800 device, and the Galaxy Tab, all resized to same height (320px):

HVGA - original size

The original 480 by 320 HVGA screen.

WVGA800 - resized

This is 800 by 480, resized to 533 by 320 (same height as the HVGA screenshot above).

Looks very, very much like the HVGA screenshot (although the text in spinners is not clipped, as they are a little larger).

Galaxy Tab - resized

This is 1024 by 600, resized to 546 by 320 (same height as the HVGA screenshot above). Doesn’t look like the screens above, does it?

The UI controls on the right still line up with list view on the left, but they are much smaller relative to the screen.

Measured in inches, the entire screen is larger, and the buttons (being the same size in pixels) are also larger than those on a 4″ WVGA800 device.

The Galaxy Tab can fit more information on the screen. The list could have about one and a half rows more.

Categories: android, ui

Creating a checkable image button

January 11, 2011 15 comments

To continue with the theme of custom styleable controls, here is how to implement your own image button that behaves like a radio button or a check box.

I wanted to make it easy (for myself) to create a bunch of these buttons, differing only by the image they use, and to avoid having to create state-dependent variations of those images.

In other words, the background uses state-dependent drawables, while the foreground image is fixed (per button).

This is what it looks like:

Creating these in XML was easy:

<your.package.name.CheckableImageButton
	android:id="@+id/checkable_image_1"
	android:layout_width="wrap_content"
	android:layout_height="wrap_content"
	android:background="@drawable/checkable_image_selector"
	android:src="@drawable/checkable_icon_1" />

<your.package.name.CheckableImageButton
	android:id="@+id/checkable_image_2"
	android:layout_width="wrap_content"
	android:layout_height="wrap_content"
	android:background="@drawable/checkable_image_selector"
	android:src="@drawable/checkable_icon_2" />

The images checkable_icon_1, checkable_icon_2 only differ by color, and are plain PNG files.

Declaring the styleable

<!-- Goes into res/values/styles.xml -->
<declare-styleable name="CheckableImageButton">
	<attr name="is_checked" format="boolean" />
        
	<attr name="personality">
            	<enum name="radio" value="0" />
            	<enum name="check" value="1" />
        	</attr>
</declare-styleable>  

The personality value will be used to make the button behave like a check box (which toggles its state when touched) or a radio button (which always goes into checked state, regardless of current state).

The code

Android’s ImageButton doesn’t support the notion of a checked state, but otherwise is pretty close.

Here is my subclass:

public class CheckableImageButton extends ImageButton implements Checkable {

	private boolean mChecked;
	private boolean mBroadcasting;
	private int mPersonality;
	private OnCheckedChangeListener mOnCheckedChangeListener;

	private static final int[] CHECKED_STATE_SET = { R.attr.is_checked };

	private static final int PERSONALITY_RADIO_BUTTON = 0;
	private static final int PERSONALITY_CHECK_BOX = 1;

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

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

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

		mPersonality = a.getInt(R.styleable.CheckableImageButton_personality, PERSONALITY_RADIO_BUTTON);
		boolean checked = a.getBoolean(R.styleable.CheckableImageButton_is_checked, false);
		setChecked(checked);

		a.recycle();
	}

	public void toggle() {
		setChecked(!mChecked);
	}

	@Override
	public boolean performClick() {
		if (mPersonality == PERSONALITY_RADIO_BUTTON) {
			setChecked(true);
		} else if (mPersonality == PERSONALITY_CHECK_BOX) {
			toggle();
		}
		return super.performClick();
	}

	public boolean isChecked() {
		return mChecked;
	}

	/**
	 * <p>
	 * Changes the checked state of this button.
	 * </p>
	 * 
	 * @param checked
	 *            true to check the button, false to uncheck it
	 */
	public void setChecked(boolean checked) {
		if (mChecked != checked) {
			mChecked = checked;
			refreshDrawableState();

			// Avoid infinite recursions if setChecked() is called from a listener
			if (mBroadcasting) {
				return;
			}

			mBroadcasting = true;
			if (mOnCheckedChangeListener != null) {
				mOnCheckedChangeListener.onCheckedChanged(this, mChecked);
			}

			mBroadcasting = false;
		}
	}

	/**
	 * Register a callback to be invoked when the checked state of this button changes.
	 * 
	 * @param listener
	 *            the callback to call on checked state change
	 */
	public void setOnCheckedChangeListener(OnCheckedChangeListener listener) {
		mOnCheckedChangeListener = listener;
	}

	/**
	 * Interface definition for a callback.
	 */
	public static interface OnCheckedChangeListener {
		/**
		 * Called when the checked state of a button has changed.
		 * 
		 * @param button
		 *            The button view whose state has changed.
		 * @param isChecked
		 *            The new checked state of button.
		 */
		void onCheckedChanged(CheckableImageButton button, boolean isChecked);
	}

	@Override
	public int[] onCreateDrawableState(int extraSpace) {
		final int[] drawableState = super.onCreateDrawableState(extraSpace + 1);
		if (isChecked()) {
			mergeDrawableStates(drawableState, CHECKED_STATE_SET);
		}
		return drawableState;
	}

	@Override
	protected void drawableStateChanged() {
		super.drawableStateChanged();
		invalidate();
	}

	static class SavedState extends BaseSavedState {
		boolean checked;

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

		private SavedState(Parcel in) {
			super(in);
			checked = (Boolean) in.readValue(null);
		}

		@Override
		public void writeToParcel(Parcel out, int flags) {
			super.writeToParcel(out, flags);
			out.writeValue(checked);
		}

		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];
			}
		};
	}

	@Override
	public Parcelable onSaveInstanceState() {
		Parcelable superState = super.onSaveInstanceState();
		SavedState ss = new SavedState(superState);
		ss.checked = isChecked();
		return ss;
	}

	@Override
	public void onRestoreInstanceState(Parcelable state) {
		SavedState ss = (SavedState) state;

		super.onRestoreInstanceState(ss.getSuperState());
		setChecked(ss.checked);
		requestLayout();
	}
}

The button adds a new drawable state to the base class, which is exposed via onCreateDrawableState.

Switchable personality is supported by getting a value from XML in the constructor:

mPersonality = a.getInt(R.styleable.CheckableImageButton_personality, PERSONALITY_RADIO_BUTTON);

and doing different things in performClick depending on whether the personality is a checkbox or a radiobutton:

	@Override
	public boolean performClick() {
		if (mPersonality == PERSONALITY_RADIO_BUTTON) {
			setChecked(true);
		} else if (mPersonality == PERSONALITY_CHECK_BOX) {
			toggle();
		}
		return super.performClick();
	}

The background drawable selector with states

This goes into res/drawable/checkable_image_selector.xml, and can shared by all CheckableImageButton objects.

<?xml version="1.0" encoding="utf-8"?>
<selector
	xmlns:android="http://schemas.android.com/apk/res/android"
	xmlns:sample="http://schemas.android.com/apk/res/your.package.name">
	<item
		android:state_pressed="true"
		android:drawable="@drawable/checkable_image_button_state_pressed" />
	<item
		sample:is_checked="true"
		android:drawable="@drawable/checkable_image_button_state_checked" />
	<item
		android:drawable="@android:color/transparent" />
</selector>

Note the use of sample namespace for is_checked. This links the attribute to the styleable in styles.xml and then on to R.styleable.CheckableImageButton in the constructor.

Finally, background drawables for individual states

This is the drawable for pressed state. You can see in the second screenshot near the top of this page. The magic colors for the gradient were picked from nine-patch drawables supplied with the SDK. The stroke color is a nice shade of pale blue I happen to like.

<?xml version="1.0" encoding="utf-8"?>
<shape
	xmlns:android="http://schemas.android.com/apk/res/android"
	android:shape="rectangle">
	<corners
		android:radius="4dp" />
	<padding
		android:left="4dp"
		android:right="4dp"
		android:top="4dp"
		android:bottom="4dp" />
	<gradient
		android:angle="90"
		android:startColor="#FFFFC700"
		android:centerColor="#FFFFA600"
		android:endColor="#FFFFC700" />
	<stroke
		android:width="3dp"
		android:color="#FF80B0E0" />
</shape>

The “non-pressed” checked state is simplier, as it doesn’t need a gradient fill:

<?xml version="1.0" encoding="utf-8"?>
<shape
	xmlns:android="http://schemas.android.com/apk/res/android"
	android:shape="rectangle">
	<corners
		android:radius="4dp" />
	<padding
		android:left="4dp"
		android:right="4dp"
		android:top="4dp"
		android:bottom="4dp" />
	<solid
		android:color="#00000000" />
	<stroke
		android:width="3dp"
		android:color="#FF80B0E0" />
</shape>

And that’s about it. Hope this was useful.

Categories: android, ui

A styleable preference for a list of integers

January 2, 2011 1 comment

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!

Categories: android, ui

Showing progress wheel in the window title

November 22, 2010 1 comment

This is a follow-up to my earlier post about customizing Andorid window title bar for showing a progress wheel.

I’ve since found a much easier way to do this, without having to create a custom window title. The standard Android window title bar (found in platforms\android-X\data\res\layout\screen.xml) already has a wheel-style progress indicator, but I didn’t know how to make it visible until recently.

Here is how:

// Somewhere in onCreate:

requestWindowFeature(Window.FEATURE_INDETERMINATE_PROGRESS);

// setContentView here

setProgressBarIndeterminateVisibility(true / false);

Even though the last method call above has the word “bar” in it, this displays a spinning wheel on the right side of the window title bar.

Categories: android, ui

Adding group headers to ListView

November 16, 2010 24 comments

Let’s say we wanted to implement a ListView with group headers, where each group is defined by a certain data column having the same value. In other words, each time the data column has a new value (proceeeding from top to bottom), we want to have a special view that highlights that fact.

You can see an example of this in the default Android email application. Here is a screenshot that also highlights this feature:

I’ve highlighted (in green) the data values used for grouping: email message dates.

The ListView is backed up by a SQLite cursor.

First, let’s talk about two wrong ways to do this.

Wrong way number one:

Implement the group header view as a separate ListView item layout. What this means is that item numbers will “slide”, because these group headers will take their own slots within the item sequence.

Now if we did this, we’d need to write code to map item numbers between what’s in the ListView, and our data (the cursor).

To avoid this complication, we’re going to merge each group header with the layout of the first item in that group. There will be two types of list item layouts: one for items that start a new group, including views to highlight the group (blue in the screenshot above), and the “regular” list item layout for items that do not start a new group.

ListView actually lets us mix several layouts within the same list, and is capable of managing list item view layout recyclidng, taking view type for a particular list item into consideration.

Wrong way number two:

We could preprocess the data, determine where groups start, and take it from there.

This is bad for peformance reasons:

– First, we’d need to traverse the entire data set defined by our cursor, which can be pretty slow in itself (managing traversals limited by how far as the user scrolls is another piece of code that I’d like to avoid writing).

– Second, we’d need to mark new groups in the database. Flash memory writes can take a long time, so now we’d need to implement a background thread.

In short, preprocessing the data is complicated and slow. We’re going to avoid this by “looking back” at the previous data item when necessary, and comparing the message date in the current and previous data items, to see if the current item starts a new group.

Now, let’s look at the code

I’m going to use a pretty typical email message list view as an example, grouping messages by date.

First, we need to define two layouts, one for items that start a new group, and one for the items that don’t.

This is the “plain” item layout:

<RelativeLayout
  xmlns:android="http://schemas.android.com/apk/res/android"
  android:layout_width="fill_parent"
  android:layout_height="wrap_content">
  
	<TextView android:id="@+id/message_item_subject"
		android:layout_width="fill_parent"
		android:layout_height="wrap_content"
		android:textSize="18sp"/>
		
	<TextView android:id="@+id/message_item_from"
			android:layout_width="fill_parent"
			android:layout_height="wrap_content"
			android:layout_marginLeft="12dp"
			android:layout_below="@id/message_item_subject"
			android:singleLine="true"
			android:textSize="16sp"/>

	<TextView android:id="@+id/message_item_when"
			android:layout_width="fill_parent"
			android:layout_height="wrap_content"
			android:layout_marginLeft="12dp"
			android:layout_below="@id/message_item_from"
			android:singleLine="true"
			android:textSize="16sp"/>

	<!-- more views snipped -->

</RelativeLayout>

The layout for items that start a group adds a view at the top, to show the date common to all the messages within that particular group. The rest is the same:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
  xmlns:android="http://schemas.android.com/apk/res/android"
  android:layout_width="fill_parent"
  android:layout_height="wrap_content">
  
	<TextView android:id="@+id/message_item_when_header"
			android:layout_width="fill_parent"
			android:layout_height="wrap_content"
			android:background="#ff4040f0"
			android:textColor="#ffffffff"
			android:textSize="22sp"/>
  
	<TextView android:id="@+id/message_item_subject"
			android:layout_width="fill_parent"
			android:layout_height="wrap_content"
			android:layout_below="@id/message_item_when_header"
			android:textSize="18sp"/>
		
	<TextView android:id="@+id/message_item_from"
			android:layout_width="fill_parent"
			android:layout_height="wrap_content"
			android:layout_marginLeft="12dp"
			android:layout_below="@id/message_item_subject"
			android:singleLine="true"
			android:textSize="16sp"/>

	<TextView android:id="@+id/message_item_when"
			android:layout_width="fill_parent"
			android:layout_height="wrap_content"
			android:layout_marginLeft="12dp"
			android:layout_below="@id/message_item_from"
			android:singleLine="true"
			android:textSize="16sp"/>

	<!-- more views snipped -->

</RelativeLayout>

And now for the message list adapter. We’re going to derive it from CursorAdapter, so we get automatic list updates whenever the underlying data changes.

We’re adding two methods to tell ListView that there are two types of list item layouts, and which list item uses which one (getViewTypeCount and getItemViewType).

The code that “looks back” at the previous data item and decides if the current item starts a new group is placed in its own method, isNewGroup. This method assumes that the position within the database cursor has already been set, and restores the position before returning.

Item view layouts are created in newView and their values are set in bindView, this is usual for subclass of CursorAdapter.

public class MessageListActivity extends Activity {

	@Override
	public void onCreate(Bundle savedInstanceState) {
		super.onCreate(savedInstanceState);
		setContentView(R.layout.messagelist);

		mMessageListView = (ListView) findViewById(R.id.message_list);

		// Set list view click handler

		mMessageListView.setOnItemClickListener(new OnItemClickListener() {
			@Override
			public void onItemClick(AdapterView<?> parnet, View view, int position, long id) {
				// display an activiting show item details
			}
		});

		// Set list view adapter

		Cursor cursor = managedQuery();  // parameters snipped
		mAdapter = new MessageAdapter(this, cursor);
		mMessageListView.setAdapter(mAdapter);
	}

	private static final class MessageAdapter extends CursorAdapter {

		// We have two list item view types

		private static final int VIEW_TYPE_GROUP_START = 0;
		private static final int VIEW_TYPE_GROUP_CONT = 1;
		private static final int VIEW_TYPE_COUNT = 2;

		MessageAdapter(Context context, Cursor cursor) {
			super(context, cursor);

			// Get the layout inflater

			mInflater = LayoutInflater.from(context);

			// Get and cache column indices

			mColSubject = cursor.getColumnIndex(MailConstants.MESSAGE_SUBJECT);
			mColFrom = cursor.getColumnIndex(MailConstants.MESSAGE_FROM);
			mColWhen = cursor.getColumnIndex(MailConstants.MESSAGE_WHEN);
		}

		@Override
		public int getViewTypeCount() {
			return VIEW_TYPE_COUNT;
		}

		@Override
		public int getItemViewType(int position) {
			// There is always a group header for the first data item

			if (position == 0) {
				return VIEW_TYPE_GROUP_START;
			}

			// For other items, decide based on current data

			Cursor cursor = getCursor();
			cursor.moveToPosition(position);
			boolean newGroup = isNewGroup(cursor, position);

			// Check item grouping

			if (newGroup) {
				return VIEW_TYPE_GROUP_START;
			} else {
				return VIEW_TYPE_GROUP_CONT;
			}
		}

		@Override
		public View newView(Context context, Cursor cursor, ViewGroup parent) {

			int position = cursor.getPosition();
			int nViewType;

			if (position == 0) {
				// Group header for position 0

				nViewType = VIEW_TYPE_GROUP_START;
			} else {
				// For other positions, decide based on data

				boolean newGroup = isNewGroup(cursor, position);

				if (newGroup) {
					nViewType = VIEW_TYPE_GROUP_START;
				} else {
					nViewType = VIEW_TYPE_GROUP_CONT;
				}
			}

			View v;

			if (nViewType == VIEW_TYPE_GROUP_START) {
				// Inflate a layout to start a new group

				v = mInflater.inflate(R.layout.message_list_item_with_header, parent, false);

				// Ignore clicks on the list header

				View vHeader = v.findViewById(R.id.message_item_when_header);
				vHeader.setOnClickListener(new OnClickListener() {
					@Override
					public void onClick(View v) {
					}
				});
			} else {
				// Inflate a layout for "regular" items

				v = mInflater.inflate(R.layout.message_list_item, parent, false);
			}
			return v;
		}

		@Override
		public void bindView(View view, Context context, Cursor cursor) {
			TextView tv;

			tv = (TextView) view.findViewById(R.id.message_item_subject);
			tv.setText(cursor.getString(mColSubject));

			tv = (TextView) view.findViewById(R.id.message_item_from);
			tv.setText(cursor.getString(mColFrom));

			tv = (TextView) view.findViewById(R.id.message_item_when);
			Date d = new Date(cursor.getLong(mColWhen));
			tv.setText(gDateFormatDataItem.format(d));

			// If there is a group header, set its value to just the date

			tv = (TextView) view.findViewById(R.id.message_item_when_header);
			if (tv != null) {
				tv.setText(gDateFormatGroupItem.format(d));
			}
		}

		/**
		 * Determines whether the current data item (at the current position
		 * within the cursor) starts a new group, based on message date.
		 * 
		 * @param cursor
		 *            SQLite database cursor, the current data item position is
		 *            assumed to have been set by the caller
		 * @param position
		 *            The current data item's position within the cursor
		 * @return True if the current data item starts a new group
		 */

		private boolean isNewGroup(Cursor cursor, int position) {
			// Get date values for current and previous data items

			long nWhenThis = cursor.getLong(mColWhen);

			cursor.moveToPosition(position - 1);
			long nWhenPrev = cursor.getLong(mColWhen);

			// Restore cursor position

			cursor.moveToPosition(position);

			// Compare date values, ignore time values

			Calendar calThis = Calendar.getInstance();
			calThis.setTimeInMillis(nWhenThis);

			Calendar calPrev = Calendar.getInstance();
			calPrev.setTimeInMillis(nWhenPrev);

			int nDayThis = calThis.get(Calendar.DAY_OF_YEAR);
			int nDayPrev = calPrev.get(Calendar.DAY_OF_YEAR);

			if (nDayThis != nDayPrev || calThis.get(Calendar.YEAR) != calPrev.get(Calendar.YEAR)) {
				return true;
			}

			return false;
		}

		LayoutInflater mInflater;

		private int mColSubject;
		private int mColFrom;
		private int mColWhen;

		private static SimpleDateFormat gDateFormatDataItem = new SimpleDateFormat(
				"E, dd MMM yyyy HH:mm:ss");
		private static SimpleDateFormat gDateFormatGroupItem = new SimpleDateFormat("EEE, dd MMMM");
	}

	// End of MessageAdapter

	private ListView mMessageListView;
	private ListAdapter mAdapter;
}

Finishing touches

Some devices have a track pad that can be used to select list view items. The currently selected item is highlighted correctly, even for items that start a new group. This is probably because the message_item_when_header TextView (group header) has a background color specified for it:

Finally, since our activity processes clicks on list items (to launch an activity showing a detailed message view), we need to ignore clicks on the group header. This is done inside newView by setting a special, empty, click handler just for the group header TextView.

Here is the code again:

			if (nViewType == VIEW_TYPE_GROUP_START) {
				// Inflate a layout that starts a new group

				v = mInflater.inflate(R.layout.message_list_item_with_header, parent, false);

				// Ignore clicks on the group header

				View vHeader = v.findViewById(R.id.message_item_when_header);
				vHeader.setOnClickListener(new OnClickListener() {
					@Override
					public void onClick(View v) {
					}
				});
			} else {
				// regular item case snipped
			}

Performance

I profiled the code above while scrolling, and found that cursor.setPosition only took about 1.5% of total CPU time. Actually, the report was dominated by TextView.setText and SimpleDateFormat.format, each taking about equal amounts of time (apart from ListView code).

This makes sense, since we’re only moving the cursor to negihboring data rows, which should be cached (either in the framework, SQLite database, or – in the worst case – by the Linux kernel file system cache). If the user doesn’t scroll very far, then we don’t touch rows that don’t become visible.

And that’s about it. I hope this was usefu.

Categories: android, ui