Kotlin Compose Button 实现长按监听并实现动画效果

06-01 1448阅读

想要实现长按按钮开始录音,松开发送的功能。发现 Button 这个控件如果去监听这些按下,松开,长按等事件,发现是不会触发的,究其原因是 Button 已经提前消耗了这些事件所以导致,这些监听无法被触发。因此为了实现这些功能就需要自己写一个 Button 来解决问题。

Button 实现原理

在 Jetpack Compose 中,Button 是一个高度封装的可组合函数(Composable),其底层是由多个 UI 组件组合而成,关键组成包括:Surface、Text、Row、InteractionSource 等

  • 源码
    @Composable
    fun Button(
        onClick: () -> Unit,
        modifier: Modifier = Modifier,
        enabled: Boolean = true,
        shape: Shape = ButtonDefaults.shape,
        colors: ButtonColors = ButtonDefaults.buttonColors(),
        elevation: ButtonElevation? = ButtonDefaults.buttonElevation(),
        border: BorderStroke? = null,
        contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
        interactionSource: MutableInteractionSource? = null,
        content: @Composable RowScope.() -> Unit
    ) {
        @Suppress("NAME_SHADOWING")
        val interactionSource = interactionSource ?: remember { MutableInteractionSource() }
        val containerColor = colors.containerColor(enabled)
        val contentColor = colors.contentColor(enabled)
        val shadowElevation = elevation?.shadowElevation(enabled, interactionSource)?.value ?: 0.dp
        Surface(
            onClick = onClick,
            modifier = modifier.semantics { role = Role.Button },
            enabled = enabled,
            shape = shape,
            color = containerColor,
            contentColor = contentColor,
            shadowElevation = shadowElevation,
            border = border,
            interactionSource = interactionSource
        ) {
            ProvideContentColorTextStyle(
                contentColor = contentColor,
                textStyle = MaterialTheme.typography.labelLarge
            ) {
                Row(
                    Modifier.defaultMinSize(
                            minWidth = ButtonDefaults.MinWidth,
                            minHeight = ButtonDefaults.MinHeight
                        )
                        .padding(contentPadding),
                    horizontalArrangement = Arrangement.Center,
                    verticalAlignment = Alignment.CenterVertically,
                    content = content
                )
            }
        }
    }
    

    1. Surface 的作用(关键)

    Surface 是 Compose 中的通用容器,负责:

    • 提供背景颜色(来自 ButtonColors)
    • 提供 elevation(阴影)
    • 提供点击行为(通过 onClick)
    • 提供 shape(圆角、裁剪等)
    • 提供 ripple 效果(内部自动通过 indication 使用 rememberRipple())
    • 使用 Modifier.clickable 实现交互响应

      注意:几乎所有 Material 组件都会使用 Surface 来包裹内容,统一管理视觉表现。

      2. InteractionSource

      • InteractionSource 是 Compose 中管理用户交互状态的机制(如 pressed、hovered、focused)
      • Button 将其传入 Surface,用于响应和处理 ripple 动画等
      • 与 MutableInteractionSource 配合,可以观察组件的状态变化

        3. ButtonDefaults

        ButtonDefaults 是提供默认值的工具类,包含:

        • elevation():返回 ButtonElevation 对象,用于设置不同状态下的阴影高度
        • buttonColors():返回 ButtonColors 对象,用于设置正常 / 禁用状态下的背景与文字颜色
        • ContentPadding:内容的默认内边距

          4. Content Slot(RowScope.() -> Unit)

          Button 的 content 是一个 RowScope 的 lambda,允许你自由组合子组件,如:

          Button(onClick = { }) {
              Icon(imageVector = Icons.Default.Add, contentDescription = null)
              Spacer(modifier = Modifier.width(4.dp))
              Text("添加")
          }
          

          因为是在 RowScope 中,所以能用 Spacer 等布局函数在水平排列子项。


          关键点说明
          Surface提供背景、阴影、圆角、点击、ripple 效果的统一封装
          InteractionSource用于收集用户交互状态(点击、悬停等)
          ButtonDefaults提供默认颜色、阴影、Padding 等参数
          Row + Text内容布局,允许图标 + 文本灵活组合
          Modifier控制尺寸、形状、边距、点击响应等

          如果想自定义 Button 的样式,也可以直接使用 Surface + Row 自己实现一个“按钮”,只需照着官方的做法组装即可。

          @Suppress("DEPRECATION_ERROR")
          @OptIn(ExperimentalMaterial3Api::class)
          @Composable
          fun Button(
              onClick: () -> Unit = {},
              onLongPress: () -> Unit = {},
              onPressed: () -> Unit = {},
              onReleased: () -> Unit = {},
              modifier: Modifier = Modifier,
              enabled: Boolean = true,
              shape: Shape = ButtonDefaults.shape,
              colors: ButtonColors = ButtonDefaults.buttonColors(),
              border: BorderStroke? = null,
              shadowElevation: Dp = 0.dp,
              contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
              content: @Composable RowScope.() -> Unit = { Text("LongButton") }
          ) {
              val containerColor = colors.containerColor
              val contentColor = colors.contentColor
              Surface(
                  modifier = modifier
                      .minimumInteractiveComponentSize()
                      .pointerInput(enabled) {
                          detectTapGestures(
                              onPress = { offset ->
                                  onPressed()
                                  tryAwaitRelease()
                                  onReleased()
                              },
                              onTap = { onClick() },
                              onLongPress = { onLongPress() }
                          )
                      }
                      .semantics { role = Role.Button },
                  shape = shape,
                  color = containerColor,
                  contentColor = contentColor,
                  shadowElevation = shadowElevation,
                  border = border,
              ) {
                  CompositionLocalProvider(
                      LocalContentColor provides contentColor,
                      LocalTextStyle provides LocalTextStyle.current.merge(MaterialTheme.typography.labelLarge),
                  ) {
                      Row(
                          Modifier
                              .defaultMinSize(ButtonDefaults.MinWidth, ButtonDefaults.MinHeight)
                              .padding(contentPadding),
                          horizontalArrangement = Arrangement.Center,
                          verticalAlignment = Alignment.CenterVertically,
                          content = content
                      )
                  }
              }
          }
          

          Button 的动画实现

          为了让按钮在按下时提供自然的视觉反馈,Compose 通常会使用状态驱动的动画。最常见的方式是通过 animateColorAsState 来实现颜色的平滑过渡,比如按钮被按下时背景色或文字颜色稍微变暗,松开时再恢复。

          这个动画实现的关键点在于:

          1. 交互状态:比如是否按下、是否禁用,可以通过 InteractionSource 结合 collectIsPressedAsState() 实时监听当前状态。
          2. 根据状态决定目标颜色:当状态变化时(如按下 -> 松开),我们会设置新的目标颜色。
          3. 使用动画驱动状态变化:通过 animateColorAsState() 把颜色变化变成带过渡效果的状态变化,而不是突变。

          这种方式符合 Compose 的声明式编程模型,不需要手动写动画过程,而是让状态驱动 UI 动画。

          下面是按钮颜色动画部分的代码片段,只展示相关的状态监听和动画逻辑,具体如何应用在 UI 中将在后续实现:

          @Composable
          fun AnimatedButtonColors(
              enabled: Boolean,
              interactionSource: InteractionSource,
              defaultContainerColor: Color,
              pressedContainerColor: Color,
              disabledContainerColor: Color
          ): State {
              val isPressed by interactionSource.collectIsPressedAsState()
              val targetColor = when {
                  !enabled -> disabledContainerColor
                  isPressed -> pressedContainerColor
                  else -> defaultContainerColor
              }
              // 返回一个状态驱动的动画颜色
              val animatedColor by animateColorAsState(targetColor, label = "containerColorAnimation")
              return rememberUpdatedState(animatedColor)
          }
          

          值得一提的是,Button 使用的动画类型为 ripple (涟漪效果)

          这段代码仅负责计算当前的按钮背景色,并通过动画使其平滑过渡。它不会直接控制按钮的点击或布局逻辑,而是为最终的 UI 提供一个可动画的颜色状态。

          后续可以将这个 animatedColor 应用于 Surface 或背景 Modifier 上,完成整体的按钮外观动画。

          Kotlin Compose Button 实现长按监听并实现动画效果
          (图片来源网络,侵删)

          完整动画代码

          // 1. 确保 interactionSource 不为空
          val interaction = interactionSource ?: remember { MutableInteractionSource() }
          // 2. 监听按下状态
          val isPressed by interaction.collectIsPressedAsState()
          // 4. 按状态选 target 值
          val defaultContainerColor = colors.containerColor
          val disabledContainerColor = colors.disabledContainerColor
          val defaultContentColor = colors.contentColor
          val disabledContentColor = colors.disabledContentColor
          val targetContainerColor = when {
              !enabled -> disabledContainerColor
              isPressed -> defaultContainerColor.copy(alpha = 0.85f)
              else -> defaultContainerColor
          }
          val targetContentColor = when {
              !enabled -> disabledContentColor
              isPressed -> defaultContentColor.copy(alpha = 0.9f)
              else -> defaultContentColor
          }
          // 5. 动画
          val containerColorAni by animateColorAsState(targetContainerColor, label = "containerColor")
          val contentColorAni by animateColorAsState(targetContentColor, label = "contentColor")
          // 涟漪效果
          // 根据当前环境选择是否使用新版 Material3 的 ripple(),还是退回到老版的 rememberRipple() 实现
          val ripple = if (LocalUseFallbackRippleImplementation.current) {
              rememberRipple(true, Dp.Unspecified, Color.Unspecified)
          } else {
              ripple(true, Dp.Unspecified, Color.Unspecified)
          }
          // 6. Surface + 手动发 PressInteraction
          Surface(
              modifier = modifier
                  .minimumInteractiveComponentSize()
                  .pointerInput(enabled) {
                      detectTapGestures(
                          onPress = { offset ->
                              // 发起 PressInteraction,供 collectIsPressedAsState 监听
                              val press = PressInteraction.Press(offset)
                              val scope = CoroutineScope(coroutineContext)
                              scope.launch {
                                  interaction.emit(press)
                              }
                              // 用户 onPressed
                              onPressed()
                              // 等待手指抬起或取消
                              tryAwaitRelease()
                              // 发 ReleaseInteraction
                              scope.launch {
                                  interaction.emit(PressInteraction.Release(press))
                              }
                              // 用户 onReleased
                              onReleased()
                          },
                          onTap = { onClick() },
                          onLongPress = { onLongPress() }
                      )
                  }
                  .indication(interaction, ripple)
                  .semantics { role = Role.Button },
              shape = shape,
              color = containerColorAni,
              contentColor = contentColorAni,
              shadowElevation = shadowElevation,
              border = border,
          ) {...}
          

          这个 Button 的动画部分主要体现在按下状态下的颜色过渡。它通过 animateColorAsState 来实现背景色和文字颜色的动态变化。

          当按钮被按下时,会使用 interaction.collectIsPressedAsState() 实时监听是否处于 Pressed 状态,进而动态计算目标颜色(targetContainerColor 和 targetContentColor)。按下状态下颜色会降低透明度(背景 alpha = 0.85,文字 alpha = 0.9),形成按压视觉反馈。

          Kotlin Compose Button 实现长按监听并实现动画效果
          (图片来源网络,侵删)

          颜色的渐变不是突变的,而是带有过渡动画,由 animateColorAsState 自动驱动。它会在目标颜色发生变化时,通过内部的动画插值器平滑过渡到目标值,用户无需手动控制动画过程。

          使用 by animateColorAsState(...) 得到的是 State 类型的值,它会在颜色变化时自动重组,使整个按钮在交互中呈现更自然的过渡效果。

          Kotlin Compose Button 实现长按监听并实现动画效果
          (图片来源网络,侵删)

          这种方式相比传统手动实现动画更简洁、声明性更强,也更容易和 Compose 的状态系统集成。

          完整代码

          // androidx.compose.material3: 1.3.0
          import androidx.compose.animation.animateColorAsState
          import androidx.compose.foundation.BorderStroke
          import androidx.compose.foundation.gestures.detectTapGestures
          import androidx.compose.foundation.indication
          import androidx.compose.foundation.interaction.MutableInteractionSource
          import androidx.compose.foundation.interaction.PressInteraction
          import androidx.compose.foundation.interaction.collectIsPressedAsState
          import androidx.compose.foundation.layout.Arrangement
          import androidx.compose.foundation.layout.PaddingValues
          import androidx.compose.foundation.layout.Row
          import androidx.compose.foundation.layout.RowScope
          import androidx.compose.foundation.layout.defaultMinSize
          import androidx.compose.foundation.layout.padding
          import androidx.compose.material.ripple.rememberRipple
          import androidx.compose.material3.Button
          import androidx.compose.material3.ButtonColors
          import androidx.compose.material3.ButtonDefaults
          import androidx.compose.material3.ExperimentalMaterial3Api
          import androidx.compose.material3.LocalContentColor
          import androidx.compose.material3.LocalTextStyle
          import androidx.compose.material3.LocalUseFallbackRippleImplementation
          import androidx.compose.material3.MaterialTheme
          import androidx.compose.material3.Surface
          import androidx.compose.material3.Text
          import androidx.compose.material3.minimumInteractiveComponentSize
          import androidx.compose.material3.ripple
          import androidx.compose.runtime.Composable
          import androidx.compose.runtime.CompositionLocalProvider
          import androidx.compose.runtime.getValue
          import androidx.compose.runtime.remember
          import androidx.compose.ui.Alignment
          import androidx.compose.ui.Modifier
          import androidx.compose.ui.graphics.Color
          import androidx.compose.ui.graphics.Shape
          import androidx.compose.ui.input.pointer.pointerInput
          import androidx.compose.ui.semantics.Role
          import androidx.compose.ui.semantics.role
          import androidx.compose.ui.semantics.semantics
          import androidx.compose.ui.unit.Dp
          import androidx.compose.ui.unit.dp
          import kotlinx.coroutines.CoroutineScope
          import kotlinx.coroutines.launch
          import kotlin.coroutines.coroutineContext
          @Suppress("DEPRECATION_ERROR")
          @OptIn(ExperimentalMaterial3Api::class)
          @Composable
          fun Button(
              onClick: () -> Unit = {},
              onLongPress: () -> Unit = {},
              onPressed: () -> Unit = {},
              onReleased: () -> Unit = {},
              modifier: Modifier = Modifier,
              enabled: Boolean = true,
              shape: Shape = ButtonDefaults.shape,
              colors: ButtonColors = ButtonDefaults.buttonColors(),
              border: BorderStroke? = null,
              shadowElevation: Dp = 0.dp,
              contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
              interactionSource: MutableInteractionSource? = null,
              content: @Composable RowScope.() -> Unit = { Text("LongButton") }
          ) {
              // 1. 确保 interactionSource 不为空
              val interaction = interactionSource ?: remember { MutableInteractionSource() }
              // 2. 监听按下状态
              val isPressed by interaction.collectIsPressedAsState()
              // 4. 按状态选 target 值
              val defaultContainerColor = colors.containerColor
              val disabledContainerColor = colors.disabledContainerColor
              val defaultContentColor = colors.contentColor
              val disabledContentColor = colors.disabledContentColor
              val targetContainerColor = when {
                  !enabled -> disabledContainerColor
                  isPressed -> defaultContainerColor.copy(alpha = 0.85f)
                  else -> defaultContainerColor
              }
              val targetContentColor = when {
                  !enabled -> disabledContentColor
                  isPressed -> defaultContentColor.copy(alpha = 0.9f)
                  else -> defaultContentColor
              }
              // 5. 动画
              val containerColorAni by animateColorAsState(targetContainerColor, label = "containerColor")
              val contentColorAni by animateColorAsState(targetContentColor, label = "contentColor")
              // 涟漪效果
              // 根据当前环境选择是否使用新版 Material3 的 ripple(),还是退回到老版的 rememberRipple() 实现
              val ripple = if (LocalUseFallbackRippleImplementation.current) {
                  rememberRipple(true, Dp.Unspecified, Color.Unspecified)
              } else {
                  ripple(true, Dp.Unspecified, Color.Unspecified)
              }
              // 6. Surface + 手动发 PressInteraction
              Surface(
                  modifier = modifier
                      .minimumInteractiveComponentSize()
                      .pointerInput(enabled) {
                          detectTapGestures(
                              onPress = { offset ->
                                  // 发起 PressInteraction,供 collectIsPressedAsState 监听
                                  val press = PressInteraction.Press(offset)
                                  val scope = CoroutineScope(coroutineContext)
                                  scope.launch {
                                      interaction.emit(press)
                                  }
                                  // 用户 onPressed
                                  onPressed()
                                  // 等待手指抬起或取消
                                  tryAwaitRelease()
                                  // 发 ReleaseInteraction
                                  scope.launch {
                                      interaction.emit(PressInteraction.Release(press))
                                  }
                                  // 用户 onReleased
                                  onReleased()
                              },
                              onTap = { onClick() },
                              onLongPress = { onLongPress() }
                          )
                      }
                      .indication(interaction, ripple)
                      .semantics { role = Role.Button },
                  shape = shape,
                  color = containerColorAni,
                  contentColor = contentColorAni,
                  shadowElevation = shadowElevation,
                  border = border,
              ) {
                  CompositionLocalProvider(
                      LocalContentColor provides contentColorAni,
                      LocalTextStyle provides LocalTextStyle.current.merge(MaterialTheme.typography.labelLarge),
                  ) {
                      Row(
                          Modifier
                              .defaultMinSize(ButtonDefaults.MinWidth, ButtonDefaults.MinHeight)
                              .padding(contentPadding),
                          horizontalArrangement = Arrangement.Center,
                          verticalAlignment = Alignment.CenterVertically,
                          content = content
                      )
                  }
              }
          }
          
免责声明:我们致力于保护作者版权,注重分享,被刊用文章因无法核实真实出处,未能及时与作者取得联系,或有版权异议的,请联系管理员,我们会立即处理! 部分文章是来自自研大数据AI进行生成,内容摘自(百度百科,百度知道,头条百科,中国民法典,刑法,牛津词典,新华词典,汉语词典,国家院校,科普平台)等数据,内容仅供学习参考,不准确地方联系删除处理! 图片声明:本站部分配图来自人工智能系统AI生成,觅知网授权图片,PxHere摄影无版权图库和百度,360,搜狗等多加搜索引擎自动关键词搜索配图,如有侵权的图片,请第一时间联系我们。

目录[+]

取消
微信二维码
微信二维码
支付宝二维码