The mysterious case of the error drawable

    The mysterious case of the error drawable
    Cover image: “Error” by Nick Webb — on flickr

    When a simple setError() is gonna take you right into the danger zone.

    [tl;dr at the end of the article]

    A relatively little known feature of TextView and its subclasses (EditText, Button, etc) is that it has a built-in way to display errors to the user. Sure, they’re not exactly pretty or in line with the Material guidelines, but they do work well enough for an MVP:

    I hope you like low density assets.

    Showing that error is as easy as this:

    editText.setError(“Error test”);

    The nice thing is that error state is automatically persisted across configuration changes, so if you rotate your device, the error is still there:

    Oh hai again, embarrassingly outdated icon.

    And on top of that, you can even specify a bette… erm, a custom icon for the error state:

    Drawable icon = getResources().getDrawable(R.drawable.ic_error_icon);
        
    if (icon != null) {
      icon.setBounds(0, 0, 
                     icon.getIntrinsicWidth(),
                     icon.getIntrinsicHeight());
    }
    editText.setError(“Error test”, icon);
    Not perfect, but better. Still not #materialyolo.

    Now, before we start running around using this thingy everywhere (you shouldn’t, in case it wasn’t clear yet), let’s see when we rotate the device again:

    WHAT THE ACTUAL F***?!?

    No, this isn’t the same screenshot uploaded twice by mistake. This actually really happens when you rotate your device: you lose the custom icon.


    The how and the why

    As a good Android developer always does (or rather, has to do) in these cases, it’s time to understand what’s going on under the hood.

    Revvin’ up your engine

    Let’s look into the TextView code for setError(CharSequence), first:

    public void setError(CharSequence error) {
      if (error == null) {
        setError(null, null);
      } else {
        Drawable dr = getContext()
            .getDrawable(
              com.android.internal.R.drawable.indicator_input_error);
        dr.setBounds(0, 0, dr.getIntrinsicWidth(), 
              dr.getIntrinsicHeight());
        setError(error, dr);
      }
    }

    So far, so good. As you can see, the setError(CharSequence) overload basically just calls the setError(CharSequence, Drawable) passing the default icon, as we’d expect. This will be important later on, so keep it in mind.

    What does setError(CharSequence, Drawable) do, then?

    public void setError(CharSequence error, Drawable icon) {
      createEditorIfNeeded();
      mEditor.setError(error, icon);
      notifyViewAccessibilityStateChangedIfNeeded(
          AccessibilityEvent.CONTENT_CHANGE_TYPE_UNDEFINED);
    }

    The most important thing here is the call to the underlying Editor. That means TextView is just delegating the error state handling to it.

    Let’s look into that, then!

    Listen to her howlin’ roar

    Here’s Editor#setError():

    public void setError(CharSequence error, Drawable icon) {
      // ...
      if (mError == null) {
        setErrorIcon(null);
        // Hide popup...
      } else {
        setErrorIcon(icon);
        if (mTextView.isFocused()) {
          showError();
        }
      }
    }

    I’m not going into the setErrorIcon() code, it does what it says on the tin, but in a quite convoluted way that’s not really worth explaining.

    The important thing you need to know is that that setErrorIcon() sets your error icon as the compound drawable on the right side of the TextView. If your brain is now in pain and screaming:

    CIRCULAR DEPENDENCIES!!1!

    then it’s definitely right. The TextView delegates work to the Editor, which in turn acts on the TextView using its internal APIs. OOP at its finest.

    Metal under tension

    Ok, we have an idea of how the error is set and handled by the TextView and its Editor. Now, the question is: where does it all go wrong?

    A fair assumption would be that the whole issue is related to the save/restore of the instance data of the TextView. Let’s see what’s in the onSaveInstanceState() code:

    @Override
    public Parcelable onSaveInstanceState() {
      Parcelable superState = super.onSaveInstanceState();
      // Save state if we are forced to
      boolean save = mFreezesText;
    
      // ...
    
      if (save) {
        SavedState ss = new SavedState(superState);
        // ...
        ss.error = getError();
        return ss;
      }
      return superState;
    }

    Well, yes, apparently the current error state is saved in the TextView’s SavedState. That’s good.

    But there’s something fishy here. We are only saving one field. One would hope that this is actually saving a compound object that wraps both the icon and the message, but that would be far, far too easy. Looking up TextView’s SavedState#error declaration, we see this:

    CharSequence error;

    Ruh-roh.

    Yes, the TextView is only saving the error message.

    Beggin’ you to touch and go

    What happens then in the onRestoreInstanceState()? You get a comment:

    // XXX restore buffer type too, as well as lots of other stuff

    Errrrrm… well, thanks. I suppose this includes restoring the error icon at some point, but that isn’t clearly the case yet.

    But wait — it gets better! Here’s how the error message is restored:

    if (ss.error != null) {
      final CharSequence error = ss.error;
      // Display the error later, after the first layout pass
      post(new Runnable() {
        public void run() {
          setError(error);
        }
      });
    }

    I won’t even begin to explain the level of WTF-ery of this code. I’ll just leave it here, for you to admire.


    Workarounds?

    Now we know the reason why things are not working. But is there a way to fix this behaviour?

    Highway to the Danger Zone

    One thing you might want to do is to subclass TextView, override the onSaveInstanceState() and onRestoreInstanceState(), and implement the fix yourself by passing the error Drawable through the saved state. But there’s a problem: Drawables aren’t Parcelable!

    So, you could instead pass the error icon resource ID in your own custom saved state along with the message, and restore them in the onRestoreInstanceState(). That might work, but you have to remember that TextView is using that awful hack of post()ing an anonymous Runnable to restore the error state. There’s basically no way to prevent that from happening. So you have to do something just as ugly and wrong, and post() another Runnable that overrides what the one previously posted by the superclass.

    You could, as an alternative to post()ing, think about using a ViewTreeObserver and reset the error state there in the onPreDraw() callback. There is a problem though, which is also present in the original TextView hack, which is that there is no guarantee of when the post()ed action will be executed. It can be at any point in time before or after onPreDraw() happens, so post()ing ourselves is a safer bet.

    Ride into the Danger Zone

    Another thing you can do is to handle the error state somewhere else, such as the containing Activity, Fragment or custom View. This approach is the cleanest, as consists simply of calling the logic to set the error state on the TextViews again, on or after onResume(), when you’re re-binding your UI to the underlying data.

    The drawback here is that this is only possible if you’re using some mechanism such as Loaders or Rx pipelines, that are started in the onResume(), and are thus guaranteed to run after the TextView’s post()ed action has been executed.


    tl;dr and a better solution

    As we’ve seen TextView#setError(CharSequence, Drawable) is broken, and there’s no clean way of fixing it.

    You can work around the problem by just being as hacky as the AOSP code, but your mileage may vary. Internal implementations might be different on different OS versions, OEMs and even devices.

    The only winning move is not to play.

    Use alternative ways of showing errors. You can go fancy with a floating label that can also show errors, or simply use another TextView in your layout to show errors. Even this simple solution, in conjunction with animateLayoutChanges, can make for a nice UX.

    Sebastiano Poggi

    Sebastiano Poggi

    “It depends” 🤷‍♂️ — Google Developer Expert for Android, Flutter and Identity. A geek 🤓 who has a serious thing for good design ✨ and for emojis 🤟working at JetBrains (opinions my own)

    London and elsewhere https://sebastiano.dev