AutoCompleteTextViewでハッシュタグの補完を実装してみる

Twitterで投稿する際や、Instagramでキャプションを書く際にハッシュタグを入力すると、補完候補のリストが表示され簡単にハッシュタグを入力することができます。私が開発に関わっているアプリ[iQON](https://play.google.com/store/apps/details?id=jp.vasily.iqon&hl=ja)でも作成したコーディネートを公開する際などにハッシュタグを付けることができますが、補完機能を実装できていません。

そこで、ハッシュタグの補完機能を実装してみようと思います。

# 取り敢えず補完してみる
調べてみると、[yanzmさんの記事](http://y-anz-m.blogspot.jp/2013/02/androidautocompletetextview.html)で紹介されているAutoCompleteTextViewを使うと実装できそうです。[AutoCompleteTextViewのReference](http://developer.android.com/intl/ja/reference/android/widget/AutoCompleteTextView.html)を見てみると以下のようなサンプルコードが紹介されていました。

```Java
public class CountriesActivity extends Activity {
    protected void onCreate(Bundle icicle) {
        super.onCreate(icicle);
        setContentView(R.layout.countries);

        ArrayAdapter<String> adapter = new ArrayAdapter<String>(this,
                android.R.layout.simple_dropdown_item_1line, COUNTRIES);
        AutoCompleteTextView textView = (AutoCompleteTextView)
                findViewById(R.id.countries_list);
        textView.setAdapter(adapter);
    }

    private static final String[] COUNTRIES = new String[] {
            "Belgium", "France", "Italy", "Germany", "Spain"
    };
}
```

AutoCompleteTextViewに補完候補の一覧をもたせたAdapterをセットすることで、補完されるようになります。補完候補をハッシュタグぽくして取り敢えず動かしてみます。

```java
private static final String[] COUNTRIES = new String[] {
    "#Belgium", "#France", "#Italy", "#Germany", "#Spain"
};
```

また、layoutは以下のように定義しておきます。

```xml
<AutoCompleteTextView
    android:id="@+id/countries_list"
    android:layout_width="match_parent"
    android:layout_height="wrap_content" />
```


アプリをビルドし試しに入力してみると、以下のように入力に合わせてポップアップで補完候補が表示されます。複数のハッシュタグがある場合には補完が効きませんが、これで補完を行うために必要な最低限の方法がわかりました。

![](http://vasily.jp/content/images/tech-blog/horie_20151212_qiita/hashtag1.gif)

# AutoCompleteTextViewの仕組み

ハッシュタグの補完を作るためにAutoCompleteTextViewの仕組みを調べました。長くなってしまったので別の投稿でまとめています。

[AutoCompleteTextViewの仕組みを調べる](http://qiita.com/Horie1024/items/d52ef21c589b135b5c37)

# ハッシュタグの補完の仕様

実現したい仕様を書き出してみます。

* 複数のハッシュタグが存在する場合、現在入力中のハッシュタグでのみ補完する
* 補完候補から選択すると、現在入力中のハッシュタグのみ選択した補完候補で置きかわる
* ハッシュタグの補完候補は動的に用意する

この仕様を実現するために実装していきます。

# ハッシュタグ補完の実装

## カスタムAdapterの作成
ArrayAdapterを継承したカスタムAdapterを作成し、getFilterで返すfilterをFilterクラスを継承したHashTagFilterクラスに置き換えます。

```Java
public class HashTagSuggestAdapter extends ArrayAdapter<String> {

    private HashTagFilter filter;

    public HashTagSuggestAdapter(Context context, int resource, String[] objects) {
        super(context, resource, objects);
    }

    @Override
    public Filter getFilter() {

        if (filter == null) {
            filter = new HashTagFilter();
        }

        return filter;
    }

    public class HashTagFilter extends Filter {

        @Override
        protected FilterResults performFiltering(CharSequence constraint) {
            return null;
        }

        @Override
        protected void publishResults(CharSequence constraint, FilterResults results) {
        }
    }
}
```

## FilterのperformFilteringとpublishResultsを実装

performFilteringとpublishResultsを実装していきます。

### publishResults
publishResultsでは、Adapterの更新処理を書くので、ArrayAdapterのコードと同じようにresultsがある場合にnotifyDataSetChangedを実行するようにします。resultsが無いのであればAdapterに更新が無いのでnotifyDataSetInvalidatedを実行呼ぶようにします。

```Java
@Override
protected void publishResults(CharSequence constraint, FilterResults results) {
    if (results != null && results.count > 0) {
        notifyDataSetChanged();
    } else {
        notifyDataSetInvalidated();
    }
}
```

### performFiltering
performFilteringにフィルタリングの処理を実装していきます。まず、複数のハッシュタグがある場合にも補完できるようにしてみます。実装する内容は以下のような感じです。

* 入力文字列からハッシュタグを抽出
* Adapterにセットされたデータセットと比較し、FilterResultsとしてreturn
* Adapterのデータセットを更新

入力された文字列中のハッシュタグは、正規表現で抽出します。[こちらの記事](http://qiita.com/corin8823/items/75309761833d823cac6f)を参考にさせていただき、実装してみます。performFilteringが受け取る引数constraintは、入力された文字列全体を指すのでそこからハッシュタグを抽出します。

ハッシュタグを抽出できたら、Adapterにセットされたデータセットと比較します。データセットはCOUNTRIESです。

```Java
private static final String[] COUNTRIES = new String[]{
        "#Belgium", "#France", "#Italy", "#Germany", "#Spain"
};
```

COUNTRIESをtoLowerCaseにした文字列と、抽出したハッシュタグをstartsWithで比較し、マッチするのであれば、予め用意したsuggestsリストに追加します。そして、filterResults.valuesにはsuggestsリストを、filterResults.countにはsuggestsリストのサイズを代入します。加えてAdapterのgetCountメソッドとgetItemメソッドをOverrideし、suggestsリストのサイズとアイテムを返すようにします。

```Java

public class HashTagSuggestAdapter extends ArrayAdapter<String> {

    private HashTagFilter filter;
    private List<String> objects;
    private List<String> suggests = new ArrayList<>();

    public HashTagSuggestAdapter(Context context, int resource, String[] objects) {
        super(context, resource, objects);
        this.objects = Arrays.asList(objects);
    }

    @Override
    public int getCount() {
        return suggests.size();
    }

    @Override
    public String getItem(int position) {
        return suggests.get(position);
    }

    @Override
    public Filter getFilter() {

        if (filter == null) {
            filter = new HashTagFilter();
        }

        return filter;
    }

    public class HashTagFilter extends Filter {

        private final Pattern pattern = Pattern.compile("[#＃]([Ａ-Ｚａ-ｚA-Za-z一-\u9FC60-9０-９ぁ-ヶｦ-ﾟー])+");

        @Override
        protected FilterResults performFiltering(CharSequence constraint) {

            FilterResults filterResults = new FilterResults();

            if (constraint != null) {

                suggests.clear();

                Matcher m = pattern.matcher(constraint.toString());
                while (m.find()) {

                    // 抽出したタグを取得
                    String tag = constraint.subSequence(m.start(), m.end()).toString();

                    // データセット(COUNTRIES)と比較
                    for (int i =0; i < objects.size();i++) {

                        String country = objects.get(i);
                        if (country.toLowerCase().startsWith(tag)) {
                            // matchすればsuggestリストにadd
                            suggests.add(country);
                        }
                    }
                }
            }

            filterResults.values = suggests;
            filterResults.count = suggests.size();

            return filterResults;
        }

        @Override
        protected void publishResults(CharSequence constraint, FilterResults results) {
            if (results != null && results.count > 0) {
                notifyDataSetChanged();
            } else {
                notifyDataSetInvalidated();
            }
        }
    }
}
```

これでハッシュタグが複数ある場合でも補完候補を表示できました。

![](http://vasily.jp/content/images/tech-blog/horie_20151212_qiita/hashtag2.gif)

### 入力中のハッシュタグのみ補完候補で置き換える

上のGifで、補完候補に表示された#Spainをタップすると、入力全体が置き換えられてしまいます。この場合#sが#Spainに置き換えられるのが正しい挙動のはずです。補完候補をタップし、入力を置き換える処理は、AutoCompleteTextViewの[replaceText](http://tools.oesf.biz/android-6.0.0_r1.0/xref/frameworks/base/core/java/android/widget/AutoCompleteTextView.java#replaceText)で行われているので、AutoCompleteTextViewを継承してOverrideします。

```Java
public class HashTagAutoCompleteTextView extends AutoCompleteTextView {

    public HashTagAutoCompleteTextView(Context context, AttributeSet attrs) {
        this(context, attrs, R.attr.autoCompleteTextViewStyle);
    }

    public HashTagAutoCompleteTextView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    @Override
    protected void replaceText(CharSequence text) {
        // replace処理
    }
}
```

replaceする際に必要になるのが、入力文字列の何文字目から何文字目までが、入力中のハッシュタグなのか？という情報です。カーソルの位置を取得すれば、ユーザーが今入力している箇所がどこか分かりますので、HashTagSuggestAdapterを以下のinterfaceを追加します。

```Java
public interface CursorPositionListener {
    int currentCursorPosition();
}
```

そして、Activityでinterfaceを実装し、カーソルの位置を返すようにします。

```Java
final AutoCompleteTextView textView = (AutoCompleteTextView) findViewById(R.id.input_form);

HashTagSuggestAdapter adapter = new HashTagSuggestAdapter(this, android.R.layout.simple_dropdown_item_1line, COUNTRIES);
adapter.setCursorPositionListener(new HashTagSuggestAdapter.CursorPositionListener() {
    @Override
    public int currentCursorPosition() {
        return textView.getSelectionStart();
    }
});
```

これでカーソルの位置が取得できるようになったので、ハッシュタグの抽出部分を修正します。マッチした入力されたハッシュタグのstart、endとカーソル位置を比較することで現在入力中のハッシュタグに対してのみ補完候補を出すようにします。また、startとendを保存しておき、入力文字列の何文字目から何文字目までが、入力中のハッシュタグなのかを判別できるようにしておきます。

```Java
int cursorPosition = listener.currentCursorPosition();

Matcher m = pattern.matcher(constraint.toString());
while (m.find()) {

    if (m.start() < cursorPosition && cursorPosition <= m.end()) {

        start = m.start();
        end = m.end();

        // 抽出したタグを取得
        String tag = constraint.subSequence(m.start(), m.end()).toString();

        // データセット(COUNTRIES)と比較
        for (int i = 0; i < objects.size(); i++) {

            String country = objects.get(i);
            if (country.toLowerCase().startsWith(tag)) {
                // matchすればsuggestリストにadd
                suggests.add(country);
            }
         }
     }
}
```

HashTagFilterのstartとendを見ることで、入力文字列の何文字目から何文字目までが入力中のハッシュタグなのかがわかるので、replaceTextを以下のように実装しました。

```Java
public class HashTagAutoCompleteTextView extends AutoCompleteTextView {
    public HashTagAutoCompleteTextView(Context context) {
        this(context, null);
    }

    public HashTagAutoCompleteTextView(Context context, AttributeSet attrs) {
        this(context, attrs, R.attr.autoCompleteTextViewStyle);
    }

    public HashTagAutoCompleteTextView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    @Override
    protected void replaceText(CharSequence text) {

        clearComposingText();

        HashTagSuggestAdapter adapter = (HashTagSuggestAdapter) getAdapter();
        HashTagSuggestAdapter.HashTagFilter filter = (HashTagSuggestAdapter.HashTagFilter) adapter.getFilter();

        // spanは入力された全文字列
        Editable span = getText();
        
        // textはリストから選択した補完候補
        span.replace(filter.start, filter.end, text);
    }
}
```

これで正しく置き換えできるようになりました。

![](http://vasily.jp/content/images/tech-blog/horie_20151212_qiita/hashtag3.gif)

## 補完候補をAPIから取得する

ここまでの例では、補完候補をハードコーディングしていたため補完候補が動的に変わる場合には使えません。そこで、以下の図のようにユーザーの入力に合わせてAPIから補完候補を取得し、それを使用して補完を行うようにしてみます。

![hashtag_suggest_diagram.png](https://qiita-image-store.s3.amazonaws.com/0/43438/2f11a0e1-5d84-f1df-b4da-4436dc03da24.png)

### APIを用意

指定したkeywordからiQONで使われたタグを取得するAPIがちょうどあったのでそれを使います。レスポンスは以下のような形式です。

```json
{
    results: [
        {
            tag: "タグ1",
            count: 1000
        },
        {
            tag: "タグ2",
            count: 100
        },
        {
            tag: "タグ3",
            count: 10
        }
    ]
}
```

### APIへリクエスト

[Retrofit](http://square.github.io/retrofit/)を使ってリクエストします。以下のようにServiceとデータクラスを用意します。

```Java
public interface SuggestService {
    @GET("適宜書き換えてください")
    Call<SuggestResponse> listHashTags(@Query("keyword") String keyword);
}

public class SuggestResponse {

    private ArrayList<HashTag> results;

    public SuggestResponse() {
    }
}

public class HashTag {
    private final String tag;
    private final String count;

    public HashTag(String tag, String count) {
        this.tag = tag;
        this.count = count;
    }
}
```

SuggestServiceの実装を取得します。

```Java
Retrofit retrofit = new Retrofit.Builder()
        .baseUrl("適宜書き換えてください")
        .addConverterFactory(GsonConverterFactory.create())
        .build();

SuggestService service = retrofit.create(SuggestService.class);

```

あとはserviceを使ってAPIへリクエストし、レスポンスをsuggestsリストにaddすれば動的に補完候補を表示できます。APIの仕様上keywordには、`m.start() + 1`をして`#`を除いた文字列を指定しています。

```Java
String keyword = constraint.subSequence(m.start() + 1, m.end()).toString();
Call<SuggestResponse> call = service.listHashTags(keyword);
try {
    List<HashTag> hashTags = call.execute().body().results;
    for (HashTag hashTag : hashTags) {
        suggests.add(hashTag.tag);
    }
} catch (IOException e) {
    e.printStackTrace();
}
```

実際動かしてみるとこんな感じです。

![](http://vasily.jp/content/images/tech-blog/horie_20151212_qiita/hashtag4.gif)

### 補完候補リストのレイアウトをカスタマイズしてみる

今回使用したAPIでは、タグ名の他に、そのタグの投稿数を取得できます。先ほどの例に追加でタグ名と投稿数を表示してみます。やり方は簡単で、getViewをOverrideしてカスタマイズすればOKです。

```Java
@Override
public View getView(int position, View convertView, ViewGroup parent) {

    if (convertView == null) {
        convertView = inflater.inflate(R.layout.hashtag_suggest_cell, null);
    }

    try {
        HashTag hashTag = getItem(position);

        AppCompatTextView tagName = (AppCompatTextView) convertView.findViewById(R.id.hash_tag_name);
        AppCompatTextView tagCount = (AppCompatTextView) convertView.findViewById(R.id.hash_tag_count);

        tagName.setText(hashTag.tag);
        tagCount.setText(hashTag.count);

    } catch (Exception e) {
        e.printStackTrace();
    }

    return convertView;
}
```

また、APIからレスポンスを受け取る部分を変更し、suggestsを`List<HashTag>`に変更しresultsをそのまま代入してしまいます。

```Java
String keyword = constraint.subSequence(m.start() + 1, m.end()).toString();
Call<SuggestResponse> call = service.listHashTags(keyword);
try {
    suggests = call.execute().body().results;
} catch (IOException e) {
    e.printStackTrace();
}
```

補完候補を選択した際にtag名だけrepalceTextに渡す必要がありますので、FilterクラスのconvertResultToStringをOverrideします。

```Java
@Override
public CharSequence convertResultToString(Object resultValue) {
    return String.format("#%s ", ((HashTag) resultValue).tag);
}
```

これでタグ名と投稿数を表示できました。

![](http://vasily.jp/content/images/tech-blog/horie_20151212_qiita/hashtag5.gif)

# サンプルコード

サンプルコードはこちらです。

https://github.com/horie1024/HashTagAutoCompleteTextView

# まとめ
今回ハッシュタグの補完に挑戦してみました。手探りでしたが、一応動くものができてよかったです。

# 参考
* http://y-anz-m.blogspot.jp/2013/02/androidautocompletetextview.html
* http://developer.android.com/reference/android/widget/AutoCompleteTextView.html
* https://www.websequencediagrams.com/
	* 図を作るのに使用しました。
* http://qiita.com/corin8823/items/75309761833d823cac6f
* https://github.com/square/retrofit/blob/master/samples/src/main/java/com/example/retrofit/SimpleService.java
* http://inthecheesefactory.com/blog/retrofit-2.0/en