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