创建自定义语音录制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. 效果展示
