package com.mapzen.pelias.widget;
import com.mapzen.pelias.Pelias;
import com.mapzen.pelias.R;
import com.mapzen.pelias.SavedSearch;
import com.mapzen.pelias.SimpleFeature;
import com.mapzen.pelias.SuggestFilter;
import com.mapzen.pelias.gson.Feature;
import com.mapzen.pelias.gson.Result;
import android.content.Context;
import android.os.Parcel;
import android.os.ResultReceiver;
import android.support.v7.widget.SearchView;
import android.util.AttributeSet;
import android.util.Log;
import android.view.View;
import android.view.animation.Animation;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputMethodManager;
import android.widget.AbsListView;
import android.widget.AdapterView;
import android.widget.EditText;
import android.widget.ListView;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import static android.view.animation.AnimationUtils.loadAnimation;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
/**
* Main UI component for interaction with {@link Pelias}. Provides ability to display autocomplete
* results and execute searches.
*/
public class PeliasSearchView extends SearchView implements SearchView.OnQueryTextListener {
public static final String TAG = PeliasSearchView.class.getSimpleName();
private static final AutoCompleteTextViewReflector HIDDEN_METHOD_INVOKER =
new AutoCompleteTextViewReflector();
private Runnable showImeRunnable = new Runnable() {
public void run() {
final InputMethodManager imm =
(InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
if (imm != null) {
editText.setCursorVisible(true);
HIDDEN_METHOD_INVOKER.showSoftInputUnchecked(imm, PeliasSearchView.this, 0);
imeVisible = true;
}
}
};
private Runnable hideImeRunnable = new Runnable() {
public void run() {
final InputMethodManager imm =
(InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
if (imm != null) {
editText.setCursorVisible(false);
imm.hideSoftInputFromWindow(getWindowToken(), 0);
imeVisible = false;
}
}
};
private boolean imeVisible = false;
private Runnable backPressedRunnable = new Runnable() {
@Override public void run() {
notifyOnBackPressListener();
}
};
private EditText editText;
private ListView autoCompleteListView;
private SavedSearch savedSearch;
private Pelias pelias;
private Callback<Result> callback;
private OnSubmitListener onSubmitListener;
private OnFocusChangeListener onPeliasFocusChangeListener;
private int recentSearchIconResourceId;
private int autoCompleteIconResourceId;
private boolean disableAutoComplete;
private boolean focusedViewHasFocus = false;
private boolean listItemClicked = false;
private boolean textSubmitted = false;
private OnBackPressListener onBackPressListener;
private boolean cacheSearchResults = true;
private boolean autoKeyboardShow = true;
private SuggestFilter suggestFilter;
private boolean checkHideAutocompleteList = false;
private Callback<Result> suggestCallback = new Callback<Result>() {
@Override public void onResponse(Call<Result> call, Response<Result> response) {
final ArrayList<AutoCompleteItem> items = new ArrayList<>();
if (response != null && response.body() != null) {
final List<Feature> features = response.body().getFeatures();
if (features != null) {
for (Feature feature : features) {
items.add(new AutoCompleteItem(SimpleFeature.fromFeature(feature)));
}
}
}
if (autoCompleteListView == null) {
return;
}
final AutoCompleteAdapter adapter = (AutoCompleteAdapter) autoCompleteListView.getAdapter();
adapter.clear();
adapter.addAll(items);
adapter.notifyDataSetChanged();
}
@Override public void onFailure(Call<Result> call, Throwable t) {
Log.e(TAG, "Unable to fetch autocomplete results", t);
}
};
private SearchSubmitListener searchSubmitListener;
private boolean dismissKeyboardOnListScroll = false;
/**
* Constructs a new search view given a context.
*/
public PeliasSearchView(Context context) {
super(context);
setup();
}
/**
* Constructs a new search view given a context and attribute set.
*/
public PeliasSearchView(Context context, AttributeSet attrs) {
super(context, attrs);
setup();
}
private void setup() {
disableAutoComplete = false;
disableDefaultSoftKeyboardBehaviour();
setOnQueryTextListener(this);
setImeOptions(EditorInfo.IME_ACTION_SEARCH);
setupEditText();
}
private void setupEditText() {
editText = (EditText) findViewById(R.id.search_src_text);
editText.setOnClickListener(new OnClickListener() {
@Override public void onClick(View view) {
if (hasFocus()) {
onFocusChange(PeliasSearchView.this, true);
}
}
});
}
/**
* Set a filter to use when querying for autocomplete results.
* @param suggestFilter
*/
public void setSuggestFilter(SuggestFilter suggestFilter) {
this.suggestFilter = suggestFilter;
}
/**
* Set the list to be used for displaying autocomplete results.
*/
public void setAutoCompleteListView(final ListView listView) {
autoCompleteListView = listView;
setOnQueryTextFocusChangeListener(new View.OnFocusChangeListener() {
@Override public void onFocusChange(View view, boolean hasFocus) {
PeliasSearchView.this.onFocusChange(view, hasFocus);
}
});
autoCompleteListView.setOnItemClickListener(new OnItemClickHandler().invoke());
autoCompleteListView.setOnScrollListener(new AbsListView.OnScrollListener() {
int scrollState = SCROLL_STATE_IDLE;
@Override public void onScrollStateChanged(AbsListView absListView, int i) {
scrollState = i;
}
@Override public void onScroll(AbsListView absListView, int i, int i1, int i2) {
if (dismissKeyboardOnListScroll && scrollState != SCROLL_STATE_IDLE && imeVisible) {
if (searchSubmitListener != null) {
checkHideAutocompleteList = true;
}
onFocusChange(PeliasSearchView.this, false);
}
}
});
}
/**
* Prevent the keyboard from showing for example when the autocomplete list is shown and
* the view has focus.
*/
public void disableAutoKeyboardShow() {
autoKeyboardShow = false;
}
/**
* Allow the keyboard to be shown when the autocomplete list is shown and the view has focus.
*/
public void enableAutoKeyboardShow() {
autoKeyboardShow = true;
}
public void setSearchSubmitListener(SearchSubmitListener listener) {
searchSubmitListener = listener;
}
/**
* Optionally dismiss the keyboard when the user scrolls the autocomplete list.
* @param dismissOnScroll
*/
public void setDismissKeyboardOnListScroll(boolean dismissOnScroll) {
dismissKeyboardOnListScroll = dismissOnScroll;
}
private void handleSearchGainingFocus() {
setAutoCompleteAdapterIcon(recentSearchIconResourceId);
loadSavedSearches();
safeShowAutocompleteList();
setOnQueryTextListener(PeliasSearchView.this);
}
private void handleSearchLosingFocus() {
safeHideAutocompleteList();
postDelayed(hideImeRunnable, 300);
setOnQueryTextListener(null);
}
private void safeShowAutocompleteList() {
if (autoCompleteListView == null) {
return;
}
if (autoCompleteListView.getVisibility() != VISIBLE) {
final Animation slideIn = loadAnimation(getContext(), R.anim.slide_in);
autoCompleteListView.setVisibility(VISIBLE);
autoCompleteListView.setAnimation(slideIn);
}
if (autoKeyboardShow) {
postDelayed(showImeRunnable, 300);
}
}
/**
* Checks whether autocomplete list should be hidden and if so, hides it.
*/
private void safeHideAutocompleteList() {
if (checkHideAutocompleteList) {
checkHideAutocompleteList = false;
if (searchSubmitListener.hideAutocompleteOnSearchSubmit()) {
hideAutocompleteList();
}
} else {
hideAutocompleteList();
}
}
/**
* Hides the autocomplete list with animation.
*/
private void hideAutocompleteList() {
if (autoCompleteListView == null) {
return;
}
final Animation slideOut = loadAnimation(getContext(), R.anim.slide_out);
autoCompleteListView.setVisibility(GONE);
autoCompleteListView.setAnimation(slideOut);
}
/**
* Overrides default behavior for showing soft keyboard. Enables manual control by this class.
*/
private void disableDefaultSoftKeyboardBehaviour() {
try {
Field showImeRunnable = SearchView.class.getDeclaredField("mShowImeRunnable");
showImeRunnable.setAccessible(true);
showImeRunnable.set(this, new Runnable() {
@Override public void run() {
// Do nothing.
}
});
} catch (IllegalAccessException e) {
Log.e(TAG, "Unable to override default soft keyboard behavior", e);
} catch (NoSuchFieldException e) {
Log.e(TAG, "Unable to override default soft keyboard behavior", e);
}
}
@Override public boolean onQueryTextSubmit(String query) {
if (pelias != null) {
if (searchSubmitListener == null || searchSubmitListener.searchOnSearchKeySubmit()) {
pelias.search(query, callback);
}
}
storeSavedSearch(query, null);
if (onSubmitListener != null) {
onSubmitListener.onSubmit();
}
if (searchSubmitListener != null) {
checkHideAutocompleteList = true;
}
textSubmitted = true;
onFocusChange(this, false);
resetCursorPosition();
return false;
}
@Override public boolean onQueryTextChange(String text) {
if (text.isEmpty() || disableAutoComplete) {
setAutoCompleteAdapterIcon(autoCompleteIconResourceId);
disableAutoComplete = false;
return false;
} else if (text.length() < 3) {
setAutoCompleteAdapterIcon(recentSearchIconResourceId);
loadSavedSearches();
} else {
setAutoCompleteAdapterIcon(autoCompleteIconResourceId);
fetchAutoCompleteSuggestions(text);
}
return false;
}
/**
* When autocomplete is disabled, autocomplete results will not be fetched on query text changes.
*/
public void disableAutoComplete() {
disableAutoComplete = true;
}
private void setAutoCompleteAdapterIcon(int resId) {
if (autoCompleteListView == null) {
return;
}
final AutoCompleteAdapter adapter = (AutoCompleteAdapter) autoCompleteListView.getAdapter();
if (adapter != null) {
adapter.setIcon(resId);
}
}
private void fetchAutoCompleteSuggestions(String text) {
if (pelias == null) {
return;
}
if (suggestFilter == null) {
pelias.suggest(text, suggestCallback);
} else {
pelias.suggest(text, suggestFilter.getLayersFilter(), suggestFilter.getCountryFilter(),
suggestFilter.getSources(), suggestCallback);
}
}
/**
* Sets the saved search to be shown in the autocomplete list.
*/
public void setSavedSearch(SavedSearch savedSearch) {
this.savedSearch = savedSearch;
updateSavedSearch();
}
/**
* Shows saved search results in the autocomplete list view.
*/
public void loadSavedSearches() {
if (autoCompleteListView == null || autoCompleteListView.getAdapter() == null) {
return;
}
final AutoCompleteAdapter adapter = (AutoCompleteAdapter) autoCompleteListView.getAdapter();
adapter.clear();
if (savedSearch != null) {
adapter.addAll(savedSearch.getItems());
}
adapter.notifyDataSetChanged();
}
/**
* Set the pelias object to be used to query for results.
*/
public void setPelias(Pelias pelias) {
this.pelias = pelias;
}
/**
* Set the callback to be invoked when searches are executed and autocomplete results are
* clicked.
*/
public void setCallback(Callback<Result> callback) {
this.callback = callback;
}
/**
* This should be used over {@link #setOnQueryTextFocusChangeListener(OnFocusChangeListener)}
* since {@link #setAutoCompleteListView(ListView)} relies on this method to control visibility
* of the autocomplete suggestion list. Events will be forwarded by the built-in listener.
*
* @param onPeliasFocusChangeListener the listener to be invoked when the query text view focus
* changes.
*/
public void setOnPeliasFocusChangeListener(OnFocusChangeListener onPeliasFocusChangeListener) {
this.onPeliasFocusChangeListener = onPeliasFocusChangeListener;
}
/**
* Copied from {@link android.support.v7.widget.SearchView.AutoCompleteTextViewReflector}.
*/
private static class AutoCompleteTextViewReflector {
private Method showSoftInputUnchecked;
AutoCompleteTextViewReflector() {
try {
showSoftInputUnchecked =
InputMethodManager.class.getMethod("showSoftInputUnchecked", int.class,
ResultReceiver.class);
showSoftInputUnchecked.setAccessible(true);
} catch (NoSuchMethodException e) {
// Ah well.
}
}
void showSoftInputUnchecked(InputMethodManager imm, View view, int flags) {
if (showSoftInputUnchecked != null) {
try {
showSoftInputUnchecked.invoke(imm, flags, null);
return;
} catch (Exception e) {
Log.e(TAG, e.getLocalizedMessage());
}
}
// Hidden method failed, call public version instead
imm.showSoftInput(view, flags);
}
}
/**
* Set a listener to be invoked when the submit key is pressed.
*/
public void setOnSubmitListener(OnSubmitListener onSubmitListener) {
this.onSubmitListener = onSubmitListener;
}
/**
* Interface for representing when the submit key is pressed.
*/
public interface OnSubmitListener {
/**
* Invoked when the submit key is pressed.
*/
void onSubmit();
}
private void resetCursorPosition() {
if (editText != null) {
editText.setSelection(0);
}
}
/**
* Set the icon resource id to be used to represent recent searches.
*/
public void setRecentSearchIconResourceId(int recentSearchIconResourceId) {
this.recentSearchIconResourceId = recentSearchIconResourceId;
}
/**
* Set the icon resource id to be used to represent autocomplete results.
*/
public void setAutoCompleteIconResourceId(int autoCompleteIconResourceId) {
this.autoCompleteIconResourceId = autoCompleteIconResourceId;
}
/**
* Used by the autocomplete list view to handle clicks on individual rows.
*/
public class OnItemClickHandler {
/**
* Returns a click listener to be used by the autocomplete list view. Listener handles setting
* the search view's query, resetting the cursor position, clearing view focus, invoking the
* callback and saving the search term.
*/
public AdapterView.OnItemClickListener invoke() {
return new AdapterView.OnItemClickListener() {
@Override public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
final AutoCompleteItem item =
(AutoCompleteItem) autoCompleteListView.getAdapter().getItem(position);
if (item.getSimpleFeature() == null) {
setQuery(item.getText(), true);
resetCursorPosition();
} else {
final Result result = new Result();
final ArrayList<Feature> features = new ArrayList<>(1);
if (hasFocus()) {
clearFocus();
} else {
onFocusChange(PeliasSearchView.this, false);
}
setQuery(item.getText(), false);
resetCursorPosition();
features.add(item.getSimpleFeature().toFeature());
result.setFeatures(features);
if (callback != null) {
callback.onResponse(null, Response.success(result));
}
storeSavedSearch(item.getText(), item.getSimpleFeature().toParcel());
}
listItemClicked = true;
}
};
}
}
private void onFocusChange(View view, boolean hasFocus) {
if (hasFocus) {
handleSearchGainingFocus();
} else {
handleSearchLosingFocus();
}
// Notify secondary listener
if (onPeliasFocusChangeListener != null) {
onPeliasFocusChangeListener.onFocusChange(view, hasFocus);
}
focusedViewHasFocus = hasFocus;
postDelayed(backPressedRunnable, 300);
}
/**
* Listener to simulate when the SearchBar gains focus and then loses it when the user presses
* back without executing a search or clicking on an item in the autocomplete list view.
*
* {@link OnBackPressListener#onBackPressed()} is called after
* {@link OnSubmitListener#onSubmit()},
* {@link OnFocusChangeListener#onFocusChange(View, boolean)}, and
* {@link android.widget.AdapterView.OnItemClickListener#onItemClick(
*AdapterView, View, int, long)} are called
*/
public interface OnBackPressListener {
/**
* Invoked when back key pressed.
*/
void onBackPressed();
}
/**
* Set a listener to be invoked when the back key is pressed.
*/
public void setOnBackPressListener(OnBackPressListener onBackPressListener) {
this.onBackPressListener = onBackPressListener;
}
/**
* Notifies the back press listener that back has been pressed. This occurs when focus changes on
* the view when a list item has not been clicked and text has not been submitted.
*/
protected void notifyOnBackPressListener() {
if (onBackPressListener == null) {
return;
}
if (!focusedViewHasFocus && !listItemClicked && !textSubmitted) {
onBackPressListener.onBackPressed();
}
textSubmitted = false;
listItemClicked = false;
}
private void storeSavedSearch(String query, Parcel parcel) {
if (savedSearch == null || !cacheSearchResults) {
return;
}
if (parcel == null) {
savedSearch.store(query);
} else {
savedSearch.store(query, parcel);
}
}
/**
* Determines whether or not to update saved search results as queries are executed.
*/
public void setCacheSearchResults(boolean cacheResults) {
cacheSearchResults = cacheResults;
updateSavedSearch();
}
/**
* Returns whether or not the view will cache search results.
*/
public boolean cacheSearchResults() {
return cacheSearchResults;
}
private void updateSavedSearch() {
if (savedSearch != null && !cacheSearchResults) {
savedSearch.clear();
if (autoCompleteListView != null) {
final AutoCompleteAdapter adapter = (AutoCompleteAdapter) autoCompleteListView.getAdapter();
if (adapter != null) {
adapter.clear();
adapter.notifyDataSetChanged();
}
}
}
}
/**
* When super is called, the {@link SearchView#getOnFocusChangeListener()} is invoked which
* posts a delayed call to the {@link PeliasSearchView#backPressedRunnable}. We cancel this
* post because it was not invoked from the back button being pressed
*/
@Override public void onActionViewCollapsed() {
super.onActionViewCollapsed();
postDelayed(new Runnable() {
@Override public void run() {
removeCallbacks(backPressedRunnable);
}
}, 150);
}
Callback<Result> getSuggestCallback() {
return suggestCallback;
}
}