Pull to refresh

Использование Android Search Dialog. Часть 3 — Custom Suggestions

Reading time 15 min
Views 12K
image

Это заключительная статья по использованию Android Search Dialog (предыдущие находятся здесь и здесь). В ней я расскажу, как добавить в диалог динамические подсказки к поиску, а также, как интегрировать поиск по вашему приложению в системный Quick Search Box (QSB). Преимущество QSB в том, что с его помощью можно получать информацию из практически любого места в OS.


Теория


Подсказки к поиску создаются при помощи данных вашего приложения, по которым осуществляется поиск. Когда пользователь выбирает одну из них, то Search Manager посылает Intent к Activity, которое отвечает за поиск. Обычно, когда пользователь нажимает иконку поиска в диалоге, то отправляется Intent типа Search, однако, при выборе подсказки в данном случае можно определить другой тип Intent, так чтобы мы могли его перехватить и совершить соответствующие действия, например, создание нового диалога, или вызов Activity для отображения информации и т.д.
Данные поискового запроса переносятся через Intent как и раньше, однако теперь мы будем использовать URI, чтобы определять тип запроса через контент-провайдер.

Снова, нам не нужно производить никаких действий по отрисовке диалога, этим занимается Search Manager, всё, что от нас требуется — представить конфигурационный xml файл.

Итак, когда Search Manager определяет наше Activity как отвечающее за поиск и обеспечивающее подсказки к поиску, то происходит следующая последовательность действий:
  1. Когда Search Manager получает текст поискового запроса, то он отправляет свой запрос к контент-провайдеру, обеспечивающему подсказки.
  2. Контент-провайдер возвращает курсор, указывающий на подсказки, которые совпадают с текстом поискового запроса.
  3. Search Manager отображает подсказки, используя курсор

После того как список подсказок был отображен, может случиться следующее:
  • Если пользователь изменяет текст запроса, то все вышеперечисленные шаги повторятся.
  • Если пользователь запускает поиск, то подсказки игнорируются, и к Activity отправляется Intent типа Search.
  • Если пользователь выбирает подсказку, то к Activity доставляется Intent другого типа (тип определяется в конфигурационном файле), переносящий URI в качестве данных. URI будет использоваться для поиска записи в таблице, соответствующей выбранной подсказке.

Итак, мы модифицируем наше приложение (то которое рассматривалось в части 1) так, чтобы добавлялись динамические подсказки, причем, для отработки механизма, при выборе подсказки будем вызывать новое Activity, которое будет отображать информацию по запросу. Для реализации потребуется:
  • Изменить конфигурационный файл диалога, добавив к нему информацию о контент-провайдере и типе Intent, используемом для подсказок
  • Создать таблицу в БД SQLite, которая будет предоставлять столбцы, требуемые Search Manager'ом для подсказок
  • Создать новый контент-провайдер, имеющий доступ к таблице подсказок, и определить его в манифесте
  • Добавить Activity, которое будет отображать информацию при выборе подсказок


Изменяем конфигурационный файл


Напоминаю, что конфигурационный файл (res/xml/searchable.xml) требуется для отображения диалога и его изменения, например, для использования голосового поиска. Чтобы использовать динамические подсказки, необходимо добавить в файл параметр: android:searchSuggestAuthority. Он будет совпадать со строкой авторизации контент-провайдера. Кроме этого добавим параметр android:searchMode=«queryRewriteFromText», его значение указывает на то, что строка поиска в диалоге будет перезаписываться при навигации по подсказкам, например с помощью трекбола. Также добавим параметры, задающие оператор выборки, тип Intent отправляемого при выборе подсказки, и минимальное количество напечатанных символов в диалоге, необходимое для запроса к контент-провайдеру.

Файл res/xml/searchable.xml
<?xml version="1.0" encoding="utf-8"?>
<searchable xmlns:android="http://schemas.android.com/apk/res/android"
    android:label="@string/app_name"
    android:hint="@string/search_hint" 
    android:searchSettingsDescription="@string/settings_description"
    android:searchMode="queryRewriteFromText"
    android:includeInGlobalSearch="true"
    android:searchSuggestAuthority="com.example.search.SuggestionProvider" 
    android:searchSuggestIntentAction="android.intent.action.VIEW"
    android:searchSuggestIntentData="content://com.example.search.SuggestionProvider/records"
    android:searchSuggestThreshold="1"
    android:searchSuggestSelection=" ?">
</searchable>


Создаем контент-провайдер


По сути наш контент-провайдер ничем не отличается от других. Но нужно сделать так, чтобы для каждой строки из таблицы подсказок выбирались нужные столбцы, те которые требует Search Manager. Мы будем запрашивать данные по подсказкам с помощью метода контент-провайдера query(). Причем вызываться он будет каждый раз, когда пользователь печатает новый символ в диалоге. Таким образом, метод query() должен возвращать курсор на записи в таблице, совпадающие с запросом, и тогда Search Manager сможет отобразить подсказки. Смотрите описание метода в комментариях к коду.
Сам текст запроса будет дописываться к URI, так что с его получением проблем не будет, нужно просто использовать стандартный метод getLastPathSegment().

Создание таблицы подсказок


Когда Search Manager получает курсор, указывающий на записи, то он ожидает определенный набор столбцов для каждой записи. Обязательными являются два: _ID — уникальный идентификатор каждой подсказки, и SUGGEST_COLUMN_TEXT_1 — текст подсказки.
Необязательных столбцов существует много, например используя SUGGEST_COLUMN_ICON_1, вы можете определить для каждой записи иконку, отображаемую с левой стороны подсказки (очень удобно, например, для поиска по контактам).

Определение типа данных для Intent


Так как мы передаем данные по запросу через URI, то нам нужен механизм для определения того, какая подсказка была выбрана. Тут есть два пути. Первый, заключается в том, чтобы определить отдельный столбец SUGGEST_COLUMN_INTENT_DATA, в котором будут уникальные данные для каждой записи, тогда можно получать данные из Intent через getData() или getDataString(). Второй вариант — определить тип данных для всех Intent в конфигурационном файле (res/xml/searchable.xml) а потом дописывать к URI уникальные данные для каждого Intent, используя столбец SUGGEST_COLUMN_INTENT_DATA_ID.
Мы будем использовать второй вариант, причем отдельных столбцов в таблице я не создавал, так как можно просто создать отображение из SUGGEST_COLUMN_INTENT_DATA_ID в rowId таблицы. Добавлю еще, что ради спортивного интереса в SQLite для поиска использовался FTS3, то есть пришлось создавать виртуальную таблицу, для которой нельзя накладывать ограничения на столбцы (constraints), такие как PRIMARY KEY или NULL/NOT NULL. Зато у виртуальных таблиц есть уникальный идентификатор строки, на него и установим отображение. То есть data для Intent будет иметь следующий вид: к URI будет дописываться "/" и rowId строки в таблице.

Создание Activity для отображения информации


Интерфейс находится в res/layout/record_activity.xml. Всё чем занимается Activity — получение данных из Intent, запрос курсора через контент-провайдер и отображение записи в текстовом поле.

Файл res/layout/record_activity.xml
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
        android:orientation="vertical"
        android:layout_width="fill_parent"
        android:layout_height="fill_parent"
        android:padding="10dp">
    <TextView
            android:id="@+id/record_header"
            android:textSize="25dp"
            android:textColor="?android:textColorPrimary"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"/>
</LinearLayout>


Теперь внесем информацию о контент-провайдере и новом Activity в манифест, также, так как у нас теперь два Activity, то укажем то, которое по умолчанию отвечает за поиск.

Файл AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
      package="com.example.search"
      android:versionCode="1"
      android:versionName="1.0">
    <application android:icon="@drawable/icon" android:label="@string/app_name">
        <activity android:name=".Main"
                  android:label="@string/app_name">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
            <intent-filter>
                <action android:name="android.intent.action.SEARCH" />
            </intent-filter>
            <meta-data 
                android:name="android.app.searchable"
                android:resource="@xml/searchable"
            />
        </activity>

        <activity android:name=".RecordActivity"
                  android:theme="@android:style/Theme.NoTitleBar" />

        <provider android:name=".SuggestionProvider"
                  android:authorities="com.example.search.SuggestionProvider" />

        <meta-data android:name="android.app.default_searchable"
                   android:value=".Main" />
    
    </application>
    <uses-sdk android:minSdkVersion="5" />
</manifest> 

Перехват Intent в Activity, отвечающем за поиск


После всех вышеперечисленных шагов нужно обработать Intent в главном Activity, которое отвечает за поиск. Так как мы определили тип Intent для подсказок как View, то нужно просто добавить проверку на него. В случае если условие выполнится, то запускается RecordActivity, используя Intent, в данные которого записывается URI + "/" + id подсказки в таблице.

Интеграция с Quick Search Box


После того, как Вы модифицировали ваше приложение для использования custom suggestions, то можно добавить его к системному поиску. Для этого в файл searchable.xml нужно добавить два параметра:
  1. android:includeInGlobalSearch=«true» — указывает на то, что QSB может осуществлять поиск по вашему приложению.
  2. android:searchSettingsDescription="@string/settings_description" — указывает на описание вашего приложения, которое отображается при настройке Quick Search Box. Эти настройки находятся в settings->search.

Эти параметры доступны с версии Android 1.6, то есть для версий ниже вы не сможете настроить ваше приложение для QSB.

Исходный код


Представляю полный исходный код всех необходимых классов. Main.java — главное Activity, отвечающее за поиск и за отправление запросов к контент-провайдеру, RecordActivity.java — получает Intent с данными по конкретной записи, получает ссылку на запись и отображает информацию. SuggestionProvider.java — контент-провайдер обрабатывающий запросы от Search Manager'a к таблице подсказок. RecordsDbHelper.java — отвечает за создание таблицы, её заполнение, установление необходимого отображения, и за сам «matching» записей.

Файл Main.java
package com.example.search;

import android.app.ListActivity;
import android.app.SearchManager;
import android.content.Intent;
import android.database.Cursor;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.SimpleCursorAdapter;
import android.widget.Toast;

public class Main extends ListActivity {
	
    private EditText text;
    private Button add;
    private RecordsDbHelper mDbHelper;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
	setContentView(R.layout.main);
	mDbHelper = new RecordsDbHelper(this);
		
	Intent intent = getIntent();
		
	if (Intent.ACTION_SEARCH.equals(intent.getAction())) {
	    //Берем строку запроса из экстры
	    String query = intent.getStringExtra(SearchManager.QUERY);
	    //Выполняем поиск
	    showResults(query);
	} else if (Intent.ACTION_VIEW.equals(intent.getAction())){
	        //Создаем Intent для открытия RecordActivity
                Intent recordIntent = new Intent(this, RecordActivity.class);
                recordIntent.setData(intent.getData());
                startActivity(recordIntent);
                finish();
	}
		
	add = (Button) findViewById(R.id.add);
	text = (EditText) findViewById(R.id.text);
	add.setOnClickListener(new View.OnClickListener() {
	    public void onClick(View view) {
	        String data = text.getText().toString();
		if (!data.equals("")) {
		    saveTask(data);
		    text.setText("");
		}
	    }
	});
		
    }

    private void saveTask(String data) {
        mDbHelper.createRecord(data);
    }
	
    private void showResults(String query) {

    	//Запрашиваем у контент-провайдера курсор на записи 
        Cursor cursor = managedQuery(SuggestionProvider.CONTENT_URI, null, null,
                                new String[] {query}, null);
        if (cursor == null) {
            Toast.makeText(this, "There are no results", Toast.LENGTH_SHORT).show();
        } else {
        	//Обновляем адаптер
            String[] from = new String[] { RecordsDbHelper.KEY_DATA };
            int[] to = new int[] { R.id.text1 };
            SimpleCursorAdapter records = new SimpleCursorAdapter(this, R.layout.record, cursor, from, to);
            getListView().setAdapter(records);            
        }        
    }
	

    //Создаем меню для вызова поиска (интерфейс в res/menu/main_menu.xml)
    public boolean onCreateOptionsMenu(Menu menu) {
        MenuInflater inflater = getMenuInflater();
        inflater.inflate(R.menu.main_menu, menu);
        return true;	
    }

    public boolean onOptionsItemSelected(MenuItem item) {
        switch (item.getItemId()) {
            case R.id.search_record:
        	 onSearchRequested();
        	return true;
            default:
            return super.onOptionsItemSelected(item);
        }
    }
}


Файл RecordActivity.java
package com.example.search;

import android.app.Activity;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.widget.TextView;

public class RecordActivity extends Activity {

	@Override
	public void onCreate(Bundle savedInstanceState) {
	    super.onCreate(savedInstanceState);
	    setContentView(R.layout.record_activity);

	    //Получаем URI с данными из Intent и запрашиваем данные через контент-провайдер
	    Uri uri = getIntent().getData();
            Cursor cursor = managedQuery(uri, null, null, null, null);

        if (cursor == null) {
            finish();
        } else {
            //Устанавливаем данные в текстовое поле
            cursor.moveToFirst();

            TextView record = (TextView) findViewById(R.id.record_header);
            int rIndex = cursor.getColumnIndexOrThrow(RecordsDbHelper.KEY_DATA);

            record.setText(cursor.getString(rIndex));
        }
    }
	
	//Создаем меню для вызова диалога поиска из этого активити
    @Override
    public boolean onCreateOptionsMenu(Menu menu) {
        MenuInflater inflater = getMenuInflater();
        inflater.inflate(R.menu.main_menu, menu);
        return true;
    }

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        switch (item.getItemId()) {
            case R.id.search_record:
                onSearchRequested();
                return true;
            default:
                return false;
        }
    }

}


Файл SuggestionProvider.java
package com.example.search;

import android.app.SearchManager;
import android.content.ContentProvider;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.UriMatcher;
import android.database.Cursor;
import android.net.Uri;
import android.provider.BaseColumns;

public class SuggestionProvider extends ContentProvider{

    private RecordsDbHelper mDbHelper;
    
    public static String AUTHORITY = "com.example.search.SuggestionProvider";
    public static final Uri CONTENT_URI = Uri.parse("content://" + AUTHORITY + "/records");
    
    //MIME типы для getType()
    public static final String RECORDS_MIME_TYPE = ContentResolver.CURSOR_DIR_BASE_TYPE +
                                                  "/vnd.example.search";
    public static final String RECORD_MIME_TYPE = ContentResolver.CURSOR_ITEM_BASE_TYPE +
                                                  "/vnd.example.search";

    //Для матчера разных URI
    private static final int SEARCH_RECORDS = 0;
    private static final int GET_RECORD = 1;
    private static final int SEARCH_SUGGEST = 2;
    private static final UriMatcher sURIMatcher = makeUriMatcher();
               
    @Override
    public boolean onCreate() {
        mDbHelper = new RecordsDbHelper(getContext());
        return true;
    }

    /**
     * Обрабатывает запросы от Search Manager'a.
     * Когда запрашивается конкретный элемент, то требуется только URI.
     * Когда запрашивается поиск по всей таблице, то первый элемент параметра selectionArgs содержит строку запроса.
     * Остальные параметры не нужны.
     */
    @Override
    public Cursor query(Uri uri, String[] projection, String selection,
     		String[] selectionArgs, String sortOrder) {
        //Используем UriMatcher, чтобы узнать какой тип запроса получен. Далее формируем соответствующий запрос к БД
        switch (sURIMatcher.match(uri)) {
            case SEARCH_SUGGEST:
                if (selectionArgs == null) {
                    throw new IllegalArgumentException(
                        "selectionArgs must be provided for the Uri: " + uri);
                }
                return getSuggestions(selectionArgs[0]);
            case SEARCH_RECORDS:
                if (selectionArgs == null) {
                    throw new IllegalArgumentException(
                        "selectionArgs must be provided for the Uri: " + uri);
                }
                return search(selectionArgs[0]);
            case GET_RECORD:
                return getRecord(uri);
            default:
                throw new IllegalArgumentException("Unknown Uri: " + uri);
        }
	}
	
    private Cursor getSuggestions(String query) {
        query = query.toLowerCase();
        String[] columns = new String[] {
             BaseColumns._ID,
             RecordsDbHelper.KEY_DATA,
             SearchManager.SUGGEST_COLUMN_INTENT_DATA_ID};
        return mDbHelper.getRecordMatches(query, columns);
    }
    
    private Cursor search(String query) {
        query = query.toLowerCase();
        String[] columns = new String[] {
            BaseColumns._ID,
            RecordsDbHelper.KEY_DATA};

        return mDbHelper.getRecordMatches(query, columns);
    }
    
    private Cursor getRecord(Uri uri) {
        String rowId = uri.getLastPathSegment();
        String[] columns = new String[] {
            RecordsDbHelper.KEY_DATA};

        return mDbHelper.getRecord(rowId, columns);
    }
    
    /**
     * Вспомогательный метод
     * нужен для сопоставления разным URI конкретных значений 
     */
    private static UriMatcher makeUriMatcher() {
        UriMatcher matcher =  new UriMatcher(UriMatcher.NO_MATCH);
        // Для записей
        matcher.addURI(AUTHORITY, "records", SEARCH_RECORDS);
        matcher.addURI(AUTHORITY, "records/#", GET_RECORD);
        // Для подсказок
        matcher.addURI(AUTHORITY, SearchManager.SUGGEST_URI_PATH_QUERY, SEARCH_SUGGEST);
        matcher.addURI(AUTHORITY, SearchManager.SUGGEST_URI_PATH_QUERY + "/*", SEARCH_SUGGEST);
        return matcher;
    }
	
    //Требуемые методы (наследуются от класса ContentProvider)
    @Override
    public String getType(Uri uri) {
        switch (sURIMatcher.match(uri)) {
            case SEARCH_RECORDS:
                return RECORDS_MIME_TYPE;
            case SEARCH_SUGGEST:
            	return SearchManager.SUGGEST_MIME_TYPE;
            case GET_RECORD:
                return RECORD_MIME_TYPE;
            default:
                throw new IllegalArgumentException("Unknown URL " + uri);
        }	
    }
	
    @Override
    public int update(Uri uri, ContentValues values, String selection,
  		               String[] selectionArgs) {
        throw new UnsupportedOperationException();
    }

    @Override
    public Uri insert(Uri uri, ContentValues values) {
        throw new UnsupportedOperationException();
    }

    @Override
    public int delete(Uri uri, String selection, String[] selectionArgs) {
        throw new UnsupportedOperationException();
    }
	
}


Файл RecordsDbHelper.java
package com.example.search;

import java.util.HashMap;

import android.app.SearchManager;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import android.database.sqlite.SQLiteQueryBuilder;
import android.provider.BaseColumns;
import android.util.Log;

public class RecordsDbHelper {

    //Единственный столбец в таблице - данные
    public static final String KEY_DATA = SearchManager.SUGGEST_COLUMN_TEXT_1;

    private static final String TAG = "RecordsDbHelper";
    private DatabaseHelper mDbHelper;
    private SQLiteDatabase mDb;

    private static final String DATABASE_NAME = "datas";
    private static final String DATABASE_TABLE = "records";
    private static final int DATABASE_VERSION = 2;
	
    //Сценарий создания БД
    private static final String DATABASE_CREATE =
		"CREATE VIRTUAL TABLE " + DATABASE_TABLE +
                " USING fts3 (" + KEY_DATA + ");";	

    private static final HashMap<String,String> mColumnMap = buildColumnMap();
	
    /**
     * Возвращает курсор, указывающий на запись с rowId
     * @param rowId id возвращаемой записи
     * @param columns возвращаемые столбцы записи; если null, то все 
     * @return курсор, указывающий на определенную запись, null - если не запись не найдена
     */
    public Cursor getRecord(String rowId, String[] columns) {
        String selection = "rowid = ?";
        String[] selectionArgs = new String[] {rowId};

        return query(selection, selectionArgs, columns);
    }
    
    /**
     * Возвращает курсор, указывающий на все записи, совпадающие с запросом
     * @param query текст поискового запроса
     * @param columns возвращаемые столбцы записи; если null, то все 
     * @return курсор, указывающий на записи, совпадающие с запросом, null - если не записи не найдена
     */
    public Cursor getRecordMatches(String query, String[] columns) {
        String selection = KEY_DATA + " MATCH ?";
        String[] selectionArgs = new String[] {query+"*"};

        return query(selection, selectionArgs, columns);
    }
    
    /**
     * Создает отображение всевозможных запрашиваемых столбцов.
     * Будет установлено как проекция в SQLiteQueryBuilder.
     * Нужно для того, чтобы назначить для каждой записи уникальные значения SUGGEST_COLUMN_INTENT_DATA_ID
     * которые используются для получения конкретной записи по URI.
     */
    private static HashMap<String,String> buildColumnMap() {
        HashMap<String,String> map = new HashMap<String,String>();
        map.put(KEY_DATA, KEY_DATA);
        map.put(BaseColumns._ID, "rowid AS " +
                BaseColumns._ID);
        map.put(SearchManager.SUGGEST_COLUMN_INTENT_DATA_ID, "rowid AS " +
                SearchManager.SUGGEST_COLUMN_INTENT_DATA_ID);
        return map;
    }

    /**
     * 
     * @param selection оператор выборки
     * @param selectionArgs аргументы, заменяющие "?" в запросе к БД
     * @param columns возвращаемые столбцы записи
     * @return курсор, указывающий на все записи, совпадающие с поисковым запросом
     */
    private Cursor query(String selection, String[] selectionArgs, String[] columns) {
        /* SQLiteBuilder предоставляет возможность создания отображения для всех
         * необходимых столбцов БД, что позволяет не сообщать контент-провайдеру
         * настоящие имена столбцов.
         */

        SQLiteQueryBuilder builder = new SQLiteQueryBuilder();
        builder.setTables(DATABASE_TABLE);
        builder.setProjectionMap(mColumnMap);

        Cursor cursor = builder.query(mDbHelper.getReadableDatabase(),
                columns, selection, selectionArgs, null, null, null);
        if (cursor == null) {
            return null;
        } else if (!cursor.moveToFirst()) {
            cursor.close();
            return null;
        }
        return cursor;
    }    
    	
    /**
     *Создает/открывает БД
     */
    private static class DatabaseHelper extends SQLiteOpenHelper {
		
        DatabaseHelper(Context context) {
		super(context, DATABASE_NAME, null, DATABASE_VERSION);
	}

	@Override
	public void onCreate(SQLiteDatabase db) {
	    db.execSQL(DATABASE_CREATE);
	}

	@Override
	public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
	    Log.w(TAG, "Upgrading database from version " + oldVersion + " to "
			+ newVersion + ", which will destroy all old data");
	    db.execSQL("DROP TABLE IF EXISTS records");
	    onCreate(db);
	}
    }

    public RecordsDbHelper(Context context) {
        mDbHelper = new DatabaseHelper(context);
    }

    /**
     * Добавляет запись в таблицу
     * @param data данные, сохраняемые в таблицу
     * @return id записи, или -1, если добавление не удалось
     */
    public long createRecord(String data) {
        mDb = mDbHelper.getWritableDatabase();
	ContentValues initialValues = new ContentValues();
	initialValues.put(KEY_DATA, data);
	return mDb.insert(DATABASE_TABLE, null, initialValues);
    }
	
}


Весь проект можно взять на code.google.com
Спасибо за внимание!
Tags:
Hubs:
+6
Comments 5
Comments Comments 5

Articles