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

后端 未结 1 816
深忆病人
深忆病人 2021-01-06 06:03

I am making a reading app and it has a full screen activity.
When user selects the part of the text a contextual action bar appears with option of copy. Thi

相关标签:
1条回答
  • 2021-01-06 06:40

    I don't know how Play Books achieves this, but you could create a PopupWindow and calculate where to position it based on the selected text using Layout.getSelectionPath and a little bit of math. Basically, we're going to:

    • Calculate the bounds of the selected text
    • Calculate the bounds and initial location of the PopupWindow
    • Calculate the difference between the two
    • Offset the PopupWindow to rest center horizontally/vertically above or below the selected text

    Calculating the selection bounds

    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.

    So, the specified offsets in our case would be the start and end of the selection, which can be found using 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);
    

    Now that we have a Rect of the selected text bounds, we can choose where we want to place the PopupWindow relative to it. In this case, we'll center it horizontally along the top or bottom of the selected text, depending on how much space we have to display our popup.

    Calculating the initial popup coordinates

    Next we'll need to calculate the bounds of the popup content. To do this, we'll first need to call PopupWindow.showAtLocation, but the bounds of the View we inflate won't immediately be available, so I'd recommend using a ViewTreeObserver.OnGlobalLayoutListener to wait for them to become available.

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

    PopupWindow.showAtLocation requires:

    • A View to retrieve a valid Window token from, which just uniquely identifies the Window to place the popup in
    • An optional gravity, but in our case it'll be Gravity.TOP
    • Optional x/y offsets

    Since we can't determine the x/y offset until the popup content is laid out, we'll just initially place it at the default location. If you try to call PopupWindow.showAtLocation before the View you pass in has been laid out, you'll receive a WindowManager.BadTokenException, so you may consider using a ViewTreeObserver.OnGlobalLayoutListener to avoid that, but it mostly comes up when you have text selected and rotate your device.

        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;
    
    • View.getLocationOnScreen will return us the x/y coordinates for the popup content.
    • View.getLocalVisibleRect will return us the bounds of the popup content
    • View.getWindowVisibleDisplayFrame will return us the offsets to accommodate for the action bar, if present
    • View.getScrollY will return us the y offset for whatever scroll container our TextView is in (ScrollView in my case)
    • View.getLocationInWindow will return us the y offset for our TextView, in case the action bar pushes it down a little

    Once we've gotten all of the info we need, we can calculate the final starting x/y of the popup content and then use this to figure out the difference between them and the selected text Rect so we can PopupWindow.update to the new location.

    Calculating the offset popup coordinates

        // 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);
    

    If there's enough room to display the popup above the selected text, we'll put it there; otherwise, we'll offset it below the selected text. In my case, I have 16dp padding around my TextView, so that needs to be taken into account too. We'll end up with the final x and y location to offset the PopupWindow with.

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

    -1 here just represents the default width/height for we supplied for the PopupWindow, in our case it'll be ViewGroup.LayoutParams.WRAP_CONTENT

    Listening for selection changes

    We want the PopupWindow to update every time we change the selected text.

    An easy way to listen for selection changes is to subclass TextView and provide a callback to 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();
        }
    
    }
    

    Listening for scroll changes

    If you have a TextView in a scroll container like ScrollView, you may also want to listen for scroll changes so that you can anchor your popup while you're scrolling. An easy way to listen for those is to subclass ScrollView and provide a callback to 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();
        }
    
    }
    

    Creating an empty ActionMode.Callback

    Like you mention in your post, we'll need to return true in ActionMode.Callback.onCreateActionMode in order for our text to remain selectable. But we'll also need to call Menu.clear in ActionMode.Callback.onPrepareActionMode in order to remove all the items you may find in an ActionMode for selected text.

    /** 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;
        }
    
    }
    

    Now we can use 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) {
    
        }
    
    }
    

    Layouts

    This is the Activity layout we're using:

    <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>
    

    This is our popup layout:

    <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>
    

    These are our popup button styles:

    <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

    The ViewUtils.onGlobalLayout you'll see is just a util method for handling some ViewTreeObserver.OnGlobalLayoutListener boilerplate.

    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);
    }
    

    Bringing it altogether

    So, now that we've:

    • Calculated the selected text bounds
    • Calculated the popup bounds
    • Calculated the difference and determined the popup offsets
    • Provided a way to listen for scroll changes and selection changes
    • Created our Activity and popup layouts

    Bringing everything together may look something like:

    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;
            }
    
        }
    
    }
    

    Results

    With the action bar (link to video):

    Without the action bar (link to video):

    Bonus - animation

    Because we know the starting location of the PopupWindow and the offset location as the selection changes, we can easily perform a linear interpolation between the two values to create a nice animation when we're moving things around.

    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 {
            ...
        }
    }
    

    Results

    With the action bar - animation (link to video)

    Extra

    I don't go into how to attach on click listeners to the popup actions and there are probably several ways to achieve this same effect with different calculations and implementations. But I will mention that if you wanted to retrieve the selected text and then do something with it, you'd just need to CharSequence.subSequence the min and max from the selected text.

    Anyway, I hope this has been helpful! Let me know if you have any questions.

    0 讨论(0)
提交回复
热议问题