Android WebView (完全)自定义文本选择弹窗

1,566 阅读7分钟

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

总结:

通过上述两个步骤,完整实现了对选中文本的监听回调:

  1. JavaScript 监听文本选择。捕获 selectionchange 事件,并获取选中文本和位置信息。
  2. 回调到客户端。使用注入的 JavaScriptInterface 方法将选中文本信息传递到 Kotlin,并解析为自定义对象。

接下来,利用这些回调信息,可以进一步实现自定义弹窗的展示。




1.3 展示自定义弹窗

展示弹窗的相关逻辑已封装在一个帮助类中,方便外部调用和维护。

实现逻辑

  1. 传入自定义弹窗 View 。外部将自定义的弹窗视图(contentPopupView)传递给帮助类,用于显示选中文本后的操作界面。

  2. 接收选中文本回调。 在 addTextSelectionJsInterface 中获取文本选择的回调信息(包括选中文本及位置信息)。

  3. 计算并更新弹窗位置。通过 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 中完全自定义的文本选择弹窗,主要包括以下关键点:

  1. 屏蔽系统默认弹窗 替代系统默认的文本选择弹窗,实现完全自定义的 UI 样式和交互逻辑。
  2. 监听选中文本回调 结合 JavaScriptKotlin,通过 selectionchange 事件监听选中文本,并将相关信息回调到客户端。
  3. 自定义弹窗承载 使用 PopupWindow 作为弹窗的容器,支持灵活替换自定义的 View
  4. 动态位置计算与展示 通过选中文本的位置信息和 WebView 的可见区域数据,计算弹窗显示位置,同时适配 WebView 滑动等动态场景。

通过这些步骤,实现了一个灵活、可扩展的文本选择弹窗解决方案,既保留了系统文本选择游标的优势,又完全满足 UI 和交互的个性化需求。