Android WebView (完全)自定义文本选择弹窗
当前一些常见自定义弹窗大多数自定义文本选择弹窗方案,通常是在系统默认弹窗基础上进行文案和点击事件的修改,难以实现完全自定义的需求。
本文将提供一种完全自定义 UI 样式的思路与实现方案:
- 支持完全自定义 UI 样式;
- 仅需客户端开发即可实现,无需前端配合(但需要通过 JS 执行部分逻辑)。
效果图
1. 原理
思路:
通过复用系统提供的文本选择游标及选中效果,移除系统默认弹窗,并使用自定义的 View 进行替代。
核心实现点
- 屏蔽系统默认弹窗: 确保系统不再显示其默认的文本选择弹窗。
- 获取选中文本的相关信息: 包括选中内容、选中位置等,用于后续自定义逻辑处理。
- 展示自定义弹窗: 计算并展示自定义的 View
为什么复用系统的文本选择游标和选中效果?
- 在 TextView 的一些自定义文本选择器开源库中,通常需要自行绘制选中状态和游标。这种方式虽然可行,但在某些场景下表现可能不够理想,同时实现较为复杂。
- 对于 WebView 而言,自定义选中游标和选中效果的难度更高,因此复用系统实现更为合理和高效。
1.1 屏蔽系统弹窗
需要在WebView内部重写startActionMode方法方法并返回一个空sentinelActionMode对象。这样当长按后就不会展示系统弹窗了。
class CustomWebView(context: Context, attrs: AttributeSet) : WebView(context, attrs) {
private var supportCustomTextSelectionPop = true
override fun startActionMode(callback: ActionMode.Callback?): ActionMode? {
if (supportCustomTextSelectionPop) {
return sentinelActionMode
}
return super.startActionMode(callback)
}
override fun startActionMode(callback: ActionMode.Callback?, type: Int): ActionMode? {
if (supportCustomTextSelectionPop) {
return sentinelActionMode
}
return super.startActionMode(callback, type)
}
}
/**
* 参考自ViewGroup
*/
private val sentinelActionMode: ActionMode = object : ActionMode() {
override fun setTitle(title: CharSequence) {}
override fun setTitle(resId: Int) {}
override fun setSubtitle(subtitle: CharSequence) {}
override fun setSubtitle(resId: Int) {}
override fun setCustomView(view: View) {}
override fun invalidate() {}
override fun finish() {}
override fun getMenu(): Menu? {
return null
}
override fun getTitle(): CharSequence? {
return null
}
override fun getSubtitle(): CharSequence? {
return null
}
override fun getCustomView(): View? {
return null
}
override fun getMenuInflater(): MenuInflater? {
return null
}
}
其他:在网上有看到这样实现屏蔽弹窗的,在大部分手机上没有问题,但在一个魅族手机上发现没能屏蔽掉系统弹窗,所以优先使用上面方案。
override fun startActionMode(callback: ActionMode.Callback?, type: Int): ActionMode? {
val actionMode = super.startActionMode(callback, type)
return resolveActionMode(actionMode)
}
/**
* 处理item,处理点击
* @param actionMode
*/
private fun resolveActionMode(actionMode: ActionMode?): ActionMode? {
actionMode?.menu?.clear()
actionMode?.invalidateContentRect()
return actionMode
}
1.2 获取选择文本回调
这部分需要配合Javascript代码一同实现(端上直接执行Js代码),单靠Java/Kotlin代码没找到好办法实现了。
第一步:注入 JavascriptInterface 接口
-
在 WebView 初始化阶段,通过 addJavascriptInterface 注入一个 Java 对象,为 JavaScript 提供 onTextSelected 方法,用于接收选中文本和相关信息的回调。
-
Java 对象方法说明
-
提供 onTextSelected(value: String?) 方法,参数为约定好的 JSON 字符串,包含以下信息:
- 选中文本:text
- 位置信息:top、left、width、height
-
-
数据解析与回调
- 将接收到的 JSON 字符串解析为自定义数据类 WebTextSelectionParams,并通过回调函数传递给外部。
private const val TEXT_SELECTION_JS_INTERFACE_NAME = "TextSelectionJavascriptBridge"
/**
* 添加文本选择JavascriptInterface(用于接收文本选择回调)
*/
fun WebView.addTextSelectionJsInterface(callback: (WebTextSelectionParams) -> Unit) {
//启用javaScript
settings.javaScriptEnabled = true
//注入java对象
addJavascriptInterface(
object : Any() {
/**
* 提供给Js的方法
*/
@JavascriptInterface
fun onTextSelected(value: String?) {
//切换到主进程
MainScope().launch {
if (value != null && value != "null") {
try {
// 解析 JSON 返回的数据
val positionInfo = JSONObject(value)
val selectedText = positionInfo.getString("text")
val top = positionInfo.getDouble("top")
val left = positionInfo.getDouble("left")
val width = positionInfo.getDouble("width")
val height = positionInfo.getDouble("height")
// 回调将解析后的数据传递给外部
callback(WebTextSelectionParams(selectedText, top, left, width, height))
} catch (e: JSONException) {
e.printStackTrace()
callback(WebTextSelectionParams())
}
}
}
}
},
TEXT_SELECTION_JS_INTERFACE_NAME //提供给Js Java对象的名称
)
}
第二步:注册 JavaScript 文本选择监听
在此步骤中,通过注入的 JavaScript 接口,实现对选中文本的监听和回调,将相关信息传递到客户端。
- 注册监听事件:在 JavaScript 中监听 selectionchange 事件,获取当前选中的文本及其位置信息(顶部、左侧位置、高度、宽度等)。
- 调用注入的接口方法:将获取的文本信息打包为 JSON 字符串,并通过第一步中注入的 Java 方法 onTextSelected 传递到客户端。
- 执行 JavaScript 代码:使用 evaluateJavascript 方法在页面加载完成后注入监听代码。
(注意这个代码需要在页面加载完成后调用)
/**
* 执行 JavaScript 代码,为页面添加文本选择监听,并将选中信息回调到 [TEXT_SELECTION_JS_INTERFACE_NAME] 的 Java 对象
*
* 注意:此方法需要在页面加载完成后(例如 WebViewClient 的 onPageFinished 回调中)调用
*/
fun WebView.evaluateJsAddSelectionChangeEvent() {
val jsCode = """
document.addEventListener('selectionchange', function() {
var selection = window.getSelection();
if (selection.rangeCount > 0) {
var range = selection.getRangeAt(0);
var rect = range.getBoundingClientRect();
var selectedText = selection.toString();
var positionInfo = {
text: selectedText,
top: rect.top + window.scrollY,
left: rect.left + window.scrollX,
width: rect.width,
height: rect.height
};
$TEXT_SELECTION_JS_INTERFACE_NAME.onTextSelected(JSON.stringify(positionInfo));
}
});
""".trimIndent()
evaluateJavascript(jsCode, null)
}
总结:
通过上述两个步骤,完整实现了对选中文本的监听回调:
- JavaScript 监听文本选择。捕获 selectionchange 事件,并获取选中文本和位置信息。
- 回调到客户端。使用注入的 JavaScriptInterface 方法将选中文本信息传递到 Kotlin,并解析为自定义对象。
接下来,利用这些回调信息,可以进一步实现自定义弹窗的展示。
1.3 展示自定义弹窗
展示弹窗的相关逻辑已封装在一个帮助类中,方便外部调用和维护。
实现逻辑
-
传入自定义弹窗 View 。外部将自定义的弹窗视图(
contentPopupView)传递给帮助类,用于显示选中文本后的操作界面。 -
接收选中文本回调。 在
addTextSelectionJsInterface中获取文本选择的回调信息(包括选中文本及位置信息)。 -
计算并更新弹窗位置。通过
updatePopupPosition方法,结合以下信息进行位置计算和展示:- WebView 滑动距离 (Y)
- WebView 可见区域尺寸
- 选中文本的 Y 轴位置
- 边界检测逻辑
class WebViewTextSelectionPopupHelper(
private val webView: WebView,
private val contentPopupView: View,
) {
//……
init {
//注册文本选择回调
webView.addTextSelectionJsInterface {
processTextSelection(it)
}
}
fun onPageFinished() {
//执行JS代码
webView.evaluateJsAddSelectionChangeEvent()
}
// 更新弹窗位置
private fun updatePopupPosition(params: WebTextSelectionParams) {
if (popupWindow == null) {
return
}
curTextSelectionParams = params
webView.post {
runCatching {
if (popupWindow == null) {
return@post
}
val location = IntArray(2)
webView.getLocationOnScreen(location)
val webViewX = location[0]
val webViewY = location[1]
val scrollX = webView.scrollX
val scrollY = webView.scrollY
// 确保 popupView 已经完成测量
popupWindow!!.contentView.measure(
View.MeasureSpec.UNSPECIFIED,
View.MeasureSpec.UNSPECIFIED
)
val popupViewWidth = popupWindow!!.contentView.measuredWidth
val popupViewHeight = popupWindow!!.contentView.measuredHeight
// 获取 WebView 的实际显示区域
val webViewHeight = webView.height
val webViewWidth = webView.width
val webViewBottomY = webViewY + webViewHeight
// 选中文案的位置
val currentX = params.leftPx
val currentY = params.topPx
val currentWidth = min(webViewWidth, params.widthPx)
val currentHeight = params.heightPx
// 计算弹窗的位置
var popupY =
webViewY + currentY - scrollY - popupViewHeight - contentDisplayMargin
var popupX =
webViewX + currentX - scrollX + currentWidth / 2 - popupViewWidth / 2
// 获取屏幕的可见区域
val screenRect = Rect()
webView.getWindowVisibleDisplayFrame(screenRect)
// 1. 判断弹窗在 WebView 顶部区域能否展示
var isPopupVisible = false
if (popupY >= defaultTopSize && popupY + popupViewHeight <= webViewBottomY) {
// 1.1 若能展示,则判断是否超出屏幕的顶部展示范围
if (popupY >= screenRect.top && popupY + popupViewHeight <= screenRect.bottom) {
isPopupVisible = true
}
}
// 2. 若顶部不能展示,则计划展示在底部
if (!isPopupVisible) {
popupY =
webViewY + currentY - scrollY + currentHeight + contentDisplayMargin
// 2.1 判断弹窗在底部展示是否超过 WebView 底部区域
if (popupY >= webViewY && popupY + popupViewHeight <= webViewBottomY) {
// 2.2 若能展示,则判断是否超出屏幕的底部展示范围
if (popupY >= screenRect.top && popupY + popupViewHeight <= screenRect.bottom) {
isPopupVisible = true
}
}
}
if (!isPopupVisible) {
// 获取 WebView 可视区域
val visibleRect = Rect()
webView.getGlobalVisibleRect(visibleRect)
// 判断选中区域是否覆盖 WebView 的整个可视区域(只考虑 Y 方向)
val isEntireScreenSelection =
params.topPx <= visibleRect.top + webView.scrollY + popupViewHeight
&& params.bottomPx > visibleRect.bottom + webView.scrollY - webViewHeight
// && params.bottomPx >= visibleRect.bottom
if (isEntireScreenSelection) {
// 选中区域包括整个屏幕,展示在屏幕中心偏上位置
popupX = (visibleRect.width() - popupViewWidth) / 2
popupY = (visibleRect.height() - popupViewHeight) / 3 // 偏上显示,距离屏幕中心偏上位置
isPopupVisible = true
// 确保弹窗位置在屏幕范围内
try {
popupX = popupX.coerceIn(
visibleRect.left,
visibleRect.right - popupViewWidth
)
popupY = popupY.coerceIn(
visibleRect.top,
visibleRect.bottom - popupViewHeight
)
} catch (e: Exception) {
e.printStackTrace()
isPopupVisible = false
}
}
}
// 3. 若有一项可以展示,则展示弹窗
// 4. 否则隐藏弹窗
if (isPopupVisible) {
if (popupWindow!!.isShowing) {
popupWindow!!.update(popupX, popupY, popupViewWidth, popupViewHeight)
} else {
popupWindow!!.showAtLocation(
webView,
Gravity.NO_GRAVITY,
popupX,
popupY
)
callback?.show(this@WebViewTextSelectionPopupHelper)
}
} else {
dismiss()
}
}
}
}
}
addOnScrollChangedListener监听WebView的滑动,确保滑动时更新弹窗位置:
//滑动时需要动态更新弹窗位置
webView.viewTreeObserver.addOnScrollChangedListener {
val visibleRect = Rect()
val isVisible = webView.getGlobalVisibleRect(visibleRect)
if (isVisible) {
if (selectionActive && curTextSelectionParams != null) {
updatePopupPosition(curTextSelectionParams!!)
}
} else {
cancelSelected()
}
}
总结
本文实现了 WebView 中完全自定义的文本选择弹窗,主要包括以下关键点:
- 屏蔽系统默认弹窗 替代系统默认的文本选择弹窗,实现完全自定义的 UI 样式和交互逻辑。
- 监听选中文本回调 结合
JavaScript和Kotlin,通过selectionchange事件监听选中文本,并将相关信息回调到客户端。 - 自定义弹窗承载 使用
PopupWindow作为弹窗的容器,支持灵活替换自定义的View。 - 动态位置计算与展示 通过选中文本的位置信息和 WebView 的可见区域数据,计算弹窗显示位置,同时适配 WebView 滑动等动态场景。
通过这些步骤,实现了一个灵活、可扩展的文本选择弹窗解决方案,既保留了系统文本选择游标的优势,又完全满足 UI 和交互的个性化需求。