Customizing the Android EditText behavior with Spans

Posted on July 25, 2011

12


Friendly names for contacts in the EditText field of PhotoRocket for Android app

Contacts show as friendly names and are underlined

While working with PhotoRocket, I designed a different edit experience for their Android app to treat contacts similarly to the other PhotoRocket client apps. Specifically, I wanted the EditText control to render contacts using their “friendly name” and to treat those as a single entity for navigation and delete.

As you can see in the image at right we chose to underline the friendly names which provides a visual indicator that they are a unified entity and are different than typed in text. This, as well as handling movement events and delete events around the ‘entities’ was possible because of Spans — a feature that allows any object to be attached to points in a CharSequence.

This is a fairly lengthy post, so I’ve divided it into sections on rendering, movement events and handling deletes.

Spannables and rendering

All of our contact data objects implement an abstract class called Recipient that includes basic email and friendly-name data.

public class Recipient {

    private String name;
    private String email;

    public String getName() {
        return name;
    }

    public String getEmail() {
        return email;
    }

    public void setName(String name) {
        this.name = name;
    }

    public void setEmail(String email) {
        this.email = email;
    }

    @Override
    public String toString() {
        return String.format("\"%1$s\" <%2$s>", name, email);
    }
}

EditText fields have Editable content which implements Spannable. The edit control in the form is a MultiAutoCompleteTextView which requires an Adapter to provide and filter the data and place it in the EditText control. Using the filter was the perfect place to convert selected text into a Spannable and add it to the EditText field. So I overrode the filter’s convertResultsToString method to have it handle Recipient items in the result list specially.

        @Override
        public CharSequence convertResultToString(Object resultValue) {
            if (resultValue instanceof Recipient) {
                return ((Recipient) resultValue).toCharSequence();
            }
            return super.convertResultToString(resultValue);
        }

And then added special handling to the Recipient‘s toCharSequence method that inserts the spans.

    public CharSequence toCharSequence() {
        String name = getName();
        SpannableString spannable = new SpannableString(name);
        int length = spannable.length();
        if (length > 0) {
            spannable.setSpan(
                    new RecipientSpan(this),
                    0,
                    length,
                    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
            );
        }
        return spannable;
    }

What this does is to create a SpannableString using the text of the recipient’s friendly name, then add a span from the beginning to the end of the returned string that includes a reference to the Recipient object. So with the RecipientSpan class I am able to attach a Recipient data object to any part of the text. (The MultiAutoCompleteTextView handles appending this to the entire Editable string in the EditText on the form.)

    public static class RecipientSpan extends ClickableSpan {
        private final Recipient recipient;

        public RecipientSpan(Recipient recipient) {
            super();
            this.recipient = recipient;
        }
        @Override
        public void updateDrawState(TextPaint ds) {
            ds.setUnderlineText(true);
        }

        @Override
        public void onClick(View view) {
        }
    }

Spans are handled specially within the framework, so this is design depends on the framework authors not changing how ClickableSpans are used. I subclasses ClickableSpan because it’s rendered at the right time and because it nicely selects the entire span when you touch or click on the text. I overrode the onClick to do nothing because I actually don’t want to do anything with clicks here. Finally, I set the drawing context to underline the text.

Interestingly the framework is extremely limited by the choices available for rendering or drawing in the edit field. Most of the rendering is hardcoded or uses explicit choices (like setUnderlineText). While you can render images on the beginning of a line or completely behind the text, it’s harder to render graphics around or with padding and there is no span that allows the object to entirely draw itself unless it replaces the text (like an emoticon).

If you’re struggling with the span concept think of how it would be applied to rendering an HTML page. That helped me make sense of what drove the current design decisions. Take a wander through the span classes in android.text.style to get a better idea of what is supported. Also look at TextPaint and it’s inherited methods to see what can be modified in terms of rendering.

Scrolling or moving the cursor

It’s great to have the underlining and pretty names (and still get access to the underlying object), but we really wanted this to feel right. One behavior that was important to me was that if I move the trackball or use d-pad arrows to move the cursor through the edit field, it should highlight the entire Recipient, rather than move the cursor one letter at a time through the text.

While I could have captured the keydown/keyup and trackball events and responded to them, I found that the edit controls take a MovementMethod class that need only respond to certain directions. The documentation for the interface itself says it “should not be implemented directly by applications.” I took that to mean it was OK to subclass the ScrollingMovementMethod class that handles scrolling content within an edit field. I ended up coding something similar to how the LinkMovementMethod works, except that I made it work more like I expect.

The code is too lengthy to include here in it’s entirety but basically has two modes: left/right and up/down. In both cases I find all the RecipientSpans that are in the visible text region. For left or right, I find the span or word character immediately before or after the cursor, respectively, and select it. Moving left looks like this:

                int beststart, bestend;

                beststart = -1;
                bestend = -1;

                for (ClickableSpan candidate1 : candidates) {
                    int end = buffer.getSpanEnd(candidate1);

                    if (end < selEnd) {
                        if (end > bestend) {
                            beststart = buffer.getSpanStart(candidate1);
                            bestend = end;
                        }
                    }
                }

                if (beststart >= 0) {
                    if (selStart - bestend > 0 && WORDS.matcher(TextUtils.substring(buffer, bestend, selStart)).find()) {
                        Selection.setSelection(buffer, selStart - 1);
                    } else {
                        Selection.setSelection(buffer, bestend, beststart);
                    }
                    return true;
                }

The WORDS constant is a pre-compiled regex Pattern to look for any ‘word’ characters (Pattern.compile("\\w")). I used this so that when the cursor gets to the edge of a Recipients name, it skips the ‘, ‘ separator and moves to the next RecipientSpan or any email address typed in.

For up and down movements, I wanted to have the cursor move to the previous or next line and select the recipient whose name was above or below the current cursor location. This differs from the LinkMovement implementation where ‘up’ movements is the same as ‘left’ and ‘down’ is the same as ‘right’. In this case I parse the text in the target line and look for any spans to select. Here’s what the up movement looks like:

                int lineUp = Math.max(currentLine - 1, 0);
                int offUp = layout.getOffsetForHorizontal(lineUp, layout.getPrimaryHorizontal(selStart));

                ClickableSpan[] linkUp = buffer.getSpans(offUp, offUp, ClickableSpan.class);

                if (lineUp == currentLine && selStart > first) {
                    Selection.setSelection(buffer, selStart - 1);
                } else if (linkUp.length != 0) {
                    Selection.setSelection(buffer,
                            buffer.getSpanStart(linkUp[0]),
                            buffer.getSpanEnd(linkUp[0]));
                } else {
                    Selection.setSelection(buffer, offUp, offUp);
                }

Handling deletes

Having made movement through the field feel right, the next step was to handle deletes. If a RecipientSpan is selected and I press delete on the keyboard it will delete the recipient as expected. However, if I just have a blinky cursor and start deleting characters I want to have it delete the recipient as a single entity. I handle this by looking for keystrokes with a View.OnKeyListener.

                if (view instanceof EditText) {
                    Editable buffer = ((EditText) view).getText();
                    // If the cursor is at the end of a RecipientSpan then remove the whole span
                    int start = Selection.getSelectionStart(buffer);
                    int end = Selection.getSelectionEnd(buffer);
                    if (start == end) {
                        Recipient.RecipientSpan[] link = buffer.getSpans(start, end, Recipient.RecipientSpan.class);
                        if (link.length > 0) {
                            buffer.replace(
                                    buffer.getSpanStart(link[0]),
                                    buffer.getSpanEnd(link[0]),
                                    ""
                            );
                            buffer.removeSpan(link[0]);
                            return true;
                        }
                    }
                }

This is pretty straightforward — look for any RecipientSpans in the selection (i.e. where the cursor is), if one is found remove the visible text and the span. While I only look for a single span here (because I don’t expect to have overlapping spans) in another implementation it might makes sense to loop through and remove all spans that are returned.

About these ads
Posted in: Coding