创建自定义语音录制View

669 阅读3分钟

创建自定义语音录制View

按住说话,上移取消,结合lottie动画

lottie集成

implementation("com.airbnb.android:lottie:6.1.0")

wechat_2025-08-19_095830_975.png

Free Voice Command Animations | Download in GIF, MP4, and Lottie JSON

color色值定义

<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属性定义

<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定义

<?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定义

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

<?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

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. 效果展示

Screen_recording_20250819_093910 00_00_00-00_00_30.gif