| // Copyright 2015 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| package org.chromium.chrome.browser.omnibox; |
| |
| import android.annotation.SuppressLint; |
| import android.content.Context; |
| import android.content.DialogInterface; |
| import android.content.res.TypedArray; |
| import android.graphics.Bitmap; |
| import android.graphics.Canvas; |
| import android.graphics.Color; |
| import android.graphics.PorterDuff; |
| import android.graphics.drawable.Drawable; |
| import android.support.annotation.IntDef; |
| import android.support.v4.view.ViewCompat; |
| import android.support.v7.app.AlertDialog; |
| import android.text.Spannable; |
| import android.text.SpannableString; |
| import android.text.TextPaint; |
| import android.text.TextUtils; |
| import android.text.style.StyleSpan; |
| import android.util.TypedValue; |
| import android.view.MotionEvent; |
| import android.view.View; |
| import android.view.ViewGroup; |
| import android.widget.ImageView; |
| import android.widget.TextView; |
| import android.widget.TextView.BufferType; |
| |
| import org.chromium.base.ApiCompatibilityUtils; |
| import org.chromium.base.metrics.RecordUserAction; |
| import org.chromium.chrome.R; |
| import org.chromium.chrome.browser.omnibox.OmniboxResultsAdapter.OmniboxResultItem; |
| import org.chromium.chrome.browser.omnibox.OmniboxResultsAdapter.OmniboxSuggestionDelegate; |
| import org.chromium.chrome.browser.omnibox.OmniboxSuggestion.MatchClassification; |
| import org.chromium.chrome.browser.widget.TintedDrawable; |
| import org.chromium.ui.base.DeviceFormFactor; |
| |
| import java.lang.annotation.Retention; |
| import java.lang.annotation.RetentionPolicy; |
| import java.util.ArrayList; |
| import java.util.List; |
| |
| /** |
| * Container view for omnibox suggestions made very specific for omnibox suggestions to minimize |
| * any unnecessary measures and layouts. |
| */ |
| class SuggestionView extends ViewGroup { |
| @Retention(RetentionPolicy.SOURCE) |
| @IntDef({ |
| SUGGESTION_ICON_UNDEFINED, |
| SUGGESTION_ICON_BOOKMARK, |
| SUGGESTION_ICON_HISTORY, |
| SUGGESTION_ICON_GLOBE, |
| SUGGESTION_ICON_MAGNIFIER, |
| SUGGESTION_ICON_VOICE |
| }) |
| private @interface SuggestionIcon {} |
| |
| private static final int SUGGESTION_ICON_UNDEFINED = -1; |
| private static final int SUGGESTION_ICON_BOOKMARK = 0; |
| private static final int SUGGESTION_ICON_HISTORY = 1; |
| private static final int SUGGESTION_ICON_GLOBE = 2; |
| private static final int SUGGESTION_ICON_MAGNIFIER = 3; |
| private static final int SUGGESTION_ICON_VOICE = 4; |
| |
| private static final long RELAYOUT_DELAY_MS = 20; |
| |
| static final int TITLE_COLOR_STANDARD_FONT_DARK = 0xFF333333; |
| private static final int TITLE_COLOR_STANDARD_FONT_LIGHT = 0xFFFFFFFF; |
| private static final int URL_COLOR = 0xFF5595FE; |
| |
| private static final float ANSWER_IMAGE_SCALING_FACTOR = 1.15f; |
| |
| private final LocationBar mLocationBar; |
| private UrlBar mUrlBar; |
| private ImageView mNavigationButton; |
| |
| private final int mSuggestionHeight; |
| private final int mSuggestionAnswerHeight; |
| |
| private OmniboxResultItem mSuggestionItem; |
| private OmniboxSuggestion mSuggestion; |
| private OmniboxSuggestionDelegate mSuggestionDelegate; |
| private Boolean mUseDarkColors; |
| private int mPosition; |
| |
| private final SuggestionContentsContainer mContentsView; |
| |
| private final int mRefineWidth; |
| private final View mRefineView; |
| private TintedDrawable mRefineIcon; |
| |
| private final int[] mViewPositionHolder = new int[2]; |
| |
| // Pre-computed offsets in px. |
| private final int mPhoneUrlBarLeftOffsetPx; |
| private final int mPhoneUrlBarLeftOffsetRtlPx; |
| |
| /** |
| * Constructs a new omnibox suggestion view. |
| * |
| * @param context The context used to construct the suggestion view. |
| * @param locationBar The location bar showing these suggestions. |
| */ |
| public SuggestionView(Context context, LocationBar locationBar) { |
| super(context); |
| mLocationBar = locationBar; |
| |
| mSuggestionHeight = |
| context.getResources().getDimensionPixelOffset(R.dimen.omnibox_suggestion_height); |
| mSuggestionAnswerHeight = |
| context.getResources().getDimensionPixelOffset( |
| R.dimen.omnibox_suggestion_answer_height); |
| |
| TypedArray a = getContext().obtainStyledAttributes( |
| new int [] {R.attr.selectableItemBackground}); |
| Drawable itemBackground = a.getDrawable(0); |
| a.recycle(); |
| |
| mContentsView = new SuggestionContentsContainer(context, itemBackground); |
| addView(mContentsView); |
| |
| mRefineView = new View(context) { |
| @Override |
| protected void onDraw(Canvas canvas) { |
| super.onDraw(canvas); |
| |
| if (mRefineIcon == null) return; |
| canvas.save(); |
| canvas.translate( |
| (getMeasuredWidth() - mRefineIcon.getIntrinsicWidth()) / 2f, |
| (getMeasuredHeight() - mRefineIcon.getIntrinsicHeight()) / 2f); |
| mRefineIcon.draw(canvas); |
| canvas.restore(); |
| } |
| |
| @Override |
| public void setVisibility(int visibility) { |
| super.setVisibility(visibility); |
| |
| if (visibility == VISIBLE) { |
| setClickable(true); |
| setFocusable(true); |
| } else { |
| setClickable(false); |
| setFocusable(false); |
| } |
| } |
| |
| @Override |
| protected void drawableStateChanged() { |
| super.drawableStateChanged(); |
| |
| if (mRefineIcon != null && mRefineIcon.isStateful()) { |
| mRefineIcon.setState(getDrawableState()); |
| } |
| } |
| }; |
| mRefineView.setContentDescription(getContext().getString( |
| R.string.accessibility_omnibox_btn_refine)); |
| |
| // Although this has the same background as the suggestion view, it can not be shared as |
| // it will result in the state of the drawable being shared and always showing up in the |
| // refine view. |
| mRefineView.setBackground(itemBackground.getConstantState().newDrawable()); |
| mRefineView.setId(R.id.refine_view_id); |
| mRefineView.setClickable(true); |
| mRefineView.setFocusable(true); |
| mRefineView.setLayoutParams(new LayoutParams(0, 0)); |
| addView(mRefineView); |
| |
| mRefineWidth = getResources() |
| .getDimensionPixelSize(R.dimen.omnibox_suggestion_refine_width); |
| |
| mUrlBar = (UrlBar) locationBar.getContainerView().findViewById(R.id.url_bar); |
| |
| mPhoneUrlBarLeftOffsetPx = getResources().getDimensionPixelOffset( |
| R.dimen.omnibox_suggestion_phone_url_bar_left_offset); |
| mPhoneUrlBarLeftOffsetRtlPx = getResources().getDimensionPixelOffset( |
| R.dimen.omnibox_suggestion_phone_url_bar_left_offset_rtl); |
| } |
| |
| @Override |
| protected void onLayout(boolean changed, int left, int top, int right, int bottom) { |
| if (getMeasuredWidth() == 0) return; |
| |
| if (mSuggestion.getType() != OmniboxSuggestionType.SEARCH_SUGGEST_TAIL) { |
| mContentsView.resetTextWidths(); |
| } |
| |
| boolean refineVisible = mRefineView.getVisibility() == VISIBLE; |
| boolean isRtl = ApiCompatibilityUtils.isLayoutRtl(this); |
| int contentsViewOffsetX = isRtl && refineVisible ? mRefineWidth : 0; |
| mContentsView.layout( |
| contentsViewOffsetX, |
| 0, |
| contentsViewOffsetX + mContentsView.getMeasuredWidth(), |
| mContentsView.getMeasuredHeight()); |
| int refineViewOffsetX = isRtl ? 0 : getMeasuredWidth() - mRefineWidth; |
| mRefineView.layout( |
| refineViewOffsetX, |
| 0, |
| refineViewOffsetX + mRefineWidth, |
| mContentsView.getMeasuredHeight()); |
| } |
| |
| @Override |
| protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { |
| int width = MeasureSpec.getSize(widthMeasureSpec); |
| int height = mSuggestionHeight; |
| if (!TextUtils.isEmpty(mSuggestion.getAnswerContents())) { |
| height = mSuggestionAnswerHeight; |
| } |
| setMeasuredDimension(width, height); |
| |
| // The width will be specified as 0 when determining the height of the popup, so exit early |
| // after setting the height. |
| if (width == 0) return; |
| |
| boolean refineVisible = mRefineView.getVisibility() == VISIBLE; |
| int refineWidth = refineVisible ? mRefineWidth : 0; |
| mContentsView.measure( |
| MeasureSpec.makeMeasureSpec(width - refineWidth, MeasureSpec.EXACTLY), |
| MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)); |
| mContentsView.getLayoutParams().width = mContentsView.getMeasuredWidth(); |
| mContentsView.getLayoutParams().height = mContentsView.getMeasuredHeight(); |
| |
| mRefineView.measure( |
| MeasureSpec.makeMeasureSpec(mRefineWidth, MeasureSpec.EXACTLY), |
| MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)); |
| mRefineView.getLayoutParams().width = mRefineView.getMeasuredWidth(); |
| mRefineView.getLayoutParams().height = mRefineView.getMeasuredHeight(); |
| } |
| |
| @Override |
| public void invalidate() { |
| super.invalidate(); |
| mContentsView.invalidate(); |
| } |
| |
| @Override |
| public boolean dispatchTouchEvent(MotionEvent ev) { |
| // Whenever the suggestion dropdown is touched, we dispatch onGestureDown which is |
| // used to let autocomplete controller know that it should stop updating suggestions. |
| if (ev.getActionMasked() == MotionEvent.ACTION_DOWN) mSuggestionDelegate.onGestureDown(); |
| return super.dispatchTouchEvent(ev); |
| } |
| |
| /** |
| * Sets the contents and state of the view for the given suggestion. |
| * |
| * @param suggestionItem The omnibox suggestion item this view represents. |
| * @param suggestionDelegate The suggestion delegate. |
| * @param position Position of the suggestion in the dropdown list. |
| * @param useDarkColors Whether dark colors should be used for fonts and icons. |
| */ |
| public void init(OmniboxResultItem suggestionItem, |
| OmniboxSuggestionDelegate suggestionDelegate, |
| int position, boolean useDarkColors) { |
| ViewCompat.setLayoutDirection(this, ViewCompat.getLayoutDirection(mUrlBar)); |
| |
| // Update the position unconditionally. |
| mPosition = position; |
| jumpDrawablesToCurrentState(); |
| boolean colorsChanged = mUseDarkColors == null || mUseDarkColors != useDarkColors; |
| if (suggestionItem.equals(mSuggestionItem) && !colorsChanged) return; |
| mUseDarkColors = useDarkColors; |
| if (colorsChanged) { |
| mContentsView.mTextLine1.setTextColor(getStandardFontColor()); |
| setRefineIcon(true); |
| } |
| |
| mSuggestionItem = suggestionItem; |
| mSuggestion = suggestionItem.getSuggestion(); |
| mSuggestionDelegate = suggestionDelegate; |
| // Reset old computations. |
| mContentsView.resetTextWidths(); |
| mContentsView.mAnswerImage.setVisibility(GONE); |
| mContentsView.mAnswerImage.getLayoutParams().height = 0; |
| mContentsView.mAnswerImage.getLayoutParams().width = 0; |
| mContentsView.mAnswerImage.setImageDrawable(null); |
| mContentsView.mAnswerImageMaxSize = 0; |
| mContentsView.mTextLine1.setTextSize(TypedValue.COMPLEX_UNIT_PX, getResources() |
| .getDimension(R.dimen.omnibox_suggestion_first_line_text_size)); |
| mContentsView.mTextLine2.setTextSize(TypedValue.COMPLEX_UNIT_PX, getResources() |
| .getDimension(R.dimen.omnibox_suggestion_second_line_text_size)); |
| |
| // Suggestions with attached answers are rendered with rich results regardless of which |
| // suggestion type they are. |
| if (mSuggestion.hasAnswer()) { |
| setAnswer(mSuggestion.getAnswer()); |
| mContentsView.setSuggestionIcon(SUGGESTION_ICON_MAGNIFIER, colorsChanged); |
| mContentsView.mTextLine2.setVisibility(VISIBLE); |
| setRefinable(true); |
| return; |
| } |
| |
| boolean sameAsTyped = |
| suggestionItem.getMatchedQuery().equalsIgnoreCase(mSuggestion.getDisplayText()); |
| int suggestionType = mSuggestion.getType(); |
| if (mSuggestion.isUrlSuggestion()) { |
| if (mSuggestion.isStarred()) { |
| mContentsView.setSuggestionIcon(SUGGESTION_ICON_BOOKMARK, colorsChanged); |
| } else if (suggestionType == OmniboxSuggestionType.HISTORY_URL) { |
| mContentsView.setSuggestionIcon(SUGGESTION_ICON_HISTORY, colorsChanged); |
| } else { |
| mContentsView.setSuggestionIcon(SUGGESTION_ICON_GLOBE, colorsChanged); |
| } |
| boolean urlShown = !TextUtils.isEmpty(mSuggestion.getUrl()); |
| boolean urlHighlighted = false; |
| if (urlShown) { |
| urlHighlighted = setUrlText(suggestionItem); |
| } else { |
| mContentsView.mTextLine2.setVisibility(INVISIBLE); |
| } |
| setSuggestedQuery(suggestionItem, true, urlShown, urlHighlighted); |
| setRefinable(!sameAsTyped); |
| } else { |
| @SuggestionIcon int suggestionIcon = SUGGESTION_ICON_MAGNIFIER; |
| if (suggestionType == OmniboxSuggestionType.VOICE_SUGGEST) { |
| suggestionIcon = SUGGESTION_ICON_VOICE; |
| } else if ((suggestionType == OmniboxSuggestionType.SEARCH_SUGGEST_PERSONALIZED) |
| || (suggestionType == OmniboxSuggestionType.SEARCH_HISTORY)) { |
| // Show history icon for suggestions based on user queries. |
| suggestionIcon = SUGGESTION_ICON_HISTORY; |
| } |
| mContentsView.setSuggestionIcon(suggestionIcon, colorsChanged); |
| setRefinable(!sameAsTyped); |
| setSuggestedQuery(suggestionItem, false, false, false); |
| if ((suggestionType == OmniboxSuggestionType.SEARCH_SUGGEST_ENTITY) |
| || (suggestionType == OmniboxSuggestionType.SEARCH_SUGGEST_PROFILE)) { |
| showDescriptionLine(SpannableString.valueOf(mSuggestion.getDescription()), false); |
| } else { |
| mContentsView.mTextLine2.setVisibility(INVISIBLE); |
| } |
| } |
| } |
| |
| private void setRefinable(boolean refinable) { |
| if (refinable) { |
| mRefineView.setVisibility(VISIBLE); |
| mRefineView.setOnClickListener(new OnClickListener() { |
| @Override |
| public void onClick(View v) { |
| // Post the refine action to the end of the UI thread to allow the refine view |
| // a chance to update its background selection state. |
| PerformRefineSuggestion performRefine = new PerformRefineSuggestion(); |
| if (!post(performRefine)) performRefine.run(); |
| } |
| }); |
| } else { |
| mRefineView.setOnClickListener(null); |
| mRefineView.setVisibility(GONE); |
| } |
| } |
| |
| private int getStandardFontColor() { |
| return (mUseDarkColors == null || mUseDarkColors) |
| ? TITLE_COLOR_STANDARD_FONT_DARK : TITLE_COLOR_STANDARD_FONT_LIGHT; |
| } |
| |
| @Override |
| public void setSelected(boolean selected) { |
| super.setSelected(selected); |
| if (selected && !isInTouchMode()) { |
| mSuggestionDelegate.onSetUrlToSuggestion(mSuggestion); |
| } |
| } |
| |
| private void setRefineIcon(boolean invalidateIcon) { |
| if (!invalidateIcon && mRefineIcon != null) return; |
| |
| mRefineIcon = TintedDrawable.constructTintedDrawable( |
| getResources(), R.drawable.btn_suggestion_refine); |
| mRefineIcon.setTint(ApiCompatibilityUtils.getColorStateList(getResources(), |
| mUseDarkColors ? R.color.dark_mode_tint : R.color.light_mode_tint)); |
| mRefineIcon.setBounds( |
| 0, 0, |
| mRefineIcon.getIntrinsicWidth(), |
| mRefineIcon.getIntrinsicHeight()); |
| mRefineIcon.setState(mRefineView.getDrawableState()); |
| mRefineView.postInvalidateOnAnimation(); |
| } |
| |
| /** |
| * Sets (and highlights) the URL text of the second line of the omnibox suggestion. |
| * |
| * @param result The suggestion containing the URL. |
| * @return Whether the URL was highlighted based on the user query. |
| */ |
| private boolean setUrlText(OmniboxResultItem result) { |
| OmniboxSuggestion suggestion = result.getSuggestion(); |
| Spannable str = SpannableString.valueOf(suggestion.getDisplayText()); |
| boolean hasMatch = applyHighlightToMatchRegions( |
| str, suggestion.getDisplayTextClassifications()); |
| showDescriptionLine(str, true); |
| return hasMatch; |
| } |
| |
| private boolean applyHighlightToMatchRegions( |
| Spannable str, List<MatchClassification> classifications) { |
| boolean hasMatch = false; |
| for (int i = 0; i < classifications.size(); i++) { |
| MatchClassification classification = classifications.get(i); |
| if ((classification.style & MatchClassificationStyle.MATCH) |
| == MatchClassificationStyle.MATCH) { |
| int matchStartIndex = classification.offset; |
| int matchEndIndex; |
| if (i == classifications.size() - 1) { |
| matchEndIndex = str.length(); |
| } else { |
| matchEndIndex = classifications.get(i + 1).offset; |
| } |
| matchStartIndex = Math.min(matchStartIndex, str.length()); |
| matchEndIndex = Math.min(matchEndIndex, str.length()); |
| |
| hasMatch = true; |
| // Bold the part of the URL that matches the user query. |
| str.setSpan(new StyleSpan(android.graphics.Typeface.BOLD), |
| matchStartIndex, matchEndIndex, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); |
| } |
| } |
| return hasMatch; |
| } |
| |
| /** |
| * Sets a description line for the omnibox suggestion. |
| * |
| * @param str The description text. |
| * @param isUrl Whether this text is a URL (as opposed to a normal string). |
| */ |
| private void showDescriptionLine(Spannable str, boolean isUrl) { |
| TextView textLine = mContentsView.mTextLine2; |
| if (textLine.getVisibility() != VISIBLE) { |
| textLine.setVisibility(VISIBLE); |
| } |
| textLine.setText(str, BufferType.SPANNABLE); |
| |
| // Force left-to-right rendering for URLs. See UrlBar constructor for details. |
| if (isUrl) { |
| textLine.setTextColor(URL_COLOR); |
| ApiCompatibilityUtils.setTextDirection(textLine, TEXT_DIRECTION_LTR); |
| } else { |
| textLine.setTextColor(getStandardFontColor()); |
| ApiCompatibilityUtils.setTextDirection(textLine, TEXT_DIRECTION_INHERIT); |
| } |
| } |
| |
| /** |
| * Sets the text of the first line of the omnibox suggestion. |
| * |
| * @param suggestionItem The item containing the suggestion data. |
| * @param showDescriptionIfPresent Whether to show the description text of the suggestion if |
| * the item contains valid data. |
| * @param isUrlQuery Whether this suggestion is showing an URL. |
| * @param isUrlHighlighted Whether the URL contains any highlighted matching sections. |
| */ |
| private void setSuggestedQuery( |
| OmniboxResultItem suggestionItem, boolean showDescriptionIfPresent, |
| boolean isUrlQuery, boolean isUrlHighlighted) { |
| String userQuery = suggestionItem.getMatchedQuery(); |
| String suggestedQuery = null; |
| List<MatchClassification> classifications; |
| OmniboxSuggestion suggestion = suggestionItem.getSuggestion(); |
| if (showDescriptionIfPresent && !TextUtils.isEmpty(suggestion.getUrl()) |
| && !TextUtils.isEmpty(suggestion.getDescription())) { |
| suggestedQuery = suggestion.getDescription(); |
| classifications = suggestion.getDescriptionClassifications(); |
| } else { |
| suggestedQuery = suggestion.getDisplayText(); |
| classifications = suggestion.getDisplayTextClassifications(); |
| } |
| if (suggestedQuery == null) { |
| assert false : "Invalid suggestion sent with no displayable text"; |
| suggestedQuery = ""; |
| classifications = new ArrayList<MatchClassification>(); |
| classifications.add(new MatchClassification(0, MatchClassificationStyle.NONE)); |
| } |
| |
| if (mSuggestion.getType() == OmniboxSuggestionType.SEARCH_SUGGEST_TAIL) { |
| String fillIntoEdit = mSuggestion.getFillIntoEdit(); |
| // Data sanity checks. |
| if (fillIntoEdit.startsWith(userQuery) |
| && fillIntoEdit.endsWith(suggestedQuery) |
| && fillIntoEdit.length() < userQuery.length() + suggestedQuery.length()) { |
| final String ellipsisPrefix = "\u2026 "; |
| suggestedQuery = ellipsisPrefix + suggestedQuery; |
| |
| // Offset the match classifications by the length of the ellipsis prefix to ensure |
| // the highlighting remains correct. |
| for (int i = 0; i < classifications.size(); i++) { |
| classifications.set(i, new MatchClassification( |
| classifications.get(i).offset + ellipsisPrefix.length(), |
| classifications.get(i).style)); |
| } |
| classifications.add(0, new MatchClassification(0, MatchClassificationStyle.NONE)); |
| |
| if (DeviceFormFactor.isTablet(getContext())) { |
| TextPaint tp = mContentsView.mTextLine1.getPaint(); |
| mContentsView.mRequiredWidth = |
| tp.measureText(fillIntoEdit, 0, fillIntoEdit.length()); |
| mContentsView.mMatchContentsWidth = |
| tp.measureText(suggestedQuery, 0, suggestedQuery.length()); |
| |
| // Update the max text widths values in SuggestionList. These will be passed to |
| // the contents view on layout. |
| mSuggestionDelegate.onTextWidthsUpdated( |
| mContentsView.mRequiredWidth, mContentsView.mMatchContentsWidth); |
| } |
| } |
| } |
| |
| Spannable str = SpannableString.valueOf(suggestedQuery); |
| if (!isUrlHighlighted) applyHighlightToMatchRegions(str, classifications); |
| mContentsView.mTextLine1.setText(str, BufferType.SPANNABLE); |
| } |
| |
| /** |
| * Sets both lines of the Omnibox suggestion based on an Answers in Suggest result. |
| * |
| * @param answer The answer to be displayed. |
| */ |
| private void setAnswer(SuggestionAnswer answer) { |
| float density = getResources().getDisplayMetrics().density; |
| |
| SuggestionAnswer.ImageLine firstLine = answer.getFirstLine(); |
| mContentsView.mTextLine1.setTextSize(AnswerTextBuilder.getMaxTextHeightSp(firstLine)); |
| Spannable firstLineText = AnswerTextBuilder.buildSpannable( |
| firstLine, mContentsView.mTextLine1.getPaint().getFontMetrics(), density); |
| mContentsView.mTextLine1.setText(firstLineText, BufferType.SPANNABLE); |
| |
| SuggestionAnswer.ImageLine secondLine = answer.getSecondLine(); |
| mContentsView.mTextLine2.setTextSize(AnswerTextBuilder.getMaxTextHeightSp(secondLine)); |
| Spannable secondLineText = AnswerTextBuilder.buildSpannable( |
| secondLine, mContentsView.mTextLine2.getPaint().getFontMetrics(), density); |
| mContentsView.mTextLine2.setText(secondLineText, BufferType.SPANNABLE); |
| |
| if (secondLine.hasImage()) { |
| mContentsView.mAnswerImage.setVisibility(VISIBLE); |
| |
| float textSize = mContentsView.mTextLine2.getTextSize(); |
| int imageSize = (int) (textSize * ANSWER_IMAGE_SCALING_FACTOR); |
| mContentsView.mAnswerImage.getLayoutParams().height = imageSize; |
| mContentsView.mAnswerImage.getLayoutParams().width = imageSize; |
| mContentsView.mAnswerImageMaxSize = imageSize; |
| |
| String url = "https:" + secondLine.getImage().replace("\\/", "/"); |
| AnswersImage.requestAnswersImage( |
| mLocationBar.getCurrentTab().getProfile(), |
| url, |
| new AnswersImage.AnswersImageObserver() { |
| @Override |
| public void onAnswersImageChanged(Bitmap bitmap) { |
| mContentsView.mAnswerImage.setImageBitmap(bitmap); |
| } |
| }); |
| } |
| } |
| |
| /** |
| * Handles triggering a selection request for the suggestion rendered by this view. |
| */ |
| private class PerformSelectSuggestion implements Runnable { |
| @Override |
| public void run() { |
| mSuggestionDelegate.onSelection(mSuggestion, mPosition); |
| } |
| } |
| |
| /** |
| * Handles triggering a refine request for the suggestion rendered by this view. |
| */ |
| private class PerformRefineSuggestion implements Runnable { |
| @Override |
| public void run() { |
| mSuggestionDelegate.onRefineSuggestion(mSuggestion); |
| } |
| } |
| |
| /** |
| * Container view for the contents of the suggestion (the search query, URL, and suggestion type |
| * icon). |
| */ |
| private class SuggestionContentsContainer extends ViewGroup implements OnLayoutChangeListener { |
| private int mSuggestionIconLeft = Integer.MIN_VALUE; |
| private int mTextLeft = Integer.MIN_VALUE; |
| private int mTextRight = Integer.MIN_VALUE; |
| private Drawable mSuggestionIcon; |
| @SuggestionIcon |
| private int mSuggestionIconType = SUGGESTION_ICON_UNDEFINED; |
| |
| private final TextView mTextLine1; |
| private final TextView mTextLine2; |
| private final ImageView mAnswerImage; |
| |
| private int mAnswerImageMaxSize; // getMaxWidth() is API 16+, so store it locally. |
| private float mRequiredWidth; |
| private float mMatchContentsWidth; |
| private boolean mForceIsFocused; |
| |
| private final Runnable mRelayoutRunnable = new Runnable() { |
| @Override |
| public void run() { |
| requestLayout(); |
| } |
| }; |
| |
| @SuppressLint("InlinedApi") |
| SuggestionContentsContainer(Context context, Drawable backgroundDrawable) { |
| super(context); |
| |
| ApiCompatibilityUtils.setLayoutDirection(this, View.LAYOUT_DIRECTION_INHERIT); |
| |
| setBackground(backgroundDrawable); |
| setClickable(true); |
| setFocusable(true); |
| setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT, mSuggestionHeight)); |
| setOnClickListener(new OnClickListener() { |
| @Override |
| public void onClick(View v) { |
| // Post the selection action to the end of the UI thread to allow the suggestion |
| // view a chance to update their background selection state. |
| PerformSelectSuggestion performSelection = new PerformSelectSuggestion(); |
| if (!post(performSelection)) performSelection.run(); |
| } |
| }); |
| setOnLongClickListener(new OnLongClickListener() { |
| @Override |
| public boolean onLongClick(View v) { |
| RecordUserAction.record("MobileOmniboxDeleteGesture"); |
| if (!mSuggestion.isDeletable()) return true; |
| |
| AlertDialog.Builder b = |
| new AlertDialog.Builder(getContext(), R.style.AlertDialogTheme); |
| b.setTitle(mSuggestion.getDisplayText()); |
| b.setMessage(R.string.omnibox_confirm_delete); |
| DialogInterface.OnClickListener okListener = |
| new DialogInterface.OnClickListener() { |
| @Override |
| public void onClick(DialogInterface dialog, int which) { |
| RecordUserAction.record("MobileOmniboxDeleteRequested"); |
| mSuggestionDelegate.onDeleteSuggestion(mPosition); |
| } |
| }; |
| b.setPositiveButton(android.R.string.ok, okListener); |
| DialogInterface.OnClickListener cancelListener = |
| new DialogInterface.OnClickListener() { |
| @Override |
| public void onClick(DialogInterface dialog, int which) { |
| dialog.cancel(); |
| } |
| }; |
| b.setNegativeButton(android.R.string.cancel, cancelListener); |
| |
| AlertDialog dialog = b.create(); |
| dialog.setOnDismissListener(new DialogInterface.OnDismissListener() { |
| @Override |
| public void onDismiss(DialogInterface dialog) { |
| mSuggestionDelegate.onHideModal(); |
| } |
| }); |
| |
| mSuggestionDelegate.onShowModal(); |
| dialog.show(); |
| return true; |
| } |
| }); |
| |
| mTextLine1 = new TextView(context); |
| mTextLine1.setLayoutParams( |
| new LayoutParams(LayoutParams.WRAP_CONTENT, mSuggestionHeight)); |
| mTextLine1.setSingleLine(); |
| mTextLine1.setTextColor(getStandardFontColor()); |
| ApiCompatibilityUtils.setTextAlignment(mTextLine1, TEXT_ALIGNMENT_VIEW_START); |
| addView(mTextLine1); |
| |
| mTextLine2 = new TextView(context); |
| mTextLine2.setLayoutParams( |
| new LayoutParams(LayoutParams.WRAP_CONTENT, mSuggestionHeight)); |
| mTextLine2.setSingleLine(); |
| mTextLine2.setVisibility(INVISIBLE); |
| ApiCompatibilityUtils.setTextAlignment(mTextLine2, TEXT_ALIGNMENT_VIEW_START); |
| addView(mTextLine2); |
| |
| mAnswerImage = new ImageView(context); |
| mAnswerImage.setVisibility(GONE); |
| mAnswerImage.setScaleType(ImageView.ScaleType.FIT_CENTER); |
| mAnswerImage.setLayoutParams(new LayoutParams(0, 0)); |
| mAnswerImageMaxSize = 0; |
| addView(mAnswerImage); |
| } |
| |
| private void resetTextWidths() { |
| mRequiredWidth = 0; |
| mMatchContentsWidth = 0; |
| } |
| |
| @Override |
| protected void onDraw(Canvas canvas) { |
| super.onDraw(canvas); |
| |
| if (DeviceFormFactor.isTablet(getContext())) { |
| // Use the same image transform matrix as the navigation icon to ensure the same |
| // scaling, which requires centering vertically based on the height of the |
| // navigation icon view and not the image itself. |
| canvas.save(); |
| mSuggestionIconLeft = getSuggestionIconLeftPosition(); |
| canvas.translate( |
| mSuggestionIconLeft, |
| (getMeasuredHeight() - mNavigationButton.getMeasuredHeight()) / 2f); |
| canvas.concat(mNavigationButton.getImageMatrix()); |
| mSuggestionIcon.draw(canvas); |
| canvas.restore(); |
| } |
| } |
| |
| @Override |
| protected boolean drawChild(Canvas canvas, View child, long drawingTime) { |
| if (child != mTextLine1 && child != mTextLine2 && child != mAnswerImage) { |
| return super.drawChild(canvas, child, drawingTime); |
| } |
| |
| int height = getMeasuredHeight(); |
| int line1Height = mTextLine1.getMeasuredHeight(); |
| int line2Height = mTextLine2.getVisibility() == VISIBLE |
| ? mTextLine2.getMeasuredHeight() : 0; |
| |
| int verticalOffset = 0; |
| if (line1Height + line2Height > height) { |
| // The text lines total height is larger than this view, snap them to the top and |
| // bottom of the view. |
| if (child == mTextLine1) { |
| verticalOffset = 0; |
| } else { |
| verticalOffset = height - line2Height; |
| } |
| } else { |
| // The text lines fit comfortably, so vertically center them. |
| verticalOffset = (height - line1Height - line2Height) / 2; |
| if (child == mTextLine2) { |
| verticalOffset += line1Height; |
| if (mSuggestion.hasAnswer() |
| && mSuggestion.getAnswer().getSecondLine().hasImage()) { |
| verticalOffset += getResources().getDimensionPixelOffset( |
| R.dimen.omnibox_suggestion_answer_line2_vertical_spacing); |
| } |
| } |
| // When one line is larger than the other, it contains extra vertical padding. This |
| // produces more apparent whitespace above or below the text lines. Add a small |
| // offset to compensate. |
| if (line1Height != line2Height) { |
| verticalOffset += (line2Height - line1Height) / 10; |
| } |
| |
| // The image is positioned vertically aligned with the second text line but |
| // requires a small additional offset to align with the ascent of the text instead |
| // of the top of the text which includes some whitespace. |
| if (child == mAnswerImage) { |
| verticalOffset += getResources().getDimensionPixelOffset( |
| R.dimen.omnibox_suggestion_answer_line2_vertical_spacing); |
| } |
| } |
| |
| canvas.save(); |
| canvas.translate(0, verticalOffset); |
| boolean retVal = super.drawChild(canvas, child, drawingTime); |
| canvas.restore(); |
| return retVal; |
| } |
| |
| @Override |
| protected void onLayout(boolean changed, int l, int t, int r, int b) { |
| View locationBarView = mLocationBar.getContainerView(); |
| if (mUrlBar == null) { |
| mUrlBar = (UrlBar) locationBarView.findViewById(R.id.url_bar); |
| mUrlBar.addOnLayoutChangeListener(this); |
| } |
| if (mNavigationButton == null) { |
| mNavigationButton = |
| (ImageView) locationBarView.findViewById(R.id.navigation_button); |
| mNavigationButton.addOnLayoutChangeListener(this); |
| } |
| |
| // Align the text to be pixel perfectly aligned with the text in the url bar. |
| mTextLeft = getSuggestionTextLeftPosition(); |
| mTextRight = getSuggestionTextRightPosition(); |
| boolean isRTL = ApiCompatibilityUtils.isLayoutRtl(this); |
| if (DeviceFormFactor.isTablet(getContext())) { |
| int textWidth = isRTL ? mTextRight : (r - l - mTextLeft); |
| final float maxRequiredWidth = mSuggestionDelegate.getMaxRequiredWidth(); |
| final float maxMatchContentsWidth = mSuggestionDelegate.getMaxMatchContentsWidth(); |
| float paddingStart = (textWidth > maxRequiredWidth) |
| ? (mRequiredWidth - mMatchContentsWidth) |
| : Math.max(textWidth - maxMatchContentsWidth, 0); |
| ApiCompatibilityUtils.setPaddingRelative( |
| mTextLine1, (int) paddingStart, mTextLine1.getPaddingTop(), |
| 0, // TODO(skanuj) : Change to ApiCompatibilityUtils.getPaddingEnd(...). |
| mTextLine1.getPaddingBottom()); |
| } |
| |
| int imageWidth = mAnswerImageMaxSize; |
| int imageSpacing = 0; |
| if (mAnswerImage.getVisibility() == VISIBLE && imageWidth > 0) { |
| imageSpacing = getResources().getDimensionPixelOffset( |
| R.dimen.omnibox_suggestion_answer_image_horizontal_spacing); |
| } |
| if (isRTL) { |
| mTextLine1.layout(0, t, mTextRight, b); |
| mAnswerImage.layout(mTextRight - imageWidth , t, mTextRight, b); |
| mTextLine2.layout(0, t, mTextRight - (imageWidth + imageSpacing), b); |
| } else { |
| mTextLine1.layout(mTextLeft, t, r - l, b); |
| mAnswerImage.layout(mTextLeft, t, mTextLeft + imageWidth, b); |
| mTextLine2.layout(mTextLeft + imageWidth + imageSpacing, t, r - l, b); |
| } |
| |
| int suggestionIconPosition = getSuggestionIconLeftPosition(); |
| if (mSuggestionIconLeft != suggestionIconPosition |
| && mSuggestionIconLeft != Integer.MIN_VALUE) { |
| mContentsView.postInvalidateOnAnimation(); |
| } |
| mSuggestionIconLeft = suggestionIconPosition; |
| } |
| |
| private int getUrlBarLeftOffset() { |
| if (DeviceFormFactor.isTablet(getContext())) { |
| mUrlBar.getLocationInWindow(mViewPositionHolder); |
| return mViewPositionHolder[0]; |
| } else { |
| return ApiCompatibilityUtils.isLayoutRtl(this) ? mPhoneUrlBarLeftOffsetRtlPx |
| : mPhoneUrlBarLeftOffsetPx; |
| } |
| } |
| |
| /** |
| * @return The left offset for the suggestion text. |
| */ |
| private int getSuggestionTextLeftPosition() { |
| if (mLocationBar == null) return 0; |
| |
| int leftOffset = getUrlBarLeftOffset(); |
| getLocationInWindow(mViewPositionHolder); |
| return leftOffset + mUrlBar.getPaddingLeft() - mViewPositionHolder[0]; |
| } |
| |
| /** |
| * @return The right offset for the suggestion text. |
| */ |
| private int getSuggestionTextRightPosition() { |
| if (mLocationBar == null) return 0; |
| |
| int leftOffset = getUrlBarLeftOffset(); |
| getLocationInWindow(mViewPositionHolder); |
| return leftOffset + mUrlBar.getWidth() - mUrlBar.getPaddingRight() |
| - mViewPositionHolder[0]; |
| } |
| |
| /** |
| * @return The left offset for the suggestion type icon that aligns it with the url bar. |
| */ |
| private int getSuggestionIconLeftPosition() { |
| if (mNavigationButton == null) return 0; |
| |
| // Ensure the suggestion icon matches the location of the navigation icon in the omnibox |
| // perfectly. |
| mNavigationButton.getLocationOnScreen(mViewPositionHolder); |
| int navButtonXPosition = mViewPositionHolder[0] + mNavigationButton.getPaddingLeft(); |
| |
| getLocationOnScreen(mViewPositionHolder); |
| |
| return navButtonXPosition - mViewPositionHolder[0]; |
| } |
| |
| @Override |
| protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { |
| super.onMeasure(widthMeasureSpec, heightMeasureSpec); |
| |
| int width = MeasureSpec.getSize(widthMeasureSpec); |
| int height = MeasureSpec.getSize(heightMeasureSpec); |
| |
| if (mTextLine1.getMeasuredWidth() != width |
| || mTextLine1.getMeasuredHeight() != height) { |
| mTextLine1.measure( |
| MeasureSpec.makeMeasureSpec(widthMeasureSpec, MeasureSpec.AT_MOST), |
| MeasureSpec.makeMeasureSpec(mSuggestionHeight, MeasureSpec.AT_MOST)); |
| } |
| |
| if (mTextLine2.getMeasuredWidth() != width |
| || mTextLine2.getMeasuredHeight() != height) { |
| mTextLine2.measure( |
| MeasureSpec.makeMeasureSpec(widthMeasureSpec, MeasureSpec.AT_MOST), |
| MeasureSpec.makeMeasureSpec(mSuggestionHeight, MeasureSpec.AT_MOST)); |
| } |
| } |
| |
| @Override |
| public void invalidate() { |
| if (getSuggestionTextLeftPosition() != mTextLeft |
| || getSuggestionTextRightPosition() != mTextRight) { |
| // When the text position is changed, it typically is caused by the suggestions |
| // appearing while the URL bar on the phone is gaining focus (if you trigger an |
| // intent that will result in suggestions being shown before focusing the omnibox). |
| // Triggering a relayout will cause any animations to stutter, so we continually |
| // push the relayout to end of the UI queue until the animation is complete. |
| removeCallbacks(mRelayoutRunnable); |
| postDelayed(mRelayoutRunnable, RELAYOUT_DELAY_MS); |
| } else { |
| super.invalidate(); |
| } |
| } |
| |
| @Override |
| public boolean isFocused() { |
| return mForceIsFocused || super.isFocused(); |
| } |
| |
| @Override |
| protected int[] onCreateDrawableState(int extraSpace) { |
| // When creating the drawable states, treat selected as focused to get the proper |
| // highlight when in non-touch mode (i.e. physical keyboard). This is because only |
| // a single view in a window can have focus, and the these will only appear if |
| // the omnibox has focus, so we trick the drawable state into believing it has it. |
| mForceIsFocused = isSelected() && !isInTouchMode(); |
| int[] drawableState = super.onCreateDrawableState(extraSpace); |
| mForceIsFocused = false; |
| return drawableState; |
| } |
| |
| private void setSuggestionIcon(@SuggestionIcon int type, boolean invalidateCurrentIcon) { |
| if (mSuggestionIconType == type && !invalidateCurrentIcon) return; |
| assert type != SUGGESTION_ICON_UNDEFINED; |
| |
| int drawableId = R.drawable.ic_omnibox_page; |
| switch (type) { |
| case SUGGESTION_ICON_BOOKMARK: |
| drawableId = R.drawable.btn_star; |
| break; |
| case SUGGESTION_ICON_MAGNIFIER: |
| drawableId = R.drawable.ic_suggestion_magnifier; |
| break; |
| case SUGGESTION_ICON_HISTORY: |
| drawableId = R.drawable.ic_suggestion_history; |
| break; |
| case SUGGESTION_ICON_VOICE: |
| drawableId = R.drawable.btn_mic; |
| break; |
| default: |
| break; |
| } |
| mSuggestionIcon = ApiCompatibilityUtils.getDrawable(getResources(), drawableId); |
| mSuggestionIcon.setColorFilter(mUseDarkColors |
| ? ApiCompatibilityUtils.getColor(getResources(), R.color.light_normal_color) |
| : Color.WHITE, PorterDuff.Mode.SRC_IN); |
| mSuggestionIcon.setBounds( |
| 0, 0, |
| mSuggestionIcon.getIntrinsicWidth(), |
| mSuggestionIcon.getIntrinsicHeight()); |
| mSuggestionIconType = type; |
| invalidate(); |
| } |
| |
| @Override |
| public void onLayoutChange( |
| View v, int left, int top, int right, int bottom, int oldLeft, |
| int oldTop, int oldRight, int oldBottom) { |
| boolean needsInvalidate = false; |
| if (v == mNavigationButton) { |
| if (mSuggestionIconLeft != getSuggestionIconLeftPosition() |
| && mSuggestionIconLeft != Integer.MIN_VALUE) { |
| needsInvalidate = true; |
| } |
| } else { |
| if (mTextLeft != getSuggestionTextLeftPosition() |
| && mTextLeft != Integer.MIN_VALUE) { |
| needsInvalidate = true; |
| } |
| if (mTextRight != getSuggestionTextRightPosition() |
| && mTextRight != Integer.MIN_VALUE) { |
| needsInvalidate = true; |
| } |
| } |
| if (needsInvalidate) { |
| removeCallbacks(mRelayoutRunnable); |
| postDelayed(mRelayoutRunnable, RELAYOUT_DELAY_MS); |
| } |
| } |
| |
| @Override |
| protected void onAttachedToWindow() { |
| super.onAttachedToWindow(); |
| if (mNavigationButton != null) mNavigationButton.addOnLayoutChangeListener(this); |
| if (mUrlBar != null) mUrlBar.addOnLayoutChangeListener(this); |
| if (mLocationBar != null) { |
| mLocationBar.getContainerView().addOnLayoutChangeListener(this); |
| } |
| getRootView().addOnLayoutChangeListener(this); |
| } |
| |
| @Override |
| protected void onDetachedFromWindow() { |
| if (mNavigationButton != null) mNavigationButton.removeOnLayoutChangeListener(this); |
| if (mUrlBar != null) mUrlBar.removeOnLayoutChangeListener(this); |
| if (mLocationBar != null) { |
| mLocationBar.getContainerView().removeOnLayoutChangeListener(this); |
| } |
| getRootView().removeOnLayoutChangeListener(this); |
| |
| super.onDetachedFromWindow(); |
| } |
| } |
| } |