SearchActivityView.java revision 8b2936607176720172aee068abc5631bdf77e843
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 17package com.android.quicksearchbox.ui; 18 19import com.android.quicksearchbox.Corpora; 20import com.android.quicksearchbox.Corpus; 21import com.android.quicksearchbox.CorpusResult; 22import com.android.quicksearchbox.Logger; 23import com.android.quicksearchbox.Promoter; 24import com.android.quicksearchbox.QsbApplication; 25import com.android.quicksearchbox.R; 26import com.android.quicksearchbox.SearchActivity; 27import com.android.quicksearchbox.SuggestionCursor; 28import com.android.quicksearchbox.Suggestions; 29import com.android.quicksearchbox.VoiceSearch; 30import com.android.quicksearchbox.ui.SuggestionsAdapter.SuggestionsAdapterChangeListener; 31 32import android.app.Activity; 33import android.content.Context; 34import android.database.DataSetObserver; 35import android.graphics.drawable.Drawable; 36import android.text.Editable; 37import android.text.TextWatcher; 38import android.util.AttributeSet; 39import android.util.Log; 40import android.view.KeyEvent; 41import android.view.View; 42import android.view.inputmethod.CompletionInfo; 43import android.view.inputmethod.InputMethodManager; 44import android.widget.AbsListView; 45import android.widget.ImageButton; 46import android.widget.RelativeLayout; 47 48import java.util.ArrayList; 49import java.util.Arrays; 50 51/** 52 * 53 */ 54public abstract class SearchActivityView extends RelativeLayout 55 implements SuggestionsAdapterChangeListener { 56 protected static final boolean DBG = false; 57 protected static final String TAG = "QSB.SearchActivityView"; 58 59 // The string used for privateImeOptions to identify to the IME that it should not show 60 // a microphone button since one already exists in the search dialog. 61 // TODO: This should move to android-common or something. 62 private static final String IME_OPTION_NO_MICROPHONE = "nm"; 63 64 private Corpus mCorpus; 65 66 protected QueryTextView mQueryTextView; 67 // True if the query was empty on the previous call to updateQuery() 68 protected boolean mQueryWasEmpty = true; 69 protected Drawable mQueryTextEmptyBg; 70 protected Drawable mQueryTextNotEmptyBg; 71 72 protected SuggestionsView mSuggestionsView; 73 74 protected SuggestionsAdapter mSuggestionsAdapter; 75 76 protected ImageButton mSearchCloseButton; 77 protected ImageButton mSearchGoButton; 78 protected ImageButton mVoiceSearchButton; 79 80 protected ButtonsKeyListener mButtonsKeyListener; 81 82 private boolean mUpdateSuggestions; 83 84 private QueryListener mQueryListener; 85 private SearchClickListener mSearchClickListener; 86 87 public SearchActivityView(Context context) { 88 super(context); 89 } 90 91 public SearchActivityView(Context context, AttributeSet attrs) { 92 super(context, attrs); 93 } 94 95 public SearchActivityView(Context context, AttributeSet attrs, int defStyle) { 96 super(context, attrs, defStyle); 97 } 98 99 @Override 100 protected void onFinishInflate() { 101 mQueryTextView = (QueryTextView) findViewById(R.id.search_src_text); 102 103 mSuggestionsView = (SuggestionsView) findViewById(R.id.suggestions); 104 mSuggestionsView.setOnScrollListener(new InputMethodCloser()); 105 mSuggestionsView.setOnKeyListener(new SuggestionsViewKeyListener()); 106 mSuggestionsView.setOnFocusChangeListener(new SuggestListFocusListener()); 107 108 mSuggestionsAdapter = createSuggestionsAdapter(); 109 mSuggestionsAdapter.setSuggestionAdapterChangeListener(this); 110 // TODO: why do we need focus listeners both on the SuggestionsView and the individual 111 // suggestions? 112 mSuggestionsAdapter.setOnFocusChangeListener(new SuggestListFocusListener()); 113 114 mSearchCloseButton = (ImageButton) findViewById(R.id.search_close_btn); 115 mSearchGoButton = (ImageButton) findViewById(R.id.search_go_btn); 116 mVoiceSearchButton = (ImageButton) findViewById(R.id.search_voice_btn); 117 118 mQueryTextView.addTextChangedListener(new SearchTextWatcher()); 119 mQueryTextView.setOnKeyListener(new QueryTextViewKeyListener()); 120 mQueryTextView.setOnFocusChangeListener(new QueryTextViewFocusListener()); 121 mQueryTextEmptyBg = mQueryTextView.getBackground(); 122 123 mSearchGoButton.setOnClickListener(new SearchGoButtonClickListener()); 124 125 mButtonsKeyListener = new ButtonsKeyListener(); 126 mSearchGoButton.setOnKeyListener(mButtonsKeyListener); 127 mVoiceSearchButton.setOnKeyListener(mButtonsKeyListener); 128 if (mSearchCloseButton != null) { 129 mSearchCloseButton.setOnKeyListener(mButtonsKeyListener); 130 mSearchCloseButton.setOnClickListener(new CloseClickListener()); 131 } 132 133 mUpdateSuggestions = true; 134 } 135 136 public void onSuggestionAdapterChanged() { 137 mSuggestionsView.setAdapter(mSuggestionsAdapter); 138 } 139 140 public abstract void onResume(); 141 142 public abstract void onStop(); 143 144 public void start() { 145 mSuggestionsAdapter.registerDataSetObserver(new SuggestionsObserver()); 146 mSuggestionsView.setAdapter(mSuggestionsAdapter); 147 } 148 149 public void destroy() { 150 mSuggestionsAdapter.setSuggestionAdapterChangeListener(null); 151 mSuggestionsView.setAdapter(null); // closes mSuggestionsAdapter 152 } 153 154 // TODO: Get rid of this. To make it more easily testable, 155 // the SearchActivityView should not depend on QsbApplication. 156 protected QsbApplication getQsbApplication() { 157 return QsbApplication.get(getContext()); 158 } 159 160 private VoiceSearch getVoiceSearch() { 161 return getQsbApplication().getVoiceSearch(); 162 } 163 164 protected SuggestionsAdapter createSuggestionsAdapter() { 165 return new DelayingSuggestionsAdapter(getQsbApplication().getDefaultSuggestionViewFactory(), 166 getQsbApplication().getCorpora()); 167 } 168 169 170 protected Corpora getCorpora() { 171 return getQsbApplication().getCorpora(); 172 } 173 174 public Corpus getCorpus() { 175 return mCorpus; 176 } 177 178 protected abstract Promoter createSuggestionsPromoter(); 179 180 protected Corpus getCorpus(String sourceName) { 181 if (sourceName == null) return null; 182 Corpus corpus = getCorpora().getCorpus(sourceName); 183 if (corpus == null) { 184 Log.w(TAG, "Unknown corpus " + sourceName); 185 return null; 186 } 187 return corpus; 188 } 189 190 public void onCorpusSelected(String corpusName) { 191 setCorpus(corpusName); 192 focusQueryTextView(); 193 showInputMethodForQuery(); 194 } 195 196 public void setCorpus(String corpusName) { 197 if (DBG) Log.d(TAG, "setCorpus(" + corpusName + ")"); 198 Corpus corpus = getCorpus(corpusName); 199 setCorpus(corpus); 200 updateUi(); 201 } 202 203 protected void setCorpus(Corpus corpus) { 204 mCorpus = corpus; 205 mSuggestionsAdapter.setPromoter(createSuggestionsPromoter()); 206 } 207 208 public String getCorpusName() { 209 Corpus corpus = getCorpus(); 210 return corpus == null ? null : corpus.getName(); 211 } 212 213 public abstract Corpus getSearchCorpus(); 214 215 public Corpus getWebCorpus() { 216 Corpus webCorpus = getCorpora().getWebCorpus(); 217 if (webCorpus == null) { 218 Log.e(TAG, "No web corpus"); 219 } 220 return webCorpus; 221 } 222 223 public void setMaxPromoted(int maxPromoted) { 224 mSuggestionsAdapter.setMaxPromoted(maxPromoted); 225 } 226 227 public void setQueryListener(QueryListener listener) { 228 mQueryListener = listener; 229 } 230 231 public void setSearchClickListener(SearchClickListener listener) { 232 mSearchClickListener = listener; 233 } 234 235 public abstract void showCorpusSelectionDialog(); 236 237 public abstract void setSettingsButtonClickListener(View.OnClickListener listener); 238 239 public void setVoiceSearchButtonClickListener(View.OnClickListener listener) { 240 if (mVoiceSearchButton != null) { 241 mVoiceSearchButton.setOnClickListener(listener); 242 } 243 } 244 245 public void setSuggestionClickListener(final SuggestionClickListener listener) { 246 mSuggestionsAdapter.setSuggestionClickListener(listener); 247 mQueryTextView.setCommitCompletionListener(new QueryTextView.CommitCompletionListener() { 248 @Override 249 public void onCommitCompletion(int position) { 250 mSuggestionsAdapter.onSuggestionClicked(position); 251 } 252 }); 253 } 254 255 protected SuggestionsAdapter getSuggestionsAdapter() { 256 return mSuggestionsAdapter; 257 } 258 259 public Suggestions getSuggestions() { 260 return mSuggestionsAdapter.getSuggestions(); 261 } 262 263 public SuggestionCursor getCurrentSuggestions() { 264 return mSuggestionsAdapter.getCurrentSuggestions(); 265 } 266 267 public void setSuggestions(Suggestions suggestions) { 268 suggestions.acquire(); 269 mSuggestionsAdapter.setSuggestions(suggestions); 270 } 271 272 public void clearSuggestions() { 273 mSuggestionsAdapter.setSuggestions(null); 274 } 275 276 public String getQuery() { 277 CharSequence q = mQueryTextView.getText(); 278 return q == null ? "" : q.toString(); 279 } 280 281 /** 282 * Sets the text in the query box. Does not update the suggestions. 283 */ 284 public void setQuery(String query, boolean selectAll) { 285 mUpdateSuggestions = false; 286 mQueryTextView.setText(query); 287 mQueryTextView.setTextSelection(selectAll); 288 mUpdateSuggestions = true; 289 } 290 291 protected SearchActivity getActivity() { 292 Context context = getContext(); 293 if (context instanceof SearchActivity) { 294 return (SearchActivity) context; 295 } else { 296 return null; 297 } 298 } 299 300 public void hideSuggestions() { 301 mSuggestionsView.setVisibility(GONE); 302 } 303 304 public void showSuggestions() { 305 mSuggestionsView.setVisibility(VISIBLE); 306 } 307 308 public void focusQueryTextView() { 309 mQueryTextView.requestFocus(); 310 } 311 312 protected void updateUi() { 313 updateUi(getQuery().length() == 0); 314 } 315 316 protected void updateUi(boolean queryEmpty) { 317 updateQueryTextView(queryEmpty); 318 updateSearchGoButton(queryEmpty); 319 updateVoiceSearchButton(queryEmpty); 320 } 321 322 private void updateQueryTextView(boolean queryEmpty) { 323 if (queryEmpty) { 324 if (isSearchCorpusWeb()) { 325 mQueryTextView.setBackgroundDrawable(mQueryTextEmptyBg); 326 mQueryTextView.setHint(null); 327 } else { 328 if (mQueryTextNotEmptyBg == null) { 329 mQueryTextNotEmptyBg = 330 getResources().getDrawable(R.drawable.textfield_search_empty); 331 } 332 mQueryTextView.setBackgroundDrawable(mQueryTextNotEmptyBg); 333 Corpus corpus = getCorpus(); 334 mQueryTextView.setHint(corpus == null ? "" : corpus.getHint()); 335 } 336 } else { 337 mQueryTextView.setBackgroundResource(R.drawable.textfield_search); 338 } 339 } 340 341 private void updateSearchGoButton(boolean queryEmpty) { 342 if (queryEmpty) { 343 mSearchGoButton.setVisibility(View.GONE); 344 } else { 345 mSearchGoButton.setVisibility(View.VISIBLE); 346 } 347 } 348 349 protected void updateVoiceSearchButton(boolean queryEmpty) { 350 if (queryEmpty && getVoiceSearch().shouldShowVoiceSearch(getCorpus())) { 351 mVoiceSearchButton.setVisibility(View.VISIBLE); 352 mQueryTextView.setPrivateImeOptions(IME_OPTION_NO_MICROPHONE); 353 } else { 354 mVoiceSearchButton.setVisibility(View.GONE); 355 mQueryTextView.setPrivateImeOptions(null); 356 } 357 } 358 359 /** 360 * Hides the input method. 361 */ 362 protected void hideInputMethod() { 363 InputMethodManager imm = (InputMethodManager) 364 getContext().getSystemService(Context.INPUT_METHOD_SERVICE); 365 if (imm != null) { 366 imm.hideSoftInputFromWindow(getWindowToken(), 0); 367 } 368 } 369 370 public abstract void considerHidingInputMethod(); 371 372 public void showInputMethodForQuery() { 373 mQueryTextView.showInputMethod(); 374 } 375 376 /** 377 * Overrides the handling of the back key to dismiss the activity. 378 */ 379 @Override 380 public boolean dispatchKeyEventPreIme(KeyEvent event) { 381 Activity activity = getActivity(); 382 if (activity != null && event.getKeyCode() == KeyEvent.KEYCODE_BACK) { 383 KeyEvent.DispatcherState state = getKeyDispatcherState(); 384 if (state != null) { 385 if (event.getAction() == KeyEvent.ACTION_DOWN 386 && event.getRepeatCount() == 0) { 387 state.startTracking(event, this); 388 return true; 389 } else if (event.getAction() == KeyEvent.ACTION_UP 390 && !event.isCanceled() && state.isTracking(event)) { 391 hideInputMethod(); 392 activity.onBackPressed(); 393 return true; 394 } 395 } 396 } 397 return super.dispatchKeyEventPreIme(event); 398 } 399 400 /** 401 * If the input method is in fullscreen mode, and the selector corpus 402 * is All or Web, use the web search suggestions as completions. 403 */ 404 protected void updateInputMethodSuggestions() { 405 InputMethodManager imm = (InputMethodManager) 406 getContext().getSystemService(Context.INPUT_METHOD_SERVICE); 407 if (imm == null || !imm.isFullscreenMode()) return; 408 Suggestions suggestions = mSuggestionsAdapter.getSuggestions(); 409 if (suggestions == null) return; 410 CompletionInfo[] completions = webSuggestionsToCompletions(suggestions); 411 if (DBG) Log.d(TAG, "displayCompletions(" + Arrays.toString(completions) + ")"); 412 imm.displayCompletions(mQueryTextView, completions); 413 } 414 415 private CompletionInfo[] webSuggestionsToCompletions(Suggestions suggestions) { 416 // TODO: This should also include include web search shortcuts 417 CorpusResult cursor = suggestions.getWebResult(); 418 if (cursor == null) return null; 419 int count = cursor.getCount(); 420 ArrayList<CompletionInfo> completions = new ArrayList<CompletionInfo>(count); 421 boolean usingWebCorpus = isSearchCorpusWeb(); 422 for (int i = 0; i < count; i++) { 423 cursor.moveTo(i); 424 if (!usingWebCorpus || cursor.isWebSearchSuggestion()) { 425 String text1 = cursor.getSuggestionText1(); 426 completions.add(new CompletionInfo(i, i, text1)); 427 } 428 } 429 return completions.toArray(new CompletionInfo[completions.size()]); 430 } 431 432 protected void onSuggestionsChanged() { 433 updateInputMethodSuggestions(); 434 } 435 436 /** 437 * Checks if the corpus used for typed searches is the web corpus. 438 */ 439 protected boolean isSearchCorpusWeb() { 440 Corpus corpus = getSearchCorpus(); 441 return corpus != null && corpus.isWebCorpus(); 442 } 443 444 protected boolean onSuggestionKeyDown(SuggestionsAdapter adapter, 445 int position, int keyCode, KeyEvent event) { 446 // Treat enter or search as a click 447 if ( keyCode == KeyEvent.KEYCODE_ENTER 448 || keyCode == KeyEvent.KEYCODE_SEARCH 449 || keyCode == KeyEvent.KEYCODE_DPAD_CENTER) { 450 if (adapter != null) { 451 SuggestionsAdapter suggestionsAdapter = adapter; 452 suggestionsAdapter.onSuggestionClicked(position); 453 return true; 454 } else { 455 return false; 456 } 457 } 458 459 return false; 460 } 461 462 protected boolean onSearchClicked(int method) { 463 if (mSearchClickListener != null) { 464 return mSearchClickListener.onSearchClicked(method); 465 } 466 return false; 467 } 468 469 /** 470 * Filters the suggestions list when the search text changes. 471 */ 472 private class SearchTextWatcher implements TextWatcher { 473 public void afterTextChanged(Editable s) { 474 boolean empty = s.length() == 0; 475 if (empty != mQueryWasEmpty) { 476 mQueryWasEmpty = empty; 477 updateUi(empty); 478 } 479 if (mUpdateSuggestions) { 480 if (mQueryListener != null) { 481 mQueryListener.onQueryChanged(); 482 } 483 } 484 } 485 486 public void beforeTextChanged(CharSequence s, int start, int count, int after) { 487 } 488 489 public void onTextChanged(CharSequence s, int start, int before, int count) { 490 } 491 } 492 493 /** 494 * Handles key events on the suggestions list view. 495 */ 496 protected class SuggestionsViewKeyListener implements View.OnKeyListener { 497 public boolean onKey(View v, int keyCode, KeyEvent event) { 498 if (event.getAction() == KeyEvent.ACTION_DOWN 499 && v instanceof SuggestionsView) { 500 SuggestionsView view = ((SuggestionsView) v); 501 int position = view.getSelectedPosition(); 502 if (onSuggestionKeyDown(view.getAdapter(), position, keyCode, event)) { 503 return true; 504 } 505 } 506 return forwardKeyToQueryTextView(keyCode, event); 507 } 508 } 509 510 private class InputMethodCloser implements SuggestionsView.OnScrollListener { 511 512 public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, 513 int totalItemCount) { 514 } 515 516 public void onScrollStateChanged(AbsListView view, int scrollState) { 517 considerHidingInputMethod(); 518 } 519 } 520 521 /** 522 * Listens for clicks on the source selector. 523 */ 524 private class SearchGoButtonClickListener implements View.OnClickListener { 525 public void onClick(View view) { 526 onSearchClicked(Logger.SEARCH_METHOD_BUTTON); 527 } 528 } 529 530 /** 531 * Handles non-text keys in the query text view. 532 */ 533 private class QueryTextViewKeyListener implements View.OnKeyListener { 534 public boolean onKey(View view, int keyCode, KeyEvent event) { 535 // Handle IME search action key 536 if (keyCode == KeyEvent.KEYCODE_ENTER && event.getAction() == KeyEvent.ACTION_UP) { 537 // if no action was taken, consume the key event so that the keyboard 538 // remains on screen. 539 return !onSearchClicked(Logger.SEARCH_METHOD_KEYBOARD); 540 } 541 return false; 542 } 543 } 544 545 /** 546 * Handles key events on the search and voice search buttons, 547 * by refocusing to EditText. 548 */ 549 private class ButtonsKeyListener implements View.OnKeyListener { 550 public boolean onKey(View v, int keyCode, KeyEvent event) { 551 return forwardKeyToQueryTextView(keyCode, event); 552 } 553 } 554 555 private boolean forwardKeyToQueryTextView(int keyCode, KeyEvent event) { 556 if (!event.isSystem() && !isDpadKey(keyCode)) { 557 if (DBG) Log.d(TAG, "Forwarding key to query box: " + event); 558 if (mQueryTextView.requestFocus()) { 559 return mQueryTextView.dispatchKeyEvent(event); 560 } 561 } 562 return false; 563 } 564 565 private boolean isDpadKey(int keyCode) { 566 switch (keyCode) { 567 case KeyEvent.KEYCODE_DPAD_UP: 568 case KeyEvent.KEYCODE_DPAD_DOWN: 569 case KeyEvent.KEYCODE_DPAD_LEFT: 570 case KeyEvent.KEYCODE_DPAD_RIGHT: 571 case KeyEvent.KEYCODE_DPAD_CENTER: 572 return true; 573 default: 574 return false; 575 } 576 } 577 578 /** 579 * Hides the input method when the suggestions get focus. 580 */ 581 private class SuggestListFocusListener implements OnFocusChangeListener { 582 public void onFocusChange(View v, boolean focused) { 583 if (DBG) Log.d(TAG, "Suggestions focus change, now: " + focused); 584 if (focused) { 585 considerHidingInputMethod(); 586 } 587 } 588 } 589 590 private class QueryTextViewFocusListener implements OnFocusChangeListener { 591 public void onFocusChange(View v, boolean focused) { 592 if (DBG) Log.d(TAG, "Query focus change, now: " + focused); 593 if (focused) { 594 // The query box got focus, show the input method 595 showInputMethodForQuery(); 596 } 597 } 598 } 599 600 private class SuggestionsObserver extends DataSetObserver { 601 @Override 602 public void onChanged() { 603 onSuggestionsChanged(); 604 } 605 } 606 607 public interface QueryListener { 608 void onQueryChanged(); 609 } 610 611 public interface SearchClickListener { 612 boolean onSearchClicked(int method); 613 } 614 615 private class CloseClickListener implements OnClickListener { 616 617 public void onClick(View v) { 618 mQueryTextView.setText(""); 619 } 620 } 621} 622