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

相关推荐
换元不配限30 分钟前
Android 架构演进实战:MVC → MVP → MVVM → MVI
android·架构·mvc·mvvm·mvp·mvi
你挚爱的强哥31 分钟前
【sgKeyboard】自定义组件:虚拟键盘
前端·javascript·计算机外设
锅里游的鱼吖37 分钟前
echarts自定义折线图
前端·javascript·数据库
hunteritself1 小时前
卷卷卷!GPT-6 Sol、Luna 正式发布,OpenAI 开始卷价格了
大数据·前端·人工智能·深度学习·transformer
何何____1 小时前
Vue 全局事件总线详解
前端·javascript·vue.js
涉密IT资质笔记2 小时前
涉密人员脱密期管理规范:期限分级模型、就业限制边界与违规认定标准
java·服务器·前端·网络·数据库
mmsx2 小时前
Android Dialog 插槽模式:用一套壳统一全应用的弹窗风格
android·java·dialog·开发语言
u0111026752 小时前
工具页案例图如何编写 alt让图片说明与处理场景对应
java·前端·javascript·图像处理·人工智能·算法·ai作画
事圆则缓2 小时前
Flutter 状态管理框架对比(五):GetX 的响应式、依赖注册与生命周期
前端·javascript·flutter
码上有光2 小时前
Linux:进程间通信——匿名管道通信、命名管道通信
android·java·linux·linux通信·匿名通信·命名通信