Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

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. This is the default behaviour. But this actionbar blocks the text under it so user cannot select it.

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

I tried returning false from onCreateActionMode but when i do this i can't select the text either.

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

like image 817
Salih Erikci Avatar asked Dec 13 '22 23:12

Salih Erikci


1 Answers

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

with action bar - no animation

Without the action bar (link to video):

without action bar - no animation

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)

with action bar - animation

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.

like image 137
adneal Avatar answered Mar 07 '23 22:03

adneal