// 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 int mNumAnswerLines = 1;
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;
boolean refineVisible = mRefineView.getVisibility() == VISIBLE;
int refineWidth = refineVisible ? mRefineWidth : 0;
if (mNumAnswerLines > 1) {
mContentsView.measure(
MeasureSpec.makeMeasureSpec(width - refineWidth, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(mSuggestionAnswerHeight * 2, MeasureSpec.AT_MOST));
height = mContentsView.getMeasuredHeight();
} else 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;
if (mNumAnswerLines == 1) {
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;
} else {
mNumAnswerLines = 1;
mContentsView.mTextLine2.setEllipsize(null);
mContentsView.mTextLine2.setSingleLine();
}
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);
}
static int parseNumAnswerLines(List<SuggestionAnswer.TextField> textFields) {
for (int i = 0; i < textFields.size(); i++) {
if (textFields.get(i).hasNumLines()) {
return Math.min(3, textFields.get(i).getNumLines());
}
}
return -1;
}
/**
* 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);
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);
mNumAnswerLines = parseNumAnswerLines(secondLine.getTextFields());
if (mNumAnswerLines == -1) mNumAnswerLines = 1;
if (mNumAnswerLines == 1) {
mContentsView.mTextLine2.setEllipsize(null);
mContentsView.mTextLine2.setSingleLine();
} else {
mContentsView.mTextLine2.setSingleLine(false);
mContentsView.mTextLine2.setEllipsize(TextUtils.TruncateAt.END);
mContentsView.mTextLine2.setMaxLines(mNumAnswerLines);
}
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();
}
};
// TODO(crbug.com/635567): Fix this properly.
@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 = 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_image_vertical_spacing);
}
if (child != mTextLine1 && verticalOffset + line2Height > height) {
verticalOffset = height - line2Height;
}
}
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) {
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));
}
if (MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.AT_MOST) {
int desiredHeight = mTextLine1.getMeasuredHeight() + mTextLine2.getMeasuredHeight();
int additionalPadding = (int) getResources().getDimension(
R.dimen.omnibox_suggestion_text_vertical_padding);
if (mSuggestion.hasAnswer()) {
additionalPadding += (int) getResources().getDimension(
R.dimen.omnibox_suggestion_multiline_text_vertical_padding);
}
desiredHeight += additionalPadding;
desiredHeight = Math.min(MeasureSpec.getSize(heightMeasureSpec), desiredHeight);
super.onMeasure(widthMeasureSpec,
MeasureSpec.makeMeasureSpec(desiredHeight, MeasureSpec.EXACTLY));
} else {
assert MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.EXACTLY;
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
}
@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 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;
}
// TODO(crbug.com/635567): Fix this properly.
@SuppressLint("SwitchIntDef")
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();
}
}
}