选择文本视图时如何显示弹出窗口而不是 CAB?

How to display popup instead of CAB when textview is selected?

我正在制作一个阅读应用程序,它有一个全屏 activity。
当用户 select 的文本部分出现 contextual action bar 并带有复制选项。这是默认行为。但是此操作栏会阻止其下方的文本,因此用户无法 select 它。

我想显示如下所示的弹出窗口 window。

我尝试从 onCreateActionMode 返回 false 但是当我这样做时我也不能 select 文本。

我想知道是否有一种标准的方法可以实现这一点,因为许多阅读应用程序都使用这种设计。

我不知道 Play Books 如何做到这一点,但您可以创建一个 PopupWindow and calculate where to position it based on the selected text using Layout.getSelectionPath 和一点数学。基本上,我们要:

  • 计算所选文本的边界
  • 计算 PopupWindow
  • 的边界和初始位置
  • 计算两者的差值
  • PopupWindow 偏移到所选文本上方或下方的休息中心 horizontally/vertically

正在计算选择范围

From the docs:

Fills in the specified Path with a representation of a highlight between the specified offsets. This will often be a rectangle or a potentially discontinuous set of rectangles. If the start and end are the same, the returned path is empty.

因此,在我们的例子中指定的偏移量将是选择的开始和结束,可以使用 Selection.getSelectionStart and Selection.getSelectionEnd. For convenience, TextView gives us TextView.getSelectionStart, TextView.getSelectionEnd and TextView.getLayout 找到。

    final Path selDest = new Path();
    final RectF selBounds = new RectF();
    final Rect outBounds = new Rect();

    // Calculate the selection start and end offset
    final int selStart = yourTextView.getSelectionStart();
    final int selEnd = yourTextView.getSelectionEnd();
    final int min = Math.max(0, Math.min(selStart, selEnd));
    final int max = Math.max(0, Math.max(selStart, selEnd));

    // Calculate the selection outBounds
    yourTextView.getLayout().getSelectionPath(min, max, selDest);
    selDest.computeBounds(selBounds, true /* this param is ignored */);
    selBounds.roundOut(outBounds);

现在我们有一个 Rect 的选定文本边界,我们可以选择我们想要放置 PopupWindow 相对于它的位置。在这种情况下,我们会将其沿所选文本的顶部或底部水平居中,具体取决于我们必须显示多少 space 我们的弹出窗口。

计算初始弹出坐标

接下来我们需要计算弹出内容的范围。为此,我们首先需要调用 PopupWindow.showAtLocation, but the bounds of the View we inflate won't immediately be available, so I'd recommend using a ViewTreeObserver.OnGlobalLayoutListener 以等待它们可用。

popupWindow.showAtLocation(yourTextView, Gravity.TOP, 0, 0)

PopupWindow.showAtLocation 要求:

  • A View 从中检索有效的 Window token,它只是唯一标识 Window 以将弹出窗口放入
  • 一个可选的重力,但在我们的例子中它将是 Gravity.TOP
  • 可选 x/y 偏移量

由于在布局弹出内容之前我们无法确定 x/y 偏移量,因此我们最初将其放置在默认位置。如果您尝试在传入的 View 布局之前调用 PopupWindow.showAtLocation,您将收到 WindowManager.BadTokenException,因此您可以考虑使用 ViewTreeObserver.OnGlobalLayoutListener 来避免这种情况,但它主要出现在您选择文本并旋转设备时。

    final Rect cframe = new Rect();
    final int[] cloc = new int[2];
    popupContent.getLocationOnScreen(cloc);
    popupContent.getLocalVisibleRect(cbounds);
    popupContent.getWindowVisibleDisplayFrame(cframe);

    final int scrollY = ((View) yourTextView.getParent()).getScrollY();
    final int[] tloc = new int[2];
    yourTextView.getLocationInWindow(tloc);

    final int startX = cloc[0] + cbounds.centerX();
    final int startY = cloc[1] + cbounds.centerY() - (tloc[1] - cframe.top) - scrollY;

一旦我们获得了我们需要的所有信息,我们就可以计算弹出内容的最终开始 x/y,然后用它来找出它们与所选文本之间的区别 Rect 这样我们就可以 PopupWindow.update 到新位置了。

计算偏移弹出坐标

    // Calculate the top and bottom offset of the popup relative to the selection bounds
    final int popupHeight = cbounds.height();
    final int textPadding = yourTextView.getPaddingLeft();
    final int topOffset = Math.round(selBounds.top - startY);
    final int btmOffset = Math.round(selBounds.bottom - (startY - popupHeight));

    // Calculate the x/y coordinates for the popup relative to the selection bounds
    final int x = Math.round(selBounds.centerX() + textPadding - startX);
    final int y = Math.round(selBounds.top - scrollY < startY ? btmOffset : topOffset);

如果有足够的空间在所选文本上方显示弹出窗口,我们将把它放在那里;否则,我们会将其偏移到所选文本下方。就我而言,我的 TextView 周围有 16dp 填充,因此也需要考虑这一点。我们将以最终的 xy 位置结束,以偏移 PopupWindow

    popupWindow.update(x, y, -1, -1);

-1 这里只是表示我们为 PopupWindow 提供的默认 width/height,在我们的例子中它将是 ViewGroup.LayoutParams.WRAP_CONTENT

侦听选择更改

我们希望每次更改所选文本时 PopupWindow 都更新。

侦听选择更改的一种简单方法是继承 TextView 并向 TextView.onSelectionChanged 提供回调。

public class NotifyingSelectionTextView extends AppCompatTextView {

    private SelectionChangeListener listener;

    public NotifyingSelectionTextView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    protected void onSelectionChanged(int selStart, int selEnd) {
        super.onSelectionChanged(selStart, selEnd);
        if (listener != null) {
            if (hasSelection()) {
                listener.onTextSelected();
            } else {
                listener.onTextUnselected();
            }
        }
    }

    public void setSelectionChangeListener(SelectionChangeListener listener) {
        this.listener = listener;
    }

    public interface SelectionChangeListener {
        void onTextSelected();
        void onTextUnselected();
    }

}

监听滚动变化

如果像 ScrollView 这样的滚动容器中有一个 TextView,您可能还想监听滚动变化,以便在滚动时锚定弹出窗口。监听这些的一个简单方法是继承 ScrollView 并提供对 View.onScrollChanged

的回调
public class NotifyingScrollView extends ScrollView {

    private ScrollChangeListener listener;

    public NotifyingScrollView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    protected void onScrollChanged(int l, int t, int oldl, int oldt) {
        super.onScrollChanged(l, t, oldl, oldt);
        if (listener != null) {
            listener.onScrollChanged();
        }
    }

    public void setScrollChangeListener(ScrollChangeListener listener) {
        this.listener = listener;
    }

    public interface ScrollChangeListener {
        void onScrollChanged();
    }

}

创建空 ActionMode.Callback

正如您在 post 中提到的,我们需要 return trueActionMode.Callback.onCreateActionMode in order for our text to remain selectable. But we'll also need to call Menu.clear in ActionMode.Callback.onPrepareActionMode 中删除您可能在 ActionMode.Callback.onCreateActionMode in order for our text to remain selectable. But we'll also need to call Menu.clear in ActionMode.Callback.onPrepareActionMode 中找到的所有项目=82=] 用于选定的文本。

/** An {@link ActionMode.Callback} used to remove all action items from text selection */
static final class EmptyActionMode extends SimpleActionModeCallback {

    @Override
    public boolean onCreateActionMode(ActionMode mode, Menu menu) {
        // Return true to ensure the text is still selectable
        return true;
    }

    @Override
    public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
        // Remove all action items to provide an actionmode-less selection
        menu.clear();
        return true;
    }

}

现在我们可以使用TextView.setCustomSelectionActionModeCallback to apply our custom ActionMode. SimpleActionModeCallback is a custom class that just provides stubs for ActionMode.Callback, kinda similar to ViewPager.SimpleOnPageChangeListener

public class SimpleActionModeCallback implements ActionMode.Callback {

    @Override
    public boolean onCreateActionMode(ActionMode mode, Menu menu) {
        return false;
    }

    @Override
    public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
        return false;
    }

    @Override
    public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
        return false;
    }

    @Override
    public void onDestroyActionMode(ActionMode mode) {

    }

}

布局

这是我们正在使用的 Activity 布局:

<your.package.name.NotifyingScrollView
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/notifying_scroll_view"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <your.package.name.NotifyingSelectionTextView
        android:id="@+id/notifying_text_view"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:padding="16dp"
        android:textIsSelectable="true"
        android:textSize="20sp" />

</your.package.name.NotifyingScrollView>

这是我们的弹出布局:

<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:background="@drawable/action_mode_popup_bg"
    android:orientation="vertical"
    tools:ignore="ContentDescription">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal">

        <ImageButton
            android:id="@+id/view_action_mode_popup_add_note"
            style="@style/ActionModePopupButton"
            android:src="@drawable/ic_note_add_black_24dp" />

        <ImageButton
            android:id="@+id/view_action_mode_popup_translate"
            style="@style/ActionModePopupButton"
            android:src="@drawable/ic_translate_black_24dp" />

        <ImageButton
            android:id="@+id/view_action_mode_popup_search"
            style="@style/ActionModePopupButton"
            android:src="@drawable/ic_search_black_24dp" />

    </LinearLayout>

    <View
        android:layout_width="match_parent"
        android:layout_height="1dp"
        android:layout_margin="8dp"
        android:background="@android:color/darker_gray" />

    <LinearLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="horizontal">

        <ImageButton
            android:id="@+id/view_action_mode_popup_red"
            style="@style/ActionModePopupSwatch"
            android:src="@drawable/round_red" />

        <ImageButton
            android:id="@+id/view_action_mode_popup_yellow"
            style="@style/ActionModePopupSwatch"
            android:src="@drawable/round_yellow" />

        <ImageButton
            android:id="@+id/view_action_mode_popup_green"
            style="@style/ActionModePopupSwatch"
            android:src="@drawable/round_green" />

        <ImageButton
            android:id="@+id/view_action_mode_popup_blue"
            style="@style/ActionModePopupSwatch"
            android:src="@drawable/round_blue" />

        <ImageButton
            android:id="@+id/view_action_mode_popup_clear_format"
            style="@style/ActionModePopupSwatch"
            android:src="@drawable/ic_format_clear_black_24dp"
            android:visibility="gone" />

    </LinearLayout>

</LinearLayout>

这些是我们的弹出按钮样式:

<style name="ActionModePopupButton">
    <item name="android:layout_width">48dp</item>
    <item name="android:layout_height">48dp</item>
    <item name="android:layout_weight">1</item>
    <item name="android:background">?selectableItemBackground</item>
</style>

<style name="ActionModePopupSwatch" parent="ActionModePopupButton">
    <item name="android:padding">12dp</item>
</style>

Util

您将看到的 ViewUtils.onGlobalLayout 只是一个用于处理一些 ViewTreeObserver.OnGlobalLayoutListener 样板文件的实用方法。

public static void onGlobalLayout(final View view, final Runnable runnable) {
    final OnGlobalLayoutListener listener = new OnGlobalLayoutListener() {

        @Override
        public void onGlobalLayout() {
            view.getViewTreeObserver().removeOnGlobalLayoutListener(this);
            runnable.run();
        }

    };
    view.getViewTreeObserver().addOnGlobalLayoutListener(listener);
}

一网打尽

所以,现在我们已经:

  • 计算了选定的文本范围
  • 计算弹出范围
  • 计算差异并确定弹出窗口偏移量
  • 提供了一种监听滚动变化和选择变化的方法
  • 创建了我们的 Activity 和弹出布局

将所有内容放在一起可能看起来像:

public class ActionModePopupActivity extends AppCompatActivity
        implements ScrollChangeListener, SelectionChangeListener {

    private static final int DEFAULT_WIDTH = -1;
    private static final int DEFAULT_HEIGHT = -1;

    private final Point currLoc = new Point();
    private final Point startLoc = new Point();

    private final Rect cbounds = new Rect();
    private final PopupWindow popupWindow = new PopupWindow();
    private final ActionMode.Callback emptyActionMode = new EmptyActionMode();

    private NotifyingSelectionTextView yourTextView;

    @SuppressLint("InflateParams")
    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_action_mode_popup);

        // Initialize the popup content, only add it to the Window once we've selected text
        final LayoutInflater inflater = LayoutInflater.from(this);
        popupWindow.setContentView(inflater.inflate(R.layout.view_action_mode_popup, null));
        popupWindow.setWidth(WRAP_CONTENT);
        popupWindow.setHeight(WRAP_CONTENT);

        // Initialize to the NotifyingScrollView to observe scroll changes
        final NotifyingScrollView scroll
                = (NotifyingScrollView) findViewById(R.id.notifying_scroll_view);
        scroll.setScrollChangeListener(this);

        // Initialize the TextView to observe selection changes and provide an empty ActionMode
        yourTextView = (NotifyingSelectionTextView) findViewById(R.id.notifying_text_view);
        yourTextView.setText(IPSUM);
        yourTextView.setSelectionChangeListener(this);
        yourTextView.setCustomSelectionActionModeCallback(emptyActionMode);
    }

    @Override
    public void onScrollChanged() {
        // Anchor the popup while the user scrolls
        if (popupWindow.isShowing()) {
            final Point ploc = calculatePopupLocation();
            popupWindow.update(ploc.x, ploc.y, DEFAULT_WIDTH, DEFAULT_HEIGHT);
        }
    }

    @Override
    public void onTextSelected() {
        final View popupContent = popupWindow.getContentView();
        if (popupWindow.isShowing()) {
            // Calculate the updated x/y pop coordinates
            final Point ploc = calculatePopupLocation();
            popupWindow.update(ploc.x, ploc.y, DEFAULT_WIDTH, DEFAULT_HEIGHT);
        } else {
        // Add the popup to the Window and position it relative to the selected text bounds
        ViewUtils.onGlobalLayout(yourTextView, () -> {
            popupWindow.showAtLocation(yourTextView, TOP, 0, 0);
            // Wait for the popup content to be laid out
            ViewUtils.onGlobalLayout(popupContent, () -> {
                final Rect cframe = new Rect();
                final int[] cloc = new int[2];
                popupContent.getLocationOnScreen(cloc);
                popupContent.getLocalVisibleRect(cbounds);
                popupContent.getWindowVisibleDisplayFrame(cframe);

                final int scrollY = ((View) yourTextView.getParent()).getScrollY();
                final int[] tloc = new int[2];
                yourTextView.getLocationInWindow(tloc);

                final int startX = cloc[0] + cbounds.centerX();
                final int startY = cloc[1] + cbounds.centerY() - (tloc[1] - cframe.top) - scrollY;
                startLoc.set(startX, startY);

                final Point ploc = calculatePopupLocation();
                popupWindow.update(ploc.x, ploc.y, DEFAULT_WIDTH, DEFAULT_HEIGHT);
            });
        });
        }
    }

    @Override
    public void onTextUnselected() {
        popupWindow.dismiss();
    }

    /** Used to calculate where we should position the {@link PopupWindow} */
    private Point calculatePopupLocation() {
        final ScrollView parent = (ScrollView) yourTextView.getParent();

        // Calculate the selection start and end offset
        final int selStart = yourTextView.getSelectionStart();
        final int selEnd = yourTextView.getSelectionEnd();
        final int min = Math.max(0, Math.min(selStart, selEnd));
        final int max = Math.max(0, Math.max(selStart, selEnd));

        // Calculate the selection bounds
        final RectF selBounds = new RectF();
        final Path selection = new Path();
        yourTextView.getLayout().getSelectionPath(min, max, selection);
        selection.computeBounds(selBounds, true /* this param is ignored */);

        // Retrieve the center x/y of the popup content
        final int cx = startLoc.x;
        final int cy = startLoc.y;

        // Calculate the top and bottom offset of the popup relative to the selection bounds
        final int popupHeight = cbounds.height();
        final int textPadding = yourTextView.getPaddingLeft();
        final int topOffset = Math.round(selBounds.top - cy);
        final int btmOffset = Math.round(selBounds.bottom - (cy - popupHeight));

        // Calculate the x/y coordinates for the popup relative to the selection bounds
        final int scrollY = parent.getScrollY();
        final int x = Math.round(selBounds.centerX() + textPadding - cx);
        final int y = Math.round(selBounds.top - scrollY < cy ? btmOffset : topOffset);
        currLoc.set(x, y - scrollY);
        return currLoc;
    }

    /** An {@link ActionMode.Callback} used to remove all action items from text selection */
    static final class EmptyActionMode extends SimpleActionModeCallback {

        @Override
        public boolean onCreateActionMode(ActionMode mode, Menu menu) {
            // Return true to ensure the yourTextView is still selectable
            return true;
        }

        @Override
        public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
            // Remove all action items to provide an actionmode-less selection
            menu.clear();
            return true;
        }

    }

}

结果

With the action bar (link to video):

Without the action bar (link to video):

奖金 - 动画

因为我们知道 PopupWindow 的起始位置和选择变化时的偏移位置,所以我们可以轻松地在两个值之间执行线性插值,以便在四处移动时创建漂亮的动画.

public static float lerp(float a, float b, float v) {
    return a + (b - a) * v;
}

private static final int DEFAULT_ANIM_DUR = 350;
private static final int DEFAULT_ANIM_DELAY = 500;

@Override
public void onTextSelected() {
    final View popupContent = popupWindow.getContentView();
    if (popupWindow.isShowing()) {
        // Calculate the updated x/y pop coordinates
        popupContent.getHandler().removeCallbacksAndMessages(null);
        popupContent.postDelayed(() -> {
            // The current x/y location of the popup
            final int currx = currLoc.x;
            final int curry = currLoc.y;
            // Calculate the updated x/y pop coordinates
            final Point ploc = calculatePopupLocation();
            currLoc.set(ploc.x, ploc.y);
            // Linear interpolate between the current and updated popup coordinates
            final ValueAnimator anim = ValueAnimator.ofFloat(0f, 1f);
            anim.addUpdateListener(animation -> {
                final float v = (float) animation.getAnimatedValue();
                final int x = Math.round(AnimUtils.lerp(currx, ploc.x, v));
                final int y = Math.round(AnimUtils.lerp(curry, ploc.y, v));
                popupWindow.update(x, y, DEFAULT_WIDTH, DEFAULT_HEIGHT);
            });
            anim.setDuration(DEFAULT_ANIM_DUR);
            anim.start();
        }, DEFAULT_ANIM_DELAY);
    } else {
        ...
    }
}

结果

With the action bar - animation (link to video)

额外

我不讨论如何将点击侦听器附加到弹出操作,可能有几种方法可以通过不同的计算和实现来实现相同的效果。但我会提到,如果您想检索选定的文本然后对其进行处理,you'd just need to CharSequence.subSequence the min and max from the selected text.

无论如何,我希望这对您有所帮助!如果您有任何问题,请告诉我。