
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 design depends on the framework authors not changing how ClickableSpans
are used. I subclassed 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.
Hi, I am very interested in using your suggestion re ClickableSpans https://ballardhack.wordpress.com/2011/07/25/customizing-the-android-edittext-behavior-with-spans/
I am trying to use the same technique, but it is not working – do you have the complete project (Eclipse or IntelliJ) so I can try to see what I am doing wrong
Thanks, Victor
Victor, I don’t have an example project that I can publish beyond the code that’s in the post. Most of the insight came from trolling through the SDK sources. It’s been a while since I looked into this but a good starting place is TextEdit class and related linked classes in the post.
Thanks, I guess it is about time I do the same!
It is amazing to me how many good Android apps there are out there, with such incomplete documentation!
Hi, where exactly do I put the “convertResultToString” method?
ArtworkAd,
Good question. It’s one of the methods in the Filter class, so you can create your own
Filter
and then return it from the getFilter method on your adapterThanks! I wonder why you have to check if start==end?
Karen,
When the start and end fields of the selection are not the same, then the existing editor behaves as expected — that is, it deletes the content that’s selected. For this project we wanted to capture the delete event when there is no selection, so we check if start and end are the same.
Great post…Exactly what I was looking for..
But, I have a doubt regarding the “Scrolling or moving the cursor”….Where should I add the code for handling the cursor movements….??
VijayRaj,
You need to create your own derivation of ScrollingMovementMethod, override the the up/down/left/right methods and then apply it by callng setMovementMethod (http://grepcode.com/file/repository.grepcode.com/java/ext/com.google.android/android/2.3.4_r1/android/widget/TextView.java#TextView.setMovementMethod%28android.text.method.MovementMethod%29) for the text control. See the LinkMovementMethod for a complete example of the code to write.
Thanks Jeremy for the quick reply…..After seeing this I found that ScrollingMovementMethod is not what I need for my project….I posted the detailed question here http://stackoverflow.com/questions/18120522/multiautocompletetextview-tokenizer-similar-to-facebook-app ….. Can you help me with this if you have any idea??
Your “Handling Delete” section does help…but if there are say 4 span and if I try to delete the 2nd span, the other span that follows the 2nd span also get deleted…
@VijayRaj replace
int start = Selection.getSelectionStart(buffer) + 1;
int end = Selection.getSelectionEnd(buffer) – 1;
getSpans will return any spans between INCLUSIVE start and end (e.g. when getSpanEnd(obj) == start)
This was a proprietary project and I do not have and cannot provide the source code. The listed code was what was approved for publishing this post.
Hey Jeremy, I have a few questions.
1) you said you are using AutoCompleteTextView , then why are you checking in delete span code:
if (view instanceof EditText)
why not instance of AutoCompleteTextView?
2) Is RecipientSpan class an inner class to Recipient? Because from this line of code it seems so:
Recipient.RecipientSpan[] link = buffer.getSpans(start, end, Recipient.RecipientSpan.class);
But how can an inner class have a static declaration? (As it is an inner class).
1) Not sure why I did that, sounds like a fair change to me.
2) I no longer have access to this code, but I’m pretty sure it was a static nested class, not an inner class (see https://docs.oracle.com/javase/tutorial/java/javaOO/nested.html).
Hey Jeremy , I have a few questions regarding this post:
1) Inside the OnkeyListener you are checking if view is instance of EditText. Why not AutoCompleteTextView , since this is what you are using?
2) Is the RecipientSpan class an inner class to Recipient? Because from this line of code it seems so:
ListItem.RecipientSpan[] link = buffer.getSpans(start, end, Recipient.RecipientSpan.class);
If it is, how can an inner class be static?