Android

AutoCompleteTextViewの仕組みを調べる

More than 3 years have passed since last update.


概要

AutoCompleteTextViewが文字列を補完する仕組みを調べてみました。


入力に対して補完候補を表示する仕組み

AutoCompleteTextViewのコードを読み、補完がどのような流れで行われるのかを追ってみます。すると以下の流れで補完候補を表示することがわかります。


  1. ListPopupWindowにAdapterをセット

  2. テキスト入力の監視を開始

  3. 入力があればAdapterから取得したFilterクラスのfilterメソッドを実行

  4. AutoCompleteTextViewのonFilterCompleteがfilterメソッドのCallbackとして実行

  5. ListPopupWindowを表示


Adapterのセット

AutoCompleteTextViewのReferenceに載っていたサンプルコードを改めて確認すると、ArrayAdapterをsetAdapterでAutoCompleteTextViewにセットしています。

AutoCompleteTextViewのsetAdapterメソッドのコードを読むと、ListPopupWindowにAdapterセットされていて、補完候補として表示するデータは、Adapterを介して表示していることがわかります。

加えてsetAdapterメソッドでは、AdapterからgetFilterでFilterを取得して変数mFilterに代入しています。ArrayAdapterのコードを読むとFilterクラスを継承したArrayFilterクラスが定義されている事がわかり、それをgetFilterが実行された際に返しています。

public Filter getFilter() {

if (mFilter == null) {
mFilter = new ArrayFilter();
}
return mFilter;
}

ArrayFilterクラスでは、 performFiltering メソッドと publishResults メソッドを実装しています。補完候補を表示する上でこの二つのメソッドが重要になります。


テキスト入力の監視とFilter#filterメソッドの実行

AutoCompleteTextViewのコンストラクタを読むと、addTextChangedListenerにMyWatcherクラスがセットされている事がわかります。MyWatcherクラスでは、AutoCompleteTextViewへの入力によってafterTextChangedが発火した場合にdoAfterTextChangedが実行されます。そして、enoughToFilterメソッドによって現在入力されている文字数がmThreshold(デフォルトでは2)以上であった場合に、performFilteringを実行します。

/**

* This is used to watch for edits to the text view. Note that we call
* to methods on the auto complete text view class so that we can access
* private vars without going through thunks.
*/

private class MyWatcher implements TextWatcher {
public void afterTextChanged(Editable s) {
doAfterTextChanged();
}
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
doBeforeTextChanged();
}
public void onTextChanged(CharSequence s, int start, int before, int count) {
}
}

void doBeforeTextChanged() {
if (mBlockCompletion) return;

// when text is changed, inserted or deleted, we attempt to show
// the drop down
mOpenBefore = isPopupShowing();
if (DEBUG) Log.v(TAG, "before text changed: open=" + mOpenBefore);
}

void doAfterTextChanged() {
if (mBlockCompletion) return;

// if the list was open before the keystroke, but closed afterwards,
// then something in the keystroke processing (an input filter perhaps)
// called performCompletion() and we shouldn't do any more processing.
if (DEBUG) Log.v(TAG, "after text changed: openBefore=" + mOpenBefore
+ " open=" + isPopupShowing());
if (mOpenBefore && !isPopupShowing()) {
return;
}

// the drop down is shown only when a minimum number of characters
// was typed in the text view
if (enoughToFilter()) {
if (mFilter != null) {
mPopupCanBeUpdated = true;
performFiltering(getText(), mLastKeyCode);
}
} else {
// drop down is automatically dismissed when enough characters
// are deleted from the text view
if (!mPopup.isDropDownAlwaysVisible()) {
dismissDropDown();
}
if (mFilter != null) {
mFilter.filter(null);
}
}
}


フィルタリングの開始からonFilterCompleteの実行まで

performFilteringは、Filterのfilterメソッドを実行しています。textは入力されたテキスト、thisはFilter.FilterListenerの実装になります。AutoCompleteTextViewではこちらで定義されています。

protected void performFiltering(CharSequence text, int keyCode) {

mFilter.filter(text, this);
}

Filter#filterメソッドではJavadocで書かれている通り、非同期でフィルタリングを実行している事がわかります。Filter#filterメソッドが実行されるとWorker Threadを作成し、RequestHandlerをWorker Threadで実行します。そして、入力されたテキストとFilter.FilterListenerをRequestHandlerへ送信します。この時、未実行のフィルタリングは全てキャンセルされます。

/**

* <p>Starts an asynchronous filtering operation. Calling this method
* cancels all previous non-executed filtering requests and posts a new
* filtering request that will be executed later.</p>
*
* <p>Upon completion, the listener is notified.</p>
*
* @param constraint the constraint used to filter the data
* @param listener a listener notified upon completion of the operation
*
* @see #filter(CharSequence)
* @see #performFiltering(CharSequence)
* @see #publishResults(CharSequence, android.widget.Filter.FilterResults)
*/

public final void filter(CharSequence constraint, FilterListener listener) {
synchronized (mLock) {
if (mThreadHandler == null) {
HandlerThread thread = new HandlerThread(
THREAD_NAME, android.os.Process.THREAD_PRIORITY_BACKGROUND);
thread.start();
mThreadHandler = new RequestHandler(thread.getLooper());
}

final long delay = (mDelayer == null) ? 0 : mDelayer.getPostingDelay(constraint);

Message message = mThreadHandler.obtainMessage(FILTER_TOKEN);

RequestArguments args = new RequestArguments();
// make sure we use an immutable copy of the constraint, so that
// it doesn't change while the filter operation is in progress
args.constraint = constraint != null ? constraint.toString() : null;
args.listener = listener;
message.obj = args;

mThreadHandler.removeMessages(FILTER_TOKEN);
mThreadHandler.removeMessages(FINISH_TOKEN);
mThreadHandler.sendMessageDelayed(message, delay);
}
}

RequestHandlerでは、入力されたテキストとFilter.FilterListenerを受け取りhandleMessageで処理します。whatの値がFILTER_TOKENである場合に、入力テキストに対してperformFilteringが実行され、その結果がResultsHandlerへ送られます。そして、whatをFINISH_TOKENにセットしたmessageをmThreadHandlerへ送信しthreadを終了します。

/**

* <p>Worker thread handler. When a new filtering request is posted from
* {@link android.widget.Filter#filter(CharSequence, android.widget.Filter.FilterListener)},
* it is sent to this handler.</p>
*/

private class RequestHandler extends Handler {
public RequestHandler(Looper looper) {
super(looper);
}

/**
* <p>Handles filtering requests by calling
* {@link Filter#performFiltering} and then sending a message
* with the results to the results handler.</p>
*
* @param msg the filtering request
*/

public void handleMessage(Message msg) {
int what = msg.what;
Message message;
switch (what) {
case FILTER_TOKEN:
RequestArguments args = (RequestArguments) msg.obj;
try {
args.results = performFiltering(args.constraint);
} catch (Exception e) {
args.results = new FilterResults();
Log.w(LOG_TAG, "An exception occured during performFiltering()!", e);
} finally {
message = mResultHandler.obtainMessage(what);
message.obj = args;
message.sendToTarget();
}

synchronized (mLock) {
if (mThreadHandler != null) {
Message finishMessage = mThreadHandler.obtainMessage(FINISH_TOKEN);
mThreadHandler.sendMessageDelayed(finishMessage, 3000);
}
}
break;
case FINISH_TOKEN:
synchronized (mLock) {
if (mThreadHandler != null) {
mThreadHandler.getLooper().quit();
mThreadHandler = null;
}
}
break;
}
}
}

ResultsHandlerでは、messageを受け取るとpublishResultsを実行し、加えてFilter.FilterListenerのonFilterCompleteを実行します。

/**

* <p>Handles the results of a filtering operation. The results are
* handled in the UI thread.</p>
*/

private class ResultsHandler extends Handler {
/**
* <p>Messages received from the request handler are processed in the
* UI thread. The processing involves calling
* {@link Filter#publishResults(CharSequence,
* android.widget.Filter.FilterResults)}
* to post the results back in the UI and then notifying the listener,
* if any.</p>
*
* @param msg the filtering results
*/

@Override
public void handleMessage(Message msg) {
RequestArguments args = (RequestArguments) msg.obj;

publishResults(args.constraint, args.results);
if (args.listener != null) {
int count = args.results != null ? args.results.count : -1;
args.listener.onFilterComplete(count);
}
}
}

Filter.FilterListenerのonFilterCompleteの実装は、AutoCompleteTextViewのこちらで定義されていて、updateDropDownForFilterを呼び出しています。そして、showDropDownを実行することで補完候補のリストをポップアップで表示します。

public void onFilterComplete(int count) {

updateDropDownForFilter(count);
}

private void updateDropDownForFilter(int count) {
// Not attached to window, don't update drop-down
if (getWindowVisibility() == View.GONE) return;

/*
* This checks enoughToFilter() again because filtering requests
* are asynchronous, so the result may come back after enough text
* has since been deleted to make it no longer appropriate
* to filter.
*/

final boolean dropDownAlwaysVisible = mPopup.isDropDownAlwaysVisible();
final boolean enoughToFilter = enoughToFilter();
if ((count > 0 || dropDownAlwaysVisible) && enoughToFilter) {
if (hasFocus() && hasWindowFocus() && mPopupCanBeUpdated) {
showDropDown();
}
} else if (!dropDownAlwaysVisible && isPopupShowing()) {
dismissDropDown();
// When the filter text is changed, the first update from the adapter may show an empty
// count (when the query is being performed on the network). Future updates when some
// content has been retrieved should still be able to update the list.
mPopupCanBeUpdated = true;
}
}


performFilteringとpulishResults

Filter#filterメソッドでの実行の流れを整理すると以下のようになります。


  1. Filter#filterメソッドがWorker Threadを作成

  2. Worker ThreadでFilter#performFilteringメソッドを実行、Adapterに表示するデータをフィルタリング

  3. UI ThreadでFilter#publishResultsを実行しAdapterを更新

  4. 併せてAutoCompleteTextViewのonFilterCompleteを実行

これを踏まえてArrayAdapterのArrayFilterの実装を読んでみます。performFilteringが実行されると、Adapterにセットされたデータから入力されたテキストにマッチするデータをフィルタリングし、新しいデータセットを作成しています。そして、pulishResultsが実行されると、AdapterのnotifyDataSetChangedが実行されることで新しいデータセットでListPopupWindowが更新されます。その結果、最初に試したサンプルのように入力されたテキストに合わせて補完候補のリストが更新されます。

ArrayFilterのperformFilteringの実装では、prefixには入力した文字列全体が入り、それと補完候補のデータセットをstartWithで比較してるため、文字列の途中に補完対象がある場合や複数の補完対象がある場合は考慮されていません。

    /**

* <p>An array filter constrains the content of the array adapter with
* a prefix. Each item that does not start with the supplied prefix
* is removed from the list.</p>
*/

private class ArrayFilter extends Filter {
@Override
protected FilterResults performFiltering(CharSequence prefix) {
FilterResults results = new FilterResults();

if (mOriginalValues == null) {
synchronized (mLock) {
mOriginalValues = new ArrayList<T>(mObjects);
}
}

if (prefix == null || prefix.length() == 0) {
ArrayList<T> list;
synchronized (mLock) {
list = new ArrayList<T>(mOriginalValues);
}
results.values = list;
results.count = list.size();
} else {
String prefixString = prefix.toString().toLowerCase();

ArrayList<T> values;
synchronized (mLock) {
values = new ArrayList<T>(mOriginalValues);
}

final int count = values.size();
final ArrayList<T> newValues = new ArrayList<T>();

for (int i = 0; i < count; i++) {
final T value = values.get(i);
final String valueText = value.toString().toLowerCase();

// First match against the whole, non-splitted value
if (valueText.startsWith(prefixString)) {
newValues.add(value);
} else {
final String[] words = valueText.split(" ");
final int wordCount = words.length;

// Start at index 0, in case valueText starts with space(s)
for (int k = 0; k < wordCount; k++) {
if (words[k].startsWith(prefixString)) {
newValues.add(value);
break;
}
}
}
}

results.values = newValues;
results.count = newValues.size();
}

return results;
}

@Override
protected void publishResults(CharSequence constraint, FilterResults results) {
//noinspection unchecked
mObjects = (List<T>) results.values;
if (results.count > 0) {
notifyDataSetChanged();
} else {
notifyDataSetInvalidated();
}
}
}


補完候補を選択した時の挙動

表示された補完候補を選択した場合にどう処理されるのかも調べました。補完候補のリストを表示しているListPopupWindowには、DropDownItemClickListenerというListenerがセットされていて、performCompletionを呼び出しています。

private class DropDownItemClickListener implements AdapterView.OnItemClickListener {

public void onItemClick(AdapterView parent, View v, int position, long id) {
performCompletion(v, position, id);
}
}

performCompletionでは、replaceTextというメソッドに選択した補完候補をStringにした上で渡しています。

private void performCompletion(View selectedView, int position, long id) {

if (isPopupShowing()) {
Object selectedItem;
if (position < 0) {
selectedItem = mPopup.getSelectedItem();
} else {
selectedItem = mAdapter.getItem(position);
}
if (selectedItem == null) {
Log.w(TAG, "performCompletion: no selected item");
return;
}

mBlockCompletion = true;
replaceText(convertSelectionToString(selectedItem));
mBlockCompletion = false;

if (mItemClickListener != null) {
final ListPopupWindow list = mPopup;

if (selectedView == null || position < 0) {
selectedView = list.getSelectedView();
position = list.getSelectedItemPosition();
id = list.getSelectedItemId();
}
mItemClickListener.onItemClick(list.getListView(), selectedView, position, id);
}
}

if (mDropDownDismissedOnCompletion && !mPopup.isDropDownAlwaysVisible()) {
dismissDropDown();
}
}

replaceTextでは、選択した補完候補文字列をsetTextしています。これによって、入力中の文字列を補完候補の文字列で置き換えています。しかし、入力中の文字列が全て上書きされるので、文字列の途中に補完対象がある場合などは考慮されていません。Javadocにも全て上書きされるのを避けるにはoverrideするべきと書いてあります。

/**

* <p>Performs the text completion by replacing the current text by the
* selected item. Subclasses should override this method to avoid replacing
* the whole content of the edit box.</p>
*
* @param text the selected suggestion in the drop down list
*/

protected void replaceText(CharSequence text) {
clearComposingText();

setText(text);
// make sure we keep the caret at the end of the text view
Editable spannable = getText();
Selection.setSelection(spannable, spannable.length());
}