选择文本视图时如何显示弹出窗口而不是 CAB?
How to display popup instead of CAB when textview is selected?
我正在制作一个阅读应用程序,它有一个全屏 activity。
当用户 select 的文本部分出现 contextual action bar
并带有复制选项。这是默认行为。但是此操作栏会阻止其下方的文本,因此用户无法 select 它。
我想显示如下所示的弹出窗口 window。
我尝试从 onCreateActionMode
返回 false
但是当我这样做时我也不能 select 文本。
我想知道是否有一种标准的方法可以实现这一点,因为许多阅读应用程序都使用这种设计。
我不知道 Play Books 如何做到这一点,但您可以创建一个 PopupWindow
and calculate where to position it based on the selected text using Layout.getSelectionPath
和一点数学。基本上,我们要:
- 计算所选文本的边界
- 计算
PopupWindow
的边界和初始位置
- 计算两者的差值
- 将
PopupWindow
偏移到所选文本上方或下方的休息中心 horizontally/vertically
正在计算选择范围
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.
因此,在我们的例子中指定的偏移量将是选择的开始和结束,可以使用 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);
现在我们有一个 Rect
的选定文本边界,我们可以选择我们想要放置 PopupWindow
相对于它的位置。在这种情况下,我们会将其沿所选文本的顶部或底部水平居中,具体取决于我们必须显示多少 space 我们的弹出窗口。
计算初始弹出坐标
接下来我们需要计算弹出内容的范围。为此,我们首先需要调用 PopupWindow.showAtLocation
, but the bounds of the View
we inflate won't immediately be available, so I'd recommend using a ViewTreeObserver.OnGlobalLayoutListener
以等待它们可用。
popupWindow.showAtLocation(yourTextView, Gravity.TOP, 0, 0)
PopupWindow.showAtLocation
要求:
- A
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;
View.getLocationOnScreen
将 return 我们使用弹出内容的 x/y 坐标。
View.getLocalVisibleRect
将 return 我们弹出内容的边界
View.getWindowVisibleDisplayFrame
将 return 使用偏移量以适应操作栏,如果存在的话
View.getScrollY
将 return 为我们的 TextView
所在的任何滚动容器提供 y
偏移量(ScrollView
在我的例子中)
View.getLocationInWindow
将 return 我们 TextView
的 y
偏移量,以防操作栏将其向下推一点
一旦我们获得了我们需要的所有信息,我们就可以计算弹出内容的最终开始 x/y,然后用它来找出它们与所选文本之间的区别 Rect
这样我们就可以 PopupWindow.update
到新位置了。
计算偏移弹出坐标
// 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
填充,因此也需要考虑这一点。我们将以最终的 x
和 y
位置结束,以偏移 PopupWindow
。
popupWindow.update(x, y, -1, -1);
-1
这里只是表示我们为 PopupWindow
提供的默认 width/height,在我们的例子中它将是 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();
}
}
正如您在 post 中提到的,我们需要 return true
在 ActionMode.Callback.onCreateActionMode
in order for our text to remain selectable. But we'll also need to call Menu.clear
in ActionMode.Callback.onPrepareActionMode
中删除您可能在 ActionMode.Callback.onCreateActionMode
in order for our text to remain selectable. But we'll also need to call Menu.clear
in ActionMode.Callback.onPrepareActionMode
中找到的所有项目=82=] 用于选定的文本。
/** 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
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) {
}
}
布局
这是我们正在使用的 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>
Util
您将看到的 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.
无论如何,我希望这对您有所帮助!如果您有任何问题,请告诉我。
我正在制作一个阅读应用程序,它有一个全屏 activity。
当用户 select 的文本部分出现 contextual action bar
并带有复制选项。这是默认行为。但是此操作栏会阻止其下方的文本,因此用户无法 select 它。
我想显示如下所示的弹出窗口 window。
我尝试从 onCreateActionMode
返回 false
但是当我这样做时我也不能 select 文本。
我想知道是否有一种标准的方法可以实现这一点,因为许多阅读应用程序都使用这种设计。
我不知道 Play Books 如何做到这一点,但您可以创建一个 PopupWindow
and calculate where to position it based on the selected text using Layout.getSelectionPath
和一点数学。基本上,我们要:
- 计算所选文本的边界
- 计算
PopupWindow
的边界和初始位置
- 计算两者的差值
- 将
PopupWindow
偏移到所选文本上方或下方的休息中心 horizontally/vertically
正在计算选择范围
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.
因此,在我们的例子中指定的偏移量将是选择的开始和结束,可以使用 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);
现在我们有一个 Rect
的选定文本边界,我们可以选择我们想要放置 PopupWindow
相对于它的位置。在这种情况下,我们会将其沿所选文本的顶部或底部水平居中,具体取决于我们必须显示多少 space 我们的弹出窗口。
计算初始弹出坐标
接下来我们需要计算弹出内容的范围。为此,我们首先需要调用 PopupWindow.showAtLocation
, but the bounds of the View
we inflate won't immediately be available, so I'd recommend using a ViewTreeObserver.OnGlobalLayoutListener
以等待它们可用。
popupWindow.showAtLocation(yourTextView, Gravity.TOP, 0, 0)
PopupWindow.showAtLocation
要求:
- A
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;
View.getLocationOnScreen
将 return 我们使用弹出内容的 x/y 坐标。View.getLocalVisibleRect
将 return 我们弹出内容的边界View.getWindowVisibleDisplayFrame
将 return 使用偏移量以适应操作栏,如果存在的话View.getScrollY
将 return 为我们的TextView
所在的任何滚动容器提供y
偏移量(ScrollView
在我的例子中)View.getLocationInWindow
将 return 我们TextView
的y
偏移量,以防操作栏将其向下推一点
一旦我们获得了我们需要的所有信息,我们就可以计算弹出内容的最终开始 x/y,然后用它来找出它们与所选文本之间的区别 Rect
这样我们就可以 PopupWindow.update
到新位置了。
计算偏移弹出坐标
// 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
填充,因此也需要考虑这一点。我们将以最终的 x
和 y
位置结束,以偏移 PopupWindow
。
popupWindow.update(x, y, -1, -1);
-1
这里只是表示我们为 PopupWindow
提供的默认 width/height,在我们的例子中它将是 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();
}
}
正如您在 post 中提到的,我们需要 return true
在 ActionMode.Callback.onCreateActionMode
in order for our text to remain selectable. But we'll also need to call Menu.clear
in ActionMode.Callback.onPrepareActionMode
中删除您可能在 ActionMode.Callback.onCreateActionMode
in order for our text to remain selectable. But we'll also need to call Menu.clear
in ActionMode.Callback.onPrepareActionMode
中找到的所有项目=82=] 用于选定的文本。
/** 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
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) {
}
}
布局
这是我们正在使用的 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>
Util
您将看到的 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.
无论如何,我希望这对您有所帮助!如果您有任何问题,请告诉我。