1 /* 2 * Copyright (C) 2008-2009 Google Inc. 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 java.util.ArrayList; 20 import java.util.Arrays; 21 import java.util.List; 22 23 import android.content.Context; 24 import android.content.res.Resources; 25 import android.graphics.Canvas; 26 import android.graphics.Paint; 27 import android.graphics.Rect; 28 import android.graphics.Typeface; 29 import android.graphics.Paint.Align; 30 import android.graphics.drawable.Drawable; 31 import android.os.Handler; 32 import android.os.Message; 33 import android.util.AttributeSet; 34 import android.view.GestureDetector; 35 import android.view.Gravity; 36 import android.view.LayoutInflater; 37 import android.view.MotionEvent; 38 import android.view.View; 39 import android.view.ViewGroup.LayoutParams; 40 import android.widget.PopupWindow; 41 import android.widget.TextView; 42 43 public class CandidateView extends View { 44 45 private static final int OUT_OF_BOUNDS = -1; 46 private static final List<CharSequence> EMPTY_LIST = new ArrayList<CharSequence>(); 47 48 private LatinIME mService; 49 private List<CharSequence> mSuggestions = EMPTY_LIST; 50 private boolean mShowingCompletions; 51 private CharSequence mSelectedString; 52 private int mSelectedIndex; 53 private int mTouchX = OUT_OF_BOUNDS; 54 private Drawable mSelectionHighlight; 55 private boolean mTypedWordValid; 56 57 private boolean mHaveMinimalSuggestion; 58 59 private Rect mBgPadding; 60 61 private TextView mPreviewText; 62 private PopupWindow mPreviewPopup; 63 private int mCurrentWordIndex; 64 private Drawable mDivider; 65 66 private static final int MAX_SUGGESTIONS = 32; 67 private static final int SCROLL_PIXELS = 20; 68 69 private static final int MSG_REMOVE_PREVIEW = 1; 70 private static final int MSG_REMOVE_THROUGH_PREVIEW = 2; 71 72 private int[] mWordWidth = new int[MAX_SUGGESTIONS]; 73 private int[] mWordX = new int[MAX_SUGGESTIONS]; 74 private int mPopupPreviewX; 75 private int mPopupPreviewY; 76 77 private static final int X_GAP = 10; 78 79 private int mColorNormal; 80 private int mColorRecommended; 81 private int mColorOther; 82 private Paint mPaint; 83 private int mDescent; 84 private boolean mScrolled; 85 private boolean mShowingAddToDictionary; 86 private CharSequence mWordToAddToDictionary; 87 private CharSequence mAddToDictionaryHint; 88 89 private int mTargetScrollX; 90 91 private int mMinTouchableWidth; 92 93 private int mTotalWidth; 94 95 private GestureDetector mGestureDetector; 96 97 Handler mHandler = new Handler() { 98 @Override 99 public void handleMessage(Message msg) { 100 switch (msg.what) { 101 case MSG_REMOVE_PREVIEW: 102 mPreviewText.setVisibility(GONE); 103 break; 104 case MSG_REMOVE_THROUGH_PREVIEW: 105 mPreviewText.setVisibility(GONE); 106 if (mTouchX != OUT_OF_BOUNDS) { 107 removeHighlight(); 108 } 109 break; 110 } 111 112 } 113 }; 114 115 /** 116 * Construct a CandidateView for showing suggested words for completion. 117 * @param context 118 * @param attrs 119 */ 120 public CandidateView(Context context, AttributeSet attrs) { 121 super(context, attrs); 122 mSelectionHighlight = context.getResources().getDrawable( 123 R.drawable.list_selector_background_pressed); 124 125 LayoutInflater inflate = 126 (LayoutInflater) context 127 .getSystemService(Context.LAYOUT_INFLATER_SERVICE); 128 Resources res = context.getResources(); 129 mPreviewPopup = new PopupWindow(context); 130 mPreviewText = (TextView) inflate.inflate(R.layout.candidate_preview, null); 131 mPreviewPopup.setWindowLayoutMode(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); 132 mPreviewPopup.setContentView(mPreviewText); 133 mPreviewPopup.setBackgroundDrawable(null); 134 mColorNormal = res.getColor(R.color.candidate_normal); 135 mColorRecommended = res.getColor(R.color.candidate_recommended); 136 mColorOther = res.getColor(R.color.candidate_other); 137 mDivider = res.getDrawable(R.drawable.keyboard_suggest_strip_divider); 138 mAddToDictionaryHint = res.getString(R.string.hint_add_to_dictionary); 139 140 mPaint = new Paint(); 141 mPaint.setColor(mColorNormal); 142 mPaint.setAntiAlias(true); 143 mPaint.setTextSize(mPreviewText.getTextSize()); 144 mPaint.setStrokeWidth(0); 145 mPaint.setTextAlign(Align.CENTER); 146 mDescent = (int) mPaint.descent(); 147 // 80 pixels for a 160dpi device would mean half an inch 148 mMinTouchableWidth = (int) (getResources().getDisplayMetrics().density * 50); 149 150 mGestureDetector = new GestureDetector(new GestureDetector.SimpleOnGestureListener() { 151 @Override 152 public void onLongPress(MotionEvent me) { 153 if (mSuggestions.size() > 0) { 154 if (me.getX() + getScrollX() < mWordWidth[0] && getScrollX() < 10) { 155 longPressFirstWord(); 156 } 157 } 158 } 159 160 @Override 161 public boolean onScroll(MotionEvent e1, MotionEvent e2, 162 float distanceX, float distanceY) { 163 final int width = getWidth(); 164 mScrolled = true; 165 int scrollX = getScrollX(); 166 scrollX += (int) distanceX; 167 if (scrollX < 0) { 168 scrollX = 0; 169 } 170 if (distanceX > 0 && scrollX + width > mTotalWidth) { 171 scrollX -= (int) distanceX; 172 } 173 mTargetScrollX = scrollX; 174 scrollTo(scrollX, getScrollY()); 175 hidePreview(); 176 invalidate(); 177 return true; 178 } 179 }); 180 setHorizontalFadingEdgeEnabled(true); 181 setWillNotDraw(false); 182 setHorizontalScrollBarEnabled(false); 183 setVerticalScrollBarEnabled(false); 184 scrollTo(0, getScrollY()); 185 } 186 187 /** 188 * A connection back to the service to communicate with the text field 189 * @param listener 190 */ 191 public void setService(LatinIME listener) { 192 mService = listener; 193 } 194 195 @Override 196 public int computeHorizontalScrollRange() { 197 return mTotalWidth; 198 } 199 200 /** 201 * If the canvas is null, then only touch calculations are performed to pick the target 202 * candidate. 203 */ 204 @Override 205 protected void onDraw(Canvas canvas) { 206 if (canvas != null) { 207 super.onDraw(canvas); 208 } 209 mTotalWidth = 0; 210 if (mSuggestions == null) return; 211 212 final int height = getHeight(); 213 if (mBgPadding == null) { 214 mBgPadding = new Rect(0, 0, 0, 0); 215 if (getBackground() != null) { 216 getBackground().getPadding(mBgPadding); 217 } 218 mDivider.setBounds(0, 0, mDivider.getIntrinsicWidth(), 219 mDivider.getIntrinsicHeight()); 220 } 221 int x = 0; 222 final int count = mSuggestions.size(); 223 final int width = getWidth(); 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 for (int i = 0; i < count; i++) { 233 CharSequence suggestion = mSuggestions.get(i); 234 if (suggestion == null) continue; 235 paint.setColor(mColorNormal); 236 if (mHaveMinimalSuggestion 237 && ((i == 1 && !typedWordValid) || (i == 0 && typedWordValid))) { 238 paint.setTypeface(Typeface.DEFAULT_BOLD); 239 paint.setColor(mColorRecommended); 240 } else if (i != 0) { 241 paint.setColor(mColorOther); 242 } 243 final int wordWidth; 244 if (mWordWidth[i] != 0) { 245 wordWidth = mWordWidth[i]; 246 } else { 247 float textWidth = paint.measureText(suggestion, 0, suggestion.length()); 248 wordWidth = Math.max(mMinTouchableWidth, (int) textWidth + X_GAP * 2); 249 mWordWidth[i] = wordWidth; 250 } 251 252 mWordX[i] = x; 253 254 if (touchX + scrollX >= x && touchX + scrollX < x + wordWidth && !scrolled && 255 touchX != OUT_OF_BOUNDS) { 256 if (canvas != null && !mShowingAddToDictionary) { 257 canvas.translate(x, 0); 258 mSelectionHighlight.setBounds(0, bgPadding.top, wordWidth, height); 259 mSelectionHighlight.draw(canvas); 260 canvas.translate(-x, 0); 261 showPreview(i, null); 262 } 263 mSelectedString = suggestion; 264 mSelectedIndex = i; 265 } 266 267 if (canvas != null) { 268 canvas.drawText(suggestion, 0, suggestion.length(), x + wordWidth / 2, y, paint); 269 paint.setColor(mColorOther); 270 canvas.translate(x + wordWidth, 0); 271 // Draw a divider unless it's after the hint 272 if (!(mShowingAddToDictionary && i == 1)) { 273 mDivider.draw(canvas); 274 } 275 canvas.translate(-x - wordWidth, 0); 276 } 277 paint.setTypeface(Typeface.DEFAULT); 278 x += wordWidth; 279 } 280 mTotalWidth = x; 281 if (mTargetScrollX != scrollX) { 282 scrollToTarget(); 283 } 284 } 285 286 private void scrollToTarget() { 287 int scrollX = getScrollX(); 288 if (mTargetScrollX > scrollX) { 289 scrollX += SCROLL_PIXELS; 290 if (scrollX >= mTargetScrollX) { 291 scrollX = mTargetScrollX; 292 scrollTo(scrollX, getScrollY()); 293 requestLayout(); 294 } else { 295 scrollTo(scrollX, getScrollY()); 296 } 297 } else { 298 scrollX -= SCROLL_PIXELS; 299 if (scrollX <= mTargetScrollX) { 300 scrollX = mTargetScrollX; 301 scrollTo(scrollX, getScrollY()); 302 requestLayout(); 303 } else { 304 scrollTo(scrollX, getScrollY()); 305 } 306 } 307 invalidate(); 308 } 309 310 public void setSuggestions(List<CharSequence> suggestions, boolean completions, 311 boolean typedWordValid, boolean haveMinimalSuggestion) { 312 clear(); 313 if (suggestions != null) { 314 mSuggestions = new ArrayList<CharSequence>(suggestions); 315 } 316 mShowingCompletions = completions; 317 mTypedWordValid = typedWordValid; 318 scrollTo(0, getScrollY()); 319 mTargetScrollX = 0; 320 mHaveMinimalSuggestion = haveMinimalSuggestion; 321 // Compute the total width 322 onDraw(null); 323 invalidate(); 324 requestLayout(); 325 } 326 327 public void showAddToDictionaryHint(CharSequence word) { 328 mWordToAddToDictionary = word; 329 ArrayList<CharSequence> suggestions = new ArrayList<CharSequence>(); 330 suggestions.add(word); 331 suggestions.add(mAddToDictionaryHint); 332 setSuggestions(suggestions, false, false, false); 333 mShowingAddToDictionary = true; 334 } 335 336 public void scrollPrev() { 337 int i = 0; 338 final int count = mSuggestions.size(); 339 int firstItem = 0; // Actually just before the first item, if at the boundary 340 while (i < count) { 341 if (mWordX[i] < getScrollX() 342 && mWordX[i] + mWordWidth[i] >= getScrollX() - 1) { 343 firstItem = i; 344 break; 345 } 346 i++; 347 } 348 int leftEdge = mWordX[firstItem] + mWordWidth[firstItem] - getWidth(); 349 if (leftEdge < 0) leftEdge = 0; 350 updateScrollPosition(leftEdge); 351 } 352 353 public void scrollNext() { 354 int i = 0; 355 int scrollX = getScrollX(); 356 int targetX = scrollX; 357 final int count = mSuggestions.size(); 358 int rightEdge = scrollX + getWidth(); 359 while (i < count) { 360 if (mWordX[i] <= rightEdge && 361 mWordX[i] + mWordWidth[i] >= rightEdge) { 362 targetX = Math.min(mWordX[i], mTotalWidth - getWidth()); 363 break; 364 } 365 i++; 366 } 367 updateScrollPosition(targetX); 368 } 369 370 private void updateScrollPosition(int targetX) { 371 if (targetX != getScrollX()) { 372 // TODO: Animate 373 mTargetScrollX = targetX; 374 requestLayout(); 375 invalidate(); 376 mScrolled = true; 377 } 378 } 379 380 public void clear() { 381 mSuggestions = EMPTY_LIST; 382 mTouchX = OUT_OF_BOUNDS; 383 mSelectedString = null; 384 mSelectedIndex = -1; 385 mShowingAddToDictionary = false; 386 invalidate(); 387 Arrays.fill(mWordWidth, 0); 388 Arrays.fill(mWordX, 0); 389 if (mPreviewPopup.isShowing()) { 390 mPreviewPopup.dismiss(); 391 } 392 } 393 394 @Override 395 public boolean onTouchEvent(MotionEvent me) { 396 397 if (mGestureDetector.onTouchEvent(me)) { 398 return true; 399 } 400 401 int action = me.getAction(); 402 int x = (int) me.getX(); 403 int y = (int) me.getY(); 404 mTouchX = x; 405 406 switch (action) { 407 case MotionEvent.ACTION_DOWN: 408 mScrolled = false; 409 invalidate(); 410 break; 411 case MotionEvent.ACTION_MOVE: 412 if (y <= 0) { 413 // Fling up!? 414 if (mSelectedString != null) { 415 if (!mShowingCompletions) { 416 TextEntryState.acceptedSuggestion(mSuggestions.get(0), 417 mSelectedString); 418 } 419 mService.pickSuggestionManually(mSelectedIndex, mSelectedString); 420 mSelectedString = null; 421 mSelectedIndex = -1; 422 } 423 } 424 invalidate(); 425 break; 426 case MotionEvent.ACTION_UP: 427 if (!mScrolled) { 428 if (mSelectedString != null) { 429 if (mShowingAddToDictionary) { 430 longPressFirstWord(); 431 clear(); 432 } else { 433 if (!mShowingCompletions) { 434 TextEntryState.acceptedSuggestion(mSuggestions.get(0), 435 mSelectedString); 436 } 437 mService.pickSuggestionManually(mSelectedIndex, mSelectedString); 438 } 439 } 440 } 441 mSelectedString = null; 442 mSelectedIndex = -1; 443 removeHighlight(); 444 hidePreview(); 445 requestLayout(); 446 break; 447 } 448 return true; 449 } 450 451 /** 452 * For flick through from keyboard, call this method with the x coordinate of the flick 453 * gesture. 454 * @param x 455 */ 456 public void takeSuggestionAt(float x) { 457 mTouchX = (int) x; 458 // To detect candidate 459 onDraw(null); 460 if (mSelectedString != null) { 461 if (!mShowingCompletions) { 462 TextEntryState.acceptedSuggestion(mSuggestions.get(0), mSelectedString); 463 } 464 mService.pickSuggestionManually(mSelectedIndex, mSelectedString); 465 } 466 invalidate(); 467 mHandler.sendMessageDelayed(mHandler.obtainMessage(MSG_REMOVE_THROUGH_PREVIEW), 200); 468 } 469 470 private void hidePreview() { 471 mCurrentWordIndex = OUT_OF_BOUNDS; 472 if (mPreviewPopup.isShowing()) { 473 mHandler.sendMessageDelayed(mHandler 474 .obtainMessage(MSG_REMOVE_PREVIEW), 60); 475 } 476 } 477 478 private void showPreview(int wordIndex, String altText) { 479 int oldWordIndex = mCurrentWordIndex; 480 mCurrentWordIndex = wordIndex; 481 // If index changed or changing text 482 if (oldWordIndex != mCurrentWordIndex || altText != null) { 483 if (wordIndex == OUT_OF_BOUNDS) { 484 hidePreview(); 485 } else { 486 CharSequence word = altText != null? altText : mSuggestions.get(wordIndex); 487 mPreviewText.setText(word); 488 mPreviewText.measure(MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED), 489 MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)); 490 int wordWidth = (int) (mPaint.measureText(word, 0, word.length()) + X_GAP * 2); 491 final int popupWidth = wordWidth 492 + mPreviewText.getPaddingLeft() + mPreviewText.getPaddingRight(); 493 final int popupHeight = mPreviewText.getMeasuredHeight(); 494 //mPreviewText.setVisibility(INVISIBLE); 495 mPopupPreviewX = mWordX[wordIndex] - mPreviewText.getPaddingLeft() - getScrollX() 496 + (mWordWidth[wordIndex] - wordWidth) / 2; 497 mPopupPreviewY = - popupHeight; 498 mHandler.removeMessages(MSG_REMOVE_PREVIEW); 499 int [] offsetInWindow = new int[2]; 500 getLocationInWindow(offsetInWindow); 501 if (mPreviewPopup.isShowing()) { 502 mPreviewPopup.update(mPopupPreviewX, mPopupPreviewY + offsetInWindow[1], 503 popupWidth, popupHeight); 504 } else { 505 mPreviewPopup.setWidth(popupWidth); 506 mPreviewPopup.setHeight(popupHeight); 507 mPreviewPopup.showAtLocation(this, Gravity.NO_GRAVITY, mPopupPreviewX, 508 mPopupPreviewY + offsetInWindow[1]); 509 } 510 mPreviewText.setVisibility(VISIBLE); 511 } 512 } 513 } 514 515 private void removeHighlight() { 516 mTouchX = OUT_OF_BOUNDS; 517 invalidate(); 518 } 519 520 private void longPressFirstWord() { 521 CharSequence word = mSuggestions.get(0); 522 if (word.length() < 2) return; 523 if (mService.addWordToDictionary(word.toString())) { 524 showPreview(0, getContext().getResources().getString(R.string.added_word, word)); 525 } 526 } 527 528 @Override 529 public void onDetachedFromWindow() { 530 super.onDetachedFromWindow(); 531 hidePreview(); 532 } 533 } 534