【问题标题】:How to display popup instead of CAB when textview is selected?选择 textview 时如何显示弹出窗口而不是 CAB?
【发布时间】:2017-05-25 14:12:06
【问题描述】:

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

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

我尝试从onCreateActionMode 返回false,但是当我这样做时,我也无法选择文本。

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

【问题讨论】:

    标签: android textview android-popupwindow android-actionmode


    【解决方案1】:

    我不知道 Play Books 是如何实现这一点的,但您可以创建一个 PopupWindow 并使用 Layout.getSelectionPath 和一些数学计算基于所选文本的位置。基本上,我们将:

    • 计算所选文本的边界
    • 计算PopupWindow 的边界和初始位置
    • 计算两者之间的差异
    • PopupWindow 偏移到所选文本上方或下方的水平/垂直中心

    计算选择范围

    From the docs:

    用高亮表示填充指定的Path 在指定的偏移量之间。这通常是一个矩形或一个 可能不连续的矩形集。如果开始和结束是 同样,返回的路径为空。

    因此,在我们的例子中,指定的偏移量将是选择的开始和结束,可以使用Selection.getSelectionStartSelection.getSelectionEnd 找到。为方便起见,TextView 给了我们TextView.getSelectionStartTextView.getSelectionEndTextView.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 的位置。在这种情况下,我们将使其沿所选文本的顶部或底部水平居中,具体取决于显示弹出窗口的空间。

    计算初始弹出坐标

    接下来我们需要计算弹出内容的边界。为此,我们首先需要调用PopupWindow.showAtLocation,但我们膨胀的View 的边界不会立即可用,所以我建议使用ViewTreeObserver.OnGlobalLayoutListener 来等待它们可用。

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

    PopupWindow.showAtLocation 需要:

    • 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,这样我们就可以@ 987654339@到新位置。

    计算偏移弹出坐标

        // 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 提供的默认宽度/高度,在我们的例子中它将是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

    就像您在帖子中提到的那样,我们需要在 ActionMode.Callback.onCreateActionMode 中返回 true,以使我们的文本保持可选状态。但我们还需要在ActionMode.Callback.onPrepareActionMode 中调用Menu.clear,以便删除您在ActionMode 中找到的所有项目。

    /** 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 来应用我们的自定义ActionModeSimpleActionModeCallback 是一个自定义类,只为ActionMode.Callback 提供存根,有点类似于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>
    

    实用程序

    您将看到的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

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

    【讨论】:

    • 非常感谢您提供这样的信息,有空我会尝试一下
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2013-04-02
    • 2012-02-23
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多