ActionBarSherlockでSearchView

2013-05-27T00:00:00+00:00 Android Java

android.widget.SearchViewなメニューのActionView自体が多分Android3.xくらいからサポートされたんだと思うんだけど、ActionBarSherlockにそれと同等な感じのWidgetが存在する模様。という事でAndroidAnnotations+ActionBarSherlockな構成でやってみた

要件

検索メニューを選択すると

んな感じで上に検索入力が出る。でまぁ「f」だけ入力してみると

みたいな感じでインクレメンタル検索的な事が出来て、✕を押すと

まぁフィルターから元に戻る的な感じな事をやる

※上記スクリーンショットのAndroidバージョンは2.2.1

サーバー側な処理に検索機能をぶちこむ

サーバー側はRailsなので

App::Application.routes.draw do
  resources :entry do
    match "search/:query", :action => :search, :on => :collection
  end
end

な感じでconfig/routes.rbを設定。rake routesすると

  • /entry/search/:query(.:format) entry#search

な感じで処理出来るように。でコントローラーにアクションを定義するのだけど、検索方式はとりまぁLIKE検索で

class EntryController < ApplicationController
  def search
    query = params[:query]
    samples = Sample.where("name like ?", "%#{query}%")

    render :json => samples
  end
end

な感じで。これでサーバー側な仕込み完了。AndroidAnnotationsなところにもちょっとした仕込みが必要

AndroidAnnotations Restな仕込み

上記でサーバー側にAPIを追加したので@Restアノテーションなインターフェースにも追加せばならぬ

package kinjouj.sample;

import com.googlecode.androidannotations.annotations.rest.Accept;
import com.googlecode.androidannotations.annotations.rest.Get;
import com.googlecode.androidannotations.annotations.rest.Post;
import com.googlecode.androidannotations.annotations.rest.Rest;

import org.springframework.http.converter.json.GsonHttpMessageConverter;
import org.springframework.web.client.RestTemplate;

import static com.googlecode.androidannotations.api.rest.MediaType.APPLICATION_JSON;

@Rest(converters = { GsonHttpMessageConverter.class })
public interface AAService {

    public void setRootUrl(String rootUrl);
    public RestTemplate getRestTemplate();

    @Get("/entry")
    @Accept(APPLICATION_JSON)
    public Sample[] getSamples();

    // 追加
    @Get("/entry/search/{query}")
    @Accept(APPLICATION_JSON)
    public Sample[] search(String query);

    @Get("/entry/{id}")
    @Accept(APPLICATION_JSON)
    public Sample getSample(long id);

    @Post("/entry")
    @Accept(APPLICATION_JSON)
    public Long save(Sample sample);
}

んまぁそんな感じで。でこいつをブリッジしちゃう@EBeanアノテーションなクラスも実装追加するんだけど、これはSample[] -> List<Sample>にするだけなので省略

本題はここから

res/menu/main.xmlにactionViewClassを持つメニューアイテムを追加

<?xml version="1.0" ?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">

    <item
        android:id="@+id/menu_item_search"
        android:showAsAction="always|collapseActionView"
        android:icon="@drawable/search"
        android:title="@string/menu_search_label"
        android:actionViewClass="com.actionbarsherlock.widget.SearchView" />

    <!-- 以下省略 -->

</menu>

Android本式だとandroid.widget.SearchViewなんだけど、ActionBarSherlockにあるのはcom.actionbarsherlock.widget.SearchView。これを指定する。あと本式はどうかは知らないけど、ActionBarSherlockでタイトルを指定しないとぬるぽでオチるので注意

MainActivity.java にSearchViewな処理を実装する

でAndroidAnnotationsを使う場合は基本的にonCreateOptionsMenuは実装しない。@OptionsMenuを使ってメニューをinflateする訳なんだけど、SearchViewを使う為にはそれが必要なのでオーバーライドする

package kinjouj.sample;

import android.annotation.SuppressLint;
import android.support.v4.app.FragmentManager;
import android.text.TextUtils;
import android.util.Log;

import com.actionbarsherlock.app.SherlockFragmentActivity;
import com.actionbarsherlock.view.ActionMode;
import com.actionbarsherlock.view.Menu;
import com.actionbarsherlock.view.MenuItem;
import com.actionbarsherlock.widget.SearchView;
import com.googlecode.androidannotations.annotations.AfterInject;
import com.googlecode.androidannotations.annotations.AfterViews;
import com.googlecode.androidannotations.annotations.EActivity;
import com.googlecode.androidannotations.annotations.OptionsItem;
import com.googlecode.androidannotations.annotations.OptionsMenu;
import com.jeremyfeinstein.slidingmenu.lib.SlidingMenu;

@EActivity(R.layout.main)
@OptionsMenu(R.menu.main)
public class MainActivity extends SherlockFragmentActivity {

    private static final String TAG = MainActivity.class.getName();

    private SlidingMenu mSlidingMenu;
    private ActionMode mMode;
    private MainListFragment_ fragment;

    @AfterInject
    public void init() {
        Log.v(TAG, "init");
        getSupportActionBar().setDisplayHomeAsUpEnabled(true);
    }

    @AfterViews
    public void initViews() {
        Log.v(TAG, "initViews");
        mSlidingMenu = new SlidingMenu(this);
        mSlidingMenu.setTouchModeAbove(SlidingMenu.TOUCHMODE_FULLSCREEN);
        mSlidingMenu.setBehindOffsetRes(R.dimen.sliding_offset);
        mSlidingMenu.setShadowWidthRes(R.dimen.shadow_width);
        mSlidingMenu.setShadowDrawable(R.drawable.shadow);
        mSlidingMenu.setFadeDegree(0.35f);
        mSlidingMenu.attachToActivity(this, SlidingMenu.SLIDING_CONTENT);
        mSlidingMenu.setMenu(R.layout.menu);
    }

    @SuppressLint("InlinedApi")
    @OptionsItem(android.R.id.home)
    public void onMenuHome() {
        Log.v(TAG, "onMenuHome");

        if (mSlidingMenu != null) {
            mSlidingMenu.toggle();
        }
    }

    @OptionsItem(R.id.menu_item_reload)
    protected void onMenuReload() {
        Log.v(TAG, "onMenuReload");
        getFragment().loadSamples();
    }

    @OptionsItem(R.id.menu_item_add)
    public void onMenuAdd() {
        Log.v(TAG, "onMenuAdd");

        AddFormDialogFragment dialogFragment = new AddFormDialogFragment();
        dialogFragment.show(getSupportFragmentManager(), "add_form_dialog_fragment");
    }

    @OptionsItem(R.id.menu_item_destroy)
    public void onMenuDestroy() {
        Log.v(TAG, "onMenuDestroy");
        getFragment().destroySelected();
    }

    @OptionsItem(R.id.menu_item_info)
    public void onMenuExit() {
        Log.v(TAG, "onMenuExit");

        mMode = startActionMode(new ActionMode.Callback() {
            @Override
            public boolean onCreateActionMode(ActionMode mode, Menu menu) {
                Log.v(TAG, "onCreateActionMode");
                getSupportMenuInflater().inflate(R.menu.action_mode, menu);

                return true;
            }

            @Override
            public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
                Log.v(TAG, "onPrepareActionMode");

                return false;
            }

            @Override
            public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
                Log.v(TAG, "onActionItemClicked");

                switch (item.getItemId()) {
                    case R.id.action_mode_app_info:
                        AppInfoDialogFragment_ fragment = (AppInfoDialogFragment_)AppInfoDialogFragment_.builder().build();
                        fragment.show(getSupportFragmentManager(), "app_info_dialog_fragment");

                        break;

                    case R.id.action_mode_app_exit:
                        finish();
                        break;

                }

                return false;
            }

            @Override
            public void onDestroyActionMode(ActionMode mode) {
                Log.v(TAG, "onDestroyActionMode");

                mMode = null;
            }
        });
    }

    // 追加
    @Override
    public boolean onCreateOptionsMenu(Menu menu) {
        Log.v(TAG, "onCreateOptionsMenu");

        SearchView searchView = (SearchView)menu.findItem(R.id.menu_item_search).getActionView();
        searchView.setSubmitButtonEnabled(false);
        searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() {
            @Override
            public boolean onQueryTextSubmit(String query) {
                return false;
            }

            @Override
            public boolean onQueryTextChange(String newText) {
                if (!TextUtils.isEmpty(newText)) {
                    getFragment().getListView().setFilterText(newText);
                } else {
                    getFragment().getListView().clearTextFilter();
                }

                return false;
            }
        });

        return super.onCreateOptionsMenu(menu);
    }

    public void onBackPressed() {
        Log.v(TAG, "onBackPressed");

        if (mSlidingMenu != null && mSlidingMenu.isMenuShowing()) {
            mSlidingMenu.toggle();
            return;
        }

        finish();
    }

    public MainListFragment_ getFragment() {
        if (fragment == null) {
            FragmentManager fragmentManager = getSupportFragmentManager();
            fragment = (MainListFragment_)fragmentManager.findFragmentById(R.id.main_fragment);
        }

        return fragment;
    }

    public SlidingMenu getSlidingMenu() {
        return mSlidingMenu;
    }

    public ActionMode getActionMode() {
        return mMode;
    }
}

ListView自体はListFragmentで処理しているので、そのFragmentなインスタンスを取得してgetListViewしつつのsetFilterTextをするっつー感じ。でonQueryTextChangeが発生する毎にそれをやるっていう所

MainListFragment.java にフィルターする処理を実装する

上記でサーバー側にクエリーをぶん投げる事でマッチする結果をJSONで取って、それをSample[]でデコードしつつ返してくれるのは実装したのでそれを利用する。方式はAdapterクラスのgetFilterメソッドでandroid.widget.Filterを返す事で出来るのでそこに実装する。

package kinjouj.sample;

import java.util.ArrayList;
import java.util.List;

import android.support.v4.app.ListFragment;
import android.text.TextUtils;
import android.util.Log;
import android.util.SparseBooleanArray;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.Filter;
import android.widget.ListAdapter;
import android.widget.ListView;
import android.widget.TextView;
import android.widget.Toast;
import com.googlecode.androidannotations.annotations.AfterInject;
import com.googlecode.androidannotations.annotations.AfterViews;
import com.googlecode.androidannotations.annotations.Background;
import com.googlecode.androidannotations.annotations.Bean;
import com.googlecode.androidannotations.annotations.EFragment;
import com.googlecode.androidannotations.annotations.UiThread;
import org.springframework.web.client.RestClientException;

@EFragment(R.layout.main_fragment_layout)
public class MainListFragment extends ListFragment {

    private static final String TAG = MainListFragment.class.getName();

    private List<Sample> samples = new ArrayList<Sample>();
    private Filter filter;
    private Toast errorToast;

    @Bean
    protected AAServiceBean serviceBean;

    @AfterInject
    public void init() {
        Log.v(TAG, "init");
        loadSamples();
    }

    @AfterViews
    public void initViews() {
        Log.v(TAG, "initViews");
        getListView().setTextFilterEnabled(true);
        getListView().setChoiceMode(ListView.CHOICE_MODE_MULTIPLE);
        registerAdapter();
    }

    public void registerAdapter() {
        ArrayAdapter<Sample> adapter = new ArrayAdapter<Sample>(
            getActivity(),
            android.R.layout.simple_list_item_checked,
            android.R.id.text1,
            samples
        ) {

            @Override
            public View getView(int position, View convertView, ViewGroup parent) {
                Log.v(TAG, "getView");

                View view = super.getView(position, convertView, parent);

                if (view instanceof TextView) {
                    Sample sample = getItem(position);

                    ((TextView)view).setText(sample.getName());
                }

                return view;
            }

            // ここから追加
            @Override
            public Filter getFilter() {
                Log.v(TAG, "getFilter");

                if (filter == null) {
                    Filterクラスのインスタンス生成時に初期状態を取っておく。だけど、こんな風にやるのはNG
                    final List<Sample> _samples = new ArrayList<Sample>(samples);

                    filter = new Filter() {
                        @Override
                        protected FilterResults performFiltering(CharSequence constraint) {
                            Log.v(TAG, "performFiltering");

                            FilterResults results = new FilterResults();
                            List<Sample> filterItems = new ArrayList<Sample>();

                            if (!TextUtils.isEmpty(constraint)) {
                                // サーバー側にsearch APIを要求して結果を取る
                                List<Sample> samples = serviceBean.search(constraint.toString());
                                filterItems.addAll(samples);
                            } else {
                                filterItems = _samples;
                            }

                            results.values = filterItems;
                            results.count = filterItems.size();

                            return results;
                        }

                        @Override
                        protected void publishResults(CharSequence constraint, FilterResults results) {
                            Log.v(TAG, "publishResults");

                            if (results.count > 0) {
                                @SuppressWarnings("unchecked")
                                List<Sample> filterItems = (ArrayList<Sample>)results.values;

                                notifyDataSetChanged();
                                clear();

                                for (Sample filterItem : filterItems) {
                                    add(filterItem);
                                }
                            }
                        }
                    };

                }

                return filter;
            }
        };

        setListAdapter(adapter);
    }

    @Background
    public void loadSamples() {
        Log.v(TAG, "loadSamples");

        try {
            List<Sample> _samples = serviceBean.getSamples();

            samples.clear();
            samples.addAll(_samples);
        } catch (RestClientException e) {
            handleError(e);
        }

        notifyAdapter();
    }

    @Background
    public void addSample(Sample sample) {
        Log.v(TAG, "addSample");

        if (sample == null) {
            throw new IllegalArgumentException("argument(sample) is null");
        }

        try {
            serviceBean.save(sample);
            samples.add(sample);

            notifyAdapter();
        } catch (RestClientException e) {
            handleError(e);
        }
    }

    public void destroySelected() {
        Log.v(TAG, "destroySelected");

        SparseBooleanArray checkedArray = getListView().getCheckedItemPositions();
        int checkedSize = checkedArray.size();

        List<Sample> selectedSamples = new ArrayList<Sample>();

        for (int i = 0; i < checkedSize; i++) {
            int key = checkedArray.keyAt(i);
            boolean checked = checkedArray.get(key);

            if (!checked) {
                continue;
            }

            Object o = getListView().getItemAtPosition(key);

            if (o instanceof Sample) {
                Sample sample = (Sample)o;

                if (!selectedSamples.contains(sample)) {
                    selectedSamples.add(sample);
                }
            }
        }

        if (selectedSamples.size() > 0) {
            try {
                serviceBean.destroy(selectedSamples);
                samples.removeAll(selectedSamples);

                notifyAdapter();
            } catch (RestClientException e) {
                handleError(e);
            }
        }
    }

    @UiThread
    public void handleError(Throwable t) {
        Log.v(TAG, "handleError");
        clearChoices();

        if (t == null) {
            throw new IllegalArgumentException("argument(Throwable) is null");
        }

        errorToast = Toast.makeText(
            getActivity(),
            "ERROR: " + t.getMessage(),
            Toast.LENGTH_LONG
        );
        errorToast.show();
    }

    @UiThread
    public void notifyAdapter() {
        Log.v(TAG, "notifyAdapter");
        clearChoices();
        notifyDataSetChanged();
    }

    private void clearChoices() {
        Log.v(TAG, "clearChoices");
        ListView listView = getListView();

        if (listView != null) {
            getListView().clearChoices();
        }
    }

    @SuppressWarnings("unchecked")
    private void notifyDataSetChanged() {
        Log.v(TAG, "notifyDataSetChanged");
        ListAdapter adapter = getListAdapter();

        if (adapter != null) {
            ((ArrayAdapter<Sample>)adapter).notifyDataSetChanged();
        }
    }
}

んまぁ今回のと関係ないコード多すぎだけどね。要はsetListAdapterで指定するAdapterクラスのgetFilterメソッドをオーバーライドして処理する。performFilteringで受け取った検索キーワードをサーバーにリクエストして結果を取る。でその結果をListViewにバインドする。まぁそんだけ

ってな感じで本題な所は短かったけど、ActionBarSherlockなSearchViewを使えばAndroid3.x以下系でも同様な感じの事は出来るんでねーかと

※Android4.x向けに検証していないのでずっこける可能性もあります

Androidでmockito+hamcrestを使うとエラーになる件