Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(163)

Unified Diff: content/public/android/java/src/org/chromium/content/browser/input/SuggestionsPopupWindow.java

Issue 2931443003: Add support for Android spellcheck menu in Chrome/WebViews (Closed)
Patch Set: Respond to latest comments Created 3 years, 5 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View side-by-side diff with in-line comments
Download patch
Index: content/public/android/java/src/org/chromium/content/browser/input/SuggestionsPopupWindow.java
diff --git a/content/public/android/java/src/org/chromium/content/browser/input/SuggestionsPopupWindow.java b/content/public/android/java/src/org/chromium/content/browser/input/SuggestionsPopupWindow.java
new file mode 100644
index 0000000000000000000000000000000000000000..4a8088496e7dd5554a5b3d7a00f2dfbbeab9faf4
--- /dev/null
+++ b/content/public/android/java/src/org/chromium/content/browser/input/SuggestionsPopupWindow.java
@@ -0,0 +1,379 @@
+// Copyright 2017 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.content.browser.input;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Color;
+import android.graphics.Rect;
+import android.graphics.drawable.ColorDrawable;
+import android.os.Build;
+import android.util.DisplayMetrics;
+import android.view.Gravity;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.AdapterView;
+import android.widget.AdapterView.OnItemClickListener;
+import android.widget.BaseAdapter;
+import android.widget.LinearLayout;
+import android.widget.ListView;
+import android.widget.PopupWindow;
+import android.widget.PopupWindow.OnDismissListener;
+import android.widget.TextView;
+
+import org.chromium.base.ApiCompatibilityUtils;
+import org.chromium.base.VisibleForTesting;
+import org.chromium.content.R;
+import org.chromium.content.browser.WindowAndroidProvider;
+import org.chromium.ui.UiUtils;
+
+/**
+ * Popup window that displays a menu for viewing and applying text replacement suggestions.
+ */
+public class SuggestionsPopupWindow
+ implements OnItemClickListener, OnDismissListener, View.OnClickListener {
+ private static final String ACTION_USER_DICTIONARY_INSERT =
+ "com.android.settings.USER_DICTIONARY_INSERT";
+ private static final String USER_DICTIONARY_EXTRA_WORD = "word";
+
+ private final Context mContext;
+ private final TextSuggestionHost mTextSuggestionHost;
+ private final View mParentView;
+ private final WindowAndroidProvider mWindowAndroidProvider;
+
+ private Activity mActivity;
+ private DisplayMetrics mDisplayMetrics;
+ private PopupWindow mPopupWindow;
+ private LinearLayout mContentView;
+
+ private SuggestionAdapter mSuggestionsAdapter;
+ private String mHighlightedText;
+ private String[] mSpellCheckSuggestions = new String[0];
+ private int mNumberOfSuggestionsToUse;
+ private TextView mAddToDictionaryButton;
+ private TextView mDeleteButton;
+ private ListView mSuggestionListView;
+ private LinearLayout mListFooter;
+ private View mDivider;
+ private int mPopupVerticalMargin;
+
+ private boolean mDismissedByItemTap;
+
+ /**
+ * @param context Android context to use.
+ * @param textSuggestionHost TextSuggestionHost instance (used to communicate with Blink).
+ * @param parentView The view used to attach the PopupWindow.
+ * @param windowAndroidProvider A WindowAndroidProvider instance used to get the window size.
+ */
+ public SuggestionsPopupWindow(Context context, TextSuggestionHost textSuggestionHost,
+ View parentView, WindowAndroidProvider windowAndroidProvider) {
+ mContext = context;
+ mTextSuggestionHost = textSuggestionHost;
+ mParentView = parentView;
+ mWindowAndroidProvider = windowAndroidProvider;
+
+ createPopupWindow();
+ initContentView();
+
+ mPopupWindow.setContentView(mContentView);
+ }
+
+ private void createPopupWindow() {
+ mPopupWindow = new PopupWindow();
+ mPopupWindow.setWidth(ViewGroup.LayoutParams.WRAP_CONTENT);
+ mPopupWindow.setHeight(ViewGroup.LayoutParams.WRAP_CONTENT);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ // On Lollipop and later, we use elevation to create a drop shadow effect.
+ // On pre-Lollipop, we use a background image instead (in the layout file).
+ mPopupWindow.setElevation(mContext.getResources().getDimensionPixelSize(
+ R.dimen.text_suggestion_popup_elevation));
+ } else {
+ // The PopupWindow does not properly dismiss on Jelly Bean without the following line.
+ mPopupWindow.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
+ }
+ mPopupWindow.setInputMethodMode(PopupWindow.INPUT_METHOD_NOT_NEEDED);
+ mPopupWindow.setFocusable(true);
+ mPopupWindow.setClippingEnabled(false);
+ mPopupWindow.setOnDismissListener(this);
+ }
+
+ private void initContentView() {
+ final LayoutInflater inflater =
+ (LayoutInflater) mContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ mContentView =
+ (LinearLayout) inflater.inflate(R.layout.text_edit_suggestion_container, null);
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ mContentView.setBackground(ApiCompatibilityUtils.getDrawable(
+ mContext.getResources(), R.drawable.floating_popup_background_light));
+ } else {
+ mContentView.setBackground(ApiCompatibilityUtils.getDrawable(
+ mContext.getResources(), R.drawable.dropdown_popup_background));
+ }
+
+ // mPopupVerticalMargin is the minimum amount of space we want to have between the popup
+ // and the top or bottom of the window.
+ mPopupVerticalMargin = mContext.getResources().getDimensionPixelSize(
+ R.dimen.text_suggestion_popup_vertical_margin);
+
+ mSuggestionListView = (ListView) mContentView.findViewById(R.id.suggestionContainer);
+ // android:divider="@null" in the XML file crashes on Android N and O
+ // when running as a WebView (b/38346876).
+ mSuggestionListView.setDivider(null);
+
+ mListFooter =
+ (LinearLayout) inflater.inflate(R.layout.text_edit_suggestion_list_footer, null);
+ mSuggestionListView.addFooterView(mListFooter, null, false);
+
+ mSuggestionsAdapter = new SuggestionAdapter();
+ mSuggestionListView.setAdapter(mSuggestionsAdapter);
+ mSuggestionListView.setOnItemClickListener(this);
+
+ mDivider = mContentView.findViewById(R.id.divider);
+
+ mAddToDictionaryButton = (TextView) mContentView.findViewById(R.id.addToDictionaryButton);
+ mAddToDictionaryButton.setOnClickListener(this);
+
+ mDeleteButton = (TextView) mContentView.findViewById(R.id.deleteButton);
+ mDeleteButton.setOnClickListener(this);
+ }
+
+ /**
+ * Dismisses the text suggestion menu (called by TextSuggestionHost when certain events occur,
+ * for example device rotation).
+ */
+ public void dismiss() {
+ mPopupWindow.dismiss();
+ }
+
+ /**
+ * Used by TextSuggestionHost to determine if the text suggestion menu is currently visible.
+ */
+ public boolean isShowing() {
+ return mPopupWindow.isShowing();
+ }
+
+ private void addToDictionary() {
+ final Intent intent = new Intent(ACTION_USER_DICTIONARY_INSERT);
+ intent.putExtra(USER_DICTIONARY_EXTRA_WORD, mHighlightedText);
+ intent.setFlags(intent.getFlags() | Intent.FLAG_ACTIVITY_NEW_TASK);
+ mContext.startActivity(intent);
+ }
+
+ private class SuggestionAdapter extends BaseAdapter {
+ private LayoutInflater mInflater =
+ (LayoutInflater) mContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+
+ @Override
+ public int getCount() {
+ return mNumberOfSuggestionsToUse;
+ }
+
+ @Override
+ public Object getItem(int position) {
+ return mSpellCheckSuggestions[position];
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return position;
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ TextView textView = (TextView) convertView;
+ if (textView == null) {
+ textView = (TextView) mInflater.inflate(
+ R.layout.text_edit_suggestion_item, parent, false);
+ }
+ final String suggestion = mSpellCheckSuggestions[position];
+ textView.setText(suggestion);
+ return textView;
+ }
+ }
+
+ private void measureContent() {
+ // Make the menu wide enough to fit its widest item.
+ int width = UiUtils.computeMaxWidthOfListAdapterItems(mSuggestionListView.getAdapter());
+ width += mContentView.getPaddingLeft() + mContentView.getPaddingRight();
+
+ final int verticalMeasure = View.MeasureSpec.makeMeasureSpec(
+ mDisplayMetrics.heightPixels, View.MeasureSpec.AT_MOST);
+ mContentView.measure(
+ View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY), verticalMeasure);
+ mPopupWindow.setWidth(width);
+ }
+
+ private void updateDividerVisibility() {
+ // If we don't have any spell check suggestions, "Add to dictionary" will be the first menu
+ // item, and we shouldn't show a divider above it.
+ if (mNumberOfSuggestionsToUse == 0) {
+ mDivider.setVisibility(View.GONE);
+ } else {
+ mDivider.setVisibility(View.VISIBLE);
+ }
+ }
+
+ /**
+ * Called by TextSuggestionHost to tell this class what text is currently highlighted (so it can
+ * be added to the dictionary if requested).
+ */
+ public void setHighlightedText(String text) {
+ mHighlightedText = text;
+ }
+
+ /**
+ * Called by TextSuggestionHost to set the list of spell check suggestions to show in the
+ * suggestion menu.
+ */
+ public void setSpellCheckSuggestions(String[] suggestions) {
+ mSpellCheckSuggestions = suggestions.clone();
+ mNumberOfSuggestionsToUse = mSpellCheckSuggestions.length;
+ }
+
+ /**
+ * Shows the text suggestion menu at the specified coordinates (relative to the viewport).
+ */
+ public void show(double caretX, double caretY) {
+ mSuggestionsAdapter.notifyDataSetChanged();
+
+ mActivity = mWindowAndroidProvider.getWindowAndroid().getActivity().get();
+ // Note: the Activity can be null here if we're in a WebView that was created without
+ // using an Activity. So all code in this class should handle this case.
+ if (mActivity != null) {
+ mDisplayMetrics = mActivity.getResources().getDisplayMetrics();
+ } else {
+ // Getting the DisplayMetrics from the passed-in context doesn't handle multi-window
+ // mode as well, but it's good enough for the "improperly-created WebView" case
+ mDisplayMetrics = mContext.getResources().getDisplayMetrics();
+ }
+
+ // In single-window mode, we need to get the status bar height to make sure we don't try to
+ // draw on top of it (we can't draw on top in older versions of Android).
+ // In multi-window mode, as of Android N, the behavior is as follows:
+ //
+ // Portrait mode, top window: the window height does not include the height of the status
+ // bar, but drawing at Y position 0 starts at the top of the status bar.
+ //
+ // Portrait mode, bottom window: the window height does not include the height of the status
+ // bar, and the status bar isn't touching the window, so we can't draw on it regardless.
+ //
+ // Landscape mode: the window height includes the whole height of the keyboard
+ // (Google-internal b/63405914), so we are unable to handle this case properly.
+ //
+ // For our purposes, we don't worry about if we're drawing over the status bar in
+ // multi-window mode, but we need to make sure we don't do it in single-window mode (in case
+ // we're on an old version of Android).
+ int statusBarHeight = 0;
+ if (mActivity != null
+ && (Build.VERSION.SDK_INT < Build.VERSION_CODES.N
+ || !mActivity.isInMultiWindowMode())) {
+ Rect rect = new Rect();
+ mActivity.getWindow().getDecorView().getWindowVisibleDisplayFrame(rect);
+ statusBarHeight = rect.top;
+ }
+
+ // We determine the maximum number of suggestions we can show by taking the available
+ // height in the window, subtracting the height of the list footer (divider, add to
+ // dictionary button, delete button), and dividing by the height of a suggestion item.
+ mListFooter.measure(View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
+ View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED));
+
+ final int verticalSpaceAvailableForSuggestions = mDisplayMetrics.heightPixels
+ - statusBarHeight - mListFooter.getMeasuredHeight() - 2 * mPopupVerticalMargin
+ - mContentView.getPaddingTop() - mContentView.getPaddingBottom();
+ final int itemHeight = mContext.getResources().getDimensionPixelSize(
+ R.dimen.text_edit_suggestion_item_layout_height);
+ final int maxItemsToShow = verticalSpaceAvailableForSuggestions > 0
+ ? verticalSpaceAvailableForSuggestions / itemHeight
+ : 0;
+
+ mNumberOfSuggestionsToUse = Math.min(mNumberOfSuggestionsToUse, maxItemsToShow);
+ // If we're not showing any suggestions, hide the divider before "Add to dictionary" and
+ // "Delete".
+ updateDividerVisibility();
+ measureContent();
+
+ final int width = mContentView.getMeasuredWidth();
Theresa 2017/07/20 00:50:33 Thanks providing screenshots! It looks like on pre
rlanday 2017/07/20 01:39:20 Good eye! I wasn't too concerned since the native
+ final int height = mContentView.getMeasuredHeight();
+
+ // Horizontally center the menu on the caret location, and vertically position the menu
+ // under the caret.
+ int positionX = (int) Math.round(caretX - width / 2.0f);
+ int positionY = (int) Math.round(caretY);
+
+ // We get the insertion point coords relative to the viewport.
+ // We need to render the popup relative to the window.
+ final int[] positionInWindow = new int[2];
+ mParentView.getLocationInWindow(positionInWindow);
+
+ positionX += positionInWindow[0];
+ positionY += positionInWindow[1];
+
+ // Subtract off the container's top padding to get the proper alignment with the caret.
+ // Note: there is no explicit padding set. On Android L and later, we use elevation to draw
+ // a drop shadow and there is no top padding. On pre-L, we instead use a background image,
+ // which results in some implicit padding getting added that we need to account for.
+ positionY -= mContentView.getPaddingTop();
+
+ // Horizontal clipping: if part of the menu (except the shadow) would fall off the left
+ // or right edge of the screen, shift the menu to keep it on-screen.
+ final int menuAtRightEdgeOfWindowPositionX = mDisplayMetrics.widthPixels - width;
+ positionX = Math.min(menuAtRightEdgeOfWindowPositionX, positionX);
+ positionX = Math.max(0, positionX);
+
+ // Vertical clipping: if part of the menu or its bottom margin would fall off the bottom of
+ // the screen, shift it up to keep it on-screen.
+ positionY = Math.min(positionY,
+ mDisplayMetrics.heightPixels - height - mContentView.getPaddingTop()
+ - mPopupVerticalMargin);
+
+ mPopupWindow.showAtLocation(mParentView, Gravity.NO_GRAVITY, positionX, positionY);
+ }
+
+ @Override
+ public void onClick(View v) {
+ if (v == mAddToDictionaryButton) {
+ addToDictionary();
+ mTextSuggestionHost.newWordAddedToDictionary(mHighlightedText);
+ mDismissedByItemTap = true;
+ mPopupWindow.dismiss();
+ } else if (v == mDeleteButton) {
+ mTextSuggestionHost.deleteActiveSuggestionRange();
+ mDismissedByItemTap = true;
+ mPopupWindow.dismiss();
+ }
+ }
+
+ @Override
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+ // Ignore taps somewhere in the list footer (divider, "Add to dictionary", "Delete") that
+ // don't get handled by a button.
+ if (position >= mNumberOfSuggestionsToUse) {
+ return;
+ }
+
+ String suggestion = mSpellCheckSuggestions[position];
+ mTextSuggestionHost.applySpellCheckSuggestion(suggestion);
+ mDismissedByItemTap = true;
+ mPopupWindow.dismiss();
+ }
+
+ @Override
+ public void onDismiss() {
+ mTextSuggestionHost.suggestionMenuClosed(mDismissedByItemTap);
+ mDismissedByItemTap = false;
+ }
+
+ /**
+ * @return The popup's content view.
+ */
+ @VisibleForTesting
+ public View getContentViewForTesting() {
+ return mContentView;
+ }
+}

Powered by Google App Engine
This is Rietveld 408576698