
1. 项目缘起一个被忽视的交互痛点在Android应用开发里TextView大概是出场率最高的控件之一了。我们用它来展示文字用户也习惯了长按它来复制、粘贴或者全选。这个默认的文本操作菜单系统已经帮我们封装好了用起来似乎很省心。但不知道你有没有遇到过这样的场景你的应用里有一段特殊的文本比如一个商品编号、一个订单号或者一个需要用户快速执行特定操作的标签。用户长按后弹出的还是那老三样——“复制”、“全选”、“粘贴”。用户想直接搜索这个编号想快速分享给客服或者想一键添加到收藏夹对不起系统没提供这个选项。这就是我们今天要聊的“自定义长按菜单”的由来。它不是一个炫技的功能而是一个实实在在的、提升用户体验和操作效率的解决方案。当默认的、通用的交互无法满足你特定业务场景下的需求时你就需要把这个交互的“控制权”拿回来定制一个属于你自己应用的、更贴心的长按菜单。2. 核心机制拆解系统菜单是如何工作的在动手改造之前我们必须先搞清楚系统默认的文本操作菜单是怎么弹出来的。这就像你要改装一辆车总得先知道它的发动机和传动系统在哪。理解了这个机制我们才能知道在哪里“动手术”最合适也才能避免把整个交互逻辑搞砸。2.1TextView的默认长按行为链当你长按一个TextView时背后触发了一系列复杂的调用。简单来说流程是这样的长按事件触发TextView检测到用户的长按手势ACTION_DOWN事件后经过一定时间阈值。尝试启动文本选择TextView会尝试进入文本选择模式。它会调用内部方法准备高亮选中的文本区域。显示浮动操作菜单如果文本选择成功启动即TextView是可选择的textIsSelectable属性为true或者实现了MovementMethod系统会调用一个名为startActionMode的方法。这个方法会启动一个ActionMode这个ActionMode就是承载那个浮动菜单也叫上下文操作栏CAB的幕后管理者。创建默认菜单系统ActionMode.Callback会创建默认的菜单项如“复制”、“全选”等并处理这些菜单项的点击事件。这里的关键在于第3步的startActionMode。系统默认的文本操作菜单本质上是一个ActionMode。我们要自定义菜单核心思路就是拦截这个流程用我们自己的ActionMode.Callback去替换系统的。2.2 关键APIActionMode与ActionMode.Callback这是实现自定义菜单的两个核心类。ActionMode 你可以把它理解为一个“临时的工作模式”。当它启动时通常会覆盖掉应用栏ActionBar/Toolbar或者在控件附近显示一个浮动工具栏Floating Action Mode。文本选择菜单就是后一种形式。它负责管理这个临时模式的整个生命周期创建、显示、销毁。ActionMode.Callback 这是ActionMode的回调接口是我们实现自定义逻辑的“剧本”。我们需要实现这个接口并在其中告诉系统onCreateActionMode 模式创建时我们需要创建哪些菜单项MenuItem。onPrepareActionMode 在每次显示前是否可以准备或更新菜单项例如根据选中文本内容动态改变菜单。onActionItemClicked 当用户点击了我们创建的某个菜单项时我们应该执行什么操作。onDestroyActionMode 模式销毁时用户点击外部或完成操作我们需要做哪些清理工作。我们的目标就是编写一个自定义的ActionMode.Callback并在TextView尝试启动文本选择菜单时将这个自定义的Callback设置进去。3. 实战三种主流实现方案对比与选型知道了原理接下来就是如何实现。根据你对控制粒度、兼容性以及实现复杂度的不同要求主要有三种路径。我会详细拆解每一种并告诉你它们各自的“脾气”。3.1 方案一重写TextView的startActionMode方法最直接这是最暴力也是最直接的一种方式。既然系统菜单是通过TextView的startActionMode方法启动的那我们继承TextView重写这个方法直接返回一个我们定制好的ActionMode。class CustomMenuTextView JvmOverloads constructor( context: Context, attrs: AttributeSet? null, defStyleAttr: Int android.R.attr.textViewStyle ) : AppCompatTextView(context, attrs, defStyleAttr) { // 自定义的ActionMode.Callback private val customCallback object : ActionMode.Callback2() { override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean { // 创建自定义菜单项 menu?.apply { add(“搜索”).setIcon(R.drawable.ic_search).setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM) add(“分享”).setIcon(R.drawable.ic_share).setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM) add(“收藏”).setIcon(R.drawable.ic_favorite).setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM) // 你甚至可以添加系统的“复制”功能 add(“复制”).setIcon(android.R.drawable.ic_menu_copy).setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM) } return true // 返回true表示成功创建 } override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean { // 可以在这里根据选中文本动态更新菜单例如选中了链接才显示“打开链接” return false // 返回false表示不需要每次重新创建菜单 } override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean { when (item?.itemId) { // 注意这里itemId是自动生成的我们用了orderInCategory来区分更好的做法是使用MenuItem的setId // 这里为了演示简洁用title判断实际项目不推荐 “搜索” - { val selectedText text?.substring(selectionStart, selectionEnd) ?: “” // 执行搜索逻辑 Toast.makeText(context, “搜索: $selectedText”, Toast.LENGTH_SHORT).show() mode?.finish() // 操作完成后关闭菜单 return true } “复制” - { // 调用系统剪贴板服务 val clipboard context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val clip ClipData.newPlainText(“label”, text?.substring(selectionStart, selectionEnd)) clipboard.setPrimaryClip(clip) mode?.finish() return true } } return false } override fun onDestroyActionMode(mode: ActionMode?) { // 清理资源 } } override fun startActionMode(callback: ActionMode.Callback?): ActionMode? { // 完全接管使用我们自己的Callback return super.startActionMode(customCallback) } // 为了兼容更多情况最好也重写这个带type参数的方法 override fun startActionMode(callback: ActionMode.Callback?, type: Int): ActionMode? { return if (type ActionMode.TYPE_FLOATING) { // 对于浮动类型文本菜单使用自定义Callback super.startActionMode(customCallback, type) } else { // 其他类型如上下文ActionBar使用默认行为 super.startActionMode(callback, type) } } }方案一评价优点 控制力最强可以完全自定义菜单内容和行为连系统的“复制”等功能都可以选择性地保留或替换。缺点侵入性强 你需要为每一个需要自定义菜单的TextView使用这个自定义类或者在布局中大量替换。兼容性风险 直接重写系统关键方法未来Android版本如果内部逻辑变更可能存在风险。菜单项管理粗糙 如上例所示用title来判断点击哪个菜单项是非常不规范的容易出错。正确做法是使用MenuItem.setId()并配合资源ID。实操心得一MenuItem的ID管理在onCreateActionMode中一定要为每个MenuItem设置一个唯一的IDMenuItem.setId(xxx)最好使用资源IDR.id.menu_search。在onActionItemClicked中通过item.itemId来判断这是标准做法。用title或order判断在字符串国际化多语言时会是灾难。3.2 方案二设置自定义的MovementMethod更优雅TextView的文本选择和长按行为其实是由一个叫MovementMethod的接口来控制的。默认情况下可选择的TextView使用的是LinkMovementMethod或其变体。我们可以通过设置一个自定义的MovementMethod来影响长按后的行为。这个方法的思路是我们并不完全阻止系统菜单而是在系统菜单创建之前或之后插入我们自己的逻辑。但更常见的做法是在自定义的MovementMethod里直接触发我们自己的ActionMode。class CustomLongClickMovementMethod : LinkMovementMethod() { override fun onTouchEvent(widget: TextView?, buffer: Spannable?, event: MotionEvent?): Boolean { val handled super.onTouchEvent(widget, buffer, event) // 在父类处理完后我们可以检测是否是长按选择文本后的状态 // 一个更直接的思路在自定义TextView中设置此MovementMethod并重写其performLongClick return handled } } // 在自定义TextView中使用 class CustomMenuTextView2 : AppCompatTextView { // ... 构造器省略 init { movementMethod CustomLongClickMovementMethod() // 关键设置一个长按监听器 setOnLongClickListener { // 在这里启动我们自定义的ActionMode startActionMode(customCallback, ActionMode.TYPE_FLOATING) // 返回true消费掉长按事件阻止系统默认处理 true } } }方案二评价优点 比方案一稍显优雅通过组合设置监听器而非继承重写关键方法侵入性降低。缺点逻辑耦合 需要MovementMethod和OnLongClickListener配合逻辑分散。文本选择丢失 直接消费长按事件并启动自定义菜单会导致系统原生的文本选择功能失效。用户长按后无法高亮选择文本只能直接弹出你的菜单。这对于需要先选中部分文本再操作的需求来说是致命的。实操心得二长按监听与文本选择的冲突这是新手最容易踩的坑。TextView.setOnLongClickListener一旦返回true就意味着你完全接管了长按事件系统后续的文本选择逻辑就不会再执行。如果你的自定义菜单操作依赖于选中的文本内容那么这种做法就不可取。你需要确保在弹出菜单时文本已经被正确选中。方案一之所以更可靠是因为它在系统启动文本选择流程后才介入。3.3 方案三使用TextView的customSelectionActionModeCallback属性最推荐从Android API 236.0开始TextView提供了一个属性customSelectionActionModeCallback。看名字就知道它是专门为了“自定义选择操作模式回调”而生的。这是官方留给我们的“后门”也是最推荐、最标准的方式。它的原理是系统在创建文本选择ActionMode时会先检查你是否设置了这个Callback。如果设置了系统会将你的Callback和它自己的Callback进行包装合并。这样你既可以添加自己的菜单项又可以保留系统的“复制”、“全选”等基础功能。class CustomMenuTextView3 : AppCompatTextView { // ... 构造器省略 private val customSelectionCallback object : ActionMode.Callback { override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean { // 系统会先调用自己的Callback创建默认菜单然后才调用我们的 // 所以我们在这里添加的菜单项会出现在默认菜单的后面或前面取决于顺序 menuInflater?.inflate(R.menu.text_selection_custom, menu) // 你可以调整菜单项顺序这里添加的项默认在系统项之后 return true // 必须返回true } override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean { // 这里可以根据选中的文本动态更新**我们自己的**菜单项状态 val selectedText getSelectedText() val searchItem menu?.findItem(R.id.menu_search) searchItem?.isEnabled !selectedText.isNullOrBlank() return true // 返回true表示菜单内容有变化需要刷新 } override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean { return when (item?.itemId) { R.id.menu_search - { handleSearch(getSelectedText()) mode?.finish() true } R.id.menu_share - { handleShare(getSelectedText()) // 分享后可以不关闭菜单 false } else - false // 不是我们的菜单项交给系统或其他Callback处理 } } override fun onDestroyActionMode(mode: ActionMode?) { // 清理 } private fun getSelectedText(): String? { return text?.substring(selectionStart, selectionEnd)?.toString() } } init { // 关键一行代码 customSelectionActionModeCallback customSelectionCallback // 确保TextView是可选择的 isTextSelectable true // 或者 movementMethod LinkMovementMethod.getInstance() } } // res/menu/text_selection_custom.xml ?xml version“1.0” encoding“utf-8”? menu xmlns:android“http://schemas.android.com/apk/res/android” xmlns:app“http://schemas.android.com/apk/res-auto” item android:id“id/menu_search” android:title“搜索” android:icon“drawable/ic_search” app:showAsAction“ifRoom” / item android:id“id/menu_share” android:title“分享” android:icon“drawable/ic_share” app:showAsAction“ifRoom” / /menu方案三评价优点官方支持 这是Google推荐的方式兼容性和稳定性最好。非侵入性 无需重写核心方法只需设置一个属性。保留系统功能 可以完美保留“复制”、“全选”等系统原生功能与自定义菜单项和谐共存。菜单资源化 可以使用标准的Menu XML资源文件来定义菜单结构清晰易于管理多语言和图标。缺点API Level限制 仅支持API 23及以上。对于需要支持更低版本的应用需要做兼容性判断。控制力稍弱 你不能删除或重新排序系统原生的菜单项在某些深度定制的ROM上可能表现不一致。方案选型总结对于新项目或目标API级别在23以上的项目无脑选择方案三。它是平衡了功能、稳定性和开发效率的最佳实践。如果需要支持旧版本可以采用方案三为主在低版本上优雅降级为方案一通过版本判断并接受无法同时保留系统菜单的折衷。方案二除非有非常特殊的场景否则不推荐作为主要方案。4. 深度优化与高级技巧实现了基础功能只是第一步要让这个自定义菜单真正好用、稳定还需要考虑很多细节。4.1 动态菜单根据选中内容智能变化一个死板的菜单是愚蠢的。好的交互应该能感知上下文。例如只有当选中文本看起来像一个URL时才显示“打开链接”或“分享链接”当选中的是数字时高亮“拨号”或“计算”菜单。这需要在ActionMode.Callback的onPrepareActionMode方法中实现。override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean { val selectedText getSelectedText() val isLink selectedText?.matches(Regex(“https?://.*”)) ?: false val isPhoneNumber selectedText?.matches(Regex(“^[\\d\\-()\\s]$”)) ?: false menu?.findItem(R.id.menu_open_link)?.isVisible isLink menu?.findItem(R.id.menu_call)?.isVisible isPhoneNumber menu?.findItem(R.id.menu_search)?.isEnabled !selectedText.isNullOrBlank() selectedText.length 1 // 返回true表示菜单需要更新 return true }实操心得三onPrepareActionMode的调用时机onPrepareActionMode在每次菜单显示前都会被调用包括第一次创建后和每次选中文本变化后。但要注意选中文本变化触发菜单更新存在延迟。系统并非实时监测选中变化通常是在用户手指抬起或选择手柄移动稳定后。因此对于实时性要求极高的动态菜单此方案可能有细微延迟感。4.2 获取准确的选中文本这是所有操作的基础。在ActionMode.Callback的方法中如何拿到当前TextView选中的文本private fun TextView.getSelectedText(): CharSequence? { return if (selectionStart 0 selectionEnd selectionStart) { text.subSequence(selectionStart, selectionEnd) } else { null } }关键点selectionStart和selectionEnd是TextView的属性直接调用即可。但务必注意边界判断因为用户可能只是长按但没有拖动选择此时selectionStart selectionEnd选中的文本长度为0。4.3 处理ActionMode的生命周期与内存泄漏ActionMode.Callback通常以匿名内部类的形式创建它会隐式持有外部类通常是Activity或Fragment的引用。如果ActionMode因为某些原因没有正确销毁比如配置变更导致Activity重建就可能造成内存泄漏。最佳实践在Activity或Fragment中管理ActionMode.Callback并在onDestroy或合适的生命周期回调中主动清空TextView的引用。class MyActivity : AppCompatActivity() { private lateinit var textView: TextView private var actionMode: ActionMode? null private val actionModeCallback object : ActionMode.Callback { // ... 实现方法 override fun onDestroyActionMode(mode: ActionMode?) { actionMode null // 清除引用 } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) textView findViewById(R.id.text_view) textView.customSelectionActionModeCallback actionModeCallback textView.setOnLongClickListener { // 或者在这里启动并保存引用 actionMode startActionMode(actionModeCallback, ActionMode.TYPE_FLOATING) true } } override fun onDestroy() { super.onDestroy() // 安全起见主动结束ActionMode actionMode?.finish() textView.customSelectionActionModeCallback null } }4.4 样式与主题定制默认的浮动操作菜单样式可能和你的应用主题不搭。你可以通过主题属性来定制它。在res/values/styles.xml中为你的Activity主题添加或修改以下属性style name“AppTheme” parent“Theme.MaterialComponents.Light.NoActionBar” !-- 浮动操作菜单的背景色 -- item name“actionModeStyle”style/MyActionModeStyle/item /style style name“MyActionModeStyle” parent“Widget.AppCompat.ActionMode” item name“background”color/your_custom_color/item item name“height”48dp/item /style更精细的控制比如菜单项的文字颜色、图标色调可能需要通过定义actionModeCloseDrawable等属性或者直接在ActionMode.Callback的onCreateActionMode中通过mode对象获取菜单视图进行修改。但后者兼容性较差不推荐。5. 避坑指南那些我踩过的“坑”在实际项目中实现这个功能远非复制粘贴那么简单。下面是我总结的几个典型问题和解决方案。5.1 菜单不显示或闪现后消失现象长按后菜单一闪而过或者根本不出现。排查检查onCreateActionMode返回值 这个方法必须返回true表示你成功创建了菜单。如果返回falseActionMode会立即被销毁。检查TextView是否可选择 确保TextView的isTextSelectable属性设置为true或者设置了movementMethod如LinkMovementMethod.getInstance()。不可选择的TextView默认不会触发文本选择ActionMode。检查事件冲突 如果TextView外层有可滑动的父容器如ScrollView、RecyclerView并且父容器也处理了长按事件可能会产生冲突。可以尝试在自定义TextView的onTouchEvent中适当处理或者调整父容器的滚动拦截逻辑。5.2 自定义菜单项与系统菜单项顺序混乱现象使用customSelectionActionModeCallback时自定义的菜单项没有出现在期望的位置。根因系统先添加自己的菜单项然后才调用我们的onCreateActionMode。我们添加的项默认会追加在后面。解决方案我们无法改变系统菜单项的顺序但可以通过一个小技巧影响整体顺序。在onCreateActionMode中我们可以先调用menu?.clear()清空菜单然后先添加我们自己的项再手动添加我们想保留的系统功能如复制。但这需要自己实现复制等逻辑失去了方案三保留系统功能的优势。通常接受默认顺序自定义项在后是更稳妥的选择。5.3 在RecyclerView的ViewHolder中处理菜单现象在RecyclerView的每个Item里都有一个可长按的TextView菜单逻辑写在ViewHolder里但滚动后菜单行为错乱。根因ViewHolder会被复用。如果为每个TextView都设置了一个匿名内部类Callback并且这个Callback持有对ViewHolder或旧数据的引用在复用时就会导致菜单操作作用在错误的Item上。解决方案方案A推荐 将菜单逻辑提升到Adapter或Activity/Fragment层级通过TextView的tag或者ViewHolder的bindingAdapterPosition来获取当前操作项对应的数据对象。方案B 在ViewHolder的bind方法中每次都为TextView重新设置Callback并确保Callback中使用的是本次bind的最新数据。同时在ViewHolder回收时或onViewRecycled中将TextView的customSelectionActionModeCallback设为null。class MyViewHolder(val binding: ItemBinding) : RecyclerView.ViewHolder(binding.root) { fun bind(data: MyData) { binding.textView.text data.content // 每次绑定都创建新的Callback捕获当前data binding.textView.customSelectionActionModeCallback object : ActionMode.Callback { override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean { // 这里操作的data就是当前Item的数据 if (item?.itemId R.id.menu_action) { performActionOn(data) return true } return false } // ... 其他方法 } } fun unbind() { // 防止内存泄漏和错乱引用 binding.textView.customSelectionActionModeCallback null } }5.4 低版本兼容性处理如果你的minSdkVersion低于23就需要为方案三做降级处理。fun setupCustomSelectionMenu(textView: TextView, callback: ActionMode.Callback) { if (Build.VERSION.SDK_INT Build.VERSION_CODES.M) { // API 23 使用官方推荐方式 textView.customSelectionActionModeCallback callback textView.isTextSelectable true } else { // 低版本使用自定义TextView方案一或其它兼容方案 // 这里可以将callback设置给一个自定义TextView的属性并在其startActionMode中调用 // 或者简单粗暴地设置一个长按监听器但会丢失文本选择方案二 textView.setOnLongClickListener { // 启动一个自定义的浮动ActionMode需要自己实现较为复杂 // 一个简单的降级显示一个普通的PopupMenu showFallbackPopupMenu(textView, callback) true } // 注意低版本上isTextSelectable可能无效需要设置movementMethod textView.movementMethod LinkMovementMethod.getInstance() } }低版本上完整实现一个浮动工具栏ActionMode.TYPE_FLOATING比较困难通常的降级策略是使用PopupMenu但这在体验上会有差异。因此对于重要功能需要产品层面权衡是否在低版本上提供。6. 扩展思考超越TextView掌握了TextView的自定义长按菜单这个思路可以扩展到其他控件吗当然可以。核心思想是拦截控件的长按事件或默认上下文菜单并启动一个自定义的ActionMode。WebView 可以重写WebView的startActionMode方法来定制网页内长按文本的菜单。EditText 与TextView类似但EditText的菜单更复杂有粘贴、自动建议等。通常不建议完全替换而是用customSelectionActionModeCallback进行增强。RecyclerView的Item整体长按 这通常用于进入项目多选模式。可以在Adapter中为ItemView设置OnLongClickListener并在其中调用startActionMode启动一个全新的、用于多选操作的ActionMode这常用于邮件、文件管理器的多选删除、移动等场景。自定义长按菜单是一个小功能点但它体现了Android交互设计的灵活性。从理解系统默认行为开始到选择最合适的拦截方案再到处理各种边界情况和兼容性问题整个过程是对开发者对Android事件分发、控件机制和UI生命周期理解的一次小考。