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.
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.
source to share
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
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 validWindow
token that just uniquely identifiesWindow
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;
-
View.getLocationOnScreen
will return us the x / y coordinates for the popup content. -
View.getLocalVisibleRect
will return us the borders of the popup content -
View.getWindowVisibleDisplayFrame
will return us the offsets to place the action bar, if present -
View.getScrollY
will return us the offsety
for whatever scroll container ours is inTextView
(ScrollView
in my case) -
View.getLocationInWindow
will return us an offsety
for ourTextView
, in case the action bar pushes it a little
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) :
Without action bar (link to video) :
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)
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.
source to share