Tags
android, custom state, custom-ui, drawable, drawable state, list-view, optimization, pseudo selectors, rounded corners, selector, state, state list drawable, state_first, state_last
It’s 25 years already! Happy birthday, WWW 😉 Let’s take a concept from HTML UI and try to achieve it in Android. CSS provides “pseudo selectors” that allows selecting nodes based on a particular state. The most commonly used one is :hover
state for links. In terms of styling, especially when it involves rounded corners, the :first-child
and the :last-child
allows us to select the first and the last elements in a group. Wouldn’t life be easier if we had similar functionality in Android?
Android provides state_first
and state_last
that could be used with StateListDrawable
. Though they aren’t inherently supported by ViewGroup
s, they are a good starting point. Let’s build a background for an element in the top horizontal row (above) with these states.
<?xml version="1.0" encoding="utf-8"?> <selector xmlns:android="http://schemas.android.com/apk/res/android"> <!-- First and last --> <item android:state_first="true" android:state_last="true"> <shape> <solid android:color="#63abed"/> <corners android:radius="10dp"/> </shape> </item> <!-- First --> <item android:state_first="true"> <shape> <solid android:color="#63abed"/> <corners android:topLeftRadius="10dp" android:bottomLeftRadius="10dp"/> </shape> </item> <!-- Last --> <item android:state_last="true"> <shape> <solid android:color="#63abed"/> <corners android:topRightRadius="10dp" android:bottomRightRadius="10dp"/> </shape> </item> <!-- Default --> <item> <shape> <solid android:color="#63abed"/> </shape> </item> </selector>
There are 4 cases — this could be the only element in its parent, or be the first, or the last, or anywhere in-between. The above selector takes care of all those states. As said earlier, Android doesn’t use these states. But we could easily override onCreateDrawableState()
to achieve it.
public class PseudoAwareTextView extends TextView { private static final int[] STATE_ONLY_ONE = new int[] { android.R.attr.state_first, android.R.attr.state_last, }; private static final int[] STATE_FIRST = new int[] { android.R.attr.state_first }; private static final int[] STATE_LAST = new int[] { android.R.attr.state_last }; public PseudoAwareTextView(Context context, AttributeSet attrs) { super(context, attrs); } @Override protected int[] onCreateDrawableState(int extraSpace) { ViewGroup parent = (ViewGroup) getParent(); if (parent == null) { return super.onCreateDrawableState(extraSpace); } final int size = parent.getChildCount(); final boolean isFirst = (parent.getChildAt(0) == this); final boolean isLast = (parent.getChildAt(size-1) == this); int[] states = super.onCreateDrawableState(extraSpace + 2); if (isFirst && isLast) { mergeDrawableStates(states, STATE_ONLY_ONE); } else if (isFirst) { mergeDrawableStates(states, STATE_FIRST); } else if (isLast) { mergeDrawableStates(states, STATE_LAST); } return states; } }
Since we know that this element is inside some ViewGroup
, we can find the order of the element and set the states accordingly! Now, even if the app demands showing only one of the three elements, the background works like a charm! We don’t need booleans to keep track of what is visible and which of the four possible backgrounds an element should have.
How do we extend this to ListView
s? They might seem tricky. But they follow similar logic. The background for a row is drawn by ListView
, as specified in android:listSelector
. (For brevity of this article, let us assume that we have such a selector in place — that is pretty similar to the one above, but has android:state_pressed=”true” in them). And since the listSelector
is a Drawable
, the owner ListView
takes care of setting the proper state. Every time, before a selector is drawn over a row, the selector’s bounds are computed and the states are set. All we need to know at this point is the row, that is currently under the thumb, that needs a pressed state to be shown. That can be computed by storing the (x, y) co-ordinates of the TOUCH_DOWN
event and finding the position using pointToPosition()
method.
public class PseudoAwareListView extends ListView { private static final int[] STATE_ONLY_ONE = new int[] { android.R.attr.state_first, android.R.attr.state_last, }; private static final int[] STATE_FIRST = new int[] { android.R.attr.state_first }; private static final int[] STATE_LAST = new int[] { android.R.attr.state_last }; private int mTouchX, mTouchY; public PseudoAwareListView(Context context, AttributeSet attrs) { super(context, attrs); } @Override public boolean onInterceptTouchEvent(MotionEvent event) { if (event.getActionMasked() == MotionEvent.ACTION_DOWN) { mTouchX = (int) event.getX(); mTouchY = (int) event.getY(); } else { mTouchX = -1; mTouchY = -1; } return super.onInterceptTouchEvent(event); } @Override protected int[] onCreateDrawableState(int extraSpace) { if (!isPressed()) { return super.onCreateDrawableState(extraSpace); } final int selection = pointToPosition(mTouchX, mTouchY); final int size = getChildCount(); final boolean isFirst = (0 == selection); final boolean isLast = (size-1 == selection); int[] states = super.onCreateDrawableState(extraSpace + 2); if (isFirst && isLast) { mergeDrawableStates(states, STATE_ONLY_ONE); } else if (isFirst) { mergeDrawableStates(states, STATE_FIRST); } else if (isLast) { mergeDrawableStates(states, STATE_LAST); } return states; } }
Note: The same effect on ListViews can be achieved by enclosing it inside a FrameLayout and drawing a foreground on top of it. That would be suitable for most cases. This is recommended if your ListView with rounded corners is over a layout with translucent background.
That was easy! Now we can do even more tricks like in the CSS — nth-child, optional, etc. Here’s to the next 25 years!
a great of thanks for your great tips on Android Selectors, as I am still one novice developer on Android platform, and can you list out the complete source code for this article.
Thank you.