Android音量均衡器实现:自定义垂直滑块与 EQ 调节方案

在音频处理应用中,均衡器是一个核心功能,它允许用户调整不同频段的增益来优化音频输出。本文将详细介绍如何在Android中实现一个美观且功能完整的垂直均衡器控件。

一、需求分析与设计思路

1.1 核心需求

  • 垂直方向的滑动条(区别于Android原生水平SeekBar)
  • 支持正负增益调节(±6dB范围)
  • 中央显示当前增益值
  • 可自定义视觉样式(宽度、颜色、圆角等)
  • 多个频段独立控制

1.2 设计思路

  • 自定义View实现垂直滑动条
  • 使用Canvas绘制背景、进度条和文本
  • 触摸事件处理实现交互逻辑
  • 多个频段组合形成完整均衡器

二、核心实现:VerticalSeekBar

2.1 自定义属性定义

    <declare-styleable name="VerticalSeekBar">
        <attr name="seekBarWidth" format="dimension" />
        <attr name="seekBarBackGroundRadius" format="dimension" />
        <attr name="seekBarStartRadius" format="dimension" />
        <attr name="seekBarEndRadius" format="dimension" />
        <attr name="seekBarBackGroundColor" format="color" />
        <attr name="seekBarProgressColor" format="color" />
        <attr name="indicatorTextColor" format="color" />
        <attr name="indicatorTextSize" format="dimension" />
    </declare-styleable>

2.2 关键实现代码分析

  1. 初始化与绘制准备
private void initPaint() {
   // 进度条背景画笔
   paintBackGround = new Paint();
   paintBackGround.setColor(seekBarBackGroundColor);
   paintBackGround.setStyle(Paint.Style.FILL);
   
   // 进度条画笔
   paintSeekBarProgress = new Paint();
   paintSeekBarProgress.setColor(seekBarProgressColor);
   paintSeekBarProgress.setStyle(Paint.Style.FILL);
   
   // 文本画笔
   paintIndicatorText = new Paint();
   paintIndicatorText.setColor(indicatorTextColor);
   paintIndicatorText.setTextSize(indicatorTextSize);
   paintIndicatorText.setTextAlign(Paint.Align.CENTER);
   paintIndicatorText.setTypeface(Typeface.DEFAULT_BOLD);
}
  1. 动态计算与绘制进度条
  • updateRects根据当前进度动态计算进度条的绘制区域,实现进度与视觉位置的对应(正值向上,负值向下)
  • 进度条形状动态变化:使用Path.addRoundRect的 8 参数重载,为矩形的四个角设置不同的圆角半径,实现进度条与中心的平滑衔接
  • 文字垂直居中计算:通过FontMetrics获取字体的 ascent(上沿)和 descent(下沿),计算中间位置作为文字的 Y 坐标
private void updateRects() {
    backgroundRect.set(0, 0, width, height); // 背景区域为整个View
    // 根据进度计算进度区域(正值在上,负值在下)
    if (progress > 0) {
        // 正值进度:从中心向上延伸
        progressRect.set(0, mCenterY - (progress / (float)mMaxProgress) * mCenterY, width, mCenterY);
    } else if (progress < 0) {
        // 负值进度:从中心向下延伸
        progressRect.set(0, mCenterY, width, mCenterY + ((-progress) / (float)mMaxProgress) * mCenterY);
    } else {
        // 进度为0时,进度区域为一条线
        progressRect.set(0, mCenterY, width, mCenterY);
    }
}

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);

    // 1. 绘制背景(圆角矩形)
    canvas.drawRoundRect(backgroundRect, mBackGroundRadius, mBackGroundRadius, paintBackGround);

    // 2. 绘制进度条(根据进度绘制不同形状)
    drawProgress(canvas);

    // 3. 绘制进度文本(当前进度值)
    Paint.FontMetrics fm = paintIndicatorText.getFontMetrics();
    // 计算文字垂直居中的Y坐标(基于字体度量)
    float textY = mCenterY - (fm.descent - (-fm.ascent + fm.descent) / FLOAT_2);
    canvas.drawText(String.valueOf(progress), mCenterX, textY, paintIndicatorText);
}

private void drawProgress(Canvas canvas) {
    progressPath.reset();
    if (progress == 0) {
        // 进度为0:绘制中心圆形指示器
        float top = mCenterY - mStartRadius;
        float bottom = mCenterY + mStartRadius;
        mProgressRectZero.set(0, topBound, width, bottomBound);
        progressPath.addRoundRect(mProgressRectZero, mStartRadius, mStartRadius, Path.Direction.CW);
    } else if (progress > 0) {
        // 正值进度:上部大圆角,下部与中心衔接
        float tr = Math.min(mEndRadius, mCenterY - progressRect.top);
        tr = Math.max(tr, mStartRadius);
        float top = Math.min(progressRect.top, mCenterY - mStartRadius);
        mProgressRect1.set(0, top, width, mCenterY + mStartRadius);
        // 8个参数分别对应:左上x、左上y、右上x、右上y、右下x、右下y、左下x、左下y的圆角半径
        progressPath.addRoundRect(mProgressRect1,
                new float[]{mEndRadius, tr, mEndRadius, tr, mStartRadius, mStartRadius, mStartRadius, mStartRadius},
                Path.Direction.CW);
    } else {
        // 负值进度:下部大圆角,上部与中心衔接
        float br = Math.min(mEndRadius, progressRect.bottom - mCenterY);
        br = Math.max(br, mStartRadius);
        float bottom = Math.max(progressRect.bottom, mCenterY + mStartRadius);
        mProgressRect2.set(0, mCenterY - mStartRadius, width, bottom);
        progressPath.addRoundRect(mProgressRect2,
               new float[]{mStartRadius, mStartRadius, mStartRadius, mStartRadius, mEndRadius, br, mEndRadius, br},
                Path.Direction.CW);
    }
    canvas.drawPath(progressPath, paintSeekBarProgress);
}
  1. 触摸事件处理
@Override
public boolean onTouchEvent(MotionEvent event) {
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            // 开始触摸:回调开始监听
            if (callBack != null) {
                callBack.onStartTrackingTouch(this);
            }
        case MotionEvent.ACTION_MOVE:
            // 滑动中:计算进度并更新
            float touchY = event.getY(); // 触摸点Y坐标
            int newProgress;
            if (touchY < mCenterY) {
                // 触摸点在中心上方:计算正值进度
                newProgress = fixProgress(mMaxProgress * (1 - touchY / mCenterY));
            } else {
                // 触摸点在中心下方:计算负值进度
                newProgress = -fixProgress(mMaxProgress * ((touchY - mCenterY) / mCenterY));
            }
            setProgress(newProgress); // 更新进度
            break;
        case MotionEvent.ACTION_UP:
        case MotionEvent.ACTION_CANCEL:
            // 结束触摸:回调结束监听
            if (callBack != null) {
                callBack.onStopTrackingTouch(this);
            }
            break;
    }
    return true; // 消费触摸事件
}

// 修正进度值(确保在有效范围,处理浮点数转整数)
public int fixProgress(float value) {
    return (int) (value > 1F ? Math.ceil(value) : Math.round(value));
}

三、均衡器整合:多频段调节实现

单个 VerticalSeekBar 只能调节一个频段,实际均衡器需要多个滑块分别控制不同频率,下面解析如何整合这些组件:

3.1 布局文件:定义多滑块布局

在 XML 布局中,为每个频段定义一个 VerticalSeekBar,并设置自定义属性:

<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:orientation="horizontal"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <!-- 80Hz频段滑块 -->
    <com.nio.settings.widget.VerticalSeekBar
        android:id="@+id/sb_equalizer_80hz"
        style="@style/SoundFieldEQSeekBar"
        android:layout_width="@dimen/fy_size_200px"
        android:layout_height="@dimen/fy_size_720px"
        android:layout_marginStart="@dimen/fy_size_160px"
        android:layout_marginTop="@dimen/fy_size_72px"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <com.nio.settings.widget.VerticalSeekBar
        android:id="@+id/sb_equalizer_200hz"
        style="@style/SoundFieldEQSeekBar"
        android:layout_width="@dimen/fy_size_200px"
        android:layout_height="@dimen/fy_size_720px"
        app:layout_constraintStart_toEndOf="@id/sb_equalizer_80hz"
        app:layout_constraintTop_toTopOf="@id/sb_equalizer_80hz" />
        <!-- 相同属性略 -->/>

    <!-- 其他频段滑块(400Hz、1kHz、2.5kHz、6kHz、14kHz) -->
    <!-- ... -->

</LinearLayout>

3.2 代码初始化:绑定滑块与频段

在 Activity 或 Fragment 中初始化所有滑块,并设置监听:

public class EQFragment extends Fragment implements VerticalSeekBar.OnSeekBarChangeListener {
    private static final int CONT_EQ_SIZE = 7; // 7个频段
    private VerticalSeekBar[] mEqualizers = new VerticalSeekBar[CONT_EQ_SIZE];
    private AudioManager mAudioManager;

    // 滑块ID数组(与布局中定义的ID对应)
    private final int[] mEqualizersRids = {
        R.id.sb_equalizer_80hz,
        R.id.sb_equalizer_200hz,
        R.id.sb_equalizer_400hz,
        R.id.sb_equalizer_1khz,
        R.id.sb_equalizer_2_5khz,
        R.id.sb_equalizer_6khz,
        R.id.sb_equalizer_14khz
    };

    // 频段文本ID数组(显示"80Hz"等)
    private final int[] mEqualizersTvHzRids = {
        R.id.tv_equalizer_80hz,
        // ... 其他文本ID
    };

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
        View view = inflater.inflate(R.layout.fragment_eq, container, false);
        mAudioManager = (AudioManager) getContext().getSystemService(Context.AUDIO_SERVICE);
        initEqualizers(view); // 初始化滑块
        return view;
    }

    private void initEqualizers(View view) {
        for (int i = 0; i < mEqualizersRids.length; i++) {
            // 绑定滑块并设置监听
            VerticalSeekBar seekBar = view.findViewById(mEqualizersRids[i]);
            if (seekBar != null) {
                seekBar.setOnSeekBarChangeListener(this);
                mEqualizers[i] = seekBar;
            }
            // 初始化频段文本(如"80Hz")
            TextView tv = view.findViewById(mEqualizersTvHzRids[i]);
            if (tv != null) {
                String text = getString(R.string.fy_settings_equalizer_title_hz, getFreqText(i));
                tv.setText(Html.fromHtml(text, Html.FROM_HTML_MODE_LEGACY));
            }
        }
    }

    private String getFreqText(int index) {
        // 返回对应索引的频率文本(80Hz、200Hz等)
        String[] freqs = {"80Hz", "200Hz", "400Hz", "1kHz", "2.5kHz", "6kHz", "14kHz"};
        return freqs[index];
    }
}

3.3 监听与生效:将调节值应用到音频系统

通过OnSeekBarChangeListener监听滑块变化,并在调节结束后将所有频段的进度同步到音频系统:

@Override
public void onProgressChanged(VerticalSeekBar seekBar, int progress, boolean fromUser) {
    // 调节过程中实时更新UI(可选)
    if (fromUser) {
        seekBar.setProgress(progress); // 确保进度正确设置
    }
}

@Override
public void onStartTrackingTouch(VerticalSeekBar seekBar) {
    // 开始调节(可记录初始值,用于取消操作)
}

@Override
public void onStopTrackingTouch(VerticalSeekBar seekBar) {
    // 调节结束:收集所有频段的进度并设置EQ
    int[] modes = new int[CONT_EQ_SIZE];
    for (int i = 0; i < modes.length; i++) {
        modes[i] = mEqualizers[i].getProgress(); // 获取每个滑块的当前进度
    }
    // 通过AudioManager设置EQ模式
    if (mAudioManager != null) {
        mAudioManager.setEQMode(modes);
    } else {
        Log.d(TAG, "mAudioManager is null");
    }
}

四、自定义VerticalSeekBar完整代码

public class VerticalSeekBar extends View {
    private static final String TAG = "VerticalSeekBar";
    private static final int MAX_PROGRESS = 6;
    private static final float FLOAT_2 = 2f;
    private final RectF backgroundRect = new RectF();
    private final RectF progressRect = new RectF();
    private final Path progressPath = new Path();
    private final RectF mProgressRectZero = new RectF();
    private final RectF mProgressRect1 = new RectF();
    private final RectF mProgressRect2 = new RectF();
    private Paint paintBackGround;
    private Paint paintSeekBarProgress;
    private Paint paintIndicatorText;
    private int mSeekBarWidth;
    private int mBackGroundRadius;
    private int mStartRadius;
    private int mEndRadius;
    // 字体大小
    private int indicatorTextSize;
    // 刻度值
    private int mMaxProgress = MAX_PROGRESS;
    private int seekBarBackGroundColor;
    private int seekBarProgressColor;
    private int indicatorTextColor;

    private int width;
    private int height;
    private int progress;
    private float mCenterX;
    private float mCenterY;

    private OnSeekBarChangeListener callBack;

    public VerticalSeekBar(Context context) {
        this(context, null);
    }

    public VerticalSeekBar(Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public VerticalSeekBar(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        setWillNotDraw(false);
        final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.VerticalSeekBar, defStyleAttr, 0);
        mSeekBarWidth = a.getDimensionPixelSize(R.styleable.VerticalSeekBar_seekBarWidth,
                getContext().getResources().getDimensionPixelSize(R.dimen.fy_size_32px));
        mBackGroundRadius = a.getDimensionPixelSize(R.styleable.VerticalSeekBar_seekBarBackGroundRadius,
                getContext().getResources().getDimensionPixelSize(R.dimen.fy_size_48px));
        mStartRadius = a.getDimensionPixelSize(R.styleable.VerticalSeekBar_seekBarStartRadius,
                getContext().getResources().getDimensionPixelSize(R.dimen.fy_size_48px));
        mEndRadius = a.getDimensionPixelSize(R.styleable.VerticalSeekBar_seekBarEndRadius,
                getContext().getResources().getDimensionPixelSize(R.dimen.fy_size_48px));
        indicatorTextSize = a.getDimensionPixelSize(R.styleable.VerticalSeekBar_indicatorTextSize,
                getContext().getResources().getDimensionPixelSize(R.dimen.fy_font_32px));
        seekBarBackGroundColor = a.getColor(R.styleable.VerticalSeekBar_seekBarBackGroundColor, Color.BLACK);
        seekBarProgressColor = a.getColor(R.styleable.VerticalSeekBar_seekBarProgressColor, Color.BLACK);
        indicatorTextColor = a.getColor(R.styleable.VerticalSeekBar_indicatorTextColor, Color.BLACK);
        a.recycle();
        initPaint();
    }

    private void initPaint() {
        // 进度条背景画笔
        paintBackGround = new Paint();
        paintBackGround.setColor(seekBarBackGroundColor);
        paintBackGround.setStyle(Paint.Style.FILL);
        paintBackGround.setStrokeCap(Paint.Cap.ROUND);
        paintBackGround.setStrokeWidth(mSeekBarWidth);
        paintBackGround.setAntiAlias(true);
        // 进度条画笔
        paintSeekBarProgress = new Paint();
        paintSeekBarProgress.setColor(seekBarProgressColor);
        paintSeekBarProgress.setStyle(Paint.Style.FILL);
        paintSeekBarProgress.setStrokeWidth(mSeekBarWidth);
        paintSeekBarProgress.setAntiAlias(true);
        paintSeekBarProgress.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));
        // float文字画笔
        paintIndicatorText = new Paint();
        paintIndicatorText.setColor(indicatorTextColor);
        paintIndicatorText.setStyle(Paint.Style.FILL);
        paintIndicatorText.setTextSize(indicatorTextSize);
        paintIndicatorText.setAntiAlias(true);
        paintIndicatorText.setTextAlign(Paint.Align.CENTER);
        paintIndicatorText.setTypeface(Typeface.DEFAULT_BOLD);
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        width = w;
        height = h;
        mCenterX = width / FLOAT_2;
        mCenterY = height / FLOAT_2;
        updateRects();
    }

    private void updateRects() {
        backgroundRect.set(0, 0, width, height);
        if (Float.compare(progress, 0) == 0) {
            progressRect.set(0, mCenterY, width, mCenterY);
        } else if (Float.compare(progress, 0) > 0) {
            progressRect.set(0, (float) Math.ceil(mCenterY - ((float) progress / mMaxProgress) * mCenterY), width,
                    mCenterY);
        } else {
            progressRect.set(0, mCenterY, width,
                    (float) Math.ceil(mCenterY + ((float) progress / -mMaxProgress) * mCenterY));
        }
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        // 绘制背景
        canvas.drawRoundRect(backgroundRect, mBackGroundRadius, mBackGroundRadius, paintBackGround);

        // 绘制进度条
        drawProgress(canvas);

        // 绘制进度值
        Paint.FontMetrics fm = paintIndicatorText.getFontMetrics();
        canvas.drawText(String.valueOf(progress), mCenterX,
                mCenterY - (fm.descent - (-fm.ascent + fm.descent) / FLOAT_2), paintIndicatorText);
    }



    private void drawProgress(Canvas canvas) {
        progressPath.reset();
        if (Float.compare(progress, 0) == 0) {
            float top;
            float bottom;
            top = mCenterY - mStartRadius;
            bottom = mCenterY + mStartRadius;
            mProgressRectZero.set(0, top, width, bottom);
            progressPath.addRoundRect(mProgressRectZero, mStartRadius, mStartRadius, Path.Direction.CW);
        } else if (Float.compare(progress, 0) > 0) {
            // 正值进度:上部大圆角,下部小圆角
            float tr = Math.min(mEndRadius, mCenterY - progressRect.top);
            tr = Math.max(tr, mStartRadius);
            float top = Math.min(progressRect.top, mCenterY - mStartRadius);
            mProgressRect1.set(0, top, width, mCenterY + mStartRadius);
            progressPath.addRoundRect(mProgressRect1,
                    new float[]{mEndRadius, tr, mEndRadius, tr, mStartRadius, mStartRadius, mStartRadius, mStartRadius},
                    Path.Direction.CW);
        } else {
            // 负值进度:上部小圆角,下部大圆角
            float br = Math.min(mEndRadius, progressRect.bottom - mCenterY);
            br = Math.max(br, mStartRadius);
            float bottom = Math.max(progressRect.bottom, mCenterY + mStartRadius);
            mProgressRect2.set(0, mCenterY - mStartRadius, width, bottom);
            progressPath.addRoundRect(mProgressRect2,
                    new float[]{mStartRadius, mStartRadius, mStartRadius, mStartRadius, mEndRadius, br, mEndRadius, br},
                    Path.Direction.CW);
        }
        canvas.drawPath(progressPath, paintSeekBarProgress);
    }

    public void setProgress(int progress) {
        this.progress = Math.max(-mMaxProgress, Math.min(mMaxProgress, progress));
        updateRects();
        if (callBack != null) {
            callBack.onProgressChanged(this, progress, false);
        }
        postInvalidate();
    }

    @Override
    public boolean dispatchTouchEvent(MotionEvent event) {
        getParent().requestDisallowInterceptTouchEvent(isEnabled());
        return super.dispatchTouchEvent(event);
    }

    // 添加触摸事件处理
    @SuppressWarnings("checkstyle:FallThrough")
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                if (callBack != null) {
                    callBack.onStartTrackingTouch(this);
                }
            case MotionEvent.ACTION_MOVE:
                float touchY = event.getY();
                int newProgress;
                if (Float.compare(touchY, mCenterY) == 0) {
                    newProgress = 0;
                } else if (Float.compare(touchY, mCenterY) < 0) {
                    newProgress = fixProgress(mMaxProgress * (1 - touchY / mCenterY));
                } else {
                    newProgress = -fixProgress(mMaxProgress * ((touchY - mCenterY) / mCenterY));
                }
                setProgress(newProgress);
            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL:
                if (callBack != null) {
                    callBack.onStopTrackingTouch(this);
                }
                break;
            default:
                break;
        }
        return true;
    }

    public int getProgress() {
        return progress;
    }

    public int fixProgress(float value) {
        return (int) (Float.compare(value, 1F) > 0 ? Math.ceil(value) : Math.round(value));
    }

    public void setOnSeekBarChangeListener(OnSeekBarChangeListener callBack) {
        this.callBack = callBack;
    }

    public interface OnSeekBarChangeListener {
        // 滑动前
        void onStartTrackingTouch(VerticalSeekBar seekBar);

        // 滑动中
        void onProgressChanged(VerticalSeekBar seekBar, int progress, boolean fromUser);

        // 滑动后
        void onStopTrackingTouch(VerticalSeekBar seekBar);
    }
}

效果预览:
在这里插入图片描述

五、总结

本文通过自定义 VerticalSeekBar 实现了垂直滑块控件,并基于此构建了一个 7 频段的音量均衡器。核心要点包括:

自定义 View 的标准流程:属性解析、画笔初始化、尺寸测量、绘制逻辑、触摸处理
垂直滑块的交互设计:触摸位置与进度的映射、正负进度的视觉区分、文本居中显示
多频段均衡器的整合:滑块数组管理、频段文本绑定、调节值的收集与生效

通过这种实现,开发者可以快速构建一个功能完整、交互友好的音量均衡器,提升应用的音频体验。自定义 View 的灵活性使得 UI 样式可以轻松适配不同的设计需求,而解耦的架构也便于后续功能扩展。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值