1 /* 2 * Copyright (C) 2008 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 * use this file except in compliance with the License. You may obtain a copy of 6 * 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, WITHOUT 12 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 * License for the specific language governing permissions and limitations under 14 * the License. 15 */ 16 17 package com.android.inputmethod.latin; 18 19 import android.content.Context; 20 import android.content.res.Resources; 21 import android.graphics.Canvas; 22 import android.graphics.Paint; 23 import android.graphics.Paint.Align; 24 import android.graphics.Rect; 25 import android.graphics.Typeface; 26 import android.graphics.drawable.Drawable; 27 import android.util.AttributeSet; 28 import android.view.GestureDetector; 29 import android.view.Gravity; 30 import android.view.LayoutInflater; 31 import android.view.MotionEvent; 32 import android.view.View; 33 import android.view.ViewGroup.LayoutParams; 34 import android.widget.PopupWindow; 35 import android.widget.TextView; 36 37 import java.util.ArrayList; 38 import java.util.Arrays; 39 import java.util.List; 40 41 public class CandidateView extends View { 42 43 private static final int OUT_OF_BOUNDS_WORD_INDEX = -1; 44 private static final int OUT_OF_BOUNDS_X_COORD = -1; 45 46 private LatinIME mService; 47 private final ArrayList<CharSequence> mSuggestions = new ArrayList<CharSequence>(); 48 private boolean mShowingCompletions; 49 private CharSequence mSelectedString; 50 private int mSelectedIndex; 51 private int mTouchX = OUT_OF_BOUNDS_X_COORD; 52 private final Drawable mSelectionHighlight; 53 private boolean mTypedWordValid; 54 55 private boolean mHaveMinimalSuggestion; 56 57 private Rect mBgPadding; 58 59 private final TextView mPreviewText; 60 private final PopupWindow mPreviewPopup; 61 private int mCurrentWordIndex; 62 private Drawable mDivider; 63 64 private static final int MAX_SUGGESTIONS = 32; 65 private static final int SCROLL_PIXELS = 20; 66 67 private final int[] mWordWidth = new int[MAX_SUGGESTIONS]; 68 private final int[] mWordX = new int[MAX_SUGGESTIONS]; 69 private int mPopupPreviewX; 70 private int mPopupPreviewY; 71 72 private static final int X_GAP = 10; 73 74 private final int mColorNormal; 75 private final int mColorRecommended; 76 private final int mColorOther; 77 private final Paint mPaint; 78 private final int mDescent; 79 private boolean mScrolled; 80 private boolean mShowingAddToDictionary; 81 private CharSequence mAddToDictionaryHint; 82 83 private int mTargetScrollX; 84 85 private final int mMinTouchableWidth; 86 87 private int mTotalWidth; 88 89 private final GestureDetector mGestureDetector; 90 91 /** 92 * Construct a CandidateView for showing suggested words for completion. 93 * @param context 94 * @param attrs 95 */ 96 public CandidateView(Context context, AttributeSet attrs) { 97 super(context, attrs); 98 mSelectionHighlight = context.getResources().getDrawable( 99 R.drawable.list_selector_background_pressed); 100 101 LayoutInflater inflate = 102 (LayoutInflater) context 103 .getSystemService(Context.LAYOUT_INFLATER_SERVICE); 104 Resources res = context.getResources(); 105 mPreviewPopup = new PopupWindow(context); 106 mPreviewText = (TextView) inflate.inflate(R.layout.candidate_preview, null); 107 mPreviewPopup.setWindowLayoutMode(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); 108 mPreviewPopup.setContentView(mPreviewText); 109 mPreviewPopup.setBackgroundDrawable(null); 110 mPreviewPopup.setAnimationStyle(R.style.KeyPreviewAnimation); 111 mColorNormal = res.getColor(R.color.candidate_normal); 112 mColorRecommended = res.getColor(R.color.candidate_recommended); 113 mColorOther = res.getColor(R.color.candidate_other); 114 mDivider = res.getDrawable(R.drawable.keyboard_suggest_strip_divider); 115 mAddToDictionaryHint = res.getString(R.string.hint_add_to_dictionary); 116 117 mPaint = new Paint(); 118 mPaint.setColor(mColorNormal); 119 mPaint.setAntiAlias(true); 120 mPaint.setTextSize(mPreviewText.getTextSize()); 121 mPaint.setStrokeWidth(0); 122 mPaint.setTextAlign(Align.CENTER); 123 mDescent = (int) mPaint.descent(); 124 mMinTouchableWidth = (int)res.getDimension(R.dimen.candidate_min_touchable_width); 125 126 mGestureDetector = new GestureDetector( 127 new CandidateStripGestureListener(mMinTouchableWidth)); 128 setWillNotDraw(false); 129 setHorizontalScrollBarEnabled(false); 130 setVerticalScrollBarEnabled(false); 131 scrollTo(0, getScrollY()); 132 } 133 134 private class CandidateStripGestureListener extends GestureDetector.SimpleOnGestureListener { 135 private final int mTouchSlopSquare; 136 137 public CandidateStripGestureListener(int touchSlop) { 138 // Slightly reluctant to scroll to be able to easily choose the suggestion 139 mTouchSlopSquare = touchSlop * touchSlop; 140 } 141 142 @Override 143 public void onLongPress(MotionEvent me) { 144 if (mSuggestions.size() > 0) { 145 if (me.getX() + getScrollX() < mWordWidth[0] && getScrollX() < 10) { 146 longPressFirstWord(); 147 } 148 } 149 } 150 151 @Override 152 public boolean onDown(MotionEvent e) { 153 mScrolled = false; 154 return false; 155 } 156 157 @Override 158 public boolean onScroll(MotionEvent e1, MotionEvent e2, 159 float distanceX, float distanceY) { 160 if (!mScrolled) { 161 // This is applied only when we recognize that scrolling is starting. 162 final int deltaX = (int) (e2.getX() - e1.getX()); 163 final int deltaY = (int) (e2.getY() - e1.getY()); 164 final int distance = (deltaX * deltaX) + (deltaY * deltaY); 165 if (distance < mTouchSlopSquare) { 166 return true; 167 } 168 mScrolled = true; 169 } 170 171 final int width = getWidth(); 172 mScrolled = true; 173 int scrollX = getScrollX(); 174 scrollX += (int) distanceX; 175 if (scrollX < 0) { 176 scrollX = 0; 177 } 178 if (distanceX > 0 && scrollX + width > mTotalWidth) { 179 scrollX -= (int) distanceX; 180 } 181 mTargetScrollX = scrollX; 182 scrollTo(scrollX, getScrollY()); 183 hidePreview(); 184 invalidate(); 185 return true; 186 } 187 } 188 189 /** 190 * A connection back to the service to communicate with the text field 191 * @param listener 192 */ 193 public void setService(LatinIME listener) { 194 mService = listener; 195 } 196 197 @Override 198 public int computeHorizontalScrollRange() { 199 return mTotalWidth; 200 } 201 202 /** 203 * If the canvas is null, then only touch calculations are performed to pick the target 204 * candidate. 205 */ 206 @Override 207 protected void onDraw(Canvas canvas) { 208 if (canvas != null) { 209 super.onDraw(canvas); 210 } 211 mTotalWidth = 0; 212 213 final int height = getHeight(); 214 if (mBgPadding == null) { 215 mBgPadding = new Rect(0, 0, 0, 0); 216 if (getBackground() != null) { 217 getBackground().getPadding(mBgPadding); 218 } 219 mDivider.setBounds(0, 0, mDivider.getIntrinsicWidth(), 220 mDivider.getIntrinsicHeight()); 221 } 222 223 final int count = mSuggestions.size(); 224 final Rect bgPadding = mBgPadding; 225 final Paint paint = mPaint; 226 final int touchX = mTouchX; 227 final int scrollX = getScrollX(); 228 final boolean scrolled = mScrolled; 229 final boolean typedWordValid = mTypedWordValid; 230 final int y = (int) (height + mPaint.getTextSize() - mDescent) / 2; 231 232 boolean existsAutoCompletion = false; 233 234 int x = 0; 235 for (int i = 0; i < count; i++) { 236 CharSequence suggestion = mSuggestions.get(i); 237 if (suggestion == null) continue; 238 final int wordLength = suggestion.length(); 239 240 paint.setColor(mColorNormal); 241 if (mHaveMinimalSuggestion 242 && ((i == 1 && !typedWordValid) || (i == 0 && typedWordValid))) { 243 paint.setTypeface(Typeface.DEFAULT_BOLD); 244 paint.setColor(mColorRecommended); 245 existsAutoCompletion = true; 246 } else if (i != 0 || (wordLength == 1 && count > 1)) { 247 // HACK: even if i == 0, we use mColorOther when this suggestion's length is 1 and 248 // there are multiple suggestions, such as the default punctuation list. 249 paint.setColor(mColorOther); 250 } 251 int wordWidth; 252 if ((wordWidth = mWordWidth[i]) == 0) { 253 float textWidth = paint.measureText(suggestion, 0, wordLength); 254 wordWidth = Math.max(mMinTouchableWidth, (int) textWidth + X_GAP * 2); 255 mWordWidth[i] = wordWidth; 256 } 257 258 mWordX[i] = x; 259 260 if (touchX != OUT_OF_BOUNDS_X_COORD && !scrolled 261 && touchX + scrollX >= x && touchX + scrollX < x + wordWidth) { 262 if (canvas != null && !mShowingAddToDictionary) { 263 canvas.translate(x, 0); 264 mSelectionHighlight.setBounds(0, bgPadding.top, wordWidth, height); 265 mSelectionHighlight.draw(canvas); 266 canvas.translate(-x, 0); 267 } 268 mSelectedString = suggestion; 269 mSelectedIndex = i; 270 } 271 272 if (canvas != null) { 273 canvas.drawText(suggestion, 0, wordLength, x + wordWidth / 2, y, paint); 274 paint.setColor(mColorOther); 275 canvas.translate(x + wordWidth, 0); 276 // Draw a divider unless it's after the hint 277 if (!(mShowingAddToDictionary && i == 1)) { 278 mDivider.draw(canvas); 279 } 280 canvas.translate(-x - wordWidth, 0); 281 } 282 paint.setTypeface(Typeface.DEFAULT); 283 x += wordWidth; 284 } 285 mService.onAutoCompletionStateChanged(existsAutoCompletion); 286 mTotalWidth = x; 287 if (mTargetScrollX != scrollX) { 288 scrollToTarget(); 289 } 290 } 291 292 private void scrollToTarget() { 293 int scrollX = getScrollX(); 294 if (mTargetScrollX > scrollX) { 295 scrollX += SCROLL_PIXELS; 296 if (scrollX >= mTargetScrollX) { 297 scrollX = mTargetScrollX; 298 scrollTo(scrollX, getScrollY()); 299 requestLayout(); 300 } else { 301 scrollTo(scrollX, getScrollY()); 302 } 303 } else { 304 scrollX -= SCROLL_PIXELS; 305 if (scrollX <= mTargetScrollX) { 306 scrollX = mTargetScrollX; 307 scrollTo(scrollX, getScrollY()); 308 requestLayout(); 309 } else { 310 scrollTo(scrollX, getScrollY()); 311 } 312 } 313 invalidate(); 314 } 315 316 public void setSuggestions(List<CharSequence> suggestions, boolean completions, 317 boolean typedWordValid, boolean haveMinimalSuggestion) { 318 clear(); 319 if (suggestions != null) { 320 int insertCount = Math.min(suggestions.size(), MAX_SUGGESTIONS); 321 for (CharSequence suggestion : suggestions) { 322 mSuggestions.add(suggestion); 323 if (--insertCount == 0) 324 break; 325 } 326 } 327 mShowingCompletions = completions; 328 mTypedWordValid = typedWordValid; 329 scrollTo(0, getScrollY()); 330 mTargetScrollX = 0; 331 mHaveMinimalSuggestion = haveMinimalSuggestion; 332 // Compute the total width 333 onDraw(null); 334 invalidate(); 335 requestLayout(); 336 } 337 338 public boolean isShowingAddToDictionaryHint() { 339 return mShowingAddToDictionary; 340 } 341 342 public void showAddToDictionaryHint(CharSequence word) { 343 ArrayList<CharSequence> suggestions = new ArrayList<CharSequence>(); 344 suggestions.add(word); 345 suggestions.add(mAddToDictionaryHint); 346 setSuggestions(suggestions, false, false, false); 347 mShowingAddToDictionary = true; 348 } 349 350 public boolean dismissAddToDictionaryHint() { 351 if (!mShowingAddToDictionary) return false; 352 clear(); 353 return true; 354 } 355 356 /* package */ List<CharSequence> getSuggestions() { 357 return mSuggestions; 358 } 359 360 public void clear() { 361 // Don't call mSuggestions.clear() because it's being used for logging 362 // in LatinIME.pickSuggestionManually(). 363 mSuggestions.clear(); 364 mTouchX = OUT_OF_BOUNDS_X_COORD; 365 mSelectedString = null; 366 mSelectedIndex = -1; 367 mShowingAddToDictionary = false; 368 invalidate(); 369 Arrays.fill(mWordWidth, 0); 370 Arrays.fill(mWordX, 0); 371 } 372 373 @Override 374 public boolean onTouchEvent(MotionEvent me) { 375 376 if (mGestureDetector.onTouchEvent(me)) { 377 return true; 378 } 379 380 int action = me.getAction(); 381 int x = (int) me.getX(); 382 int y = (int) me.getY(); 383 mTouchX = x; 384 385 switch (action) { 386 case MotionEvent.ACTION_DOWN: 387 invalidate(); 388 break; 389 case MotionEvent.ACTION_MOVE: 390 if (y <= 0) { 391 // Fling up!? 392 if (mSelectedString != null) { 393 // If there are completions from the application, we don't change the state to 394 // STATE_PICKED_SUGGESTION 395 if (!mShowingCompletions) { 396 // This "acceptedSuggestion" will not be counted as a word because 397 // it will be counted in pickSuggestion instead. 398 TextEntryState.acceptedSuggestion(mSuggestions.get(0), 399 mSelectedString); 400 } 401 mService.pickSuggestionManually(mSelectedIndex, mSelectedString); 402 mSelectedString = null; 403 mSelectedIndex = -1; 404 } 405 } 406 break; 407 case MotionEvent.ACTION_UP: 408 if (!mScrolled) { 409 if (mSelectedString != null) { 410 if (mShowingAddToDictionary) { 411 longPressFirstWord(); 412 clear(); 413 } else { 414 if (!mShowingCompletions) { 415 TextEntryState.acceptedSuggestion(mSuggestions.get(0), 416 mSelectedString); 417 } 418 mService.pickSuggestionManually(mSelectedIndex, mSelectedString); 419 } 420 } 421 } 422 mSelectedString = null; 423 mSelectedIndex = -1; 424 requestLayout(); 425 hidePreview(); 426 invalidate(); 427 break; 428 } 429 return true; 430 } 431 432 private void hidePreview() { 433 mTouchX = OUT_OF_BOUNDS_X_COORD; 434 mCurrentWordIndex = OUT_OF_BOUNDS_WORD_INDEX; 435 mPreviewPopup.dismiss(); 436 } 437 438 private void showPreview(int wordIndex, String altText) { 439 int oldWordIndex = mCurrentWordIndex; 440 mCurrentWordIndex = wordIndex; 441 // If index changed or changing text 442 if (oldWordIndex != mCurrentWordIndex || altText != null) { 443 if (wordIndex == OUT_OF_BOUNDS_WORD_INDEX) { 444 hidePreview(); 445 } else { 446 CharSequence word = altText != null? altText : mSuggestions.get(wordIndex); 447 mPreviewText.setText(word); 448 mPreviewText.measure(MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED), 449 MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)); 450 int wordWidth = (int) (mPaint.measureText(word, 0, word.length()) + X_GAP * 2); 451 final int popupWidth = wordWidth 452 + mPreviewText.getPaddingLeft() + mPreviewText.getPaddingRight(); 453 final int popupHeight = mPreviewText.getMeasuredHeight(); 454 //mPreviewText.setVisibility(INVISIBLE); 455 mPopupPreviewX = mWordX[wordIndex] - mPreviewText.getPaddingLeft() - getScrollX() 456 + (mWordWidth[wordIndex] - wordWidth) / 2; 457 mPopupPreviewY = - popupHeight; 458 int [] offsetInWindow = new int[2]; 459 getLocationInWindow(offsetInWindow); 460 if (mPreviewPopup.isShowing()) { 461 mPreviewPopup.update(mPopupPreviewX, mPopupPreviewY + offsetInWindow[1], 462 popupWidth, popupHeight); 463 } else { 464 mPreviewPopup.setWidth(popupWidth); 465 mPreviewPopup.setHeight(popupHeight); 466 mPreviewPopup.showAtLocation(this, Gravity.NO_GRAVITY, mPopupPreviewX, 467 mPopupPreviewY + offsetInWindow[1]); 468 } 469 mPreviewText.setVisibility(VISIBLE); 470 } 471 } 472 } 473 474 private void longPressFirstWord() { 475 CharSequence word = mSuggestions.get(0); 476 if (word.length() < 2) return; 477 if (mService.addWordToDictionary(word.toString())) { 478 showPreview(0, getContext().getResources().getString(R.string.added_word, word)); 479 } 480 } 481 482 @Override 483 public void onDetachedFromWindow() { 484 super.onDetachedFromWindow(); 485 hidePreview(); 486 } 487 } 488