创建自定义语音录制View
按住说话,上移取消,结合lottie动画
lottie集成
scss
implementation("com.airbnb.android:lottie:6.1.0")

color色值定义
ini
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<color name="gray_light">#607D8B</color>
<color name="green">#4CAF50</color>
<color name="red">#F44336</color>
attr属性定义
xml
<declare-styleable name="LottieVoiceRecorderView">
<!-- 圆角半径 -->
<attr name="cornerRadius" format="dimension" />
<!-- 正常状态背景色 -->
<attr name="normalBgColor" format="color" />
<!-- 录音状态背景色 -->
<attr name="recordingBgColor" format="color" />
<!-- 取消状态背景色 -->
<attr name="cancelBgColor" format="color" />
<!-- 文本颜色 -->
<attr name="textColor" format="color" />
<!-- Lottie动画文件名称 -->
<attr name="lottieAssetName" format="string" />
</declare-styleable>
view_lottie_voice_recorder.xml定义
ini
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:tools="http://schemas.android.com/tools">
<!-- 文本提示 -->
<TextView
android:id="@+id/record_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_above="@id/container"
android:layout_gravity="center_horizontal"
android:gravity="center"
android:paddingVertical="8dp"
tools:text="松手发送,上移取消"
android:textColor="#666666"
android:textSize="14dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<!-- 圆角矩形容器 - 确保这个视图能正确显示背景 -->
<View
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_gravity="center_horizontal"
android:background="@color/gray_light"
app:layout_constraintTop_toBottomOf="@id/record_text" /> <!-- 初始背景 -->
<!-- Lottie动画视图 -->
<com.airbnb.lottie.LottieAnimationView
android:id="@+id/wave_animation"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_gravity="center_horizontal|center_vertical"
app:layout_constraintBottom_toBottomOf="@id/container"
app:layout_constraintTop_toTopOf="@id/container"
app:lottie_colorFilter="@color/white" />
</androidx.constraintlayout.widget.ConstraintLayout>
LottieVoiceRecorderView定义
kotlin
package cn.nio.voicerecorderdemo
import android.content.Context
import android.graphics.Outline
import android.util.AttributeSet
import android.util.Log
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewOutlineProvider
import android.widget.FrameLayout
import android.widget.TextView
import androidx.core.content.ContextCompat
import com.airbnb.lottie.LottieAnimationView
import com.airbnb.lottie.LottieDrawable
/**
* @desc 按住说话,上移取消,松手发送
* @Author nio
* @Date 2025/8/18-17:28
*/
class LottieVoiceRecorderView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
) : FrameLayout(context, attrs, defStyleAttr) {
private val TAG = "LottieVoiceRecorderView"
// 状态定义
private enum class State {
IDLE, RECORDING, CANCEL
}
// 视图组件
private lateinit var containerView: View
private lateinit var textView: TextView
private lateinit var lottieAnimationView: LottieAnimationView
// 属性变量
private var cornerRadius: Float = 30.dpToPx()
private var normalBgColor: Int = ContextCompat.getColor(context, R.color.gray_light)
private var recordingBgColor: Int = ContextCompat.getColor(context, R.color.green)
private var cancelBgColor: Int = ContextCompat.getColor(context, R.color.red)
private var textColor: Int = ContextCompat.getColor(context, R.color.white)
private var lottieAssetName: String = "voice_wave.json"
// 状态变量
private var currentState = State.IDLE
private var isRecording = false
private var recordStartTime = 0L
private var currentRecordTime = 0L
private var cancelThreshold = 50.dpToPx()
// 回调接口
var onRecordListener: OnRecordListener? = null
init {
// 初始化视图前先确保没有背景干扰
setBackgroundColor(ContextCompat.getColor(context, android.R.color.transparent))
initViews()
initAttributes(attrs)
setupLottieAnimation()
// 初始状态更新
updateUIForState(State.IDLE)
}
private fun initViews() {
// 加载布局
val inflater = LayoutInflater.from(context)
val rootView = inflater.inflate(R.layout.view_lottie_voice_recorder, this, true)
// 确保正确获取视图引用
containerView = rootView.findViewById(R.id.container)
textView = rootView.findViewById(R.id.record_text)
lottieAnimationView = rootView.findViewById(R.id.wave_animation)
}
private fun initAttributes(attrs: AttributeSet?) {
attrs?.let {
val typedArray = context.obtainStyledAttributes(it, R.styleable.LottieVoiceRecorderView)
cornerRadius = typedArray.getDimension(
R.styleable.LottieVoiceRecorderView_cornerRadius,
30.dpToPx()
)
normalBgColor = typedArray.getColor(
R.styleable.LottieVoiceRecorderView_normalBgColor,
ContextCompat.getColor(context, R.color.gray_light)
)
recordingBgColor = typedArray.getColor(
R.styleable.LottieVoiceRecorderView_recordingBgColor,
ContextCompat.getColor(context, R.color.green)
)
cancelBgColor = typedArray.getColor(
R.styleable.LottieVoiceRecorderView_cancelBgColor,
ContextCompat.getColor(context, R.color.red)
)
textColor = typedArray.getColor(
R.styleable.LottieVoiceRecorderView_textColor,
ContextCompat.getColor(context, R.color.white)
)
typedArray.getString(R.styleable.LottieVoiceRecorderView_lottieAssetName)
?.let { assetName ->
lottieAssetName = assetName
}
typedArray.recycle()
}
// 应用文本颜色
textView.setTextColor(textColor)
// 应用初始背景色
containerView.setBackgroundColor(normalBgColor)
// 确保容器有正确的初始设置
containerView.clipToOutline = true
containerView.outlineProvider = RoundedCornerOutlineProvider(cornerRadius)
Log.d(
TAG,
"初始化颜色 - 正常: $normalBgColor, 录音: $recordingBgColor, 取消: $cancelBgColor"
)
}
private fun setupLottieAnimation() {
lottieAnimationView.setAnimation(lottieAssetName)
lottieAnimationView.repeatCount = LottieDrawable.INFINITE
lottieAnimationView.visibility = View.INVISIBLE
}
private fun updateUIForState(state: State) {
if (currentState == state) {
return // 状态未变化,无需更新
}
currentState = state
Log.d(TAG, "更新状态为: $state")
// 强制更新背景颜色
val newColor = when (state) {
State.IDLE -> normalBgColor
State.RECORDING -> recordingBgColor
State.CANCEL -> cancelBgColor
}
Log.d(TAG, "设置背景颜色为: $newColor")
containerView.setBackgroundColor(newColor)
// 强制重绘
containerView.invalidate()
// 更新文本
textView.text = when (state) {
State.IDLE -> ""// "按住说话"
State.RECORDING -> "松手发送 (${currentRecordTime}s),上移取消"
State.CANCEL -> "上滑取消"
}
// 控制Lottie动画
when (state) {
State.RECORDING, State.CANCEL -> {
lottieAnimationView.visibility = View.VISIBLE
if (!lottieAnimationView.isAnimating) {
lottieAnimationView.playAnimation()
}
}
else -> {
lottieAnimationView.visibility = View.INVISIBLE
if (lottieAnimationView.isAnimating) {
lottieAnimationView.pauseAnimation()
lottieAnimationView.progress = 0f
}
}
}
}
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
Log.d(TAG, "触摸按下")
isRecording = true
recordStartTime = System.currentTimeMillis()
onRecordListener?.onStartRecording()
updateUIForState(State.RECORDING)
post(updateRecordTime)
return true
}
MotionEvent.ACTION_MOVE -> {
if (isRecording) {
val startY = height / 2f
val distance = startY - event.y
Log.d(TAG, "触摸移动 - 距离: $distance, 阈值: $cancelThreshold")
val newState = if (distance > cancelThreshold) {
State.CANCEL
} else {
State.RECORDING
}
updateUIForState(newState)
}
}
MotionEvent.ACTION_UP -> {
Log.d(TAG, "触摸抬起")
handleTouchEnd()
}
MotionEvent.ACTION_CANCEL -> {
Log.d(TAG, "触摸取消")
handleTouchEnd()
}
}
return true
}
private fun handleTouchEnd() {
if (isRecording) {
isRecording = false
removeCallbacks(updateRecordTime)
when (currentState) {
State.RECORDING -> {
val duration = (System.currentTimeMillis() - recordStartTime) / 1000
onRecordListener?.onFinishRecording(duration)
}
State.CANCEL -> {
onRecordListener?.onCancelRecording()
}
else -> {}
}
currentRecordTime = 0
updateUIForState(State.IDLE)
}
}
private val updateRecordTime = object : Runnable {
override fun run() {
currentRecordTime = (System.currentTimeMillis() - recordStartTime) / 1000
onRecordListener?.onRecording(currentRecordTime)
if (currentState == State.RECORDING) {
textView.text = "松手发送 (${currentRecordTime}s),上移取消"
}
postDelayed(this, 1000)
}
}
interface OnRecordListener {
fun onStartRecording()
fun onRecording(time: Long)
fun onCancelRecording()
fun onFinishRecording(time: Long)
}
private fun Int.dpToPx(): Float {
return this * resources.displayMetrics.density
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
removeCallbacks(updateRecordTime)
lottieAnimationView.cancelAnimation()
}
}
class RoundedCornerOutlineProvider(private val radius: Float) : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
outline.setRoundRect(
0,
0,
view.width,
view.height,
radius
)
}
}
接下来是实现
1. activity_main.xml
ini
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<com.google.android.material.button.MaterialButton
android:id="@+id/textView"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginHorizontal="16dp"
android:gravity="center"
android:insetTop="0dp"
android:insetBottom="0dp"
android:minHeight="0dp"
android:text="按住 说话"
android:textColor="#333333"
android:textSize="14sp"
app:backgroundTint="#00FFFFFF"
app:cornerRadius="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:strokeColor="#999999"
app:strokeWidth="1dp" />
<cn.nio.voicerecorderdemo.LottieVoiceRecorderView
android:id="@+id/voiceRecorderView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
app:cancelBgColor="@color/red"
app:cornerRadius="8dp"
app:layout_constraintBottom_toBottomOf="@id/textView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:lottieAssetName="Audio-Voice-A-002.lottie"
app:normalBgColor="#004e4eff"
app:recordingBgColor="#4e4eff"
app:textColor="#999999" />
</androidx.constraintlayout.widget.ConstraintLayout>
2. MainActivity
kotlin
package cn.nio.voicerecorderdemo
import android.os.Bundle
import android.widget.Toast
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
class MainActivity : AppCompatActivity() {
private lateinit var voiceRecorderView: LottieVoiceRecorderView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContentView(R.layout.activity_main)
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
voiceRecorderView = findViewById(R.id.voiceRecorderView)
// 设置录音监听器
voiceRecorderView.onRecordListener = object : LottieVoiceRecorderView.OnRecordListener {
override fun onStartRecording() {
// 开始录音
Toast.makeText(this@MainActivity, "开始录音", Toast.LENGTH_SHORT).show()
// 在这里实现实际的录音逻辑
}
override fun onRecording(time: Long) {
// 录音中,更新UI或处理其他逻辑
}
override fun onCancelRecording() {
// 取消录音
Toast.makeText(this@MainActivity, "取消录音", Toast.LENGTH_SHORT).show()
// 在这里实现取消录音的逻辑
}
override fun onFinishRecording(time: Long) {
// 完成录音
Toast.makeText(this@MainActivity, "录音完成,时长: $time 秒", Toast.LENGTH_SHORT)
.show()
// 在这里实现停止录音并保存的逻辑
}
}
}
}
3. 效果展示
