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

相关推荐
一周七喜h15 分钟前
在Vue3和TypeScripts中使用pinia
前端·javascript·vue.js
weixin_3954489120 分钟前
main.c_cursor_0202
前端·网络·算法
东东5161 小时前
基于vue的电商购物网站vue +ssm
java·前端·javascript·vue.js·毕业设计·毕设
MediaTea1 小时前
<span class=“js_title_inner“>Python:实例对象</span>
开发语言·前端·javascript·python·ecmascript
梦梦代码精2 小时前
开源、免费、可商用:BuildingAI一站式体验报告
开发语言·前端·数据结构·人工智能·后端·开源·知识图谱
0思必得02 小时前
[Web自动化] Selenium执行JavaScript语句
前端·javascript·爬虫·python·selenium·自动化
程序员敲代码吗2 小时前
MDN全面接入Deno兼容性数据:现代Web开发的“一张图”方案
前端
0思必得02 小时前
[Web自动化] Selenium截图
前端·爬虫·python·selenium·自动化
小天源2 小时前
银河麒麟 V10(x86_64)离线安装 MySQL 8.0
android·mysql·adb·麒麟v10
2501_915921432 小时前
傻瓜式 HTTPS 抓包,简单抓取iOS设备数据
android·网络协议·ios·小程序·https·uni-app·iphone