Monday, September 6, 2010

Soft keyboard hardship


The plug-in design of soft (virtual) keyboards is definitely one of Android's better architecture decisions. For one thing it allows Swype to exist, and I have no wish to return to a Swype-less existence.

But there is a curious omission from the APIs that work with it: there is no API for knowing when the soft keyboard is dismissed or shown. (See the official word from Android engineer Dianne Hackborn here).

It's curious because you can ask InputMethodManager to show the keyboard - most likely to be wanted when a text field receives focus - and you can ask it to hide the keyboard. But you can't tell at runtime whether the keyboard is shown or hidden.

The reason you'd want this in the first place is to exercise control over your layout when the keyboard appears and disappears. You do get a primitive degree of control over this via windowSoftInputMode, but if you want to optimize your layout with respect to the reduced amount of space you get once the keyboard's stolen half the screen, you're strangely out of luck.

The recommended way to get this information is to override the onSizeChanged() or onLayout() of a top-level view and basically guess at whether the keyboard is there or not. Here's an example of this:

public class MainLayout extends LinearLayout {

public MainLayout(Context context, AttributeSet attrs) {
super(context, attrs);
}

@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
if (changed) {
int height = bottom-top;
Activity activity = (Activity)getContext();
Rect rect = new Rect();
activity.getWindow().getDecorView().getWindowVisibleDisplayFrame(rect);
int statusBarHeight = rect.top;
int screenHeight = activity.getWindowManager().getDefaultDisplay().getHeight();
int diff = (screenHeight - statusBarHeight) - height;
boolean softKeyboardShown = (diff>0);
}
}
}

Here we get the height of the screen and subtract the height of the system status bar to get the size in pixels of the area of screen that the activity should be filling. If there's a mismatch between that size and the activity's height, that must be because the soft keyboard is showing.

This works well, despite the assumption about where the system status bar is. The real catch is that this code runs in the middle of a layout pass. Since the reason you want to do this at all is so you can adjust layout, the middle of a layout pass is the least desirable place possible. As soon as you change the size or position of anything, you end up having to queue a requestLayout() call on a posted runnable to get your adjustment to work. And that introduces a horrible flicker as the activity spends a fraction of a second in the intermediate layout.

Really we want to know about the keyboard change before any layout change occurs. After much research it seems this might - MIGHT - be possible.

The trick is to subclass EditText and override onCreateInputConnection(). This function is called before the soft keyboard is shown, and before the layout is changed to make room for it. So that's one half of the problem solved already.

The other half - to know when the soft keyboard is about to disappear - is harder. So hard in fact, that I haven't got a real solution yet. The closest I've got is to wrap the InputConnection object returned by onCreateInputConnection and hook into its finishComposingText() function. This is called the moment the soft keyboard detaches from a view.

Unfortunately finishComposingText() isn't necessarily called when the keyboard is going to be hidden, only when it's stopped being attached to a given EditText. If the focus is merely changing from one EditText to another, then the keyboard won't be going anywhere.

So as I finish this first article on this irritating subject, I have an EditText-based class which mostly works:

public class EditTextEx extends EditText {

public EditTextEx(Context context, AttributeSet attrs) {
super(context, attrs);
}

static boolean keyboardDefinitelyShown = false;
static boolean ignoreNextFinishComposingTextEvent = false;
static long timeWeLostFocus;
@Override
protected void onFocusChanged (boolean gainFocus, int direction, Rect previouslyFocusedRect) {
long now = android.os.SystemClock.uptimeMillis();
if (!gainFocus) {
timeWeLostFocus = now;
keyboardDefinitelyShown = false;
ignoreNextFinishComposingTextEvent = false;
}
else {
long timeSinceLosingFocus = now - timeWeLostFocus;
keyboardDefinitelyShown = (timeSinceLosingFocus <>
ignoreNextFinishComposingTextEvent = keyboardDefinitelyShown;
}
super.onFocusChanged(gainFocus, direction, previouslyFocusedRect);
}

private class MyInputConnection extends InputConnectionWrapper {
MyInputConnection(InputConnection conn) {
super(conn, true);
}
@Override
public boolean finishComposingText() {
final MyActivity activity = (MyActivity)getContext();
if (ignoreNextFinishComposingTextEvent) {
ignoreNextFinishComposingTextEvent = false;
}
else {
if (keyboardDefinitelyShown) {
keyboardDefinitelyShown = false;
activity.onSoftKeyboardShown(false);
}
}
return super.finishComposingText();
}
}
@Override public InputConnection onCreateInputConnection (EditorInfo outAttrs) {
MyActivity activity = (MyActivity)getContext();
keyboardDefinitelyShown = true;
activity.onSoftKeyboardShown(true);
return new MyInputConnection(super.onCreateInputConnection(outAttrs));
}
}

This class exploits the fact that onFocusChanged(false) will always be called before onFocusChanged(true). If the focus is switching between two EditTextEx instances, these events will occur within a very short space of time, and so we know that the keyboard isn't going to be hidden when the next finishComposingText() occurs.

It works, but I run into problems with ViewFlippers and what happens to focus when the screen's turned off then on. Am sure these are solveable but for the minute I'm out of time.