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