最近在做新项目的视觉规范统一,设计那边把默认的涟漪效果全部否决了,要求换成一套更贴近品牌气质的按压反馈。我一开始的直觉是给每个按钮加个背景色切换,加个缩放的 Modifier,结果代码写出来又臭又长,每个组件都要重复一套逻辑。
直到我把目光转到Indication上,才发现 Compose 早就把"按压反馈"这种东西抽象成了独立的机制,只是一直被默认的涟漪效果掩盖了存在感。这篇文章就把我折腾下来的东西完整写出来:Indication 到底是什么、默认涟漪是怎么转起来的、如何手写一个能上生产的自定义 Indication,以及实测中踩到的一堆坑。
1. 先搞清楚 Indication 在 Compose 里到底扮演什么角色
1.1 扒开 Modifier.clickable:indication 只是其中一个参数
很多人在写Modifier.clickable的时候,根本没注意过它背后挂着三个职责:手势识别、无障碍语义、点击反馈。手势识别负责把触摸变成 onClick 回调,无障碍语义负责让读屏软件知道这是个可点击元素,而点击反馈,也就是按压时给用户视觉反馈的那部分,正是交给indication参数处理的。
看一个简化版的调用关系:
Modifier.clickable( interactionSource = interactionSource, indication = indication, enabled = true, onClick = { } )如果不传interactionSource和indication,clickable会在内部通过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 新版 API:IndicationNodeFactory 取代了 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,它对外暴露一个Flow<Interaction>:
interface InteractionSource { val interactions: Flow<Interaction> }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 当成 Indication
Material 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 与 IndicationNode
IndicationNodeFactory.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()会在内容绘制之前被调用,画出来的东西会被组件内容盖掉,结果就是光晕完全看不见。
为了把光晕画在内容上面,我用的是DrawWithContentModifierNode:
override 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这个设计到底省了多少事。