[Перевод] Добавляем в Android-приложение систему локального поиска
Многие программы нуждаются в функции поиска. Сегодня мы рассмотрим пример реализации подобного функционала в приложении для ресторана. Наша основная цель — дать пользователю возможность быстро и легко найти в меню из множества блюд то, чего ему хочется.
Мы расскажем о том, как добавили функцию локального поиска по содержимому существующего приложения и при этом сохранили единообразие пользовательского интерфейса. Мы рассмотрим здесь изменения, внесённые в пользовательский интерфейс и причины этих изменений, так же поговорим о добавлении в элемент класса Activity объекта GestureOverlayView и о создании собственных жестов. Естественно, особое внимание будет уделено поиску.
Экран результатов поиска в приложении ресторана
Подробнее о поиске
Перед тем, как добавить в приложение функцию поиска, нужно учесть некоторые особенности, ответить на ряд вопросов.
Что именно нужно искать? Мы хотим выполнять поиск по заголовкам продуктов и по их описаниям для того, чтобы пользователь мог получить наиболее полный набор результатов, так как одни лишь заголовки не всегда позволяют точно понять, о каком именно блюде идёт речь. Кроме того, к элементам, по которым выполняется поиск, можно добавить скрытые метаданные.
Как нужно отображать результаты поиска, какой макет для этого использовать? Мы начали со списка (объект ListView), сделали его в том же стиле, в котором выполнена корзина покупателя. Однако при таком представлении блюда выглядят не особо привлекательно — виной всему слишком маленькие размеры фотоснимков. Когда мы увеличили размеры изображений в списке, оказалось, что на странице теперь слишком мало места для отображения результатов. В итоге было принято решение разместить результаты поиска в сетке (элемент GridView), поступить так же, как сделано в основной части ресторанного меню, но вместо того, чтобы размещать сбоку большой блок с подробностями о выбранном блюде, мы поместили на всём экране набор продуктов. Это, в частности, помогает быстро отличить страницу с результатами поиска от обычного экрана меню. Для того чтобы посмотреть подробности о блюде, пользователь должен коснуться его фотографии на странице результатов поиска. В ответ на это над страницей появится диалоговое окно (объект DialogFragment), его можно видеть на одном из рисунков, приведенных ниже. Благодаря этому пользователь может быстро вернуться к странице поиска и продолжить просмотр других блюд, просто коснувшись пространства за пределами диалогового окна. Поиск, в идеале, должен выполняться практически мгновенно, без задержек, так как пользователи хотят найти то, что им нужно, как можно быстрее. Иначе они либо не смогут найти то, что хотят, либо им просто надоест ждать результатов, они просто не будут ничего искать и покинут приложение.
Как обрабатывать конфиденциальные данные пользователя? Можно создать систему поиска, которая будет выдавать подсказки, основываясь на введённых ранее поисковых запросах или поиск, для работы которого пользователю нужно будет вводить более или менее подробные сведения о себе. Это поднимает вопросы о том, что другие люди могут увидеть, что именно ищет пользователь, и о том, куда именно отправляются введённые персональные данные. В нашем случае речь идёт о приложении ресторана, поэтому если кто-то узнает, что пользователю нравятся шоколадные пирожные, ничего страшного не произойдёт. Однако есть и проекты, в которых конфиденциальности нужно уделять самое пристальное внимание. В нашем приложении пользователю не нужно вводить никакой информации о себе, не производится логирования поисковых запросов, не ведётся их история.
Поиск в приложении для ресторана
Первый шаг реализации поиска в ресторанном приложении заключается в переработке класса базы данных и добавлении в него метода для построения новой таблицы с результатами поиска. Таблица будет использована для вывода информации на экран. Подробности о базе данных, с которой мы работаем в данном приложении, можно почитать здесь. Поиск по базе данных легко реализовать с использованием SQLite-запросов. Фактически, тут нужно несколько строк кода. Здесь мы выполняем поиск в названиях и описаниях товаров всего, что содержит введённый пользователем поисковый запрос. В качестве результатов поиска мы возвращаем все столбцы базы данных, так как эти сведения понадобятся позже для отображения подробностей о блюде, изображения которого коснулся пользователь. Учтите, что если ваша база данных очень большая, поиск может занять заметное время, поэтому стоит задуматься об отображении прогресс-бара или вращающегося индикатора, чтобы пользователь видел, что приложение работает. Вот метод для выполнения поиска по базе данных.
/**
* Построение таблицы элементов, содержащих поисковый запрос (searchTerm) в названиях или описаниях
*/
public Cursor searchMenuItems(String searchTerm) {
SQLiteDatabase db = getReadableDatabase();
SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
qb.setTables(TABLES.MENU);
Cursor c = qb.query(db, null, "("+MenuColumns.NAME+" LIKE '%"+searchTerm+"%') " +
"OR ("+MenuColumns.DESCRIPTION+" LIKE '%" + searchTerm+"%')",
null, null, null, null);
return c;
}
Теперь нужно доработать нашу главную Activity, включить в ActionBar строку поиска. Для того чтобы узнать подробнее о настройке ActionBar, обратитесь к этому материалу. Поисковый функционал будет полностью реализован внутри приложения. Нам не нужно, чтобы в поиске участвовали другие программы, установленные на устройстве, не надо нам и посылать запрос на поиск некоему внешнему приложению.
Добавим эту строковую переменную в класс MainActivity. Мы будем использовать её для отправки строки запроса в поисковый Intent. Это — переменная класса для добавления дополнительных данных в объект класса Intent.
/* Метка строки поиска */
public final static String SEARCH_MESSAGE= "com.example.restaurant.MESSAGE";
Теперь обновим метод onCreateOptionsMenu в классе MainActivity. Добавим в него код для инициализации ActionBar:
/**
* Инициализация action menu в ActionBar
*/
public boolean onCreateOptionsMenu(Menu menu) {
getMenuInflater().inflate(R.menu.action_bar, menu);
//настраиваем поисковую строку
MenuItem searchItem = menu.findItem(R.id.action_search);
SearchView mSearchView = (SearchView) searchItem.getActionView();
searchItem.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_IF_ROOM
| MenuItem.SHOW_AS_ACTION_COLLAPSE_ACTION_VIEW);
// задаём слушатель запросов
mSearchView.setOnQueryTextListener(new OnQueryTextListener() {
@Override
public boolean onQueryTextSubmit(String query) {
//запускаем поисковый intent
Intent searchIntent = new Intent(MainActivity.this, SearchResultsActivity.class);
searchIntent.putExtra(SEARCH_MESSAGE, query);
startActivity(searchIntent);
return false;
}
@Override
public boolean onQueryTextChange(String query) {
//в этом случае не делаем ничего
return true;
}
});
return super.onCreateOptionsMenu(menu);
}
Теперь добавим класс SearchResultActivity.
public class SearchResultsActivity extends Activity{
TextView mQueryText;
GridView searchListResults;
SearchAdapter adapter;
Vector searchList;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.search_query_grid_results);
mQueryText = (TextView) findViewById(R.id.txt_query);
//Настройка GridView
searchListResults = (GridView)findViewById(R.id.search_results);
searchList= new Vector();
//здесь получаем и обрабатываем поисковый запрос
final Intent queryIntent = getIntent();
doSearchQuery(queryIntent);
adapter= new SearchAdapter(this,searchList);
searchListResults.setAdapter(adapter);
//Слушатель для GridView
searchListResults.setOnItemClickListener(new AdapterView.OnItemClickListener() {
@Override
public void onItemClick(AdapterView> parent, View v, int position, long id){
FragmentTransaction ft = getFragmentManager().beginTransaction();
Fragment prev = getFragmentManager().findFragmentByTag("dialog");
if (prev != null) {
ft.remove(prev);
}
ft.addToBackStack(null);
DialogFragment newFragment = SearchResultsDialogFragment.newInstance(searchList.elementAt(position));
newFragment.show(ft, "dialog");
}
});
}
Занимаясь построением списка, мы обрабатываем и вариант, когда в результате поиска ничего не нашлось. А именно, если поиск не дал результатов, мы покажем пользователю диалоговое окно, сообщим о том, что произошло и закроем Activity, в которой планировалось отобразить результаты. В итоге пользователь не увидит пустой страницы. Делаем это мы в том же классе SearchResultActivity, ниже дано его продолжение.
/**
* Строим список найденных элементов.
*/
private void doSearchQuery(final Intent queryIntent) {
//Получаем текст запроса
String message= queryIntent.getStringExtra(MainActivity.SEARCH_MESSAGE);
//Записываем его в поле в пользовательском интерфейсе
mQueryText.setText(message);
RestaurantDatabase dB= new RestaurantDatabase(this);
MenuFactory mMF= MenuFactory.getInstance();
Cursor c= dB.searchMenuItems(message);
Set categories = new HashSet();
while (c.moveToNext()) {
String category = c.getString(c.getColumnIndexOrThrow(RestaurantDatabase.MenuColumns.CATEGORY));
categories.add(category);
//Создаём новый элемент меню и добавляем его в список MenuItem item= mMF.new MenuItem();
item.setCategory(category);
item.setName(c.getString(c.getColumnIndexOrThrow(RestaurantDatabase.MenuColumns.NAME)));
item.setDescription(c.getString(c.getColumnIndexOrThrow(RestaurantDatabase.MenuColumns.DESCRIPTION)));
item.setNutrition(c.getString(c.getColumnIndexOrThrow(RestaurantDatabase.MenuColumns.NUTRITION)));
item.setPrice(c.getString(c.getColumnIndexOrThrow(RestaurantDatabase.MenuColumns.PRICE)));
item.setImageName(c.getString(c.getColumnIndexOrThrow(RestaurantDatabase.MenuColumns.IMAGENAME)));
searchList.add(item);
}
c.close();
//Если ничего не нашлось, сообщаем об этом пользователю
if(searchList.size()==0){
Intent intent = new Intent(SearchResultsActivity.this, OrderViewDialogue.class);
intent.putExtra(OrderViewActivity.DIALOGUE_MESSAGE, "Sorry, no matching items found.");
startActivity(intent);
SearchResultsActivity.this.finish();
}
}
Далее рассмотрен адаптер для элемента GridView. Его мы построили на основе похожего кода из основного меню, внеся в него незначительные правки. Кроме того, мы можем доработать и существующие файлы макетов. Построение единообразно выглядящих экранов приложения — одно из преимуществ повторного использования кода, дополняющее отсутствие необходимости создавать каждый экран с нуля. Выше вы могли заметить, что мы повторно использовали класс OrderViewDialogue, изначально, написанный для корзины, но подходящий и здесь. Итак, вот продолжение кода:
/**
* Код SearchAdapter для поддержки GridView с найденными элементами. Каждый элемент содержит view_grid_item, который включает в себя изображение, название и цену блюда.
*/
class SearchAdapter extends BaseAdapter {
private Vector mFoundList;
private LayoutInflater inflater;
public SearchAdapter(Context c, Vector list) {
mFoundList= list;
inflater = LayoutInflater.from(c);
}
public int getCount() {
return mFoundList.size();
}
public Object getItem(int position) {
return mFoundList.get(position);
}
public long getItemId(int position) {
return 0;
}
// Создаём новый элемент ItemView для каждого элемента, перечисленного в адаптере
public View getView(int position, View convertView, ViewGroup parent) {
View v = convertView;
ImageView picture;
TextView name;
TextView price;
if(v == null) {
v = inflater.inflate(R.layout.view_grid_item, parent, false);
v.setTag(R.id.picture, v.findViewById(R.id.picture));
v.setTag(R.id.grid_name, v.findViewById(R.id.grid_name));
v.setTag(R.id.grid_price, v.findViewById(R.id.grid_price));
}
picture= (ImageView) v.getTag(R.id.picture);
name= (TextView) v.getTag(R.id.grid_name);
price= (TextView) v.getTag(R.id.grid_price);
final MenuItem foundItem = (MenuItem) mFoundList.get(position);
InputStream inputStream = null;
AssetManager assetManager = null;
try {
assetManager = getAssets();
inputStream = assetManager.open(foundItem.imageName);
picture.setImageBitmap(BitmapFactory.decodeStream(inputStream));
} catch (Exception e) {
Log.d("ActionBarLog", e.getMessage());
} finally {
}
name.setText(foundItem.name);
price.setText(foundItem.price);
return v;
}
}
}
Ещё одна деталь, которую стоит рассмотреть — это использование портретной или ландшафтной версии интерфейса. Ниже приведен код файла search_query_grid_results.xml, который находится в папке res/layout-land folder и задаёт ландшафтный вариант интерфейса. Число столбцов (numColumns) установлено в 4. Почти такой же файл для портретной ориентации интерфейса расположен в папке res/layout-port. От ландшафтного варианта он отличается лишь тем, что в нём элементы располагаются в два столбца.
Вот, как выглядит экран результатов поиска, поверх которого открыто диалоговое окно с подробностями о блюде, изображения которого коснулся пользователь.
Поисковый экран и окно с подробностями о блюде
Обработка жестов
Для того чтобы выйти из окна с результатами поиска, мы хотели бы сдвинуть, смахнуть его соответствующим жестом влево или вправо, так же как при просмотре других страниц в основном меню. Класс GestureDetector отлично работает со списками (ListView), но вот с таблицами (GridView) — нет. Поэтому нам нужно перейти на использование GestureOverlayView.
Для начала нужно создать библиотеку жестов, используя приложение GestureBuilder, которое можно найти среди примеров, поставляющихся вместе с Android SDK. В частности, то, что нам нужно, расположено по адресу android\sdk\samples\android-23\legacy\GestureBuilder.
Для использования приложения его нужно скомпилировать и запустить на устройстве, а затем задать с его помощью имена и конфигурации жестов. После добавления всех необходимых жестов (в нашем случае это скольжение влево, left swipe, и скольжение вправо, right swipe), нужно скопировать файл «gestures» с устройства и поместить в папку res/raw. Приложение подскажет точное расположение этого файла, в нашем случае достаточно было подключить устройство к ПК по USB и заглянуть в корневую директорию.
Приложение Gesture Builder
Скопировав файл с жестами в нужное место, необходимо обновить класс SearhcResultActivity, добавив в него объявления переменных для работы с GestureOverlayView:
GestureLibrary gestureLibrary;
GestureOverlayView gestureOverlayView;
В методе onCreate нужно инициализировать элемент интерфейса, загрузить библиотеку и настроить слушатель, который определяет действия программы в ответ на жесты, выполняемые пользователем. Для успешной работы рассматриваемого механизма нужно убедиться в том, что в коде используются те же имена, которые записаны в библиотеке жестов.
В качестве анимации мы решили применить overridePendingTransition. Для входящей анимации используется значение 0, что означает отсутствие анимации. Можно создать пустой xml-файл анимации и использовать его, но это приведёт к тому, что система довольно много времени потратит на «размышления» и исходящая анимация будет исполнена слишком быстро.
gestureOverlayView = (GestureOverlayView)findViewById(R.id.gestures);
//Инициализация библиотеки жестов и установка слушателя
gestureLibrary = GestureLibraries.fromRawResource(this, R.raw.gestures);
gestureLibrary.load();
gestureOverlayView.addOnGesturePerformedListener(new OnGesturePerformedListener(){
@Override
public void onGesturePerformed(GestureOverlayView view, Gesture gesture) {
ArrayList prediction = gestureLibrary.recognize(gesture);
if(prediction.size() > 0){
String action= prediction.get(0).name;
//Наша библиотека жестов содержит жесты с названиями "left swipe" и "right swipe"
if("left swipe".equals(action)){
//скольжение влево
SearchResultsActivity.this.finish();
overridePendingTransition(0, R.anim.move_left);
} else if("right swipe".equals(action)){
//скольжение влево
SearchResultsActivity.this.finish();
overridePendingTransition(0, R.anim.move_right);
}
}
}});
// «Линии жестов» теперь прозрачные, а не жёлтые, как в приложении для их создания
gestureOverlayView.setGestureVisible(false);
Вот код файла анимации сдвига влево, move_left.xml. Файл, ответственный за анимацию сдвига вправо, move_right.xml, выглядит точно так же за исключением того, что значение toXDelta неотрицательно.
Обратите внимание на то, что GrivView не может иметь параметр layout_height равный 0dp, когда находится внутри GestureOverlayView, так как он займёт указанные 0dp вместо того, чтобы расшириться, наподобие LinearLayout. Для того чтобы выполнить это требование, в нашем случае, параметр layout_height был установлен в значение fill_parent. Кроме того, нам не нужно, чтобы жесты были видимыми, не нужна нам и задержка, которая вызвана тем, что линия жеста плавно исчезает с экрана. Наши «линии жестов» и так прозрачны, да и лишние задержки нам ни к чему. Поэтому устанавливаем fadeOffset и fadeDuration в значение 0. Ниже показан обновлённый xml-код, задающий взаимоотношения GridView и GestureOverlayView.
Выводы
Мы рассмотрели последовательность решений, которые нужно принять при проектировании системы локального поиска для Android-приложения. Кроме того, мы рассказали о некоторых потенциальных проблемах, и о том, как их избежать, привели примеры кода и xml-разметки. Сейчас вы вполне можете встроить поисковую функциональность в собственное приложение и при этом ваш поиск будет не только работать, но и учитывать требования пользователей. А это значит, что они обязательно найдут то, что им нужно.