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

相关推荐
fatiaozhang95273 分钟前
中国移动浪潮云电脑CD1000-系统全分区备份包-可瑞芯微工具刷机-可救砖
android·网络·电脑·电视盒子·刷机固件·机顶盒刷机
因吹斯汀25 分钟前
一饭封神:当AI厨神遇上你的冰箱,八大菜系大师在线battle!
前端·vue.js·ai编程
再学一点就睡29 分钟前
NATAPP 内网穿透指南:让本地项目轻松 “走出去”
前端
拜无忧30 分钟前
2025最新React项目架构指南:从零到一,为前端小白打造
前端·react.js·typescript
稻草人不怕疼31 分钟前
记一次从“按钮点不动”到“窗口派发缺失”的排查过程
前端
irving同学462381 小时前
TypeORM 列装饰器完整总结
前端·后端·nestjs
彭于晏爱编程1 小时前
你真的了解 Map、Set 嘛
前端
崔璨1 小时前
详解Vue3的响应式系统
前端·vue.js
摸鱼的鱼lv1 小时前
🔥 Vue.js组件通信全攻略:从父子传值到全局状态管理,一篇搞定所有场景!🚀
前端·vue.js
2501_915918411 小时前
iOS 开发全流程实战 基于 uni-app 的 iOS 应用开发、打包、测试与上架流程详解
android·ios·小程序·https·uni-app·iphone·webview