1 /* 2 * Copyright (C) 2010 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.quicksearchbox.ui; 18 19 import android.content.Context; 20 import android.database.DataSetObserver; 21 import android.graphics.drawable.Drawable; 22 import android.text.Editable; 23 import android.text.TextUtils; 24 import android.text.TextWatcher; 25 import android.util.AttributeSet; 26 import android.util.Log; 27 import android.view.KeyEvent; 28 import android.view.View; 29 import android.view.inputmethod.CompletionInfo; 30 import android.view.inputmethod.InputMethodManager; 31 import android.widget.AbsListView; 32 import android.widget.ImageButton; 33 import android.widget.ListAdapter; 34 import android.widget.RelativeLayout; 35 import android.widget.TextView; 36 import android.widget.TextView.OnEditorActionListener; 37 38 import com.android.quicksearchbox.Logger; 39 import com.android.quicksearchbox.QsbApplication; 40 import com.android.quicksearchbox.R; 41 import com.android.quicksearchbox.SearchActivity; 42 import com.android.quicksearchbox.SourceResult; 43 import com.android.quicksearchbox.SuggestionCursor; 44 import com.android.quicksearchbox.Suggestions; 45 import com.android.quicksearchbox.VoiceSearch; 46 47 import java.util.ArrayList; 48 import java.util.Arrays; 49 50 public abstract class SearchActivityView extends RelativeLayout { 51 protected static final boolean DBG = false; 52 protected static final String TAG = "QSB.SearchActivityView"; 53 54 // The string used for privateImeOptions to identify to the IME that it should not show 55 // a microphone button since one already exists in the search dialog. 56 // TODO: This should move to android-common or something. 57 private static final String IME_OPTION_NO_MICROPHONE = "nm"; 58 59 protected QueryTextView mQueryTextView; 60 // True if the query was empty on the previous call to updateQuery() 61 protected boolean mQueryWasEmpty = true; 62 protected Drawable mQueryTextEmptyBg; 63 protected Drawable mQueryTextNotEmptyBg; 64 65 protected SuggestionsListView<ListAdapter> mSuggestionsView; 66 protected SuggestionsAdapter<ListAdapter> mSuggestionsAdapter; 67 68 protected ImageButton mSearchGoButton; 69 protected ImageButton mVoiceSearchButton; 70 71 protected ButtonsKeyListener mButtonsKeyListener; 72 73 private boolean mUpdateSuggestions; 74 75 private QueryListener mQueryListener; 76 private SearchClickListener mSearchClickListener; 77 protected View.OnClickListener mExitClickListener; 78 SearchActivityView(Context context)79 public SearchActivityView(Context context) { 80 super(context); 81 } 82 SearchActivityView(Context context, AttributeSet attrs)83 public SearchActivityView(Context context, AttributeSet attrs) { 84 super(context, attrs); 85 } 86 SearchActivityView(Context context, AttributeSet attrs, int defStyle)87 public SearchActivityView(Context context, AttributeSet attrs, int defStyle) { 88 super(context, attrs, defStyle); 89 } 90 91 @Override onFinishInflate()92 protected void onFinishInflate() { 93 mQueryTextView = (QueryTextView) findViewById(R.id.search_src_text); 94 95 mSuggestionsView = (SuggestionsView) findViewById(R.id.suggestions); 96 mSuggestionsView.setOnScrollListener(new InputMethodCloser()); 97 mSuggestionsView.setOnKeyListener(new SuggestionsViewKeyListener()); 98 mSuggestionsView.setOnFocusChangeListener(new SuggestListFocusListener()); 99 100 mSuggestionsAdapter = createSuggestionsAdapter(); 101 // TODO: why do we need focus listeners both on the SuggestionsView and the individual 102 // suggestions? 103 mSuggestionsAdapter.setOnFocusChangeListener(new SuggestListFocusListener()); 104 105 mSearchGoButton = (ImageButton) findViewById(R.id.search_go_btn); 106 mVoiceSearchButton = (ImageButton) findViewById(R.id.search_voice_btn); 107 mVoiceSearchButton.setImageDrawable(getVoiceSearchIcon()); 108 109 mQueryTextView.addTextChangedListener(new SearchTextWatcher()); 110 mQueryTextView.setOnEditorActionListener(new QueryTextEditorActionListener()); 111 mQueryTextView.setOnFocusChangeListener(new QueryTextViewFocusListener()); 112 mQueryTextEmptyBg = mQueryTextView.getBackground(); 113 114 mSearchGoButton.setOnClickListener(new SearchGoButtonClickListener()); 115 116 mButtonsKeyListener = new ButtonsKeyListener(); 117 mSearchGoButton.setOnKeyListener(mButtonsKeyListener); 118 mVoiceSearchButton.setOnKeyListener(mButtonsKeyListener); 119 120 mUpdateSuggestions = true; 121 } 122 onResume()123 public abstract void onResume(); 124 onStop()125 public abstract void onStop(); 126 onPause()127 public void onPause() { 128 // Override if necessary 129 } 130 start()131 public void start() { 132 mSuggestionsAdapter.getListAdapter().registerDataSetObserver(new SuggestionsObserver()); 133 mSuggestionsView.setSuggestionsAdapter(mSuggestionsAdapter); 134 } 135 destroy()136 public void destroy() { 137 mSuggestionsView.setSuggestionsAdapter(null); // closes mSuggestionsAdapter 138 } 139 140 // TODO: Get rid of this. To make it more easily testable, 141 // the SearchActivityView should not depend on QsbApplication. getQsbApplication()142 protected QsbApplication getQsbApplication() { 143 return QsbApplication.get(getContext()); 144 } 145 getVoiceSearchIcon()146 protected Drawable getVoiceSearchIcon() { 147 return getResources().getDrawable(R.drawable.ic_btn_speak_now); 148 } 149 getVoiceSearch()150 protected VoiceSearch getVoiceSearch() { 151 return getQsbApplication().getVoiceSearch(); 152 } 153 createSuggestionsAdapter()154 protected SuggestionsAdapter<ListAdapter> createSuggestionsAdapter() { 155 return new DelayingSuggestionsAdapter<ListAdapter>(new SuggestionsListAdapter( 156 getQsbApplication().getSuggestionViewFactory())); 157 } 158 setMaxPromotedResults(int maxPromoted)159 public void setMaxPromotedResults(int maxPromoted) { 160 } 161 limitResultsToViewHeight()162 public void limitResultsToViewHeight() { 163 } 164 setQueryListener(QueryListener listener)165 public void setQueryListener(QueryListener listener) { 166 mQueryListener = listener; 167 } 168 setSearchClickListener(SearchClickListener listener)169 public void setSearchClickListener(SearchClickListener listener) { 170 mSearchClickListener = listener; 171 } 172 setVoiceSearchButtonClickListener(View.OnClickListener listener)173 public void setVoiceSearchButtonClickListener(View.OnClickListener listener) { 174 if (mVoiceSearchButton != null) { 175 mVoiceSearchButton.setOnClickListener(listener); 176 } 177 } 178 setSuggestionClickListener(final SuggestionClickListener listener)179 public void setSuggestionClickListener(final SuggestionClickListener listener) { 180 mSuggestionsAdapter.setSuggestionClickListener(listener); 181 mQueryTextView.setCommitCompletionListener(new QueryTextView.CommitCompletionListener() { 182 @Override 183 public void onCommitCompletion(int position) { 184 mSuggestionsAdapter.onSuggestionClicked(position); 185 } 186 }); 187 } 188 setExitClickListener(final View.OnClickListener listener)189 public void setExitClickListener(final View.OnClickListener listener) { 190 mExitClickListener = listener; 191 } 192 getSuggestions()193 public Suggestions getSuggestions() { 194 return mSuggestionsAdapter.getSuggestions(); 195 } 196 getCurrentSuggestions()197 public SuggestionCursor getCurrentSuggestions() { 198 return mSuggestionsAdapter.getSuggestions().getResult(); 199 } 200 setSuggestions(Suggestions suggestions)201 public void setSuggestions(Suggestions suggestions) { 202 suggestions.acquire(); 203 mSuggestionsAdapter.setSuggestions(suggestions); 204 } 205 clearSuggestions()206 public void clearSuggestions() { 207 mSuggestionsAdapter.setSuggestions(null); 208 } 209 getQuery()210 public String getQuery() { 211 CharSequence q = mQueryTextView.getText(); 212 return q == null ? "" : q.toString(); 213 } 214 isQueryEmpty()215 public boolean isQueryEmpty() { 216 return TextUtils.isEmpty(getQuery()); 217 } 218 219 /** 220 * Sets the text in the query box. Does not update the suggestions. 221 */ setQuery(String query, boolean selectAll)222 public void setQuery(String query, boolean selectAll) { 223 mUpdateSuggestions = false; 224 mQueryTextView.setText(query); 225 mQueryTextView.setTextSelection(selectAll); 226 mUpdateSuggestions = true; 227 } 228 getActivity()229 protected SearchActivity getActivity() { 230 Context context = getContext(); 231 if (context instanceof SearchActivity) { 232 return (SearchActivity) context; 233 } else { 234 return null; 235 } 236 } 237 hideSuggestions()238 public void hideSuggestions() { 239 mSuggestionsView.setVisibility(GONE); 240 } 241 showSuggestions()242 public void showSuggestions() { 243 mSuggestionsView.setVisibility(VISIBLE); 244 } 245 focusQueryTextView()246 public void focusQueryTextView() { 247 mQueryTextView.requestFocus(); 248 } 249 updateUi()250 protected void updateUi() { 251 updateUi(isQueryEmpty()); 252 } 253 updateUi(boolean queryEmpty)254 protected void updateUi(boolean queryEmpty) { 255 updateQueryTextView(queryEmpty); 256 updateSearchGoButton(queryEmpty); 257 updateVoiceSearchButton(queryEmpty); 258 } 259 updateQueryTextView(boolean queryEmpty)260 protected void updateQueryTextView(boolean queryEmpty) { 261 if (queryEmpty) { 262 mQueryTextView.setBackgroundDrawable(mQueryTextEmptyBg); 263 mQueryTextView.setHint(null); 264 } else { 265 mQueryTextView.setBackgroundResource(R.drawable.textfield_search); 266 } 267 } 268 updateSearchGoButton(boolean queryEmpty)269 private void updateSearchGoButton(boolean queryEmpty) { 270 if (queryEmpty) { 271 mSearchGoButton.setVisibility(View.GONE); 272 } else { 273 mSearchGoButton.setVisibility(View.VISIBLE); 274 } 275 } 276 updateVoiceSearchButton(boolean queryEmpty)277 protected void updateVoiceSearchButton(boolean queryEmpty) { 278 if (shouldShowVoiceSearch(queryEmpty) 279 && getVoiceSearch().shouldShowVoiceSearch()) { 280 mVoiceSearchButton.setVisibility(View.VISIBLE); 281 mQueryTextView.setPrivateImeOptions(IME_OPTION_NO_MICROPHONE); 282 } else { 283 mVoiceSearchButton.setVisibility(View.GONE); 284 mQueryTextView.setPrivateImeOptions(null); 285 } 286 } 287 shouldShowVoiceSearch(boolean queryEmpty)288 protected boolean shouldShowVoiceSearch(boolean queryEmpty) { 289 return queryEmpty; 290 } 291 292 /** 293 * Hides the input method. 294 */ hideInputMethod()295 protected void hideInputMethod() { 296 InputMethodManager imm = (InputMethodManager) 297 getContext().getSystemService(Context.INPUT_METHOD_SERVICE); 298 if (imm != null) { 299 imm.hideSoftInputFromWindow(getWindowToken(), 0); 300 } 301 } 302 considerHidingInputMethod()303 public abstract void considerHidingInputMethod(); 304 showInputMethodForQuery()305 public void showInputMethodForQuery() { 306 mQueryTextView.showInputMethod(); 307 } 308 309 /** 310 * Dismiss the activity if BACK is pressed when the search box is empty. 311 */ 312 @Override dispatchKeyEventPreIme(KeyEvent event)313 public boolean dispatchKeyEventPreIme(KeyEvent event) { 314 SearchActivity activity = getActivity(); 315 if (activity != null && event.getKeyCode() == KeyEvent.KEYCODE_BACK 316 && isQueryEmpty()) { 317 KeyEvent.DispatcherState state = getKeyDispatcherState(); 318 if (state != null) { 319 if (event.getAction() == KeyEvent.ACTION_DOWN 320 && event.getRepeatCount() == 0) { 321 state.startTracking(event, this); 322 return true; 323 } else if (event.getAction() == KeyEvent.ACTION_UP 324 && !event.isCanceled() && state.isTracking(event)) { 325 hideInputMethod(); 326 activity.onBackPressed(); 327 return true; 328 } 329 } 330 } 331 return super.dispatchKeyEventPreIme(event); 332 } 333 334 /** 335 * If the input method is in fullscreen mode, and the selector corpus 336 * is All or Web, use the web search suggestions as completions. 337 */ updateInputMethodSuggestions()338 protected void updateInputMethodSuggestions() { 339 InputMethodManager imm = (InputMethodManager) 340 getContext().getSystemService(Context.INPUT_METHOD_SERVICE); 341 if (imm == null || !imm.isFullscreenMode()) return; 342 Suggestions suggestions = mSuggestionsAdapter.getSuggestions(); 343 if (suggestions == null) return; 344 CompletionInfo[] completions = webSuggestionsToCompletions(suggestions); 345 if (DBG) Log.d(TAG, "displayCompletions(" + Arrays.toString(completions) + ")"); 346 imm.displayCompletions(mQueryTextView, completions); 347 } 348 webSuggestionsToCompletions(Suggestions suggestions)349 private CompletionInfo[] webSuggestionsToCompletions(Suggestions suggestions) { 350 SourceResult cursor = suggestions.getWebResult(); 351 if (cursor == null) return null; 352 int count = cursor.getCount(); 353 ArrayList<CompletionInfo> completions = new ArrayList<CompletionInfo>(count); 354 for (int i = 0; i < count; i++) { 355 cursor.moveTo(i); 356 String text1 = cursor.getSuggestionText1(); 357 completions.add(new CompletionInfo(i, i, text1)); 358 } 359 return completions.toArray(new CompletionInfo[completions.size()]); 360 } 361 onSuggestionsChanged()362 protected void onSuggestionsChanged() { 363 updateInputMethodSuggestions(); 364 } 365 onSuggestionKeyDown(SuggestionsAdapter<?> adapter, long suggestionId, int keyCode, KeyEvent event)366 protected boolean onSuggestionKeyDown(SuggestionsAdapter<?> adapter, 367 long suggestionId, int keyCode, KeyEvent event) { 368 // Treat enter or search as a click 369 if ( keyCode == KeyEvent.KEYCODE_ENTER 370 || keyCode == KeyEvent.KEYCODE_SEARCH 371 || keyCode == KeyEvent.KEYCODE_DPAD_CENTER) { 372 if (adapter != null) { 373 adapter.onSuggestionClicked(suggestionId); 374 return true; 375 } else { 376 return false; 377 } 378 } 379 380 return false; 381 } 382 onSearchClicked(int method)383 protected boolean onSearchClicked(int method) { 384 if (mSearchClickListener != null) { 385 return mSearchClickListener.onSearchClicked(method); 386 } 387 return false; 388 } 389 390 /** 391 * Filters the suggestions list when the search text changes. 392 */ 393 private class SearchTextWatcher implements TextWatcher { 394 @Override afterTextChanged(Editable s)395 public void afterTextChanged(Editable s) { 396 boolean empty = s.length() == 0; 397 if (empty != mQueryWasEmpty) { 398 mQueryWasEmpty = empty; 399 updateUi(empty); 400 } 401 if (mUpdateSuggestions) { 402 if (mQueryListener != null) { 403 mQueryListener.onQueryChanged(); 404 } 405 } 406 } 407 408 @Override beforeTextChanged(CharSequence s, int start, int count, int after)409 public void beforeTextChanged(CharSequence s, int start, int count, int after) { 410 } 411 412 @Override onTextChanged(CharSequence s, int start, int before, int count)413 public void onTextChanged(CharSequence s, int start, int before, int count) { 414 } 415 } 416 417 /** 418 * Handles key events on the suggestions list view. 419 */ 420 protected class SuggestionsViewKeyListener implements View.OnKeyListener { 421 @Override onKey(View v, int keyCode, KeyEvent event)422 public boolean onKey(View v, int keyCode, KeyEvent event) { 423 if (event.getAction() == KeyEvent.ACTION_DOWN 424 && v instanceof SuggestionsListView<?>) { 425 SuggestionsListView<?> listView = (SuggestionsListView<?>) v; 426 if (onSuggestionKeyDown(listView.getSuggestionsAdapter(), 427 listView.getSelectedItemId(), keyCode, event)) { 428 return true; 429 } 430 } 431 return forwardKeyToQueryTextView(keyCode, event); 432 } 433 } 434 435 private class InputMethodCloser implements SuggestionsView.OnScrollListener { 436 437 @Override onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount)438 public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, 439 int totalItemCount) { 440 } 441 442 @Override onScrollStateChanged(AbsListView view, int scrollState)443 public void onScrollStateChanged(AbsListView view, int scrollState) { 444 considerHidingInputMethod(); 445 } 446 } 447 448 /** 449 * Listens for clicks on the source selector. 450 */ 451 private class SearchGoButtonClickListener implements View.OnClickListener { 452 @Override onClick(View view)453 public void onClick(View view) { 454 onSearchClicked(Logger.SEARCH_METHOD_BUTTON); 455 } 456 } 457 458 /** 459 * This class handles enter key presses in the query text view. 460 */ 461 private class QueryTextEditorActionListener implements OnEditorActionListener { 462 @Override onEditorAction(TextView v, int actionId, KeyEvent event)463 public boolean onEditorAction(TextView v, int actionId, KeyEvent event) { 464 boolean consumed = false; 465 if (event != null) { 466 if (event.getAction() == KeyEvent.ACTION_UP) { 467 consumed = onSearchClicked(Logger.SEARCH_METHOD_KEYBOARD); 468 } else if (event.getAction() == KeyEvent.ACTION_DOWN) { 469 // we have to consume the down event so that we receive the up event too 470 consumed = true; 471 } 472 } 473 if (DBG) Log.d(TAG, "onEditorAction consumed=" + consumed); 474 return consumed; 475 } 476 } 477 478 /** 479 * Handles key events on the search and voice search buttons, 480 * by refocusing to EditText. 481 */ 482 private class ButtonsKeyListener implements View.OnKeyListener { 483 @Override onKey(View v, int keyCode, KeyEvent event)484 public boolean onKey(View v, int keyCode, KeyEvent event) { 485 return forwardKeyToQueryTextView(keyCode, event); 486 } 487 } 488 forwardKeyToQueryTextView(int keyCode, KeyEvent event)489 private boolean forwardKeyToQueryTextView(int keyCode, KeyEvent event) { 490 if (!event.isSystem() && shouldForwardToQueryTextView(keyCode)) { 491 if (DBG) Log.d(TAG, "Forwarding key to query box: " + event); 492 if (mQueryTextView.requestFocus()) { 493 return mQueryTextView.dispatchKeyEvent(event); 494 } 495 } 496 return false; 497 } 498 shouldForwardToQueryTextView(int keyCode)499 private boolean shouldForwardToQueryTextView(int keyCode) { 500 switch (keyCode) { 501 case KeyEvent.KEYCODE_DPAD_UP: 502 case KeyEvent.KEYCODE_DPAD_DOWN: 503 case KeyEvent.KEYCODE_DPAD_LEFT: 504 case KeyEvent.KEYCODE_DPAD_RIGHT: 505 case KeyEvent.KEYCODE_DPAD_CENTER: 506 case KeyEvent.KEYCODE_ENTER: 507 case KeyEvent.KEYCODE_SEARCH: 508 return false; 509 default: 510 return true; 511 } 512 } 513 514 /** 515 * Hides the input method when the suggestions get focus. 516 */ 517 private class SuggestListFocusListener implements OnFocusChangeListener { 518 @Override onFocusChange(View v, boolean focused)519 public void onFocusChange(View v, boolean focused) { 520 if (DBG) Log.d(TAG, "Suggestions focus change, now: " + focused); 521 if (focused) { 522 considerHidingInputMethod(); 523 } 524 } 525 } 526 527 private class QueryTextViewFocusListener implements OnFocusChangeListener { 528 @Override onFocusChange(View v, boolean focused)529 public void onFocusChange(View v, boolean focused) { 530 if (DBG) Log.d(TAG, "Query focus change, now: " + focused); 531 if (focused) { 532 // The query box got focus, show the input method 533 showInputMethodForQuery(); 534 } 535 } 536 } 537 538 protected class SuggestionsObserver extends DataSetObserver { 539 @Override onChanged()540 public void onChanged() { 541 onSuggestionsChanged(); 542 } 543 } 544 545 public interface QueryListener { onQueryChanged()546 void onQueryChanged(); 547 } 548 549 public interface SearchClickListener { onSearchClicked(int method)550 boolean onSearchClicked(int method); 551 } 552 553 private class CloseClickListener implements OnClickListener { 554 @Override onClick(View v)555 public void onClick(View v) { 556 if (!isQueryEmpty()) { 557 mQueryTextView.setText(""); 558 } else { 559 mExitClickListener.onClick(v); 560 } 561 } 562 } 563 } 564