资讯动态

Compose自定义Indication:手写按压反馈替代默认涟漪

发布时间:2026/10/14 6:36:21 来源:尧图企业网站定制
最近在做新项目的视觉规范统一设计那边把默认的涟漪效果全部否决了要求换成一套更贴近品牌气质的按压反馈。我一开始的直觉是给每个按钮加个背景色切换加个缩放的 Modifier结果代码写出来又臭又长每个组件都要重复一套逻辑。直到我把目光转到Indication上才发现 Compose 早就把按压反馈这种东西抽象成了独立的机制只是一直被默认的涟漪效果掩盖了存在感。这篇文章就把我折腾下来的东西完整写出来Indication 到底是什么、默认涟漪是怎么转起来的、如何手写一个能上生产的自定义 Indication以及实测中踩到的一堆坑。1. 先搞清楚 Indication 在 Compose 里到底扮演什么角色1.1 扒开 Modifier.clickableindication 只是其中一个参数很多人在写Modifier.clickable的时候根本没注意过它背后挂着三个职责手势识别、无障碍语义、点击反馈。手势识别负责把触摸变成 onClick 回调无障碍语义负责让读屏软件知道这是个可点击元素而点击反馈也就是按压时给用户视觉反馈的那部分正是交给indication参数处理的。看一个简化版的调用关系Modifier.clickable( interactionSource interactionSource, indication indication, enabled true, onClick { } )如果不传interactionSource和indicationclickable会在内部通过remember自动创建一个MutableInteractionSource并且从LocalIndication.current读取默认的指示器。也就是说我们平时看到的涟漪效果并不是 Button 或 IconButton 组件自己写死的而是主题层提供的默认 Indication。这一点很关键。它意味着你不必去改每个组件的源码只需要替换掉LocalIndication提供的对象整个子树里的可点击组件都会换一套按压反馈。1.2 LocalIndication主题换肤机制的漏油点Compose 的CompositionLocal机制在这里扮演了全局变量的角色。LocalIndication是个staticCompositionLocalOf默认值通常被 Material 主题设置成涟漪效果。当我们写MaterialTheme { // 内部所有的 clickable都会用 LocalIndication.current 作为默认反馈 }实际发生的事是MaterialTheme内部会包裹一个CompositionLocalProvider把LocalIndication指向一个基于当前主题色、透明度曲线生成的涟漪 Indication。这也是为什么 M2 和 M3 的按钮点击反馈看起来不完全一样它们各自的 MaterialTheme 里塞了不同的 Indication 实现。顺着这个思路想要全局替换成自定义反馈只需要在MaterialTheme内部再嵌套一层MaterialTheme { CompositionLocalProvider( LocalIndication provides SpotIndication() ) { // 这里所有的 clickable 默认都会用 SpotIndication } }注意嵌套位置。如果你把CompositionLocalProvider放在MaterialTheme外面主题内部那层 Provider 会把你的设置盖掉等于白做。这是我第一次折腾时踩的坑后面会细说。1.3 新版 APIIndicationNodeFactory 取代了 IndicationInstance如果你翻过旧版本资料会看到一套基于IndicationInstance的写法有个rememberUpdatedInstance()方法返回一个实例去监听交互并驱动绘制。这套 API 在 Compose 1.7 里已经被新接口取代了现在推荐实现的是interface IndicationNodeFactory : Indication { fun create(interactionSource: InteractionSource): IndicationNode }IndicationNode继承自Modifier.Node。这意味着我们可以在节点里直接使用Modifier.Node的生命周期、协程作用域以及它作为绘制节点参与绘制的能力。对比旧 API新写法更接近 Compose 底层 modifier 的运作方式也更容易与drawWithContent、pointerInput等机制组合。我在下面所有示例代码里都用这套新接口如果你还在用旧接口建议先升级再看能省不少踩坑时间。2. 默认涟漪 RippleIndication 的运行链路2.1 InteractionSource把用户手势翻译成交互事件流要理解 Indication必须先明白它消费的数据从哪来。所有按压反馈的数据源头都是InteractionSource它对外暴露一个FlowInteractioninterface InteractionSource { val interactions: FlowInteraction }Interaction是一个密封接口常见的有PressInteraction.Press、PressInteraction.Release、PressInteraction.Cancel还有HoverInteraction、FocusInteraction。涟漪这种按压反馈主要关心前三者。PressInteraction.Press携带了一个非常有用的字段pressPosition: Offset也就是手指按下的位置。默认涟漪就是从那个点扩散出去的。如果一个组件没有把交互源暴露出来你在外部几乎不可能拿到按下的具体坐标因为手势识别部分并不负责记录这些数据它只负责往 flow 里发事件。这里有个容易被忽略的点InteractionSource是只读的它只管发射和传递不负责写入。真正往 flow 里发送事件的是MutableInteractionSource它由手势处理内部持有。组件外部如果需要感知按压状态通常用扩展函数val isPressed by interactionSource.collectIsPressedAsState()这个扩展在底层做了distinctUntilChanged处理配合collect把最新按压状态暴露成 Compose 状态。2.2 RippleNode 如何把 Press/Release 变成动画Ripple 实现的核心并不复杂。RippleIndication实现IndicationNodeFactory根据InteractionSource创建出RippleNode。这个RippleNode是一个Modifier.Node在onAttach里启动协程收集interactionSource.interactions。收集到PressInteraction.Press时它会把按压位置和按压状态记下来然后开始驱动一个动画进度收到Release时再让涟漪从当前状态淡出。从数据结构上看涟漪为了支持多点触控内部不是简单记一个布尔值而是维护了一组PressInteraction.Press每个按压事件对应一个动画状态。手指抬起时只移除对应的那一个并启动它的离场动画。这也是为什么不要图省事用Boolean去记录按压状态来做反馈——多点触控下第一根手指抬起就会把状态置为 false第二根手指还按着呢反馈瞬间就消失了。2.3 涟漪长得怎么样RippleTheme 决定颜色、透明度和边界平时看默认涟漪只是在MaterialTheme底下自动出现的圈圈但它的颜色、透明度曲线、是否 bounded全都来自RippleTheme。注意RippleTheme和Indication是两回事前者只管涟漪的视觉参数后者才是完整的反馈机制。RippleTheme有两个核心方法interface RippleTheme { Composable fun defaultColor(): Color Composable fun rippleAlpha(): RippleAlpha }RippleAlpha里包含按下、悬停、聚焦、拖拽四种状态下的透明度。M3 的默认值是基于内容颜色的所以你在不同颜色的 Button 上按下去涟漪颜色会自动适配靠的就是LocalContentColor被RippleTheme.defaultColor()消费了。如果想调整涟漪的圆角边界Material 主题会通过一个RippleDrawable的兼容层去解析LocalRippleTheme最终决定涟漪是圆形扩散还是矩形扩散。这些细节多数时候不用碰但当你自定义主题发现涟漪颜色死活不对时第一反应应该是查RippleTheme而不是直接改Indication。2.4 别把 StateLayer 当成 IndicationMaterial 3 里很多组件在按压时会出现一层半透明蒙版这个效果经常被误认为是 ripple。实际那是StateLayer它由组件内部的另一个 Modifier 绘制跟Indication是彼此独立的。最典型的例子是Button。M3 的 Button 内部先通过Surface绘制背景和 StateLayer再通过clickable的 indication 绘制涟漪。如果你自定义 Indication 后发现组件上出现两层反馈一层自己的一层淡色蒙版问题通常出在 StateLayer 还在生效而不是你的 Indication 画重了。我当时的处理办法是对于需要完全统一按压反馈的组件用组件库里的Surface参数把状态层特效关掉再把自己的 Indication 挂上去。这个操作在不同组件上叫法不同多数情况是通过Surface的tonalElevation或单独的LocalIndication覆盖解决。3. 手写一个 SpotIndication从 0 到 1 的完整实现3.1 设计目标视觉需求先讲清楚这次要做的是按压时出现一个从手指落点扩散的光晕同时组件略微变暗的反馈。之所以选这种效果是因为它不像涟漪那样完全复刻 Material又比纯变色显得有质感。视觉上的关键参数是光晕颜色、光晕扩散半径、整体压暗透明度。class SpotIndication( private val spotColor: Color, private val overlayAlpha: Float 0.12f, private val expandRadius: Float 120f, ) : IndicationNodeFactory { override fun create(interactionSource: InteractionSource): IndicationNode { return SpotIndicationNode( interactionSource interactionSource, spotColor spotColor, overlayAlpha overlayAlpha, expandRadius expandRadius ) } }构造函数里不持有任何 Compose 运行时类型方便在普通类里定义、在列表外复用。这是我在代码设计上的一个偏好Indication实例尽量保持无状态、参数化这样在不同场景里只需要传不同参数而不是反复创建新对象。3.2 IndicationNodeFactory 与 IndicationNodeIndicationNodeFactory.create()接收的是InteractionSource返回一个IndicationNode。这个节点是所有反馈逻辑的家交互收集、动画驱动、绘制都在这里完成。class SpotIndicationNode( private val interactionSource: InteractionSource, private val spotColor: Color, private val overlayAlpha: Float, private val expandRadius: Float, ) : Modifier.Node(), DrawWithContentModifierNode { private val progress Animatable(0f) private var pressPosition: Offset Offset.Unspecified override fun onAttach() { coroutineScope.launch { interactionSource.interactions.collect { interaction - when (interaction) { is PressInteraction.Press - { pressPosition interaction.pressPosition progress.animateTo( targetValue 1f, animationSpec tween(180, easing FastOutSlowInEasing) ) } is PressInteraction.Release - { progress.animateTo( targetValue 0f, animationSpec tween(300, easing FastOutSlowInEasing) ) } is PressInteraction.Cancel - { progress.animateTo( targetValue 0f, animationSpec tween(150, easing LinearEasing) ) } } } } } }注意Modifier.Node自带的coroutineScope它会在节点 attach 时启动、detach 时自动取消。这比自己在 composable 里管理协程安全得多。节点在 Lazy 列表中被回收时协程也不会泄漏。3.3 在 Modifier.Node 里收集交互事件上面代码里我用了一个interactionSource.interactions.collect { }这个写法对单个按压/抬起场景足够但有个细节值得注意Press和Release事件是成对出现的如果用户按下去之后手指滑出组件且没有抬起会收到Cancel而不是Release。所以在Cancel分支里同样要让动画归零否则光晕会一直卡在最大扩散状态。再一个实际操作中的习惯不要把pressPosition定义成一个普通局部变量就完事。它会被绘制阶段的 lambda 读取所以要放在Modifier.Node的属性里确保绘制闭包能读到最新值。我一开始犯过这个错绘制的扩散圆永远停在初始位置花了不少时间定位。收集交互事件时如果你还想处理 hover/focus可以继续在when里加分支。不过 Indication 通常不该把所有交互都做完保持只负责视觉反馈的单一职责会好维护很多。3.4 把动画画出来DrawWithContentModifierNode 的绘制时机这里最容易翻车的是绘制层级。如果只实现DrawModifierNode它的draw()会在内容绘制之前被调用画出来的东西会被组件内容盖掉结果就是光晕完全看不见。为了把光晕画在内容上面我用的是DrawWithContentModifierNodeoverride fun ContentDrawScope.draw() { drawContent() if (pressPosition Offset.Unspecified || progress.value 0f) return val alphaMultiplier (1f - progress.value) * overlayAlpha if (alphaMultiplier 0f) return drawRect( color spotColor.copy(alpha alphaMultiplier), // 全组件范围的压暗蒙版 ) drawCircle( color spotColor.copy(alpha alphaMultiplier * 1.2f), radius 36f progress.value * expandRadius, center pressPosition, ) }drawContent()必须放在最前面先把组件的背景、边框、内容画完再画我们的蒙版和光晕。这样光晕天然覆盖在内容之上。顺序反了视觉反馈就藏在组件底下看起来像没生效一样。这里你可能注意到我画了一个全组件的drawRect作为压暗蒙版还有一圈从按压点扩展的光晕。两个一起用视觉层次比单一光晕丰富。如果不想让蒙版盖住圆角可以把drawRect换成drawRoundRect圆角大小按组件实际尺寸算出或者直接用clipRect包一层。3.5 局部挂载与全局换肤写完之后挂载有两种方式。第一种是局部使用也是我建议优先采用的val interactionSource remember { MutableInteractionSource() } Box( modifier Modifier .size(120.dp) .background(Color.White, RoundedCornerShape(16.dp)) .clickable( interactionSource interactionSource, indication SpotIndication( spotColor Color(0xFF7C4DFF), overlayAlpha 0.12f, expandRadius 120f, ), onClick { /* ... */ } ) )第二种是全局替换适合新项目从第一天就统一反馈风格MaterialTheme { CompositionLocalProvider( LocalIndication provides SpotIndication( spotColor MaterialTheme.colorScheme.primary, overlayAlpha 0.10f, expandRadius 140f, ) ) { // 子树里所有 clickable 默认都走 SpotIndication } }全局替换时有个数据流上的细节LocalIndication只决定默认值如果你在某处给clickable显式传了indication参数局部优先。这符合 Compose 一贯的就近覆盖原则。所以我一般把全局值当成兜底特殊组件再单独覆盖。4. 进阶快速连点、取消手势与列表复用的坑4.1 Animatable 的打断机制解决连点问题当用户快速连点一个按钮时Press和Release事件会在极短时间内交替到达。如果用animateTo顺序执行前一个动画还没跑完后一个就来了穷于一帧一帧地叠加视觉上会出现光晕闪烁甚至停在半路。Animatable内部自带一个 MutatorMutex新的animateTo会取消当前正在运行的动画并从当前值继续往新目标值动画。所以上面那段代码天然支持连点打断第二次Press的animateTo(1f)会立刻接管光晕不会出现卡住的缝隙。如果你自己写协程去控制进度那就要格外小心。比如用while(true)循环加delay去驱动遇到连点大概率会出问题。这也是为什么我在上面强调用Animatable别看它是个简单包了一层的东西底层的互斥和帧协调逻辑已经被处理好了比自己写可靠得多。4.2 Cancel 事件手势被抢占时必须收尾用户按下按钮后如果手指滑动到组件外面或者父容器启动了滚动组件会收到PressInteraction.Cancel。这个事件非常容易被忽略因为大多数 Demo 只会演示 Press 和 Release。我在实测时就遇到过在LazyColumn里按住一个卡片然后上下滚动卡片的光晕被卡在最大扩散状态松开也不消失。查了半天发现是滚动手势抢占后手指没在卡片上抬起触发了Cancel而不是Release而我的代码里没有处理Cancel分支。解决方案就是上面代码里已经加上去的is PressInteraction.Cancel - { progress.animateTo( targetValue 0f, animationSpec tween(150, easing LinearEasing) ) }这个分支的核心是快速、干净地归零不需要太花哨的退场动画。用户手势都被抢走了反馈也应当及时让位。4.3 Lazy 列表里的 Indication 实例复用问题在LazyColumn里每个 item 的 composable 会随着滑动反复进入和离开组合。如果直接写成item { Button( onClick { }, modifier Modifier.clickable( interactionSource remember { MutableInteractionSource() }, indication SpotIndication(spotColor ...), // 每次重组都 new ) {} ) }那每次重组都会创建一个新的SpotIndication实例节点也会被反复创建销毁。虽然小项目不一定能测出明显性能问题但这是不必要的开销。我的习惯是把固定参数的Indication定义成顶层 object或者顺手remember住val spotIndication remember { SpotIndication(spotColor brandColor) }如果再严格一点InteractionSource也应该在 item 层级remember。否则列表快速滑动时旧的交互事件可能被新的节点再次收集出现闪烁。4.4 与 graphicsLayer 组合做缩放反馈时的顺序细节很多产品设计喜欢按压缩小 0.96这种反馈它比纯颜色变化更直接。但缩放不是 Indication 的活它属于graphicsLayer的 transform 能力。要让两种反馈叠加得自然Modifier 顺序很关键。我推荐的写法是把graphicsLayer放在外层val interactionSource remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() val scale by animateFloatAsState( targetValue if (isPressed) 0.96f else 1f, label pressScale ) Box( modifier Modifier .graphicsLayer { scaleX scale scaleY scale } .clickable( interactionSource interactionSource, indication SpotIndication(...), onClick { } ) )这样graphicsLayer是外层 transform它会影响整个内部节点的绘制包括clickable的 indication。按压时光晕和内容一起缩放视觉上是一个整体在反馈。如果你把clickable放外层、graphicsLayer放内层光晕画在 transform 之外内容缩下去了光晕纹丝不动那个分离感非常明显。5. 实测下来的几条心得5.1 不是所有按压反馈都适合做成 Indication做这些样本下来我最大的感受是Indication 适合用来承载状态驱动的视觉反馈比如颜色蒙版、光晕扩散、涟漪这些都能从 InteractionSource 的事件流里拿到依据。但如果你要的是按下时整个组件弹跳一下那本质上是一个不依赖按压力度的位移动画更适合交给graphicsLayer加animateFloatAsState而不是塞进 Indication。原因是 Indication 的节点在绘制阶段接入它拿到的是组件尺寸和手指坐标做颜色、透明度、半径这类绘制型变化非常顺手做布局位移、缩放这类变换型变化要么得配合 graphicsLayer要么就得自己处理矩阵变换绕一大圈反而不如直接外部驱动。所以我的选择标准很朴素反馈是否和按压点坐标相关相关就进 Indication不相关用 graphicsLayer 状态动画。5.2 开发调试时三个实用习惯第一刚开始调试绘制效果时把光晕颜色设成不透明、透明度设成 1方便确认绘制层级和位置。确认无误后再调回半透明。第二在draw()开头加一个临时fillMaxSize能直观看到组件实际绘制区域判断光晕扩散范围是否超出组件边界或者被裁剪。第三如果怀疑指示器没触发又不想打断交互流程去加日志可以先在onAttach里加一行println打印交互事件。事件流有没有进来一眼就能看到。这几个方法看着土但在排查为什么光晕没出现这个问题上比对着文档猜效率高很多。5.3 给代码维护留个后门最后分享一个实际经验自定义 Indication 的参数不要写死在组件内部尽量通过构造函数暴露给调用方并且给出一组默认值。我见过团队里有人花费大力气写好一个反馈组件结果设计师改了两个参数他被迫改了三个文件。与其这样不如在一开始就把颜色、透明度、扩散半径、退场时长都做成参数。这样后续做 A/B 测试或者适配不同主题时只需要调整一处。我自己现在会在项目里维护一个小工具文件里面放三四个预制好的 Indication分别对应卡片点击、按钮点击、列表项点击。调用方按场景取用基本不再写重复逻辑。等你真的用它把一套反馈统一起来就会明白LocalIndication这个设计到底省了多少事。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑