/* * Copyright (C) 2010 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package android.widget; import static android.widget.SuggestionsAdapter.getColumnString; import com.android.internal.R; import android.app.SearchManager; import android.app.SearchableInfo; import android.content.Context; import android.content.Intent; import android.content.res.TypedArray; import android.database.Cursor; import android.graphics.Rect; import android.graphics.drawable.Drawable; import android.net.Uri; import android.text.Editable; import android.text.TextUtils; import android.text.TextWatcher; import android.util.AttributeSet; import android.util.Log; import android.view.KeyEvent; import android.view.LayoutInflater; import android.view.View; import android.view.inputmethod.InputMethodManager; import android.widget.AdapterView.OnItemClickListener; import android.widget.AdapterView.OnItemSelectedListener; import android.widget.TextView.OnEditorActionListener; import java.util.WeakHashMap; /** * Provides the user interface elements for the user to enter a search query and submit a * request to a search provider. Shows a list of query suggestions or results, if * available and allows the user to pick a suggestion or result to launch into. */ public class SearchView extends LinearLayout { private static final boolean DBG = false; private static final String LOG_TAG = "SearchView"; private OnQueryChangeListener mOnQueryChangeListener; private OnCloseListener mOnCloseListener; private boolean mIconifiedByDefault; private boolean mIconified; private CursorAdapter mSuggestionsAdapter; private View mSearchButton; private View mSubmitButton; private View mCloseButton; private View mSearchEditFrame; private AutoCompleteTextView mQueryTextView; private boolean mSubmitButtonEnabled; private CharSequence mQueryHint; private boolean mQueryRefinement; private SearchableInfo mSearchable; // A weak map of drawables we've gotten from other packages, so we don't load them // more than once. private final WeakHashMap mOutsideDrawablesCache = new WeakHashMap(); /** * Callbacks for changes to the query text. */ public interface OnQueryChangeListener { /** * Called when the user submits the query. This could be due to a key press on the * keyboard or due to pressing a submit button. * The listener can override the standard behavior by returning true * to indicate that it has handled the submit request. Otherwise return false to * let the SearchView handle the submission by launching any associated intent. * * @param query the query text that is to be submitted * * @return true if the query has been handled by the listener, false to let the * SearchView perform the default action. */ boolean onSubmitQuery(String query); /** * Called when the query text is changed by the user. * * @param newText the new content of the query text field. * * @return false if the SearchView should perform the default action of showing any * suggestions if available, true if the action was handled by the listener. */ boolean onQueryTextChanged(String newText); } public interface OnCloseListener { /** * The user is attempting to close the SearchView. * * @return true if the listener wants to override the default behavior of clearing the * text field and dismissing it, false otherwise. */ boolean onClose(); } public SearchView(Context context) { this(context, null); } public SearchView(Context context, AttributeSet attrs) { super(context, attrs); LayoutInflater inflater = (LayoutInflater) context .getSystemService(Context.LAYOUT_INFLATER_SERVICE); inflater.inflate(R.layout.search_view, this, true); mSearchButton = findViewById(R.id.search_button); mQueryTextView = (AutoCompleteTextView) findViewById(R.id.search_src_text); mSearchEditFrame = findViewById(R.id.search_edit_frame); mSubmitButton = findViewById(R.id.search_go_btn); mCloseButton = findViewById(R.id.search_close_btn); mSearchButton.setOnClickListener(mOnClickListener); mCloseButton.setOnClickListener(mOnClickListener); mSubmitButton.setOnClickListener(mOnClickListener); mQueryTextView.addTextChangedListener(mTextWatcher); mQueryTextView.setOnEditorActionListener(mOnEditorActionListener); mQueryTextView.setOnItemClickListener(mOnItemClickListener); mQueryTextView.setOnItemSelectedListener(mOnItemSelectedListener); TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.SearchView, 0, 0); setIconifiedByDefault(a.getBoolean(R.styleable.SearchView_iconifiedByDefault, true)); a.recycle(); updateViewsVisibility(mIconifiedByDefault); } /** * Sets the SearchableInfo for this SearchView. Properties in the SearchableInfo are used * to display labels, hints, suggestions, create intents for launching search results screens * and controlling other affordances such as a voice button. * * @param searchable a SearchableInfo can be retrieved from the SearchManager, for a specific * activity or a global search provider. */ public void setSearchableInfo(SearchableInfo searchable) { mSearchable = searchable; if (mSearchable != null) { updateSearchAutoComplete(); } updateViewsVisibility(mIconifiedByDefault); } /** * Sets a listener for user actions within the SearchView. * * @param listener the listener object that receives callbacks when the user performs * actions in the SearchView such as clicking on buttons or typing a query. */ public void setOnQueryChangeListener(OnQueryChangeListener listener) { mOnQueryChangeListener = listener; } /** * Sets a listener to inform when the user closes the SearchView. * * @param listener the listener to call when the user closes the SearchView. */ public void setOnCloseListener(OnCloseListener listener) { mOnCloseListener = listener; } /** * Sets a query string in the text field and optionally submits the query as well. * * @param query the query string. This replaces any query text already present in the * text field. * @param submit whether to submit the query right now or only update the contents of * text field. */ public void setQuery(CharSequence query, boolean submit) { mQueryTextView.setText(query); // If the query is not empty and submit is requested, submit the query if (submit && !TextUtils.isEmpty(query)) { onSubmitQuery(); } } /** * Sets the hint text to display in the query text field. This overrides any hint specified * in the SearchableInfo. * * @param hint the hint text to display */ public void setQueryHint(CharSequence hint) { mQueryHint = hint; updateQueryHint(); } /** * Sets the default or resting state of the search field. If true, a single search icon is * shown by default and expands to show the text field and other buttons when pressed. Also, * if the default state is iconified, then it collapses to that state when the close button * is pressed. Changes to this property will take effect immediately. * *

The default value is false.

* * @param iconified whether the search field should be iconified by default */ public void setIconifiedByDefault(boolean iconified) { mIconifiedByDefault = iconified; updateViewsVisibility(iconified); } /** * Returns the default iconified state of the search field. * @return */ public boolean isIconfiedByDefault() { return mIconifiedByDefault; } /** * Iconifies or expands the SearchView. Any query text is cleared when iconified. This is * a temporary state and does not override the default iconified state set by * {@link #setIconifiedByDefault(boolean)}. If the default state is iconified, then * a false here will only be valid until the user closes the field. And if the default * state is expanded, then a true here will only clear the text field and not close it. * * @param iconify a true value will collapse the SearchView to an icon, while a false will * expand it. */ public void setIconified(boolean iconify) { if (iconify) { onCloseClicked(); } else { onSearchClicked(); } } /** * Returns the current iconified state of the SearchView. * * @return true if the SearchView is currently iconified, false if the search field is * fully visible. */ public boolean isIconified() { return mIconified; } /** * Enables showing a submit button when the query is non-empty. In cases where the SearchView * is being used to filter the contents of the current activity and doesn't launch a separate * results activity, then the submit button should be disabled. * * @param enabled true to show a submit button for submitting queries, false if a submit * button is not required. */ public void setSubmitButtonEnabled(boolean enabled) { mSubmitButton.setVisibility(enabled ? VISIBLE : GONE); mSubmitButtonEnabled = enabled; } /** * Returns whether the submit button is enabled when necessary or never displayed. * * @return whether the submit button is enabled automatically when necessary */ public boolean isSubmitButtonEnabled() { return mSubmitButtonEnabled; } /** * Specifies if a query refinement button should be displayed alongside each suggestion * or if it should depend on the flags set in the individual items retrieved from the * suggestions provider. Clicking on the query refinement button will replace the text * in the query text field with the text from the suggestion. This flag only takes effect * if a SearchableInfo has been specified with {@link #setSearchableInfo(SearchableInfo)} * and not when using a custom adapter. * * @param enable true if all items should have a query refinement button, false if only * those items that have a query refinement flag set should have the button. * * @see SearchManager#SUGGEST_COLUMN_FLAGS * @see SearchManager#FLAG_QUERY_REFINEMENT */ public void setQueryRefinementEnabled(boolean enable) { mQueryRefinement = enable; if (mSuggestionsAdapter instanceof SuggestionsAdapter) { ((SuggestionsAdapter) mSuggestionsAdapter).setQueryRefinement( enable ? SuggestionsAdapter.REFINE_ALL : SuggestionsAdapter.REFINE_BY_ENTRY); } } /** * Returns whether query refinement is enabled for all items or only specific ones. * @return true if enabled for all items, false otherwise. */ public boolean isQueryRefinementEnabled() { return mQueryRefinement; } /** * You can set a custom adapter if you wish. Otherwise the default adapter is used to * display the suggestions from the suggestions provider associated with the SearchableInfo. * * @see #setSearchableInfo(SearchableInfo) */ public void setSuggestionsAdapter(CursorAdapter adapter) { mSuggestionsAdapter = adapter; mQueryTextView.setAdapter(mSuggestionsAdapter); } /** * Returns the adapter used for suggestions, if any. * @return the suggestions adapter */ public CursorAdapter getSuggestionsAdapter() { return mSuggestionsAdapter; } private void updateViewsVisibility(final boolean collapsed) { mIconified = collapsed; // Visibility of views that are visible when collapsed final int visCollapsed = collapsed ? VISIBLE : GONE; // Visibility of views that are visible when expanded final int visExpanded = collapsed ? GONE : VISIBLE; mSearchButton.setVisibility(visCollapsed); mSubmitButton.setVisibility(mSubmitButtonEnabled ? visExpanded : GONE); mSearchEditFrame.setVisibility(visExpanded); setImeVisibility(!collapsed); } private void setImeVisibility(boolean visible) { // don't mess with the soft input if we're not iconified by default if (mIconifiedByDefault) { InputMethodManager imm = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE); // We made sure the IME was displayed, so also make sure it is closed // when we go away. if (imm != null) { if (visible) { imm.showSoftInputUnchecked(0, null); } else { imm.hideSoftInputFromWindow(getWindowToken(), 0); } } } } /** * Called by the SuggestionsAdapter * @hide */ /* package */void onQueryRefine(CharSequence queryText) { setQuery(queryText); } private final OnClickListener mOnClickListener = new OnClickListener() { public void onClick(View v) { if (v == mSearchButton) { onSearchClicked(); } else if (v == mCloseButton) { onCloseClicked(); } else if (v == mSubmitButton) { onSubmitQuery(); } } }; /** * Handles the key down event for dealing with action keys. * * @param keyCode This is the keycode of the typed key, and is the same value as * found in the KeyEvent parameter. * @param event The complete event record for the typed key * * @return true if the event was handled here, or false if not. */ @Override public boolean onKeyDown(int keyCode, KeyEvent event) { if (mSearchable == null) { return false; } // if it's an action specified by the searchable activity, launch the // entered query with the action key SearchableInfo.ActionKeyInfo actionKey = mSearchable.findActionKey(keyCode); if ((actionKey != null) && (actionKey.getQueryActionMsg() != null)) { launchQuerySearch(keyCode, actionKey.getQueryActionMsg(), mQueryTextView.getText() .toString()); return true; } return super.onKeyDown(keyCode, event); } private void updateQueryHint() { if (mQueryHint != null) { mQueryTextView.setHint(mQueryHint); } else if (mSearchable != null) { CharSequence hint = null; int hintId = mSearchable.getHintId(); if (hintId != 0) { hint = getContext().getString(hintId); } if (hint != null) { mQueryTextView.setHint(hint); } } } /** * Updates the auto-complete text view. */ private void updateSearchAutoComplete() { // close any existing suggestions adapter //closeSuggestionsAdapter(); mQueryTextView.setDropDownAnimationStyle(0); // no animation // attach the suggestions adapter, if suggestions are available // The existence of a suggestions authority is the proxy for "suggestions available here" if (mSearchable.getSuggestAuthority() != null) { mSuggestionsAdapter = new SuggestionsAdapter(getContext(), this, mSearchable, mOutsideDrawablesCache); mQueryTextView.setAdapter(mSuggestionsAdapter); ((SuggestionsAdapter) mSuggestionsAdapter).setQueryRefinement( mQueryRefinement ? SuggestionsAdapter.REFINE_ALL : SuggestionsAdapter.REFINE_BY_ENTRY); } } private final OnEditorActionListener mOnEditorActionListener = new OnEditorActionListener() { /** * Called when the input method default action key is pressed. */ public boolean onEditorAction(TextView v, int actionId, KeyEvent event) { onSubmitQuery(); return true; } }; private void onTextChanged(CharSequence newText) { CharSequence text = mQueryTextView.getText(); boolean hasText = !TextUtils.isEmpty(text); if (isSubmitButtonEnabled()) { mSubmitButton.setVisibility(hasText ? VISIBLE : GONE); } if (mOnQueryChangeListener != null) mOnQueryChangeListener.onQueryTextChanged(newText.toString()); } private void onSubmitQuery() { CharSequence query = mQueryTextView.getText(); if (!TextUtils.isEmpty(query)) { if (mOnQueryChangeListener == null || !mOnQueryChangeListener.onSubmitQuery(query.toString())) { if (mSearchable != null) { launchQuerySearch(KeyEvent.KEYCODE_UNKNOWN, null, query.toString()); } } } } private void onCloseClicked() { if (mOnCloseListener == null || !mOnCloseListener.onClose()) { mQueryTextView.setText(""); updateViewsVisibility(mIconifiedByDefault); } } private void onSearchClicked() { mQueryTextView.requestFocus(); updateViewsVisibility(false); } private final OnItemClickListener mOnItemClickListener = new OnItemClickListener() { /** * Implements OnItemClickListener */ public void onItemClick(AdapterView parent, View view, int position, long id) { if (DBG) Log.d(LOG_TAG, "onItemClick() position " + position); launchSuggestion(position, KeyEvent.KEYCODE_UNKNOWN, null); } }; private final OnItemSelectedListener mOnItemSelectedListener = new OnItemSelectedListener() { /** * Implements OnItemSelectedListener */ public void onItemSelected(AdapterView parent, View view, int position, long id) { if (DBG) Log.d(LOG_TAG, "onItemSelected() position " + position); // A suggestion has been selected, rewrite the query if possible, // otherwise the restore the original query. rewriteQueryFromSuggestion(position); } /** * Implements OnItemSelectedListener */ public void onNothingSelected(AdapterView parent) { if (DBG) Log.d(LOG_TAG, "onNothingSelected()"); } }; /** * Query rewriting. */ private void rewriteQueryFromSuggestion(int position) { CharSequence oldQuery = mQueryTextView.getText(); Cursor c = mSuggestionsAdapter.getCursor(); if (c == null) { return; } if (c.moveToPosition(position)) { // Get the new query from the suggestion. CharSequence newQuery = mSuggestionsAdapter.convertToString(c); if (newQuery != null) { // The suggestion rewrites the query. // Update the text field, without getting new suggestions. setQuery(newQuery); } else { // The suggestion does not rewrite the query, restore the user's query. setQuery(oldQuery); } } else { // We got a bad position, restore the user's query. setQuery(oldQuery); } } /** * Launches an intent based on a suggestion. * * @param position The index of the suggestion to create the intent from. * @param actionKey The key code of the action key that was pressed, * or {@link KeyEvent#KEYCODE_UNKNOWN} if none. * @param actionMsg The message for the action key that was pressed, * or null if none. * @return true if a successful launch, false if could not (e.g. bad position). */ private boolean launchSuggestion(int position, int actionKey, String actionMsg) { Cursor c = mSuggestionsAdapter.getCursor(); if ((c != null) && c.moveToPosition(position)) { Intent intent = createIntentFromSuggestion(c, actionKey, actionMsg); // launch the intent launchIntent(intent); return true; } return false; } /** * Launches an intent, including any special intent handling. */ private void launchIntent(Intent intent) { if (intent == null) { return; } try { // If the intent was created from a suggestion, it will always have an explicit // component here. getContext().startActivity(intent); } catch (RuntimeException ex) { Log.e(LOG_TAG, "Failed launch activity: " + intent, ex); } } /** * Sets the text in the query box, without updating the suggestions. */ private void setQuery(CharSequence query) { mQueryTextView.setText(query, true); // Move the cursor to the end mQueryTextView.setSelection(TextUtils.isEmpty(query) ? 0 : query.length()); } private void launchQuerySearch(int actionKey, String actionMsg, String query) { String action = Intent.ACTION_SEARCH; Intent intent = createIntent(action, null, null, query, actionKey, actionMsg); getContext().startActivity(intent); } /** * Constructs an intent from the given information and the search dialog state. * * @param action Intent action. * @param data Intent data, or null. * @param extraData Data for {@link SearchManager#EXTRA_DATA_KEY} or null. * @param query Intent query, or null. * @param actionKey The key code of the action key that was pressed, * or {@link KeyEvent#KEYCODE_UNKNOWN} if none. * @param actionMsg The message for the action key that was pressed, * or null if none. * @param mode The search mode, one of the acceptable values for * {@link SearchManager#SEARCH_MODE}, or {@code null}. * @return The intent. */ private Intent createIntent(String action, Uri data, String extraData, String query, int actionKey, String actionMsg) { // Now build the Intent Intent intent = new Intent(action); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); // We need CLEAR_TOP to avoid reusing an old task that has other activities // on top of the one we want. We don't want to do this in in-app search though, // as it can be destructive to the activity stack. if (data != null) { intent.setData(data); } intent.putExtra(SearchManager.USER_QUERY, query); if (query != null) { intent.putExtra(SearchManager.QUERY, query); } if (extraData != null) { intent.putExtra(SearchManager.EXTRA_DATA_KEY, extraData); } if (actionKey != KeyEvent.KEYCODE_UNKNOWN) { intent.putExtra(SearchManager.ACTION_KEY, actionKey); intent.putExtra(SearchManager.ACTION_MSG, actionMsg); } intent.setComponent(mSearchable.getSearchActivity()); return intent; } /** * When a particular suggestion has been selected, perform the various lookups required * to use the suggestion. This includes checking the cursor for suggestion-specific data, * and/or falling back to the XML for defaults; It also creates REST style Uri data when * the suggestion includes a data id. * * @param c The suggestions cursor, moved to the row of the user's selection * @param actionKey The key code of the action key that was pressed, * or {@link KeyEvent#KEYCODE_UNKNOWN} if none. * @param actionMsg The message for the action key that was pressed, * or null if none. * @return An intent for the suggestion at the cursor's position. */ private Intent createIntentFromSuggestion(Cursor c, int actionKey, String actionMsg) { try { // use specific action if supplied, or default action if supplied, or fixed default String action = getColumnString(c, SearchManager.SUGGEST_COLUMN_INTENT_ACTION); if (action == null) { action = mSearchable.getSuggestIntentAction(); } if (action == null) { action = Intent.ACTION_SEARCH; } // use specific data if supplied, or default data if supplied String data = getColumnString(c, SearchManager.SUGGEST_COLUMN_INTENT_DATA); if (data == null) { data = mSearchable.getSuggestIntentData(); } // then, if an ID was provided, append it. if (data != null) { String id = getColumnString(c, SearchManager.SUGGEST_COLUMN_INTENT_DATA_ID); if (id != null) { data = data + "/" + Uri.encode(id); } } Uri dataUri = (data == null) ? null : Uri.parse(data); String query = getColumnString(c, SearchManager.SUGGEST_COLUMN_QUERY); String extraData = getColumnString(c, SearchManager.SUGGEST_COLUMN_INTENT_EXTRA_DATA); return createIntent(action, dataUri, extraData, query, actionKey, actionMsg); } catch (RuntimeException e ) { int rowNum; try { // be really paranoid now rowNum = c.getPosition(); } catch (RuntimeException e2 ) { rowNum = -1; } Log.w(LOG_TAG, "Search Suggestions cursor at row " + rowNum + " returned exception" + e.toString()); return null; } } /** * Callback to watch the text field for empty/non-empty */ private TextWatcher mTextWatcher = new TextWatcher() { public void beforeTextChanged(CharSequence s, int start, int before, int after) { } public void onTextChanged(CharSequence s, int start, int before, int after) { SearchView.this.onTextChanged(s); } public void afterTextChanged(Editable s) { } }; /* * Avoid getting focus when searching for something to focus on. * The user will have to touch the text view to get focus. */ protected boolean onRequestFocusInDescendants(int direction, Rect previouslyFocusedRect) { return false; } }