4. 按钮组件实现:Material 主题 Button 定制、按压状态动画、加载状态替换、禁用状态处理

按钮是支付页面最核心的交互入口,其视觉效果与反馈体验直接影响用户完成支付的意愿。本章聚焦 Jetpack Compose 中基于 Material 3 主题体系的 Button 深度定制,涵盖按压状态动画、加载状态替换以及禁用状态处理三大实战场景。

学习目标:掌握 Button 的样式系统原理、状态驱动动画机制、Composable 状态切换实现禁用与加载态,并能独立构建支付级按钮组件。

4.1 原理:Material 3 Button 的状态层级与样式槽

Compose 的 Button 组件底层依赖 ButtonDefaults 与 InteractionSource。每个 Button 维护一个 InteractionSource 对象,发射 Interaction.Pressed、Interaction.Focused、Interaction.Dragged 等事件。主题通过 ButtonColors、ButtonElevation、BorderStroke 等样式槽,为每种交互状态提供独立的视觉配置。

4.2 实现流程:从样式槽到状态动画

  1. 定义主题配色:基于 MaterialTheme.colorScheme 或自定义 ButtonColors 实例。
  2. 创建 InteractionSource:使用 remember { MutableInteractionSource() }。
  3. 绑定按压动画:通过 animateFloatAsState 或 Animatable 监听 isPressed 状态。
  4. 加载状态替换:根据业务逻辑的 isLoading 布尔值,切换显示 CircularProgressIndicator 与文本。
  5. 禁用状态处理:设置 enabled = false,Material 自动应用 disabled 配色。
关键 API 对照表:Button 状态定制核心函数
API 函数作用示例配置
ButtonColors定义各状态下的背景/内容色ButtonColors(containerColor, contentColor, disabledContainerColor, disabledContentColor)
ButtonElevation各状态下的阴影高度ButtonElevation(defaultElevation, pressedElevation, disabledElevation)
BorderStroke各状态下的边框描边BorderStroke(width, color) 配合 ButtonDefaults.outlinedButtonBorder
InteractionSource交互事件流,驱动自定义动画MutableInteractionSource() + collectIsPressedAsState()
// 完整示例:支付主按钮(含按压缩放动画 + 加载态替换)
@Composable
fun PayButton(
    text: String,
    onClick: () -> Unit,
    isLoading: Boolean = false,
    enabled: Boolean = true,
    modifier: Modifier = Modifier
) {
    val interactionSource = remember { MutableInteractionSource() }
    val isPressed by interactionSource.collectIsPressedAsState()
    val animatedScale by animateFloatAsState(
        targetValue = if (isPressed) 0.95f else 1f,
        animationSpec = spring(dampingRatio = 0.6f, stiffness = 300f),
        label = "pressScale"
    )

    Button(
        onClick = onClick,
        enabled = enabled && !isLoading,
        interactionSource = interactionSource,
        colors = ButtonDefaults.buttonColors(
            containerColor = MaterialTheme.colorScheme.primary,
            contentColor = MaterialTheme.colorScheme.onPrimary,
            disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
            disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant
        ),
        elevation = ButtonDefaults.buttonElevation(
            defaultElevation = 4.dp,
            pressedElevation = 8.dp,
            disabledElevation = 0.dp
        ),
        modifier = modifier
            .scale(animatedScale)
            .fillMaxWidth()
            .height(48.dp)
    ) {
        if (isLoading) {
            CircularProgressIndicator(
                modifier = Modifier.size(20.dp),
                strokeWidth = 2.dp,
                color = MaterialTheme.colorScheme.onPrimary
            )
        } else {
            Text(text = text, style = MaterialTheme.typography.labelLarge)
        }
    }
}

4.3 加载状态替换的两种模式

实际支付场景中,点击按钮后通常需要等待网络响应。推荐使用 局部替换模式:按钮宽高不变,文本替换为尺寸匹配的 CircularProgressIndicator。另一种是全屏遮罩模式,但按钮级别局部替换体验更连贯。

避坑点:使用 Modifier.scale 实现按压回弹时,务必先设置 Modifier.fillMaxWidth() 再 .scale(),否则缩放会改变布局尺寸,导致按钮位移。

4.4 禁用状态的视觉与交互策略

禁用状态不仅改变颜色,还应阻止点击事件。Compose 的 enabled 参数自动完成两者。但需注意:如果加载态中用户再次点击,业务层面应通过 enabled = !isLoading 双重保护。典型配色:背景 surfaceVariant,文字 onSurfaceVariant,阴影为 0.dp。

// 禁用状态配置片段 (继承上面 PayButton)
ButtonDefaults.buttonColors(
    disabledContainerColor = Color(0xFFE0E0E0),   // 浅灰
    disabledContentColor = Color(0x61000000)       // 半透明黑
)
性能建议:animateFloatAsState 的 animationSpec 使用 spring() 替代默认的 tween(),能提供更自然的回弹手感,且对 CPU 压力很小。
按钮状态转换与动画流程 初始态 (正常) 按压态 禁用态 加载态 按压 enabled=false 弹起 (spring动画) isLoading=true isLoading=false 按压时 scale 0.95 加载时文字→进度条 禁用时颜色变灰

4.5 调试常见问题

  • 动画卡顿:检查是否在 animateFloatAsState 的 animationSpec 中使用了高精度阻尼比的 spring,推荐阻尼比 0.6~0.8。
  • 加载态按钮不可点击但视觉仍显示正常色:确认已传递 enabled = !isLoading,并且 ButtonDefaults.buttonColors 中 disabledContainerColor 有有效值。
  • 缩放动画导致按钮位置偏移:必须将 .scale() 置于 .fillMaxWidth() 之后,并确保父布局未启用 IntrinsicSize。
  • 按压动画不生效:检查 interactionSource 是否通过 remember 保持稳定,重组时不会重建。

4.6 稳定性与性能优化

优化点做法效果
重组范围最小化将状态相关 lambda 抽成独立 Composable / 使用 derivedStateOf减少不必要的 Button 重组
加载指示器复用使用 remember 保持 CircularProgressIndicator 实例避免重启动画
点击防抖在 onClick 中结合 LaunchedEffect 加锁防止多次提交支付请求
禁用高精度触摸反馈设置 indication = null 并自己管理涟漪减少绘制开销(仅在自定义较大按钮时使用)
部署建议:将 PayButton 封装为独立组件放入 core:ui 模块,所有支付页面统一引用。主题色从 CompositionLocal 传递,便于换肤。