Android AutoCompleteTextView с подсказками из веб-сервиса

Для одного из своих Android-приложений Book Tracker я реализовал кастомный AutoCompleteTextView с подсказками для названий книг, которые динамически подгружаются с Google Books по мере ввода названия книги.

Задача перед компонентом стояла следующая:
  • Загрузка данных должна осуществляться в отдельном потоке, чтобы не блокировать UI-поток;
  • Загрузка подсказок должна начинаться только, если пользователь приостанавливает набор (чтобы предотвратить отправку множества запросов к серверу после каждого введенного символа);
  • Подсказки должны загружаться, если пользователь ввел строку некоторой минимальной длины (нет смысла начинать загрузку данных для строки из двух или трех символов);
  • При запросе к серверу в правой части поля должен быть показан анимированный прогресс, чтобы информировать пользователя о загрузке.

Финальный результат:




Шаг 1 – реализация кастомного адаптера для AutoCompleteTextView


Адаптер для AutoCompleteTextView – это ключевой компонент, в котором происходит загрузка и хранение подсказок. BookAutoCompleteAdapter реализовывает интерфейс Filterable, чтобы перехватывать ввод пользователя из AutoCompleteTextView и передавать его в качестве поискового запроса в веб-сервис. Единственный метод интерфейса Filterable – это getFilter(), который должен возвращать экземпляр класса Filter, осуществляющий загрузку и публикацию данных. Наследники класса Filter должны реализовать два метода: performFiltering(CharSequence constraint) и publishResults(CharSequence constraint, Filter.FilterResults results).

Метод performFiltering будет вызван в отдельном потоке автоматически, поэтому нет необходимости создавать и запускать новый поток вручную. Это уже сделано за разработчика в классе Filter. Метод publishResults же вызывается в UI-потоке, чтобы опубликовать результаты на экране.

BookAutoCompleteAdapter.java

public class BookAutoCompleteAdapter extends BaseAdapter implements Filterable {
    
    private static final int MAX_RESULTS = 10;
    
    private final Context mContext;
    private List<Book> mResults;

    public BookAutoCompleteAdapter(Context context) {
        mContext = context;
        mResults = new ArrayList<Book>();
    }

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

    @Override
    public Book getItem(int index) {
        return mResults.get(index);
    }

    @Override
    public long getItemId(int position) {
        return position;
    }

    @Override
    public View getView(int position, View convertView, ViewGroup parent) {
        if (convertView == null) {
            LayoutInflater inflater = LayoutInflater.from(mContext);
            convertView = inflater.inflate(R.layout.simple_dropdown_item_2line, parent, false);
        }
        Book book = getItem(position);
        ((TextView) convertView.findViewById(R.id.text1)).setText(book.getTitle());
        ((TextView) convertView.findViewById(R.id.text2)).setText(book.getAuthor());

        return convertView;
    }

    @Override
    public Filter getFilter() {
        Filter filter = new Filter() {
            @Override
            protected FilterResults performFiltering(CharSequence constraint) {
                FilterResults filterResults = new FilterResults();
                if (constraint != null) {
                    List<Books> books = findBooks(mContext, constraint.toString());
                    // Assign the data to the FilterResults
                    filterResults.values = books;
                    filterResults.count = books.size();
                }
                return filterResults;
            }

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

        return filter;
    }

    /**
     * Returns a search result for the given book title.
     */
    private List<Book> findBooks(String bookTitle) {
        // GoogleBooksService is a wrapper for the Google Books API
        GoogleBooksService service = new GoogleBooksService (mContext, MAX_RESULTS);
        return service.findBooks(bookTitle);
    }
}


Шаг 2 – создание XML-разметки для строки подсказки


Когда подсказки загружены, будет показан выпадающий список с результатами. Каждая строка состоит из двух элементов: названия книги и имени автора.

simple_dropdown_item_2line.xml

<?xml version="1.0" encoding="utf-8"?>

<TwoLineListItem xmlns:android="http://schemas.android.com/apk/res/android"
                 android:layout_width="match_parent"
                 android:layout_height="wrap_content"
                 android:minHeight="?android:attr/listPreferredItemHeight"
                 android:mode="twoLine"
                 android:paddingStart="?android:attr/listPreferredItemPaddingStart"
                 android:paddingEnd="?android:attr/listPreferredItemPaddingEnd">

    <TextView android:id="@+id/text1"
              android:layout_width="match_parent"
              android:layout_height="wrap_content"
              android:layout_marginTop="16dp"
              android:textAppearance="?android:attr/textAppearanceLargePopupMenu"/>

    <TextView android:id="@+id/text2"
              android:layout_width="match_parent"
              android:layout_height="wrap_content"
              android:layout_below="@id/text1"
              android:layout_alignStart="@id/text1"
              android:layout_marginBottom="16dp"
              android:textAppearance="?android:attr/textAppearanceSmall"/>

</TwoLineListItem>


Шаг 3 – добавление задержки перед отправкой запроса на сервер


При использовании стандартного AutoCompleteTextView запрос инициируется после каждого введенного символа. Если пользователь набирает текст без остановки, подсказки, полученные для предыдущего запроса, могут оказаться неактуальными при вводе каждого последующего символа. Это порождает ненужные и ресурсоемкие обращения к серверу, появляется шанс превышения лимитов API, которые может иметь веб-сервис, а также возвращаются устаревшие результаты, загруженные для предыдущего состояния строки запроса.

Для того, чтобы избежать вышеописанных проблем, необходимо добавить небольшую задержку между вводом символа и отправкой запроса на сервер. Если во время этой задержки человек вводит следующий символ, запрос для предыдущей строки отменяется и переносится вперед на время задержки. Если же пользователь не изменяет строку на протяжении этого времени, запрос отправляется на сервер.

Чтобы реализовать вышеописанное поведение, нужно создать кастомную реализацию AutoCompleteTextView и переопределить метод performFiltering(CharSequence text, int keyCode). Поле mAutoCompleteDelay определяет время в миллисекундах, после которого запрос будет отправлен на сервер, если пользователь не ввел новых символов.

DelayAutoCompleteTextView.java

public class DelayAutoCompleteTextView extends AutoCompleteTextView {

    private static final int MESSAGE_TEXT_CHANGED = 100;
    private static final int DEFAULT_AUTOCOMPLETE_DELAY = 750;

    private int mAutoCompleteDelay = DEFAULT_AUTOCOMPLETE_DELAY;
    private ProgressBar mLoadingIndicator;

    private final Handler mHandler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            DelayAutoCompleteTextView.super.performFiltering((CharSequence) msg.obj, msg.arg1);
        }
    };

    public DelayAutoCompleteTextView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public void setLoadingIndicator(ProgressBar progressBar) {
        mLoadingIndicator = progressBar;
    }

    public void setAutoCompleteDelay(int autoCompleteDelay) {
        mAutoCompleteDelay = autoCompleteDelay;
    }

    @Override
    protected void performFiltering(CharSequence text, int keyCode) {
        if (mLoadingIndicator != null) {
            mLoadingIndicator.setVisibility(View.VISIBLE);
        }
        mHandler.removeMessages(MESSAGE_TEXT_CHANGED);
        mHandler.sendMessageDelayed(mHandler.obtainMessage(MESSAGE_TEXT_CHANGED, text), mAutoCompleteDelay);
    }

    @Override
    public void onFilterComplete(int count) {
        if (mLoadingIndicator != null) {
            mLoadingIndicator.setVisibility(View.GONE);
        }
        super.onFilterComplete(count);
    }
}


Шаг 4 – добавление анимированного прогресса к полю ввода


Очень важно обеспечить обратную связь, когда пользователь набирает текст. Необходимо показать анимированный прогресс в поле ввода названия книги. Прогресс нужен для того, чтобы проинформировать человека о том, что подсказки загружаются и будут скоро отображены. Таким образом пользователь будет осведомлен и сможет подождать пока они не появятся. Без такой обратной связи человек может даже не подозревать о том, что поле может показывать подсказки.

Элементы ProgressBar и DelayAutoCompleteTextView необходимо поместить во FrameLayout и выровнять ProgressBar по правой стороне родительской группы. Также необходимо изначально скрыть прогресс с помощью установки атрибута android:visibility=«gone».

<?xml version="1.0" encoding="utf-8"?>

<FrameLayout android:layout_width="match_parent"
                 android:layout_height="wrap_content"
                 android:layout_margin="16dp">

    <com.melnykov.booktracker.ui.DelayAutoCompleteTextView
            android:id="@+id/book_title"
            android:inputType="textCapSentences"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:paddingRight="32dp"
            android:imeOptions="flagNoExtractUi|actionSearch"/>

    <ProgressBar
            android:id="@+id/progress_bar"
            style="?android:attr/progressBarStyleSmall"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center_vertical|right"
            android:layout_marginRight="16dp"
            android:visibility="gone"/>
</FrameLayout>


ProgressBar подключается к DelayAutoCompleteTextView с помощью метода setLoadingIndicator(ProgressBar view) последнего. Видимость элемента прогресса устанавливается в View.VISIBLE, когда происходит загрузка подсказок и в View.GONE, когда загрузка завершена.

Шаг 5 – соединение компонентов


Теперь, когда все части готовы, необходимо соединить их вместе:

DelayAutoCompleteTextView bookTitle = (DelayAutoCompleteTextView) findViewById(R.id.book_title);
bookTitle.setThreshold(4);
bookTitle.setAdapter(new BookAutoCompleteAdapter(context));
bookTitle.setLoadingIndicator((ProgressBar) findViewById(R.id.progress_bar));
bookTitle.setOnItemClickListener(new AdapterView.OnItemClickListener() {
        @Override
        public void onItemClick(AdapterView<?> adapterView, View view, int position, long id) {
            Book book = (Book) adapterView.getItemAtPosition(position);
            bookTitle.setText(book.getTitle());
        }
    });


bookTitle.setThreshold(4) определяет минимальное количество символов, которые должен ввести пользователь, чтобы были показаны подсказки.

bookTitle.setLoadingIndicator((ProgressBar) findViewById(R.id.progress_bar)) соединяет ProgressBar с DelayAutoCompleteTextView.

Важно установить OnItemClickListener для DelayAutoCompleteTextView и присвоить правильное значение полю ввода. Если этого не сделать, результат вызова метода toString() выбранного объекта будет вставлен в поле вместо названия книги.
  • +18
  • 28.2k
  • 9
Share post

Similar posts

Comments 9

    0
    Лучше, когда поиск идёт не по .startsWith(), а через .contains()
    Не всегда знаешь точно название того, что ищешь
      +2
      А где вы заметили startsWith()? Я передаю на сервер запрос пользователя без изменений, а Google Books использует поиск по всему названию. Вот описание параметра запроса, который я использовал:

      intitle: Returns results where the text following this keyword is found in the title.
        –3
        Мне так показалось из гифки, лучше тогда другой пример привести.
        >«intitle: Returns results where the text following this keyword is found in the title.»
        Вот именно. keyword. А это не есть .contains()
        Скажем, я не помню этот самый Keyword — плохо запомнил имя автора или название, но зато помню как оно, это имя заканчивается, или какое-то сочетание букв точно запомнил. У книг бывают странные названия. Например, я буду искать «Криптономикон» на английском. Я буду думать, как написать «Criptonom...», «Krypto...», «Crypto...», «kripto...», но зато точно помню «ptonom» — это я и наберу.
        Это пример; но думаю, что мысль я донёс.

        P.S. отвечать не могу, хабраограничение
      0
      Ожидал увидеть «The Call Of Cthulhu» на первой гифке.
        0
        А можно сделать так, чтобы при наборе сразу же появлялся dropdown с анимированным прогрессом?

        Я эппловвод, и может на андроиде так принято, но судя по гиф-ке не очень понятно (читай неюзабельно), что будет дропдаун для автокомплита
          0
          Да, конечно можно реализовать. То, что написано в статье, является лишь моим субъективным мнением и моим подходом к данной конкретной задаче. Стандарта, описывающего такое поведение в Android нет.
        0
        Статья в фэйворитс.
        Я бы еще добавил вариант с асинхронным запросом к веб-сервису, например, с использованием CountDownLatch внутри performFiltering.
          0
          Очень интересно посмотреть код всей апликухи…

          Only users with full accounts can post comments. Log in, please.