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