Building a Twitter style autocomplete for Android

I have recently been working on an android app and found myself needing a Twitter style input box that allowed certain words to be looked up using an API driven autocomplete but also supporting freetext (if you have used the compose tweet input on android, like this - free text, but if you type @ then you get a user name autocomplete).



I google'd for a while, and stumbled through a few StackOverflow answers but none of there were 100% clear, and further more, just copy-pasting the code into a dummy project I was working in didn't work. So here is a break down of the what and how this works.

There are four main components:

The XML Layout: MultiAutoCompleteTextView

<MultiAutoCompleteTextView
android:layout_marginTop="100dp"
android:id="@+id/multiAutoCompleteTextView1"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:layout_alignParentTop="true"
android:layout_centerHorizontal="true"
android:completionThreshold="1"
android:ems="10" >
</MultiAutoCompleteTextView>
view raw gistfile1.xml hosted with ❤ by GitHub

This is simple - just add the MultiAutoCompleteTextView just like you would any input. The only point of interest here is the "completionThreshold" - this is just the number of characters that have to be typed before auto-complete kicks in. We have set this to one char so it kicks in early.


We then just setup the auto-complete in our Activity onCreate
@Override protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_fullscreen);
inputEditText = (MultiAutoCompleteTextView) findViewById(R.id.multiAutoCompleteTextView1);
inputEditText.setTokenizer(new UsernameTokenizer());
inputEditText.addTextChangedListener( this );
}
view raw gistfile1.java hosted with ❤ by GitHub

SetTokeniser: UsernameTokenizer

This is a custom class that implements the Tokenizer interface. This will be used by our autocomplete imput box to work out whether or not it should be displaying a dropdown menu. If you are using a static list for lookups (countries, fixed codes from your app, etc) then this is the only thing you really need to do - then you can just set the fields ArrayAdapter as the list of Strings etc and it will automatically kick in.

In our case, we are using the @ character to identify the start of pieces of text that should be lookedup and spaces to determine the end of the look up token.
public class UsernameTokenizer implements Tokenizer {
@Override public CharSequence terminateToken(CharSequence text) {
int i = text.length();
while (i > 0 && text.charAt(i - 1) == ' ') { i--; }
if (text instanceof Spanned) {
SpannableString sp = new SpannableString(text + " ");
TextUtils.copySpansFrom((Spanned) text, 0, text.length(), Object.class, sp, 0);
return sp;
} else {
return text + " ";
}
}
@Override public int findTokenStart(CharSequence text, int cursor) {
int i = cursor;
while (i > 0 && text.charAt(i - 1) != '@') {
i--;
}
if (i < 1 || text.charAt(i - 1) != '@') {
return cursor;
}
return i;
}
@Override public int findTokenEnd(CharSequence text, int cursor) {
int i = cursor;
int len = text.length();
while (i < len) {
if (text.charAt(i) == ' ') {
return i;
} else {
i++;
}
}
return len;
}
}
view raw gistfile1.java hosted with ❤ by GitHub


The three methods are relatively straight forward - and will drive when your app presents the drop down:
  • terminateToken(CharSequence text) - this basically just provides a presentable version of our token with a proper terminator at the end (e.g. makes sure a trailing single space in this case)
  • findTokenStart(CharSequence text, int cursor) - Just finds the position of the start of the token. It does this by iterating backwards through the provided CharSequence (the text input in the input box) starting from the position of the cursor, which is just the position of the last character edited (this means if you go back and edit text in the middle of a block it still finds the correct token).  If no valid token start is found (e.g. we go backwards and we can't find a @ character) then the current position is returned - no dropdown is displayed.
  • findTokenEnd(CharSequence text, int cursor) - As above, but finds the end position. Iterates forward until a token terminator (in our case a space) or the end of the text is found

As long as you implement these to support your token identification pattern then you will get a dropdown appearing appropriately.


Adding a text changed listener

For ease of use on this one, I have just set the activity to implement the TextWatcher interface - the reason for this is just convenience - the implementation is going to handle calling our API asyncronously, so it is easier if it has the activity context.

There are three methods that need to be implemented - four our case there will be two no-op methods and just one implementation:
@Override public void afterTextChanged(Editable editable) {}
@Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
@Override public void onTextChanged(CharSequence s, int start, int before, int count) {
int cursor = start;
if (cursor >= s.length()) cursor = s.length()-1;
if (isValidToken(s, cursor)){
String token = getToken(s, start);
new LinkedinSkillAsyncTask( this ).execute( token );
}
}
view raw gistfile1.java hosted with ❤ by GitHub


The method onTextChanged is implemented - unfortunately the tokenizer will only indicate to the application when to display the dropdown - it doesn't actually call the API, so we have to slightly repeat ourselves here in handling the API invocation.  In this method we need to again check for and find the relevant valid token in the input, and if a valid token found, then pass it to our API to lookup the dataset.

To help with that, I also added some additional methods to help check for the existence of a valid token (reading them should be self explanatory, but comments included inline)
/**
* Checks if the current word being edited is a valid token (e.g. starts with @ and has no spaces)
* @param text - all text being edited in input
* @param cursor - current position of text change
* @return is valid
*/
private boolean isValidToken(CharSequence text, int cursor){
for (int i=cursor; i>=0; i--){
if (text.charAt(i) == '@') return true;
if (text.charAt(i) == ' ') return false;
}
return false;
}
/**
* Fetches the current token being edited - assumes valid token (use isValidToken to confirm)
* @param text
* @param cursor
* @return
*/
private String getToken(CharSequence text, int cursor){
int start=findTokenStart(text, cursor);
int end=findTokenEnd(text, cursor);
return text.subSequence(start, end).toString();
}
/**
* In the current input text, finds the start position of the current token (iterates backwards from current position
* until finds the token prefix "@")
* @param text - all text being edited in input
* @param cursor - current position of text change
* @return position of token start
*/
private int findTokenStart(CharSequence text, int cursor) {
int i = cursor;
while (i > 0 && text.charAt(i - 1) != '@') { i--; }
return i;
}
/**
* In the current input text, finds the position of the end of the current token (iterates forwards from current
* position
* until finds the the end, e.g. a space or end of all input)
* @param text - all text being edited in input
* @param cursor - current position of text change
* @return position of token end
*/
private int findTokenEnd(CharSequence text, int cursor) {
int i = cursor;
int len = text.length();
while (i < len && text.charAt(i) != ' ' && text.charAt(i) != ',' && text.charAt(i) != '.' ) {
i++;
}
return i;
}
view raw gistfile1.java hosted with ❤ by GitHub


In this case, as it was just an experiment, and I didn't actually have a user lookup API I have just used the LinkedIn skills API (this code also taken in part from a StackOverflow answer).

The Async task should also be straight forward, and can be implemented in whatever pattern you are using for Async calls - but the key point to note is how we are updating our dropdown list.
public class LinkedinSkillAsyncTask extends AsyncTask<String, String, String>{
private Activity context;
public String data;
public List<String> suggest;
public ArrayAdapter<String> aAdapter;
public LinkedinSkillAsyncTask(Activity cntxt) {
context = cntxt;
}
@Override protected String doInBackground(String... key) {
String newText = key[0];
newText = newText.trim();
newText = newText.replace(" ", "+");
try {
HttpClient hClient = new DefaultHttpClient();
HttpGet hGet = new HttpGet("http://www.linkedin.com/ta/skill?query="+newText);
ResponseHandler<String> rHandler = new BasicResponseHandler();
data = hClient.execute(hGet, rHandler);
suggest = new ArrayList<String>();
JSONObject jobj = new JSONObject(data);
JSONArray jArray = jobj.getJSONArray("resultList");
for (int i = 0; i < jArray.length(); i++) {
String SuggestKey = jArray.getJSONObject(i).getString("displayName");
suggest.add(SuggestKey);
}
} catch (Exception e) {
Log.w("Error", e.getMessage());
}
context.runOnUiThread(new Runnable() {
public void run() {
MultiAutoCompleteTextView inputEditText = (MultiAutoCompleteTextView) context.findViewById(R.id.multiAutoCompleteTextView1);
aAdapter = new ArrayAdapter<String>( context, android.R.layout.simple_dropdown_item_1line, suggest);
inputEditText.setAdapter(aAdapter);
aAdapter.notifyDataSetChanged();
}
});
return null;
}
}
view raw gistfile1.java hosted with ❤ by GitHub


The API driven, multi-autocomplete/free text field should then be working as expected



2 comments:

Coming Soon..




New mobile-first product currently underway..

0 comments:

GitHub as a CV (GaaC)

Over the recent year or so, as it has become more popular throughout the tech industry, there has been a growing amount of discussion around the idea of "GitHub as a CV" - the idea of using your online tech footprint, primarily for most people in the form of your GitHub profile, as a CV and a better representation of a potential employees ability/preference/mindset etc. There has even been a GitHub project that can automatically create a CV for you based on your profile: http://resume.github.io/ (here is mine - depressing that Coldfusion features so highly in the stats though!).  Over at NerdAbility we have really taken that idea forward (incorporating other sites like BitBucket, GoogleCode, Coursera, LinkedIn, StackOverflow, etc) and is obviously something that we think is a good idea.

But its a bad idea..

A big argument against it is that it furthers the already engrained bias towards white men. If you look at the demographic of the most active GitHub profiles there is no denying the common pattern.

I agree completely - that using GitHub as a filtering mechanism or pre-requisite for a candidate sucks. You really shouldn't do that. I have had conversations with agents where they have told me that a client only wants to see candidates who contribute to OSS, and I have declined. It doesn't qualify a candidate as being half-way competent and rules out lots of very competent people who don't have spare time to work on OSS (multiple jobs, family responsibility).


Just another data point..

I guess this is really the point here. You shouldn't rule out candidates because they don't have GitHub accounts, just like you shouldn't rule out a candidate for not having a degree etc.  I think we can all agree tech recruiting is hard, and its really hard to assess whether someone is actually a good developer and not just blagging it - so a computer scientist, I'm grateful for as much data to help with this decision as possible.

It's not that a candidate with an active GitHub profile trumps one without, but it means that its another point in an interview that we can try and use to tease out  a little bit more of an insight into the candidates skills, interests, passions.

GitHub is another point on your CV (if you are fortunate enough to have the time to setup and contribute to a GitHub profile) - just like your academic achievements and career history or anything else you choose to put on there.  If you have an interesting project it can be a talking point for an interview, in much the same way an interesting role on your CV would be.  From my point of view, I love going into interviews and hearing that the interviewer has checked out my projects on GitHub, and when the inevitable "tell us about an interesting/challenging/etc project/problem you have had" comes up, it's great to be able to talk about projects on GitHub that they have seen - not just because I know the project well, but also it will inevitably be a project that I am passionate about (otherwise I wouldn't be doing it in my spare time!).


We shouldn't be demanding OSS contributions, or set online profiles, or StackOverflow credibility - but we probably shouldn't be dismissing it as irrelevant.



0 comments: