How to show popup instead of CAB when selecting text?

I am making a reader application and it has full screen activity.
When the user selects a piece of text, a contextual action bar

copy option appears . This is the default behavior. But this action bar locks the text below it, so the user cannot select it.

I want to show a popup like below.
enter image description here

I tried to return false

from onCreateActionMode

, but when I do, I can't select the text either.

I want to know if there is a standard way to achieve this as many reading apps use this design.

+3


source to share


1 answer


I don't know how Play Books achieves this, but you can create PopupWindow

and calculate where to place it based on the selected text using a Layout.getSelectionPath

little math. Basically, we're going to:

  • Calculate the boundaries of the selected text
  • Calculate boundaries and starting location PopupWindow

  • Calculate the difference between the two
  • Slide PopupWindow

    to drop center horizontally / vertically above or below selected text

Computing selection boundaries

From the docs :

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

Thus, the indicated offsets in our case will be the beginning and end of the selection, which can be found using Selection.getSelectionStart

and Selection.getSelectionEnd

. For convenience, TextView

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

the borders of the text selected, we can choose where we want to position 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 starting coordinates of pop-up windows

Next, we need to calculate the boundaries of the popup content. This needs to be called first PopupWindow.showAtLocation

, but the borders View

we're inflating won't be immediately available, so I would recommend using ViewTreeObserver.OnGlobalLayoutListener

to wait for them to appear.

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

      

PopupWindow.showAtLocation

required:

  • A View

    to retrieve a valid Window

    token
    that just uniquely identifies Window

    to place the popup
  • Optional gravity, but in our case it would be Gravity.TOP

  • Optional x / y offsets

Since we cannot determine the x / y offset until the content of the popup has been laid out, we will place it by default first. If you try to summon PopupWindow.showAtLocation

before you get through View

, you get WindowManager.BadTokenException

, so you can use ViewTreeObserver.OnGlobalLayoutListener

to avoid this, but it mostly happens when you select text 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;

      

Once we have all the information we need, we can calculate the final initial x / y of the popup content and then use that to figure out the difference between them and the selected text Rect

so we can move PopupWindow.update

to a new location.

Calculating Offset Floating 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 is enough space to display the popup above the selected text, we will place it there; otherwise we will compensate for it below the selected text. In my case, I have an 16dp

padding around mine TextView

, so this needs to be considered as well. We'll finish with the final location x

and y

for the offset PopupWindow

with.

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

      

-1

here just represents the default width / height we provided for PopupWindow

, in our case it would beViewGroup.LayoutParams.WRAP_CONTENT

Listening for selection changes

We want to PopupWindow

update every time we change the selected text.

An easy way to listen for selection changes is to subclass TextView

and provide a callback 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 TextView

scrolling in the container, for example ScrollView

, you might also need to listen for scroll changes so you can bind the popup while scrolling. The easy way to listen is to subclass ScrollView

and provide a callbackView.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();
    }

}

      

Making empty ActionMode.Callback

As you mentioned in your post, we need to revert to true

in ActionMode.Callback.onCreateActionMode

order for our text to remain selectable, but we will also need to call Menu.clear

in ActionMode.Callback.onPrepareActionMode

to remove all elements that you can find in ActionMode

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

}

      

We can now use TextView.setCustomSelectionActionModeCallback

to apply our custom ActionMode

. SimpleActionModeCallback

is a custom class that simply provides stubs for things ActionMode.Callback

likeViewPager.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 layout Activity

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

ViewUtils.onGlobalLayout

you will see is just a utility method for handling some pattern 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);
}

      

Bringing in general

So now we have:

  • Calculate selected text borders
  • Calculating pop-ups
  • Calculated the difference and determined the offsets of the popups
  • A way to listen for scrolling changes and selection changes is provided.
  • Our Activity

    and Popup Layouts Created

Bringing everything together might look something like this:

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 action bar (video link) :

with action bar - no animation

Without action bar (link to video) :

no action bar - no animation

Bonus - animation

Since we know the starting location PopupWindow

and the offset location as the selection changes, we can easily do linear interpolation between these two values ​​to create a nice animation as we move 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 action bar - animation (video link)

with action bar - animation

Extra

I don't understand how to attach click listeners to popup actions, and there are probably several ways to achieve the same effect across different computations and implementations. But I mentioned that if you want to get the selected text, and then do something with it, you just need to CharSequence.subSequence

min

and max

from the selected text
.

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

+7


source







All Articles