资讯详情

Compose 自定义 Indication 完全指南:从 InteractionSource 到可复用点击反馈

📅 2026/10/16 9:12:59 | 华诺云谱 👁 阅读
Compose 自定义 Indication 完全指南:从 InteractionSource 到可复用点击反馈
做了几年 Compose 的界面开发你会发现一个迟早会碰上的问题默认的ripple水波纹虽然够用但它永远长成一个样。Material 官方把波纹设计成了统一的颜色、统一的按压缩放逻辑一旦视觉设计师给了你一张“按下时要这样这样反馈”的效果图或者你需要一个跟品牌色、跟桌面端交互习惯完全对齐的点击反馈默认波纹就像不合身的衬衫——能穿但别扭。我当时是在做某个跨平台桌面项目时被这个问题彻底激发的。项目要求所有可点击卡片在悬停时有一层淡淡的品牌色光晕按压时要出现以手指触点为中心的扩散光斑而且这套效果不能只服务一个页面要在整个应用甚至多个项目里复用。我翻了一圈官方文档发现Indication这个抽象其实早就在那儿只是资料散、例子少今天把它从原理到实现完整拆一遍。这篇文章不会讲怎么把 ripple 改个颜色就收工而是带你把 Compose 自定义视觉反馈的全链路走通InteractionSource 怎么通知节点、IndicationNode 怎么自绘、动画怎么驱动、怎么把整套效果封装成一个可复用模块。读完你至少能自己动手写出比默认波纹更贴合业务的反馈效果并且知道在做的时候哪里容易翻车。1. 为什么放着现成 ripple 不用自定义 Indication 解决的问题1.1 默认反馈的局限只服务 Material 一种审美先来对齐一下概念。Indication在 Compose 里的职责很简单当用户与可交互组件发生接触时给出一个视觉上的“发生了交互”的信号。默认情况下你在 Material 组件里看到的圆形扩散波纹就是这个抽象的基本实现。问题是这个实现和你 UI 的质感深度绑定了。Material 的 ripple 设计目标是“让反馈不明显干扰内容”所以它用半透明蒙层覆盖动画快、颜色跟着主题走。但这套逻辑到了桌面端、到了游戏化界面、到了品牌定制场景立刻就显得不太合适桌面端鼠标悬停需要清晰的边框高亮感而不是一点渐变。某些品牌界面需要按压时背景变亮而不是变暗。有的控件交互范围大需要触点为中心的圆形扩散而不是整块矩形蒙层。官方提供LocalIndication这个组合局部变量全局把 ripple 替换成你自己的实现理论上可行但很多人会在这里卡住——因为Indication的 API 一直在演进网上搜到的老文章用的还是一套形如rememberUpdatedInstance的老签名拿来用在新版本上会报错或行为诡异。1.2 可复用性才是真正痛点如果你只是给某一个按钮写死一个特殊反馈效果那根本不需要自定义Indication。在clickable里加个自定义 modifier甚至直接自己监听PressedInteraction画个圆就行。但一旦反馈样式要同时出现在十几个组件上要随主题变化要被不同模块引用你就需要一个“描述这段反馈逻辑的独立产物”。Indication就是干这件事的它把“什么交互状态”“画什么样子”这两个问题解耦开让你像插头一样插到任何带InteractionSource的组件上。这也是标题里“可重用”三个字的分量。它意味着这套效果不是贴在某一个组件上的补丁而是一个独立包、独立演进、独立测试的黑盒。我后面会详细讲怎么把黑盒做干净。2. 从 InteractionSource 到绘制回调自定义 Indication 的工作原理2.1 接口演进IndicationNodeFactory 取代老式的可组合实例理解自定义Indication先得知道 Composable 拿到的是什么。Compose 1.7 时代Indication的推荐实现是IndicationNodeFactory。它不再像老版本那样在 Composable 作用域里返回一个IndicationInstance而是要求你返回一个真正的布局节点Node让节点自己完成状态监听和绘制。OptIn(ExperimentalComposeUiApi::class) class MyIndication : IndicationNodeFactory { override fun createInstance( interactionSource: InteractionSource ): IndicationNode { return MyIndicationNode(interactionSource) } }注意IndicationNodeFactory和IndicationNode目前仍然标记为实验性 API需要加OptIn(ExperimentalComposeUiApi::class)如果你的工程启用了严格检查记得在自己的类上配好注释。它实验性的原因主要是节点 API 本身还在微调并不是说不稳定到不能用我之后实际跑下来是没问题的只是要跟着版本更新走。这套设计比老的IndicationInstance舒服在哪老接口需要你用Composable函数包装内容在内容之上叠一层。新接口直接把节点挂到 modifier 链上这样就能用Modifier.Node的完整能力协程、重组作用域、drawing mask全都可以直接使用。2.2 InteractionSource 的状态流Press、Hover、Focus 的完整生命周期IndicationNode的信息来源是InteractionSource。它是一个类似于事件总线的接口当用户按下、释放、暂停、悬停、聚焦时会把对应的事件对象推进一个Flow里。我把最常用的几种交互事件整理一下交互事件触发时机类型PressInteraction.Press手指/鼠标按下瞬时PressInteraction.Release正常松开瞬时PressInteraction.Cancel因为滑动/打断而取消瞬时HoverInteraction.Enter鼠标进入区域瞬时HoverInteraction.Exit鼠标移出区域瞬时FocusInteraction.Focus获得焦点瞬时FocusInteraction.Unfocus失去焦点瞬时一个容易忽略的细节Release并不代表“这次按压一定会结束视觉反馈”因为它后面可能紧接着另一次Press。比如用户快速点击同一个按钮两次事件流是Press - Release - Press - Release你的视觉状态必须能正确支持这个循环。如果你用一个简单的布尔值isPressed来做状态管理天然就是安全的因为每次Press都重新置 true每次Release都置 false不会出现状态机死锁。但如果你一上来就想用“当前按压次数”这种累加值那处理Cancel的时候就会非常头疼。所以我在做自己的第一个版本时坚定地采用“分散状态”而非“统一状态”pressed、hovered、focused三个布尔分别维护这样组合起来就是四个视觉档位无反馈、悬停光晕、聚焦描边、按压光斑。3. 动手实现一个独立且能跑的 IndicationNode3.1 节点类的基本骨架下面我以一个“按压时画圆、悬停/聚焦时画边框”的自定义效果为例展示一个最小闭环的实现。这个例子叫PulseIndication我把完整代码放出来它的逻辑不复杂但足够解释所有关键点。OptIn(ExperimentalComposeUiApi::class) class PulseIndication( private val color: Color, private val pulseRadius: Dp 24.dp, private val pressedAlpha: Float 0.30f ) : IndicationNodeFactory { override fun createInstance( interactionSource: InteractionSource ): IndicationNode { return PulseIndicationNode( interactionSource interactionSource, color color, pulseRadius pulseRadius, pressedAlpha pressedAlpha ) } } private class PulseIndicationNode( private val interactionSource: InteractionSource, private val color: Color, private val pulseRadius: Dp, private val pressedAlpha: Float ) : Modifier.Node(), IndicationNode { private var pressed by mutableStateOf(false) private var hovered by mutableStateOf(false) private var focused by mutableStateOf(false) private var pressPosition: Offset? by mutableStateOf(null) private val alpha Animatable(0f) override fun onAttach() { super.onAttach() coroutineScope.launch { interactionSource.interactions.collect { interaction - when (interaction) { is PressInteraction.Press - { pressed true pressPosition interaction.pressPosition } is PressInteraction.Release - pressed false is PressInteraction.Cancel - pressed false is HoverInteraction.Enter - hovered true is HoverInteraction.Exit - hovered false is FocusInteraction.Focus - focused true is FocusInteraction.Unfocus - focused false } } } coroutineScope.launch { snapshotFlow { Triple(pressed, hovered, focused) } .collectLatest { (isPressed, isHovered, isFocused) - val targetAlpha when { isPressed - pressedAlpha isHovered - 0.10f isFocused - 0.06f else - 0f } alpha.animateTo( targetAlpha, animationSpec tween( durationMillis if (isPressed) 120 else 240 ) ) } } } override fun draw(drawScope: DrawScope) { if (alpha.value 0.001f) return val overlayColor color.copy(alpha alpha.value * color.alpha) val position pressPosition if (position ! null) { drawScope.drawCircle( color overlayColor, radius pulseRadius.toPx(), center position ) } else { drawScope.drawRoundRect( color overlayColor, topLeft Offset.Zero, size drawScope.size, cornerRadius CornerRadius(8.dp.toPx()) ) } } }3.2 节点的两个关键协程事件收集与动画驱动很多初次接触Modifier.Node的人会犯一个错误在onAttach里只开一个协程收集interactionSource.interactions然后每收到一个事件就去触发动画。这会导致动画频繁被打断而且animateTo之间互相竞争视觉上会出现抖动。更稳妥的做法是把事件收集和动画驱动分开。第一段协程只负责把事件翻译成状态也就是pressed、hovered、focused这几个布尔值的更新第二段协程用snapshotFlow观察这几个状态的变化然后统一决定目标透明度并让Animatable去跑。snapshotFlowcollectLatest这个组合很重要。collectLatest意味着当状态连续变化时新的动画会直接取代旧的动画用户快速按下松开再按下反馈也不会拖泥带水。如果你用普通collect旧的动画还没跑完新动画又进来了那透明度会像抽搐一样乱跳。3.3 绑定到可点击组件indication 修饰符的用法节点和工厂都写好了怎么把它挂到一个按钮上模式非常固定val interactionSource remember { MutableInteractionSource() } val pulseIndication remember(color) { PulseIndication(color brandColor) } Box( modifier Modifier .indication(interactionSource, pulseIndication) .clickable( interactionSource interactionSource, indication null, enabled true ) { onClick() } )这里有个非常常见的坑clickable默认会应用LocalIndication.current作为反馈如果你既手动添加了Modifier.indication(...)又没告诉clickable不要再加默认反馈最终会出现两套指示层叠在一起的情况视觉上是“双重闪烁”。正确姿势是二选一要么用Modifier.indication(interactionSource, myIndication)并让clickable的indication参数传null要么直接把自定义 indication 传给clickable的indication参数让它自己去挂。我更推荐后者因为clickable底层还会处理一些点击手势和 accessibility 的联动减少顺序出错的风险。Modifier.clickable( interactionSource interactionSource, indication pulseIndication ) { onClick() }4. 把基础效果做成手感状态合成、动画曲线与触点追踪4.1 从“能出现”到“好看”透明度、半径和贝塞尔曲线上面的代码能跑但你要真拿去做产品手感还差一截。第一个要打磨的是动画曲线。默认tween是线性插值按下去的时候光斑感觉“平铺直叙”缺少力度。我建议按压出现用FastOutSlowInEasing释放消失用LinearOutSlowInEasing看起来就像是“弹进去、松出来”。第二个要调整的是光斑半径。pulseRadius如果固定不变在小按钮上可能炸出边界在大卡片上又显得小气。我后来在复用版本里把默认半径改成了组件最小边的一定比例而不是写死 dpval radius (drawScope.size.minDimension * 0.35f)这招在异形尺寸的组件上特别好用。按钮窄、卡片宽固定 dp 无法兼顾比例方式能让反馈范围始终跟组件视觉重量成正比。如果你想要更夸张的波纹可以把比例提到 0.6 甚至 0.8然后配合裁切成圆形组件使用。第三个细节是颜色透明度的叠加方式。color.copy(alpha alpha.value * color.alpha)这行看着多余但很有用。它保证了自定义指示传入的颜色本身就带透明度时不会把原本的透明度覆盖掉。比如品牌色本身是 80% 透明度你又想在按压时叠加 30% 反馈两层透明度应该相乘而不是替换。4.2 多状态合成按压优先级必须高于悬停回了桌面端鼠标经常同时处于“悬停”和“按压”状态。比如用户按住按钮不放鼠标还停在按钮上方这时hovered和pressed同时为 true。我一开始没做优先级处理结果边缘出现了一个光晕、一个背景两个反馈叠加显得很脏。正确处理就是when的顺序。先判断isPressed再判断isHovered最后是isFocused这样按压永远拥有最高视觉权重。如果你希望聚焦反馈比悬停更明显可以把聚焦放到第二优先级。另外Release之后不能直接把pressed设为 false 就完事还要考虑手指虽然抬起了但鼠标还悬停着。状态合成逻辑会自动接管pressed变 false 时snapshotFlow重新计算目标透明度发现hovered还是 true就平滑过渡到悬停透明度不会一下子全消失。这就是分散状态管理的妙处。4.3 多点触控下的 pressPosition 处理预URES写代码有个隐含假设同一时间只有一个触点。现实是用户可以双指同时按下同一个组件这时InteractionSource会依次发出两个PressInteraction.Press分别带各自的pressPosition随后各自对应一个Release或Cancel。如果你用单个pressPosition字段存位置后面的事件会覆盖前面的视觉上光斑会突然跳到第二个手指的位置。处理方式有两种如果你的组件根本不该被双指点击那可以在拖动手势层面禁止多点直接不用管。如果想正确支持多点那就得把 position 存成一个 Map以PressInteraction的实例作为 Key收到对应Release时再移除。实际业务里绝大多数按钮都不需要第二种方案几乎所有常见 UI 框架都默认按钮只响应单指。所以我建议第一版用简单覆盖就够了别过度设计等真有需求了再升级。你只需要在代码注释里留一句“此处仅单点触控”方便后来人理解。5. 把 Indication 封装成真正可复用的模块5.1 构造函数参数与默认值的平衡当你开始从“给自己项目写一个效果”转向“做一个别人也能调的效果”最先要设计的不是绘制代码而是参数。我看到过太多自定义指示把参数堆到十个八个调用方根本不知道从哪配起。以PulseIndication为例真正值得暴露的参数只有四类主色、按压透明度、悬停/聚焦透明度、动画时长。下面的接口设计我用了很长时间最后的平衡点是这样class PulseIndication( private val color: Color, private val pressedAlpha: Float 0.30f, private val hoverAlpha: Float 0.08f, private val focusAlpha: Float 0.05f, private val animationDuration: Int 180 ) : IndicationNodeFactory主色必须要有因为它是识别效果的灵魂。剩下的所有数值都设默认值这样大部分调用方只需要传一个颜色就能用高定制需求再手动微调。你要记住一个原则默认参数是给 80% 场景用的不是给你自己用的。你自己调起来顺手不代表别人调起来也顺手。5.2 如何在 Composable 作用域里读取主题色remember 包装纯IndicationNodeFactory是一个普通类它在构造时没法读取CompositionLocal所以如果你希望“默认用主题内容颜色”不能直接把LocalContentColor.current塞给构造函数。要提供一个 Composable 版本的创建函数Composable fun rememberPulseIndication( color: Color LocalContentColor.current ): PulseIndication { return remember(color) { PulseIndication(color) } }这个细节特别重要。因为LocalContentColor.current是组合局部值只有可组合函数里能读所以永远不要在Modifier.indication(interactionSource, PulseIndication(...))里尝试读取它。必须把它包进Composable上下文。不只是PulseIndication任何自定义指示如果要跟主题联动都要用这个模式。remember(color)的 key 也要写对。如果省略 key颜色变了但指示实例不会重建如果你把整个PulseIndication当 key那每次参数变化都会重建新实例导致已挂载的指示节点被替换动画状态被重置。正确做法是只把会影响节点属性的基础参数作为 key。5.3 发布成独立模块依赖边界与 API 文档可复用的最后一步是把它和业务代码拆开。我的做法是在工程里单独拉一个:ui:feedback模块只依赖 Compose 基础库不依赖 Material。这样它既能给 Material 项目用也能给纯 Compose 的自定义组件库用。拆模块时必须注意一个依赖方向问题不要把Material依赖进反馈模块里哪怕你只是想引用一个Shape或ColorScheme类。不然用你模块的人会被迫引入整套 Material 主题系统这在大型工程里很容易引发版本冲突。反馈模块只应该依赖compose-ui和compose-foundation。接口文档也别偷懒。Indication的参数语义很容易猜错比如pressedAlpha是指“按压时叠加的透明度”不是“按压后的最终透明度”。我吃过这个亏使用者把pressedAlpha理解成最终不透明度结果反馈颜色怎么都调不深。后来我在 KDoc 里写明“叠加透明度最终透明度 主色透明度 × 此参数”类似这种边界说明真的能少一堆问题单。6. 实测中踩过的坑生命周期、局部变量与状态残留6.1 节点生命周期detach 时别忘了重置状态Modifier.Node的生命周期比 Composable 更难察觉。当你的指示效果被添加到 LazyColumn 中的一个 item 时item 滚出屏幕后节点会 detach滚回来重新 attach。如果你在snapshotFlow里启动了一个永不结束的动画比如视觉上一直保持的呼吸效果节点重挂时Animatable还在上一个状态重新 attach 后不会自动归零视觉上就会出现“离开屏幕时是亮着的回来依然是亮着的”的残影。处理方式有两个。一是自主在onDetach里重置所有状态override fun onDetach() { pressed false hovered false focused false pressPosition null super.onDetach() }二是不在节点里持有任何“持续动画”只用Animatable.animateTo这种有终点的动画它跑完就自然停在最终值。对于按压反馈这种短时效果第二种方式通常就够了如果你要做长时间背景流动效果才需要认真处理 detach。6.2 不要在节点构造时偷读 Local* 值这是我在项目里亲自踩出来的教训。有一版代码为了省事在PulseIndicationNode的构造参数里给color设置了默认值Color.Unspecified然后在draw里判断如果没指定颜色就尝试读取组合局部值。听起来很巧妙但实际上Modifier.Node的 draw 阶段没有可靠的方式直接读CompositionLocal你只能通过compositionLocal相关 API 在组合阶段读好再传进来。别绕路老老实实用上一节说的rememberPulseIndication包装函数。顺带一提就算你能在 draw 里读到LocalContentColor也不建议这么做因为 draw 阶段读状态意味着每次主题切换你都得靠额外的重组来触发重绘这远不如在组合阶段读好、生成新指示实例来得干净。6.3 测试指示效果runComposeUiTest 和图像的配合框架对自定义 Indication 没有现成的测试 API但你可以用 Compose 图像对比的方式来做回归。在 UI 测试里声明一个带自定义指示的组件用performTouchInput { down() }模拟触控在没松开时用captureToImage()捕捉画面然后断言关键像素点的颜色接近预期。我一般会把“按压出现光斑”“悬停出现边框”“释放后消失”三个场景各留一个截图测试。这些测试不用跑满每帧动画重点验证的是状态变化后第一帧的绘制结果避免以后有人重构节点类时把行为改坏。截图对比容易因为字体渲染差异产生误报所以断言区域要选在反馈效果覆盖、内容文字覆盖不到的地方。6.4 一个被忽略的老问题InteractionSource 的重复订阅如果同一个组件上有多处代码同时对同一个MutableInteractionSource收集事件是可选的。比如你在外部业务代码里也 collect 了这个 source 用于统计埋点那个收集过程得保证不阻塞、不抛异常因为节点内部的事件收集协程是长时间挂起的一旦中间某个 collector 出问题可能影响整个事件流。我的建议是如果业务代码确实要监听交互永远用独立的collect协程职责单一不要在一个 collector 里既改视觉状态又做统计上报。事件流是广播式的多几个订阅者没问题最怕的就是每个订阅者都带着副作用出问题互相甩锅。另外一个冷门但真实的场景组件复用。同一个InteractionSource被两个 LazyColumn item 持有比如列表中的“展开项”状态缓存了 interactionSource如果同时有两个 item 显示出来了它们的指示就会同时响应同一个 source 的事件。视觉上可能 B 按钮按下去A 按钮也跟着亮。解决办法永远是一个交互源只属于一个组件实例父子组件、缓存组件都不能共用同一个MutableInteractionSource。我在项目里遇到的类似场景最后排查下来都是因为缓存的interactionSource被多个 item 引用了。这个错了不会崩但反馈效果会神出鬼没很难一眼看出原因。测试的时候最好也加一条同一个 source 只能绑定到单个可视组件的断言。现在回头看自定义 Indication 并没有想象中那么多神秘的部分。核心其实就三步实现一个IndicationNodeFactory、在节点里收集InteractionSource事件、把事件翻译成绘制参数。难的是把这三步做得够干净、够可复用。参数设计、主题联动、生命周期处理这些是需要在实际项目里滚几轮才能悟出来的东西。如果你正打算给项目做一套自定义点击反馈我建议先别急着把所有效果都塞进去从最简单的按压变色或触点光斑开始跑通整个链路之后再逐步加悬停、聚焦、动画曲线这些手感细节。这样一个版本一个版本沉淀下来的指示效果才是真正能跟着设计系统走、能在多个业务里复用的成品。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑