创建自定义语音录制View

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

相关推荐
AI多Agent协作实战派9 小时前
AI多Agent协作系统实战(二十三):Agent读HEARTBEAT.md不读AGENTS.md——openclaw的文件加载之谜
前端·数据库·人工智能·uni-app
时间的拾荒人10 小时前
MySQL C语言连接 - 从入门到实战
android·c语言·mysql
Meteors.10 小时前
Android性能优化:01. 指标体系 + 分析工具链
android
2601_9557600710 小时前
如何用 Claude Opus 5 API 批量扩展长尾关键词和文章选题
前端·python·搜索引擎
KaMeidebaby10 小时前
卡梅德生物技术快报|bli亲和力检测gst:告别批量跑胶:BLI实时酶切监测技术加速GST融合蛋白下游流程优化
前端·网络·数据库·人工智能·算法
触底反弹11 小时前
🚀 删了数据刷新又回来?3 组件 × 4 回调 × 3 坑讲透 React 父子通信
前端·javascript·react.js
jike_202611 小时前
安卓平台免费录音转文字工具深度测评:5 款 APP 功能对比与选型指南
android·智能电视
Code Man11 小时前
Windows 下使用 Appium
android·windows·appium
码农coding12 小时前
android 12 中的VSYNC接收
android
耳东小鹿12 小时前
对象常用方法
前端