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> |
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 ); | |
} |
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; | |
} | |
} |
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 ); | |
} | |
} |
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; | |
} |
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; | |
} | |
} |
The API driven, multi-autocomplete/free text field should then be working as expected
2 comments: