Building reusable custom views, part 2
Posted 10-11-2009 at 07:09 PM by divestoclimb
This is a continuation from my last post. This gets into the complicated, annoying parts that are the result of some limitations in the Android framework.
Internal listeners
I'll omit most of the internals of my Button OnClickListener and TextWatcher implementations, except where there's something really strange going on. For instance, how I detect whether a value change came from the user or not (which I need to pass to my ValueChangedListener) is not easy.
First, we know that if the user enters a value directly into the EditText using the keyboard, the first thing to get called within our class is going to be the TextWatcher. The only way the TextWatcher can tell if the user caused that change, then, is if every other possible way text can change, I set a variable that the TextWatcher can check so it knows the change did not come from the user. The TextWatcher could then infer when a change did come from the user because we didn't tell it otherwise.
The way this works is with a boolean member variable mChangeFromUser. This variable is normally always true, except when setValue() is called:
The TextWatcher can then detect the change was not from the user. Of course, this variable needs to be reset before the next change so the TextWatcher switches it back:
Unfortunately, it's just not that simple. What about the plus and minus buttons? If I were to use setValue() now to adjust the value in my button listeners, the TextWatcher wouldn't interpret that as a user change. We can work around this if we instead call setText on the EditText directly:
This brings up an interesting side effect of this design: anything that tries to use the setText method directly will cause the TextWatcher to think the user made the change, but in reality things don't always work that way. setText() gets called when the EditText is restoring its instance state, which happens soon after initialization. I came up with a very simple workaround for this in initNumberSelector:
That was easy! I thought this might cause a problem if no state was being restored, but in practice it seems fine. This may be because I always initialize the value in the widget with setValue() in my Activities' onResume() methods, and after setValue() is called the TextWatcher will set mChangeFromUser back to true anyway.
Saving/Restoring Instance State
Everything I've outlined so far works just fine... until you put more than one of these widgets in a layout and start switching screen orientations. Why? Well, Android destroys and recreates your Activity when the screen orientation changes, but it has its own builtin way to preserve state within its widgets so this is transparent to the user. Any stateful widget (EditText is one, SeekBar is another) will save a Parcelable object for its current state, and it will be indexed by the ID of the widget's view. Well, we specified in our layout XML that all EditTexts that are part of a NumberSelector widget have id R.id.text1! That means Android can't properly save/restore the state of our widget.
The full solution for this can get incredibly annoying, but luckily with the NumberSelector example it's not too bad. The only way the EditText can save and restore its state successfully is if we assign it an ID that's guaranteed to be unique within the root view. To generate such an ID, I built this static method in its own class:
You pass generateUnique the root view, and it will return a valid ID that's not in use.
Now we can reassign our EditText ID's in initNumberSelector:
That was easy... too easy! It turns out this isn't the full answer because, when the Activity is destroyed and recreated, the random ID we gave to the EditText will be gone. We need to know what the ID was last time, and the way we do that is to implement state saving within our widget. Here's how that works, using various implementations in the Android framework for guidance:
Essentially we're building a custom class that implements Parcelable, an interface for saving and restoring data. We put the ID of the EditText we created into it, and pull it back out when the instance state for our widget is restored.
If you're building a complex UI architecture like I was, here's another thing to keep in mind here: this solution made NumberSelector itself a stateful widget just like EditText! If you were to build another custom view that contained a NumberSelector, you would have to assign random ID's to that NumberSelector too.
That's all the basics that were needed to make this widget work. I've made some other tweaks to the implementation to improve the user experience, but I'll save those for another post.
All that work is worth it, though, because in the end you have your own custom widget that implements a correct object model and has a modular layout. You can also define different layout XML files for different screen orientations, and the widget won't have to do anything special to load them. The best part for me, though, was deleting all the repetitive findViewById(...) calls I was making in my main Activity before I built this.
Internal listeners
I'll omit most of the internals of my Button OnClickListener and TextWatcher implementations, except where there's something really strange going on. For instance, how I detect whether a value change came from the user or not (which I need to pass to my ValueChangedListener) is not easy.
First, we know that if the user enters a value directly into the EditText using the keyboard, the first thing to get called within our class is going to be the TextWatcher. The only way the TextWatcher can tell if the user caused that change, then, is if every other possible way text can change, I set a variable that the TextWatcher can check so it knows the change did not come from the user. The TextWatcher could then infer when a change did come from the user because we didn't tell it otherwise.
The way this works is with a boolean member variable mChangeFromUser. This variable is normally always true, except when setValue() is called:
Code:
public void setValue(float value) {
mChangeFromUser = false;
mEditText.setText(mNumberFormat.format(getValidValue(value)));
}
Code:
public void afterTextChanged(Editable s) {
// Validation code here, decide whether
// or not to call ValueChangedListener
mChangeFromUser = true;
}
Code:
public void onClick(View v) {
// Lots of code to compute the new incremented value
mEditText.setText(mNumberFormat.format(new_val));
}
Code:
protected void initNumberSelector(Context context) {
LayoutInflater i = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
i.inflate(SELECTOR_LAYOUT, this);
mChangeFromUser = false;
...
}
Saving/Restoring Instance State
Everything I've outlined so far works just fine... until you put more than one of these widgets in a layout and start switching screen orientations. Why? Well, Android destroys and recreates your Activity when the screen orientation changes, but it has its own builtin way to preserve state within its widgets so this is transparent to the user. Any stateful widget (EditText is one, SeekBar is another) will save a Parcelable object for its current state, and it will be indexed by the ID of the widget's view. Well, we specified in our layout XML that all EditTexts that are part of a NumberSelector widget have id R.id.text1! That means Android can't properly save/restore the state of our widget.
The full solution for this can get incredibly annoying, but luckily with the NumberSelector example it's not too bad. The only way the EditText can save and restore its state successfully is if we assign it an ID that's guaranteed to be unique within the root view. To generate such an ID, I built this static method in its own class:
Code:
public class ViewId {
public static int generateUnique(View v) {
Random r = new Random();
int id;
do {
id = r.nextInt();
} while(id <= 0 || v.findViewById(id) != null);
return id;
}
}
Now we can reassign our EditText ID's in initNumberSelector:
Code:
protected void initNumberSelector(Context context) {
LayoutInflater i = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
i.inflate(SELECTOR_LAYOUT, this);
mChangeFromUser = false;
mPlusButton = (ImageButton)findViewById(R.id.plus);
mMinusButton = (ImageButton)findViewById(R.id.minus);
mEditText = (EditText)findViewById(R.id.text1);
setEditTextId(ViewId.generateUnique(getRootView()));
}
protected void setEditTextId(int id) {
mEditText.setId(id);
}
Code:
public static class SavedState extends BaseSavedState {
int textId;
SavedState(Parcelable superState) {
super(superState);
}
@Override
public void writeToParcel(Parcel out, int flags) {
super.writeToParcel(out, flags);
out.writeInt(textId);
}
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];
}
};
private SavedState(Parcel in) {
super(in);
textId = in.readInt();
}
}
@Override
public Parcelable onSaveInstanceState() {
Parcelable superState = super.onSaveInstanceState();
SavedState ss = new SavedState(superState);
ss.textId = mEditText.getId();
return ss;
}
@Override
public void onRestoreInstanceState(Parcelable state) {
SavedState ss = (SavedState)state;
super.onRestoreInstanceState(ss.getSuperState());
setEditTextId(ss.textId);
}
If you're building a complex UI architecture like I was, here's another thing to keep in mind here: this solution made NumberSelector itself a stateful widget just like EditText! If you were to build another custom view that contained a NumberSelector, you would have to assign random ID's to that NumberSelector too.
That's all the basics that were needed to make this widget work. I've made some other tweaks to the implementation to improve the user experience, but I'll save those for another post.
All that work is worth it, though, because in the end you have your own custom widget that implements a correct object model and has a modular layout. You can also define different layout XML files for different screen orientations, and the widget won't have to do anything special to load them. The best part for me, though, was deleting all the repetitive findViewById(...) calls I was making in my main Activity before I built this.
Total Comments 0







